From 139d15213e3a64191e856c0912534c7ebe15b0ab Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 10:37:23 +0530 Subject: [PATCH 01/91] feat: enhanced target create with onboarding simplification flags Add --init-extended-location, --init-context, --init-hierarchy, --service-group, and --release-train flags to 'az workload-orchestration target create' to reduce onboarding from 15+ manual steps to a single command. New commands: - target prepare: Prepares Arc cluster (cert-mgr, trust-mgr, extension, CL) - hierarchy create: Creates site hierarchy (SG, Site, Config, SiteRef) Pre-operation hooks in target create: 1. --init-extended-location: calls target_prepare to set up cluster + CL 2. --init-context: discovers/creates WO context with capability injection 3. --init-hierarchy: creates site hierarchy linked to context 4. Default target-specification: injects Helm v3 in-cluster if not provided Post-operation hook: 5. --service-group: links target to a service group after creation 8 flag combinations supported (from vanilla to full onboarding). Includes unit tests for hierarchy, SG link, utils, and target prepare. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 69 ++ .../azext_workload_orchestration/_params.py | 145 ++++ .../workload_orchestration/target/_create.py | 535 ++++++++++++- .../azext_workload_orchestration/commands.py | 7 + .../azext_workload_orchestration/custom.py | 4 + .../onboarding/__init__.py | 15 + .../onboarding/consts.py | 82 ++ .../onboarding/hierarchy_create.py | 560 +++++++++++++ .../onboarding/target_prepare.py | 739 ++++++++++++++++++ .../onboarding/target_sg_link.py | 110 +++ .../onboarding/utils.py | 120 +++ .../tests/test_onboarding/__init__.py | 10 + .../test_onboarding/test_hierarchy_create.py | 182 +++++ .../test_onboarding/test_sg_link_and_utils.py | 95 +++ .../test_onboarding/test_target_prepare.py | 152 ++++ 15 files changed, 2817 insertions(+), 8 deletions(-) create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/__init__.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_hierarchy_create.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 1f93a9946f0..2a60399b633 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -46,3 +46,72 @@ - name: Use a specific kubeconfig and context text: az workload-orchestration support create-bundle --kube-config ~/.kube/prod-config --kube-context my-cluster """ + +helps['workload-orchestration target prepare'] = """ +type: command +short-summary: Prepare an Arc-connected Kubernetes cluster for Workload Orchestration. +long-summary: | + Installs all prerequisites needed to run Workload Orchestration on an Arc-connected + Kubernetes cluster. This is a convenience command that wraps multiple setup steps into one. + + Steps performed: + 1. Install cert-manager (if not already installed) + 2. Install trust-manager via helm (if not already installed) + 3. Install the WO extension (microsoft.workloadorchestration) + 4. Create a custom location linked to the cluster and extension + + Prerequisites: + - Cluster must already be Arc-connected (az connectedk8s connect) + - kubectl must be in PATH and configured for the target cluster + - helm must be in PATH (required for trust-manager) + + The command is idempotent - it skips components that are already installed. + On completion, it outputs an extended-location.json file in the current directory + for use with target create. +examples: + - name: Prepare a cluster with defaults + text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus + - name: Prepare with a specific extension version + text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus --extension-version 2.1.18 + - name: Prepare without waiting for extension (fire and forget) + text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus --no-wait + - name: Skip cert-manager (already installed separately) + text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus --skip-cert-manager + - name: Use a specific kubeconfig + text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus --kube-config ~/.kube/prod +""" + +helps['workload-orchestration hierarchy'] = """ +type: group +short-summary: Commands for managing WO site hierarchy levels. +""" + +helps['workload-orchestration hierarchy create'] = """ +type: command +short-summary: Create a hierarchy level (Service Group + Site + Configuration) in one command. +long-summary: | + Creates all resources needed for a single hierarchy level in Workload Orchestration. + This replaces 4 separate az rest calls with a single CLI command. + + Resources created: + 1. Service Group (Microsoft.Management/serviceGroups) + 2. Site (Microsoft.Edge/sites) — in the Service Group + 3. Configuration (Microsoft.Edge/configurations) — in the Resource Group + 4. Configuration Reference — links the Configuration to the Site + + If no WO context exists, one is auto-created and set as the current context. + A site-reference is also auto-created to link the site to the context. + + All operations are idempotent (PUT upsert) — safe to re-run. +examples: + - name: Create a top-level Region hierarchy + text: az workload-orchestration hierarchy create --name my-region -g my-rg -l eastus --level-label Region + - name: Create a Factory nested under Region + text: az workload-orchestration hierarchy create --name my-factory -g my-rg -l eastus --level-label Factory --parent my-region + - name: Create with capabilities (auto-added to context) + text: az workload-orchestration hierarchy create --name my-region -g my-rg -l eastus --level-label Region --capabilities soap shampoo + - name: Use an existing context + text: az workload-orchestration hierarchy create --name my-factory -g my-rg -l eastus --level-label Factory --context-name my-context --context-rg context-rg + - name: Skip context auto-creation (manual context management) + text: az workload-orchestration hierarchy create --name my-factory -g my-rg -l eastus --level-label Factory --skip-context +""" diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index b17dd8cccd7..6b94fa46320 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -65,3 +65,148 @@ def load_arguments(self, _): # pylint: disable=unused-argument options_list=['--kube-context'], help='Kubernetes context to use. Defaults to current context.', ) + + # ----------------------------------------------------------------------- + # target prepare + # ----------------------------------------------------------------------- + with self.argument_context('workload-orchestration target prepare') as c: + c.argument( + 'cluster_name', + options_list=['--cluster-name'], + help='Name of the Arc-connected Kubernetes cluster.', + required=True, + ) + c.argument( + 'resource_group', + options_list=['--resource-group', '-g'], + help='Resource group of the cluster.', + required=True, + ) + c.argument( + 'location', + options_list=['--location', '-l'], + help='Azure region (e.g., eastus, westeurope).', + required=True, + ) + c.argument( + 'extension_name', + options_list=['--extension-name'], + help='Name for the WO extension. Defaults to wo-extension.', + ) + c.argument( + 'custom_location_name', + options_list=['--custom-location-name'], + help='Name for the custom location. Defaults to {cluster-name}-cl.', + ) + c.argument( + 'extension_version', + options_list=['--extension-version'], + help='WO extension version to install (e.g., 2.1.18).', + ) + c.argument( + 'release_train', + options_list=['--release-train'], + help='Extension release train. Defaults to preview.', + ) + c.argument( + 'cert_manager_version', + options_list=['--cert-manager-version'], + help='cert-manager version to install. Defaults to v1.15.3.', + ) + c.argument( + 'skip_cert_manager', + options_list=['--skip-cert-manager'], + action='store_true', + help='Skip cert-manager installation.', + ) + c.argument( + 'skip_trust_manager', + options_list=['--skip-trust-manager'], + action='store_true', + help='Skip trust-manager installation.', + ) + c.argument( + 'kube_config', + options_list=['--kube-config'], + help='Path to kubeconfig file. Defaults to ~/.kube/config.', + ) + c.argument( + 'kube_context', + options_list=['--kube-context'], + help='Kubernetes context to use. Defaults to current context.', + ) + c.argument( + 'no_wait', + options_list=['--no-wait'], + action='store_true', + help="Don't wait for the WO extension to finish installing.", + ) + + # ----------------------------------------------------------------------- + # hierarchy create + # ----------------------------------------------------------------------- + with self.argument_context('workload-orchestration hierarchy create') as c: + c.argument( + 'name', + options_list=['--name', '-n'], + help='Name for this hierarchy level. Used for Service Group, Site, and ' + 'Configuration resources. Maximum 24 characters.', + required=True, + ) + c.argument( + 'resource_group', + options_list=['--resource-group', '-g'], + help='Resource group for the Configuration resource.', + required=True, + ) + c.argument( + 'location', + options_list=['--location', '-l'], + help='Azure region (determines regional API endpoint for Site/Config).', + required=True, + ) + c.argument( + 'level_label', + options_list=['--level-label'], + help='Label for this hierarchy level (e.g., Region, Factory, Line).', + required=True, + ) + c.argument( + 'parent', + options_list=['--parent'], + help='Parent service group name for nesting. ' + 'Omit for top-level (parent defaults to tenant root).', + ) + c.argument( + 'capabilities', + options_list=['--capabilities'], + nargs='+', + help='Capabilities to add to the WO context (e.g., soap shampoo).', + ) + c.argument( + 'description', + options_list=['--description'], + help='Description for the Site resource. Defaults to the name.', + ) + c.argument( + 'context_name', + options_list=['--context-name'], + help='Use an existing context by name (skip auto-create).', + ) + c.argument( + 'context_rg', + options_list=['--context-rg'], + help='Resource group of the existing context.', + ) + c.argument( + 'skip_context', + options_list=['--skip-context'], + action='store_true', + help='Skip auto-creation of context and site-reference.', + ) + c.argument( + 'skip_site_reference', + options_list=['--skip-site-reference'], + action='store_true', + help='Skip auto-creation of site-reference to context.', + ) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index 7308557c30a..541c359d029 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -117,10 +117,41 @@ def _build_arguments_schema(cls, *args, **kwargs): options=["--target-specification"], arg_group="Properties", help="Specifies that we are using Helm charts for the k8s deployment", - required=True, ) + # V2: Onboarding simplification arguments + _args_schema.init_context = AAZStrArg( + options=["--init-context"], + arg_group="Onboarding", + help="Auto-create a default context if none exists. Value is the context name (e.g., 'default'). " + "Auto-injects hierarchy level and capabilities into the context.", + ) + _args_schema.service_group = AAZStrArg( + options=["--service-group"], + arg_group="Onboarding", + help="ServiceGroup name to auto-link this target to after creation.", + ) + _args_schema.init_hierarchy = AAZStrArg( + options=["--init-hierarchy"], + arg_group="Onboarding", + help="Auto-create a regular site hierarchy. Value is the site name. " + "Creates a Site in the target's resource group and links to context.", + ) + _args_schema.init_extended_location = AAZStrArg( + options=["--init-extended-location"], + arg_group="Onboarding", + help="Auto-prepare an Arc-connected cluster for WO and create a custom location. " + "Value is the connected cluster ARM resource ID. " + "Installs cert-manager, trust-manager, WO extension, and creates custom location.", + ) + _args_schema.release_train = AAZStrArg( + options=["--release-train"], + arg_group="Onboarding", + help="Release train for WO extension (used with --init-extended-location). " + "Default: 'dev'. Options: dev, preview, stable.", + ) + capabilities = cls._args_schema.capabilities capabilities.Element = AAZStrArg() @@ -170,30 +201,518 @@ def _execute_operations(self): @register_callback def pre_operations(self): - # If context_id is not provided, try to get it from config + # --- V2: --init-extended-location (auto-prepare cluster + create CL) --- + if hasattr(self.ctx.args, 'init_extended_location') and self.ctx.args.init_extended_location: + self._handle_init_extended_location() + + # --- V2: --init-context (auto-create context if not exists) --- + if hasattr(self.ctx.args, 'init_context') and self.ctx.args.init_context: + self._handle_init_context() + + # --- V2: --init-hierarchy (auto-create regular site hierarchy) --- + if hasattr(self.ctx.args, 'init_hierarchy') and self.ctx.args.init_hierarchy: + self._handle_init_hierarchy() + + # If context_id is not provided, try to get it from config if not self.ctx.args.context_id: try: - # Attempt to retrieve the context_id from the config file context_id = self.ctx.cli_ctx.config.get('workload_orchestration', 'context_id') if context_id: self.ctx.args.context_id = context_id else: - # This else block handles the case where the section exists, but the key is empty raise CLIInternalError( "No context-id was provided, and no default context is set. " - "Please provide the --context-id argument or set a default context using 'az workload-orchestration context use'." + "Please provide the --context-id argument, use --init-context, " + "or set a default context using 'az workload-orchestration context use'." ) except configparser.NoSectionError as e: logger.debug("Config section 'workload_orchestration' not found: %s", e) - # This is the fix: catch the specific error when the [workload_orchestration] section is missing raise CLIInternalError( "No context-id was provided, and no default context is set. " - "Please provide the --context-id argument or set a default context using 'az workload-orchestration context use'." + "Please provide the --context-id argument, use --init-context, " + "or set a default context using 'az workload-orchestration context use'." ) + # V2: Default target specification (helm.v3) if not provided + if not self.ctx.args.target_specification: + self.ctx.args.target_specification = { + "topologies": [{ + "bindings": [{ + "role": "helm.v3", + "provider": "providers.target.helm", + "config": {"inCluster": "true"} + }] + }] + } + + def _handle_init_extended_location(self): + """Auto-prepare cluster (cert-mgr, trust-mgr, extension, custom location).""" + from azext_workload_orchestration.onboarding.target_prepare import target_prepare + from azext_workload_orchestration.onboarding.utils import CmdProxy + + cluster_arm_id = str(self.ctx.args.init_extended_location) + location = str(self.ctx.args.location) + + # Parse cluster name and RG from ARM ID + # Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Kubernetes/connectedClusters/{name} + parts = cluster_arm_id.strip("/").split("/") + if len(parts) < 8: + raise CLIInternalError( + f"Invalid connected cluster ARM ID: {cluster_arm_id}\n" + "Expected format: /subscriptions/{{sub}}/resourceGroups/{{rg}}" + "/providers/Microsoft.Kubernetes/connectedClusters/{{name}}" + ) + + # Extract RG and cluster name from ARM ID + cluster_rg = None + cluster_name = None + for i, part in enumerate(parts): + if part.lower() == "resourcegroups" and i + 1 < len(parts): + cluster_rg = parts[i + 1] + if part.lower() == "connectedclusters" and i + 1 < len(parts): + cluster_name = parts[i + 1] + + if not cluster_rg or not cluster_name: + raise CLIInternalError( + f"Could not extract cluster name/RG from: {cluster_arm_id}\n" + "Expected format: /subscriptions/{{sub}}/resourceGroups/{{rg}}" + "/providers/Microsoft.Kubernetes/connectedClusters/{{name}}" + ) + + release_train = None + if hasattr(self.ctx.args, 'release_train') and self.ctx.args.release_train: + release_train = str(self.ctx.args.release_train) + + print(f"\n[init-extended-location] Preparing cluster '{cluster_name}' in RG '{cluster_rg}'...") + + # Create a cmd proxy for target_prepare + cmd_proxy = CmdProxy(self.ctx.cli_ctx) + + result = target_prepare( + cmd=cmd_proxy, + cluster_name=cluster_name, + resource_group=cluster_rg, + location=location, + release_train=release_train, + ) + + # Set extended_location from the result + cl_id = result.get("customLocationId", "") + if cl_id: + self.ctx.args.extended_location = { + "name": cl_id, + "type": "CustomLocation" + } + print(f"[init-extended-location] Cluster prepared, CL: {cl_id} [OK]\n") + else: + raise CLIInternalError( + "target prepare succeeded but no custom location ID was returned." + ) + + def _handle_init_context(self): + """Auto-create context if none exists, inject hierarchy+capabilities.""" + from azure.cli.core import get_default_cli + from azext_workload_orchestration.onboarding.utils import invoke_cli_command, CmdProxy + import io + import sys + import json + + ctx_name = str(self.ctx.args.init_context) + rg = str(self.ctx.args.resource_group) + location = str(self.ctx.args.location) + hierarchy_level = str(self.ctx.args.hierarchy_level) if self.ctx.args.hierarchy_level else "line" + capabilities = [str(c) for c in self.ctx.args.capabilities] if self.ctx.args.capabilities else [] + + # Create a cmd proxy for invoke_cli_command + cmd_proxy = CmdProxy(self.ctx.cli_ctx) + cli = get_default_cli() + + # Check if context already exists via config + try: + existing_ctx_id = self.ctx.cli_ctx.config.get('workload_orchestration', 'context_id') + if existing_ctx_id: + logger.info("Context already set: %s", existing_ctx_id) + self.ctx.args.context_id = existing_ctx_id + # Still need to ensure capabilities are present + self._ensure_context_capabilities(cmd_proxy, cli, existing_ctx_id, hierarchy_level, capabilities) + print(f"[init-context] Using existing context [OK]") + return + except (configparser.NoSectionError, configparser.NoOptionError): + pass + + # First, try to find existing context in this RG + try: + existing = invoke_cli_command(cmd_proxy, [ + "workload-orchestration", "context", "list", "-g", rg + ]) + if existing and isinstance(existing, list) and len(existing) > 0: + ctx = existing[0] + ctx_id = ctx.get("id", "") + if ctx_id: + self.ctx.args.context_id = ctx_id + ctx_parts = ctx_id.split("/") + found_name = ctx_parts[-1] if ctx_parts else ctx_name + self._set_context_current(cli, found_name, rg) + self._ensure_context_capabilities(cmd_proxy, cli, ctx_id, hierarchy_level, capabilities) + print(f"[init-context] Using existing context '{found_name}' [OK]") + return + except Exception: + pass # No contexts in this RG, proceed to create + + # Build capabilities args + cap_args = [] + for i, cap in enumerate(capabilities): + cap_args.extend([f"[{i}].name={cap}", f"[{i}].description={cap}"]) + + # Build hierarchies args + hier_args = [f"[0].name={hierarchy_level}", f"[0].description={hierarchy_level}"] + + print(f"[init-context] Creating context '{ctx_name}'...") + + # Try to create the context + create_args = [ + "workload-orchestration", "context", "create", + "-g", rg, "-l", location, "--name", ctx_name, + "--hierarchies", + ] + hier_args + + if cap_args: + create_args.append("--capabilities") + create_args.extend(cap_args) + create_args.extend(["-o", "none"]) + + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + try: + exit_code = cli.invoke(create_args) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + + if exit_code == 0: + # Created successfully — set as current + self._set_context_current(cli, ctx_name, rg) + + try: + ctx_id = self.ctx.cli_ctx.config.get('workload_orchestration', 'context_id') + if ctx_id: + self.ctx.args.context_id = ctx_id + print(f"[init-context] Context '{ctx_name}' created [OK]") + return + except (configparser.NoSectionError, configparser.NoOptionError): + pass + + sub_id = self.ctx.subscription_id + ctx_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/contexts/{ctx_name}" + self.ctx.args.context_id = ctx_id + print(f"[init-context] Context '{ctx_name}' created [OK]") + return + + # Context create failed — likely "already exists" in another RG + logger.warning("Context create failed (exit %d). Searching for existing context...", exit_code) + + # Search subscription-wide for existing contexts + from azure.cli.core.util import send_raw_request + sub_id = self.ctx.subscription_id + try: + resp = send_raw_request( + self.ctx.cli_ctx, + method="GET", + url=( + f"https://management.azure.com/subscriptions/{sub_id}" + f"/providers/Microsoft.Edge/contexts?api-version=2025-08-01" + ), + resource="https://management.azure.com" + ) + if resp.status_code == 200: + data = resp.json() + contexts = data.get("value", []) + if contexts: + existing_ctx = contexts[0] + existing_id = existing_ctx.get("id", "") + if existing_id: + self.ctx.args.context_id = existing_id + parts = existing_id.split("/") + found_rg = None + found_name = None + for i, p in enumerate(parts): + if p.lower() == "resourcegroups" and i + 1 < len(parts): + found_rg = parts[i + 1] + if p.lower() == "contexts" and i + 1 < len(parts): + found_name = parts[i + 1] + + if found_rg and found_name: + self._set_context_current(cli, found_name, found_rg) + self._ensure_context_capabilities( + cmd_proxy, cli, existing_id, hierarchy_level, capabilities + ) + + print(f"[init-context] Using existing context '{found_name}' in RG '{found_rg}' [OK]") + return + except Exception as exc: + logger.warning("Failed to search for existing context: %s", exc) + + raise CLIInternalError( + "Could not create or find an existing context. " + "Please provide --context-id explicitly." + ) + + def _set_context_current(self, cli, ctx_name, ctx_rg): + """Set a context as the current default (silently).""" + import io + import sys + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + try: + cli.invoke(["workload-orchestration", "context", "use", + "--name", ctx_name, "-g", ctx_rg, "-o", "none"]) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + + def _ensure_context_capabilities(self, cmd_proxy, cli, ctx_id, hierarchy_level, capabilities): + """Ensure the context has the required hierarchy level and capabilities.""" + import io + import sys + import json + from azext_workload_orchestration.onboarding.utils import invoke_cli_command + + if not capabilities: + return + + # Get existing context details + parts = ctx_id.split("/") + ctx_rg = None + ctx_name = None + for i, p in enumerate(parts): + if p.lower() == "resourcegroups" and i + 1 < len(parts): + ctx_rg = parts[i + 1] + if p.lower() == "contexts" and i + 1 < len(parts): + ctx_name = parts[i + 1] + + if not ctx_rg or not ctx_name: + return + + try: + ctx_data = invoke_cli_command(cmd_proxy, [ + "workload-orchestration", "context", "show", + "-g", ctx_rg, "--name", ctx_name + ]) + except Exception: + return + + if not ctx_data or not isinstance(ctx_data, dict): + return + + props = ctx_data.get("properties", {}) + existing_caps = {c.get("name", "") for c in (props.get("capabilities") or [])} + existing_hiers = {h.get("name", "") for h in (props.get("hierarchies") or [])} + + # Check if we need to add capabilities or hierarchies + missing_caps = [c for c in capabilities if c not in existing_caps] + missing_hier = hierarchy_level not in existing_hiers + + if not missing_caps and not missing_hier: + return # All present + + # Build update — merge existing + new + all_caps = list(props.get("capabilities") or []) + for cap in missing_caps: + all_caps.append({"name": cap, "description": cap}) + + all_hiers = list(props.get("hierarchies") or []) + if missing_hier: + all_hiers.append({"name": hierarchy_level, "description": hierarchy_level}) + + # Build update args + cap_args = [] + for i, cap in enumerate(all_caps): + cap_args.extend([f"[{i}].name={cap.get('name', '')}", f"[{i}].description={cap.get('description', '')}"]) + + hier_args = [] + for i, h in enumerate(all_hiers): + hier_args.extend([f"[{i}].name={h.get('name', '')}", f"[{i}].description={h.get('description', '')}"]) + + print(f"[init-context] Adding capabilities {missing_caps} to context...") + + # Use REST PUT to update context (no 'context update' CLI command) + from azure.cli.core.util import send_raw_request + parts2 = ctx_id.split("/") + sub_id = None + for i2, p2 in enumerate(parts2): + if p2.lower() == "subscriptions" and i2 + 1 < len(parts2): + sub_id = parts2[i2 + 1] + break + if not sub_id: + sub_id = self.ctx.subscription_id + + # Build updated body from existing context data + location = ctx_data.get("location", str(self.ctx.args.location)) + update_body = { + "location": location, + "properties": { + "capabilities": [{"name": c.get("name", ""), "description": c.get("description", "")} for c in all_caps], + "hierarchies": [{"name": h.get("name", ""), "description": h.get("description", "")} for h in all_hiers], + } + } + + try: + resp = send_raw_request( + self.ctx.cli_ctx, + method="PUT", + url=( + f"https://management.azure.com/subscriptions/{sub_id}" + f"/resourceGroups/{ctx_rg}/providers/Microsoft.Edge" + f"/contexts/{ctx_name}?api-version=2025-08-01" + ), + body=json.dumps(update_body), + resource="https://management.azure.com" + ) + if resp.status_code in (200, 201): + print(f"[init-context] Capabilities updated [OK]") + else: + logger.warning("Context update returned %d: %s", resp.status_code, resp.text) + except Exception as exc: + logger.warning("Failed to update context capabilities: %s", exc) + + def _handle_init_hierarchy(self): + """Auto-create a regular site hierarchy (RG-scoped, no SG).""" + from azure.cli.core import get_default_cli + import io + import sys + import json + + site_name = str(self.ctx.args.init_hierarchy) + rg = str(self.ctx.args.resource_group) + location = str(self.ctx.args.location) + + # Create site in RG scope via az rest + sub_id = self.ctx.subscription_id + site_url = ( + f"https://{location}.management.azure.com" + f"/subscriptions/{sub_id}/resourceGroups/{rg}" + f"/providers/Microsoft.Edge/sites/{site_name}" + f"?api-version=2025-06-01" + ) + site_body = json.dumps({ + "properties": { + "displayName": site_name, + "description": site_name, + "labels": {"level": str(self.ctx.args.hierarchy_level) if self.ctx.args.hierarchy_level else "line"} + } + }) + + print(f"[init-hierarchy] Creating site '{site_name}'...") + + cli = get_default_cli() + + # Helper to invoke CLI silently (suppress stdout/stderr) + def _invoke_silent(args): + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + try: + return cli.invoke(args) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + + # Step 1: Create site + _invoke_silent([ + "rest", "--method", "put", "--url", site_url, + "--body", site_body, + "--resource", "https://management.azure.com", + "--header", "Content-Type=application/json", + "-o", "none", + ]) + + # Step 2: Create configuration + config_url = ( + f"https://{location}.management.azure.com" + f"/subscriptions/{sub_id}/resourceGroups/{rg}" + f"/providers/Microsoft.Edge/configurations/{site_name}" + f"?api-version=2025-08-01" + ) + _invoke_silent([ + "rest", "--method", "put", "--url", config_url, + "--body", json.dumps({"location": location}), + "--resource", "https://management.azure.com", + "--header", "Content-Type=application/json", + "-o", "none", + ]) + + # Step 3: Create config reference + site_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/sites/{site_name}" + config_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/configurations/{site_name}" + config_ref_url = ( + f"https://management.azure.com{site_id}" + f"/providers/Microsoft.Edge/configurationreferences/default" + f"?api-version=2025-08-01" + ) + _invoke_silent([ + "rest", "--method", "put", "--url", config_ref_url, + "--body", json.dumps({"properties": {"configurationResourceId": config_id}}), + "--resource", "https://management.azure.com", + "--header", "Content-Type=application/json", + "-o", "none", + ]) + + # Step 4: Create site reference to context + if self.ctx.args.context_id: + parts = str(self.ctx.args.context_id).split("/") + ctx_rg = rg + ctx_name = "default" + for i, p in enumerate(parts): + if p.lower() == "resourcegroups" and i + 1 < len(parts): + ctx_rg = parts[i + 1] + if p.lower() == "contexts" and i + 1 < len(parts): + ctx_name = parts[i + 1] + + try: + _invoke_silent([ + "workload-orchestration", "context", "site-reference", "create", + "-g", ctx_rg, "--context-name", ctx_name, + "--name", f"{site_name}-ref", + "--site-id", site_id, + "-o", "none", + ]) + except Exception: + pass + + print(f"[init-hierarchy] Site '{site_name}' + config + references created [OK]") + @register_callback def post_operations(self): - pass + # --- V2: --service-group (auto-link target to SG after creation) --- + if hasattr(self.ctx.args, 'service_group') and self.ctx.args.service_group: + self._handle_service_group_link() + + def _handle_service_group_link(self): + """Link the created target to a service group.""" + from azext_workload_orchestration.onboarding.target_sg_link import ( + link_target_to_service_group + ) + from azext_workload_orchestration.onboarding.utils import CmdProxy + sg_name = str(self.ctx.args.service_group) + # Get target ID from the response + target_id = None + if hasattr(self.ctx.vars, 'instance') and self.ctx.vars.instance: + target_id = self.ctx.vars.instance.get("id") + + if not target_id: + # Construct it + sub_id = self.ctx.subscription_id + rg = str(self.ctx.args.resource_group) + name = str(self.ctx.args.target_name) + target_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/targets/{name}" + + print(f"[service-group] Linking target to '{sg_name}'...") + try: + cmd_proxy = CmdProxy(self.ctx.cli_ctx) + link_target_to_service_group(cmd_proxy, target_id, sg_name) + print(f"[service-group] Linked [OK]") + except Exception as exc: + logger.warning("Service group link failed (non-critical): %s", exc) + print(f"[service-group] Link failed (non-critical): {exc}") def _output(self, *args, **kwargs): result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index 1f1d9c002a7..ac413568f53 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -12,3 +12,10 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration support', is_preview=True) as g: g.custom_command('create-bundle', 'create_support_bundle') + + # Onboarding simplification commands + with self.command_group('workload-orchestration target', is_preview=True) as g: + g.custom_command('prepare', 'target_prepare') + + with self.command_group('workload-orchestration hierarchy', is_preview=True) as g: + g.custom_command('create', 'hierarchy_create') diff --git a/src/workload-orchestration/azext_workload_orchestration/custom.py b/src/workload-orchestration/azext_workload_orchestration/custom.py index 849a65ef9de..1588145a4ee 100644 --- a/src/workload-orchestration/azext_workload_orchestration/custom.py +++ b/src/workload-orchestration/azext_workload_orchestration/custom.py @@ -7,3 +7,7 @@ # Support bundle command from azext_workload_orchestration.support import create_support_bundle # pylint: disable=unused-import # noqa: F401 + +# Onboarding simplification commands +from azext_workload_orchestration.onboarding import target_prepare # pylint: disable=unused-import # noqa: F401 +from azext_workload_orchestration.onboarding import hierarchy_create # pylint: disable=unused-import # noqa: F401 diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py new file mode 100644 index 00000000000..cc9875c64d5 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -0,0 +1,15 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Onboarding simplification commands for Workload Orchestration. + +Provides convenience CLI commands that wrap multiple API calls +into single-command operations to reduce onboarding steps. +""" + +from azext_workload_orchestration.onboarding.target_prepare import target_prepare +from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create + +__all__ = ['target_prepare', 'hierarchy_create'] diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py new file mode 100644 index 00000000000..e683127701f --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py @@ -0,0 +1,82 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Constants for onboarding simplification commands.""" + +# pylint: disable=line-too-long + +# --------------------------------------------------------------------------- +# API Versions +# --------------------------------------------------------------------------- +SERVICE_GROUP_API_VERSION = "2024-02-01-preview" +SITE_API_VERSION = "2025-06-01" +CONFIGURATION_API_VERSION = "2025-08-01" +CONFIG_REF_API_VERSION = "2025-08-01" +TARGET_API_VERSION = "2025-08-01" +SG_MEMBER_API_VERSION = "2023-09-01-preview" +CONTEXT_API_VERSION = "2025-08-01" + +# --------------------------------------------------------------------------- +# ARM Endpoints +# --------------------------------------------------------------------------- +ARM_ENDPOINT = "https://management.azure.com" +ARM_RESOURCE = "https://management.azure.com" + +# --------------------------------------------------------------------------- +# Resource Providers +# --------------------------------------------------------------------------- +EDGE_RP_NAMESPACE = "Microsoft.Edge" +SERVICE_GROUP_RP = "Microsoft.Management" +RELATIONSHIPS_RP = "Microsoft.Relationships" + +# --------------------------------------------------------------------------- +# cert-manager Defaults +# --------------------------------------------------------------------------- +DEFAULT_CERT_MANAGER_VERSION = "v1.15.3" +CERT_MANAGER_MANIFEST_URL = ( + "https://github.com/cert-manager/cert-manager/releases/download" + "/{version}/cert-manager.yaml" +) +CERT_MANAGER_NAMESPACE = "cert-manager" +CERT_MANAGER_WEBHOOK_DEPLOYMENT = "cert-manager-webhook" +CERT_MANAGER_MIN_PODS = 3 # webhook, controller, cainjector + +# --------------------------------------------------------------------------- +# trust-manager Defaults +# --------------------------------------------------------------------------- +TRUST_MANAGER_DEPLOYMENT = "trust-manager" +TRUST_MANAGER_HELM_REPO = "https://charts.jetstack.io" +TRUST_MANAGER_HELM_REPO_NAME = "jetstack" +TRUST_MANAGER_HELM_CHART = "jetstack/trust-manager" + +# --------------------------------------------------------------------------- +# WO Extension Defaults +# --------------------------------------------------------------------------- +DEFAULT_EXTENSION_TYPE = "Microsoft.workloadorchestration" +DEFAULT_EXTENSION_NAME = "wo-extension" +DEFAULT_RELEASE_TRAIN = "stable" +DEFAULT_EXTENSION_NAMESPACE = "workloadorchestration" +DEFAULT_EXTENSION_SCOPE = "cluster" + +# --------------------------------------------------------------------------- +# Limits & Timeouts +# --------------------------------------------------------------------------- +MAX_HIERARCHY_NAME_LENGTH = 24 # Configuration resource name limit +LRO_TIMEOUT_SECONDS = 600 # 10 minutes per LRO step +LRO_DEFAULT_POLL_INTERVAL = 15 # seconds, overridden by Retry-After header +CERT_MANAGER_WAIT_TIMEOUT = "300s" + +# --------------------------------------------------------------------------- +# Default Target Specification (helm.v3) +# --------------------------------------------------------------------------- +DEFAULT_TARGET_SPECIFICATION = { + "topologies": [{ + "bindings": [{ + "role": "helm.v3", + "provider": "providers.target.helm", + "config": {"inCluster": "true"} + }] + }] +} diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py new file mode 100644 index 00000000000..89020cae6ce --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -0,0 +1,560 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Hierarchy create command - creates a hierarchy level in a single command. + +Wraps 4-6 operations into one: + 0. Auto-create Context + update hierarchies/capabilities (if needed) + 1. Create Service Group + 2. Create Site (in Service Group) + 3. Create Configuration + 4. Create Configuration Reference (links Config to Site) + 5. Create Site Reference (links Site to Context) + +Usage: + az workload-orchestration hierarchy create \\ + --name my-factory -g my-rg -l eastus --level-label Factory \\ + --parent my-region --capabilities soap shampoo +""" + +# pylint: disable=broad-exception-caught +# pylint: disable=too-many-locals +# pylint: disable=too-many-statements +# pylint: disable=too-many-branches +# pylint: disable=import-outside-toplevel + +import json +import logging +from datetime import datetime, timezone + +from azure.cli.core.azclierror import ( + CLIInternalError, + ValidationError, +) +from azure.cli.core.util import send_raw_request + +from azext_workload_orchestration.onboarding.consts import ( + ARM_ENDPOINT, + SERVICE_GROUP_API_VERSION, + SITE_API_VERSION, + CONFIGURATION_API_VERSION, + CONFIG_REF_API_VERSION, + EDGE_RP_NAMESPACE, + MAX_HIERARCHY_NAME_LENGTH, +) +from azext_workload_orchestration.onboarding.utils import ( + invoke_cli_command, + print_step, + print_success, + print_detail, +) + +logger = logging.getLogger(__name__) + +TOTAL_STEPS_WITH_CONTEXT = 6 +TOTAL_STEPS_NO_CONTEXT = 4 + + +def hierarchy_create( + cmd, + name, + resource_group, + location, + level_label, + parent=None, + capabilities=None, + description=None, + context_name=None, + context_rg=None, + skip_context=False, + skip_site_reference=False, +): + """Create a hierarchy level (ServiceGroup + Site + Config + ConfigRef) in one command. + + Optionally auto-creates a default Context and SiteReference if none exists. + All PUT operations are idempotent (safe to re-run). + """ + # ----------------------------------------------------------------------- + # Pre-flight validation + # ----------------------------------------------------------------------- + if len(name) > MAX_HIERARCHY_NAME_LENGTH: + raise ValidationError( + f"Name '{name}' is {len(name)} characters. " + f"Maximum is {MAX_HIERARCHY_NAME_LENGTH} " + "(limited by the Configuration resource name constraint)." + ) + + description = description or name + sub_id = _get_sub_id(cmd) + tenant_id = _get_tenant_id(cmd) + + total_steps = TOTAL_STEPS_NO_CONTEXT if skip_context else TOTAL_STEPS_WITH_CONTEXT + step = 0 + step_results = {} + + print(f"\nCreating hierarchy level '{name}' ({level_label})...\n") + + # ----------------------------------------------------------------------- + # Step 0: Auto-create / detect Context (if not skipped) + # ----------------------------------------------------------------------- + ctx_name = None + ctx_rg = None + + if not skip_context: + step += 1 + try: + ctx_name, ctx_rg = _ensure_context( + cmd, resource_group, location, context_name, context_rg, + level_label, capabilities, step, total_steps + ) + step_results["context"] = "Succeeded" + except Exception as exc: + step_results["context"] = f"FAILED: {exc}" + _print_hierarchy_diagnostic(step_results, name, resource_group) + raise CLIInternalError( + f"Context setup failed: {exc}", + recommendation=( + "Try creating context manually:\n" + f" az workload-orchestration context create -g {resource_group} " + f"-l {location} --name {resource_group}-context " + "--capabilities [] --hierarchies []" + ) + ) + + # ----------------------------------------------------------------------- + # Step 1: Create Service Group + # ----------------------------------------------------------------------- + step += 1 + try: + parent_id = ( + f"/providers/Microsoft.Management/serviceGroups/{parent}" + if parent + else f"/providers/Microsoft.Management/serviceGroups/{tenant_id}" + ) + sg_id = f"/providers/Microsoft.Management/serviceGroups/{name}" + + _arm_put_quiet(cmd, f"{ARM_ENDPOINT}{sg_id}", { + "properties": { + "displayName": name, + "parent": {"resourceId": parent_id} + } + }, SERVICE_GROUP_API_VERSION) + + print_step(step, total_steps, "Service Group", "[OK] Created") + step_results["service-group"] = "Succeeded" + except Exception as exc: + step_results["service-group"] = f"FAILED: {exc}" + _print_hierarchy_diagnostic(step_results, name, resource_group) + raise CLIInternalError( + f"Service Group creation failed: {exc}", + recommendation=( + f"Try manually: az rest --method put " + f"--url \"{ARM_ENDPOINT}{sg_id}?api-version={SERVICE_GROUP_API_VERSION}\" " + f"--header Content-Type=application/json " + f"--body \"{{\\\"properties\\\":{{\\\"displayName\\\":\\\"{name}\\\"," + f"\\\"parent\\\":{{\\\"resourceId\\\":\\\"{parent_id}\\\"}}}}}}\" " + f"--resource {ARM_ENDPOINT}" + ) + ) + + # ----------------------------------------------------------------------- + # Step 2: Create Site (in Service Group, regional endpoint) + # Retry with backoff - RBAC on new ServiceGroup scope takes time to propagate + # ----------------------------------------------------------------------- + step += 1 + site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + try: + regional_url = f"https://{location}.management.azure.com{site_id}" + + max_retries = 4 + retry_delay = 10 # seconds + last_err = None + for attempt in range(max_retries): + try: + _arm_put_quiet(cmd, regional_url, { + "properties": { + "displayName": name, + "description": description, + "labels": {"level": level_label} + } + }, SITE_API_VERSION) + last_err = None + break + except Exception as exc: + last_err = exc + err_str = str(exc).lower() + is_auth_error = any(x in err_str for x in [ + "authorizationfailed", "forbidden", "403", + "does not have authorization" + ]) + if is_auth_error and attempt < max_retries - 1: + wait = retry_delay * (attempt + 1) + logger.info( + "Site creation got 403 (RBAC propagation). " + "Retry %d/%d in %ds...", attempt + 1, max_retries - 1, wait + ) + print_step(step, total_steps, "Site", + f"Waiting for permissions ({wait}s)...") + import time + time.sleep(wait) + else: + raise + + if last_err: + raise last_err + + print_step(step, total_steps, "Site", "[OK] Created") + step_results["site"] = "Succeeded" + except Exception as exc: + step_results["site"] = f"FAILED: {exc}" + _print_hierarchy_diagnostic(step_results, name, resource_group) + raise CLIInternalError( + f"Site creation failed: {exc}", + recommendation=( + "Check that the region supports the Sites API. " + f"Region used: {location}. " + "Try eastus2euap for canary testing." + ) + ) + + # ----------------------------------------------------------------------- + # Step 3: Create Configuration + # ----------------------------------------------------------------------- + step += 1 + config_id = ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/{EDGE_RP_NAMESPACE}/configurations/{name}" + ) + try: + regional_config_url = f"https://{location}.management.azure.com{config_id}" + + _arm_put_quiet(cmd, regional_config_url, { + "location": location + }, CONFIGURATION_API_VERSION) + + print_step(step, total_steps, "Configuration", "[OK] Created") + step_results["configuration"] = "Succeeded" + except Exception as exc: + step_results["configuration"] = f"FAILED: {exc}" + _print_hierarchy_diagnostic(step_results, name, resource_group) + raise CLIInternalError( + f"Configuration creation failed: {exc}", + recommendation=( + f"Configuration name must be ≤{MAX_HIERARCHY_NAME_LENGTH} chars. " + f"Current name: '{name}' ({len(name)} chars)." + ) + ) + + # ----------------------------------------------------------------------- + # Step 4: Create Configuration Reference (links Config → Site) + # ----------------------------------------------------------------------- + step += 1 + try: + config_ref_url = ( + f"{ARM_ENDPOINT}{site_id}" + f"/providers/{EDGE_RP_NAMESPACE}/configurationreferences/default" + ) + + _arm_put_quiet(cmd, config_ref_url, { + "properties": { + "configurationResourceId": config_id + } + }, CONFIG_REF_API_VERSION) + + print_step(step, total_steps, "Configuration Reference", "[OK] Linked") + step_results["config-reference"] = "Succeeded" + except Exception as exc: + step_results["config-reference"] = f"FAILED: {exc}" + _print_hierarchy_diagnostic(step_results, name, resource_group) + raise CLIInternalError( + f"Configuration Reference creation failed: {exc}", + recommendation="This links the Configuration to the Site. Check ARM access." + ) + + # ----------------------------------------------------------------------- + # Step 5: Create Site Reference (links Site → Context) + # ----------------------------------------------------------------------- + if not skip_context and not skip_site_reference and ctx_name: + step += 1 + try: + invoke_cli_command(cmd, [ + "workload-orchestration", "context", "site-reference", "create", + "-g", ctx_rg or resource_group, + "--context-name", ctx_name, + "--name", f"{name}-ref", + "--site-id", site_id, + ], expect_json=False) + + print_step(step, total_steps, "Site Reference", "[OK] Linked to context") + step_results["site-reference"] = "Succeeded" + except Exception as exc: + # Site reference may already exist (not critical) + if "already exists" in str(exc).lower() or "conflict" in str(exc).lower(): + print_step(step, total_steps, "Site Reference", "Already exists [OK]") + step_results["site-reference"] = "Already exists" + else: + step_results["site-reference"] = f"FAILED: {exc}" + logger.warning("Site reference creation failed (non-critical): %s", exc) + print_step(step, total_steps, "Site Reference", f"[WARN] Warning: {exc}") + + # ----------------------------------------------------------------------- + # Output + # ----------------------------------------------------------------------- + _print_hierarchy_diagnostic(step_results, name, resource_group) + + print_success(f"Hierarchy level '{name}' created") + print_detail("Service Group", sg_id) + print_detail("Site ID", site_id) + print_detail("Configuration ID", config_id) + if ctx_name: + print_detail("Context", ctx_name) + print() + + return { + "name": name, + "levelLabel": level_label, + "serviceGroupId": sg_id, + "siteId": site_id, + "configurationId": config_id, + "contextName": ctx_name, + "contextAutoCreated": ctx_name is not None and not context_name, + } + + +# --------------------------------------------------------------------------- +# Context helpers +# --------------------------------------------------------------------------- + +def _ensure_context( + cmd, resource_group, location, context_name, context_rg, + level_label, capabilities, step, total_steps +): + """Ensure a WO context exists. Auto-create if needed. + + Returns (context_name, context_rg) tuple. + """ + # Check if context is already set in CLI config + try: + current = invoke_cli_command(cmd, [ + "workload-orchestration", "context", "current", + ]) + if current and isinstance(current, dict): + existing_name = current.get("name") or current.get("contextName") + existing_rg = current.get("resourceGroup") + if existing_name: + print_step(step, total_steps, "Context", + f"Using existing '{existing_name}' [OK]") + + # Update context with new hierarchy level and capabilities if needed + _update_context_if_needed( + cmd, existing_name, existing_rg or resource_group, + level_label, capabilities + ) + return existing_name, existing_rg or resource_group + except Exception: + pass # No context set, try to find or create one + + # Try to use explicitly provided context + if context_name: + ctx_rg = context_rg or resource_group + print_step(step, total_steps, "Context", + f"Using specified '{context_name}' [OK]") + _update_context_if_needed(cmd, context_name, ctx_rg, level_label, capabilities) + + # Set as current + try: + invoke_cli_command(cmd, [ + "workload-orchestration", "context", "use", + "--name", context_name, "-g", ctx_rg, + ], expect_json=False) + except Exception: + pass + return context_name, ctx_rg + + # Auto-create default context + default_ctx_name = f"{resource_group}-context" + ctx_rg = context_rg or resource_group + print_step(step, total_steps, "Context", + f"Creating default '{default_ctx_name}'") + + # Build hierarchies and capabilities for context create + hierarchies_args = [ + f"[0].name={level_label.lower()}", + f"[0].description={level_label}", + ] + capabilities_args = [] + if capabilities: + for i, cap in enumerate(capabilities): + capabilities_args.extend([ + f"[{i}].name={cap}", + f"[{i}].description={cap}", + ]) + + create_args = [ + "workload-orchestration", "context", "create", + "-g", ctx_rg, + "-l", location, + "--name", default_ctx_name, + "--hierarchies", + ] + hierarchies_args + + if capabilities_args: + create_args.append("--capabilities") + create_args.extend(capabilities_args) + + invoke_cli_command(cmd, create_args, expect_json=False) + + # Set as current + try: + invoke_cli_command(cmd, [ + "workload-orchestration", "context", "use", + "--name", default_ctx_name, "-g", ctx_rg, + ], expect_json=False) + except Exception: + pass + + print_step(step, total_steps, "Context", + f"Created '{default_ctx_name}' [OK]") + return default_ctx_name, ctx_rg + + +def _update_context_if_needed(cmd, context_name, context_rg, level_label, capabilities): + """Update existing context with new hierarchy level or capabilities if not already present.""" + try: + ctx = invoke_cli_command(cmd, [ + "workload-orchestration", "context", "show", + "-g", context_rg, "--name", context_name, + ]) + if not ctx or not isinstance(ctx, dict): + return + + props = ctx.get("properties", {}) + existing_hierarchies = [ + h.get("name", "").lower() + for h in (props.get("hierarchies") or []) + ] + existing_capabilities = [ + c.get("name", "").lower() + for c in (props.get("capabilities") or []) + ] + + needs_update = False + + # Check if hierarchy level needs adding + if level_label.lower() not in existing_hierarchies: + logger.info("Adding hierarchy level '%s' to context", level_label) + needs_update = True + + # Check if capabilities need adding + if capabilities: + new_caps = [c for c in capabilities if c.lower() not in existing_capabilities] + if new_caps: + logger.info("Adding capabilities %s to context", new_caps) + needs_update = True + + if needs_update: + # Context update with hierarchies/capabilities is complex, + # log the need but don't auto-update to avoid breaking existing config + logger.info( + "Context '%s' may need hierarchy/capability updates. " + "Run: az workload-orchestration context update ...", + context_name + ) + except Exception as exc: + logger.debug("Could not check/update context: %s", exc) + + +# --------------------------------------------------------------------------- +# ARM helper (quiet - no output) +# --------------------------------------------------------------------------- + +def _arm_put_quiet(cmd, url, body, api_version): + """PUT request using send_raw_request with manual token for regional endpoints. + + We manually acquire the token with the correct subscription context and + pass it as an Authorization header. This bypasses send_raw_request's + built-in auth which fails on regional URLs (eastus2euap.management.azure.com) + because it can't match them to a known cloud endpoint. + """ + from azure.cli.core._profile import Profile + + full_url = f"{url}?api-version={api_version}" + body_str = json.dumps(body) if isinstance(body, dict) else body + logger.debug("PUT %s", full_url) + + # Get token manually with correct subscription + profile = Profile(cli_ctx=cmd.cli_ctx) + token_info, _, _ = profile.get_raw_token( + resource="https://management.azure.com", + subscription=profile.get_subscription_id() + ) + token_type, token, _ = token_info + + send_raw_request( + cmd.cli_ctx, + method="PUT", + url=full_url, + body=body_str, + headers=[ + f"Authorization={token_type} {token}", + "Content-Type=application/json", + ], + skip_authorization_header=True, + ) + + +# --------------------------------------------------------------------------- +# Diagnostics +# --------------------------------------------------------------------------- + +def _print_hierarchy_diagnostic(step_results, name, resource_group): + """Print diagnostic summary for hierarchy creation.""" + print("\n" + "=" * 60) + print(" Hierarchy Creation - Diagnostic Summary") + print(f" Name: {name}") + print(f" Resource Group: {resource_group}") + print(f" Timestamp: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}") + print("=" * 60) + + for step_name, result in step_results.items(): + if "FAILED" in result: + icon = "[FAIL]" + elif "Warning" in result: + icon = "[WARN]" + else: + icon = "[OK]" + print(f" {icon} {step_name}: {result}") + + has_failure = any("FAILED" in v for v in step_results.values()) + if has_failure: + print("\n [WARN] One or more steps failed.") + print(" Re-run the command to retry - PUTs are idempotent (safe to re-run).") + print("=" * 60 + "\n") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _get_sub_id(cmd): + """Get subscription ID from CLI context.""" + try: + from azure.cli.core._profile import Profile + profile = Profile(cli_ctx=cmd.cli_ctx) + sub = profile.get_subscription() + return sub.get("id", "") + except Exception: + return "" + + +def _get_tenant_id(cmd): + """Get tenant ID from CLI context.""" + try: + from azure.cli.core._profile import Profile + profile = Profile(cli_ctx=cmd.cli_ctx) + sub = profile.get_subscription() + return sub.get("tenantId", "") + except Exception: + return "" diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py new file mode 100644 index 00000000000..c0329ef3f03 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -0,0 +1,739 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Target prepare command - prepares an Arc-connected K8s cluster for WO. + +Installs cert-manager, trust-manager, WO extension, and creates a custom +location. Idempotent - skips components that already exist. + +Usage: + az workload-orchestration target prepare \\ + --cluster-name my-cluster -g my-rg -l eastus +""" + +# pylint: disable=broad-exception-caught +# pylint: disable=too-many-locals +# pylint: disable=too-many-statements +# pylint: disable=too-many-branches +# pylint: disable=import-outside-toplevel + +import json +import os +import subprocess +import logging + +from azure.cli.core.azclierror import ( + CLIInternalError, + ValidationError, +) + +from azext_workload_orchestration.onboarding.consts import ( + DEFAULT_CERT_MANAGER_VERSION, + CERT_MANAGER_MANIFEST_URL, + CERT_MANAGER_NAMESPACE, + CERT_MANAGER_WEBHOOK_DEPLOYMENT, + CERT_MANAGER_MIN_PODS, + CERT_MANAGER_WAIT_TIMEOUT, + TRUST_MANAGER_DEPLOYMENT, + TRUST_MANAGER_HELM_REPO, + TRUST_MANAGER_HELM_REPO_NAME, + TRUST_MANAGER_HELM_CHART, + DEFAULT_EXTENSION_TYPE, + DEFAULT_EXTENSION_NAME, + DEFAULT_RELEASE_TRAIN, + DEFAULT_EXTENSION_NAMESPACE, + DEFAULT_EXTENSION_SCOPE, +) +from azext_workload_orchestration.onboarding.utils import ( + invoke_cli_command, + print_step, + print_success, + print_detail, +) + +from azure.cli.core.util import send_raw_request + +logger = logging.getLogger(__name__) + +TOTAL_STEPS = 4 + + +def target_prepare( + cmd, + cluster_name, + resource_group, + location, + extension_name=None, + custom_location_name=None, + extension_version=None, + release_train=None, + cert_manager_version=None, + skip_cert_manager=False, + skip_trust_manager=False, + kube_config=None, + kube_context=None, + no_wait=False, +): + """Prepare an Arc-connected K8s cluster for Workload Orchestration. + + Installs cert-manager, trust-manager, WO extension, and creates a custom + location. Skips components that are already installed (idempotent). + """ + extension_name = extension_name or DEFAULT_EXTENSION_NAME + custom_location_name = custom_location_name or f"{cluster_name}-cl" + release_train = release_train or DEFAULT_RELEASE_TRAIN + cert_manager_version = cert_manager_version or DEFAULT_CERT_MANAGER_VERSION + + print(f"\nPreparing cluster '{cluster_name}' for Workload Orchestration...\n") + + # Track step results for diagnostic summary + step_results = {} + + # Pre-flight: verify cluster is Arc-connected and features enabled + try: + connected_cluster_id = _preflight_checks(cmd, cluster_name, resource_group) + step_results["preflight"] = "Passed" + except Exception as exc: + step_results["preflight"] = f"FAILED: {exc}" + _print_diagnostic_summary(step_results, cluster_name, resource_group) + raise + + # Step 1: cert-manager + try: + if skip_cert_manager: + print_step(1, TOTAL_STEPS, "cert-manager", "Skipped (--skip-cert-manager)") + step_results["cert-manager"] = "Skipped" + else: + _ensure_cert_manager(cert_manager_version, kube_config, kube_context) + step_results["cert-manager"] = "Succeeded" + except Exception as exc: + step_results["cert-manager"] = f"FAILED: {exc}" + logger.error("Step 1/4 failed (cert-manager): %s", exc) + _print_diagnostic_summary(step_results, cluster_name, resource_group) + raise CLIInternalError( + f"cert-manager installation failed: {exc}", + recommendation=( + "Check cluster connectivity and kubectl access. " + "Verify the cluster has internet access to github.com. " + "Try manually: kubectl apply -f https://github.com/cert-manager/" + f"cert-manager/releases/download/{cert_manager_version}/cert-manager.yaml" + ) + ) + + # Step 2: trust-manager + try: + if skip_trust_manager: + print_step(2, TOTAL_STEPS, "trust-manager", "Skipped (--skip-trust-manager)") + step_results["trust-manager"] = "Skipped" + else: + _ensure_trust_manager(kube_config, kube_context) + step_results["trust-manager"] = "Succeeded" + except CLIInternalError: + raise # Already has good error message (e.g., helm not installed) + except Exception as exc: + step_results["trust-manager"] = f"FAILED: {exc}" + logger.error("Step 2/4 failed (trust-manager): %s", exc) + _print_diagnostic_summary(step_results, cluster_name, resource_group) + raise CLIInternalError( + f"trust-manager installation failed: {exc}", + recommendation=( + "Ensure helm is installed and the cluster can reach charts.jetstack.io. " + "Try manually: helm upgrade trust-manager jetstack/trust-manager " + "--install --namespace cert-manager --wait" + ) + ) + + # Step 3: WO extension + try: + extension_id = _ensure_wo_extension( + cmd, cluster_name, resource_group, extension_name, + extension_version, release_train, no_wait, + kube_config, kube_context + ) + step_results["wo-extension"] = "Succeeded" + except Exception as exc: + step_results["wo-extension"] = f"FAILED: {exc}" + logger.error("Step 3/4 failed (WO extension): %s", exc) + _print_diagnostic_summary(step_results, cluster_name, resource_group) + raise CLIInternalError( + f"WO extension installation failed: {exc}", + recommendation=( + "Common causes:\n" + " - Wrong release train for this region (try --release-train preview or dev)\n" + " - Insufficient cluster resources (need 2+ CPU cores, 4Gi+ memory)\n" + " - Storage class not available (check: kubectl get sc)\n" + "Try manually: az k8s-extension create -g {rg} --cluster-name {cluster} " + "--cluster-type connectedClusters --name {ext} " + "--extension-type Microsoft.workloadorchestration --scope cluster " + f"--release-train {release_train}" + ).format(rg=resource_group, cluster=cluster_name, ext=extension_name) + ) + + # Step 4: Custom location + try: + cl_id = _ensure_custom_location( + cmd, cluster_name, resource_group, location, + custom_location_name, extension_id, connected_cluster_id + ) + step_results["custom-location"] = "Succeeded" + except Exception as exc: + step_results["custom-location"] = f"FAILED: {exc}" + logger.error("Step 4/4 failed (Custom location): %s", exc) + _print_diagnostic_summary(step_results, cluster_name, resource_group) + raise CLIInternalError( + f"Custom location creation failed: {exc}", + recommendation=( + "Ensure custom-locations feature is enabled:\n" + f" az connectedk8s enable-features -n {cluster_name} " + f"-g {resource_group} --features cluster-connect custom-locations\n" + "Also verify the extension is in 'Succeeded' state:\n" + f" az k8s-extension show -g {resource_group} " + f"--cluster-name {cluster_name} --cluster-type connectedClusters " + f"--name {extension_name}" + ) + ) + + # Output extended-location.json + extended_location = {"name": cl_id, "type": "CustomLocation"} + _write_extended_location_file(extended_location) + + # Print diagnostic summary (all steps succeeded) + _print_diagnostic_summary(step_results, cluster_name, resource_group) + + print_success(f"Cluster '{cluster_name}' is ready for Workload Orchestration") + print_detail("Custom Location ID", cl_id) + print_detail("Extended Location JSON", json.dumps(extended_location)) + print() + print(" Next steps:") + print(" 1. Create hierarchy: az workload-orchestration hierarchy create ...") + print(" 2. Create target: az workload-orchestration target create " + "--extended-location '@extended-location.json' ...") + print() + + return { + "clusterName": cluster_name, + "customLocationId": cl_id, + "extensionId": extension_id, + "extendedLocation": extended_location, + "connectedClusterId": connected_cluster_id, + } + + +# --------------------------------------------------------------------------- +# Pre-flight checks +# --------------------------------------------------------------------------- + +def _preflight_checks(cmd, cluster_name, resource_group): + """Verify cluster is Arc-connected and custom-locations feature enabled.""" + # Check cluster is Arc-connected + try: + cluster_info = invoke_cli_command( + cmd, + ["connectedk8s", "show", "-n", cluster_name, "-g", resource_group] + ) + except CLIInternalError: + raise ValidationError( + f"Cluster '{cluster_name}' is not Arc-connected or not found " + f"in resource group '{resource_group}'.", + recommendation=( + f"Run: az connectedk8s connect -g {resource_group} " + f"-n {cluster_name} -l " + ) + ) + + connected_cluster_id = cluster_info.get("id", "") + if not connected_cluster_id: + raise CLIInternalError( + f"Could not get resource ID for cluster '{cluster_name}'." + ) + + # Check custom-locations feature enabled + features = cluster_info.get("features", {}) + # Different API versions return this differently + cl_enabled = ( + features.get("customLocationsEnabled", False) + or cluster_info.get("properties", {}).get( + "customLocationsEnabled", False + ) + ) + # If we can't determine, proceed anyway - the custom location + # create step will fail with a clear error if not enabled + if cl_enabled is False: + logger.warning( + "custom-locations feature may not be enabled. " + "If custom location creation fails, run: " + "az connectedk8s enable-features -n %s -g %s " + "--features cluster-connect custom-locations", + cluster_name, resource_group + ) + + return connected_cluster_id + + +# --------------------------------------------------------------------------- +# Step 1: cert-manager +# --------------------------------------------------------------------------- + +def _ensure_cert_manager(version, kube_config, kube_context): + """Check if cert-manager is installed; install if missing.""" + try: + from kubernetes import client, config as k8s_config + from kubernetes.client.rest import ApiException + except ImportError: + raise CLIInternalError( + "kubernetes Python package is required.", + recommendation="Run: pip install kubernetes" + ) + + # Load kubeconfig + try: + k8s_config.load_kube_config( + config_file=kube_config, + context=kube_context + ) + except Exception as exc: + raise CLIInternalError( + f"Failed to load kubeconfig: {exc}", + recommendation=( + "Ensure kubectl is configured. " + "Use --kube-config and --kube-context if needed." + ) + ) + + v1 = client.CoreV1Api() + + # Check if cert-manager namespace exists with running pods + try: + v1.read_namespace(CERT_MANAGER_NAMESPACE) + pods = v1.list_namespaced_pod(CERT_MANAGER_NAMESPACE) + running = [ + p for p in pods.items + if p.status and p.status.phase == "Running" + ] + if len(running) >= CERT_MANAGER_MIN_PODS: + print_step( + 1, TOTAL_STEPS, "cert-manager", + f"Already installed [OK] ({len(running)} pods running)" + ) + return + logger.info( + "cert-manager namespace exists but only %d/%d pods running. Reinstalling.", + len(running), CERT_MANAGER_MIN_PODS + ) + except ApiException as exc: + if exc.status != 404: + raise CLIInternalError(f"Failed to check cert-manager: {exc}") + # 404 = namespace doesn't exist, proceed with install + + # Install cert-manager + print_step(1, TOTAL_STEPS, f"cert-manager... Installing {version}") + _run_kubectl([ + "apply", "-f", + CERT_MANAGER_MANIFEST_URL.format(version=version), + "--wait" + ], kube_config, kube_context) + + # Wait for webhook to be ready + _run_kubectl([ + "wait", "--for=condition=Available", + f"deployment/{CERT_MANAGER_WEBHOOK_DEPLOYMENT}", + "-n", CERT_MANAGER_NAMESPACE, + f"--timeout={CERT_MANAGER_WAIT_TIMEOUT}" + ], kube_config, kube_context) + + print_step(1, TOTAL_STEPS, "cert-manager", f"Installed {version} [OK]") + + +# --------------------------------------------------------------------------- +# Step 2: trust-manager +# --------------------------------------------------------------------------- + +def _ensure_trust_manager(kube_config, kube_context): + """Check if trust-manager is installed; install via helm if missing.""" + try: + from kubernetes import client, config as k8s_config + from kubernetes.client.rest import ApiException + except ImportError: + raise CLIInternalError( + "kubernetes Python package is required.", + recommendation="Run: pip install kubernetes" + ) + + # Load kubeconfig (may already be loaded from cert-manager step) + try: + k8s_config.load_kube_config( + config_file=kube_config, + context=kube_context + ) + except Exception: + pass # Already loaded, or will fail below + + apps_v1 = client.AppsV1Api() + + # Check if trust-manager deployment exists + try: + apps_v1.read_namespaced_deployment( + TRUST_MANAGER_DEPLOYMENT, CERT_MANAGER_NAMESPACE + ) + print_step(2, TOTAL_STEPS, "trust-manager", "Already installed [OK]") + return + except ApiException as exc: + if exc.status != 404: + raise CLIInternalError(f"Failed to check trust-manager: {exc}") + # 404 = not found, proceed with install + + # Check if helm is available + if not _is_helm_available(): + raise CLIInternalError( + "helm is required to install trust-manager.", + recommendation=( + "Install helm from https://helm.sh/docs/intro/install/ " + "and try again." + ) + ) + + # Install trust-manager via helm + print_step(2, TOTAL_STEPS, "trust-manager... Installing via helm") + + _run_command([ + "helm", "repo", "add", + TRUST_MANAGER_HELM_REPO_NAME, + TRUST_MANAGER_HELM_REPO, + "--force-update" + ]) + + _run_command([ + "helm", "upgrade", TRUST_MANAGER_DEPLOYMENT, + TRUST_MANAGER_HELM_CHART, + "--install", + "--namespace", CERT_MANAGER_NAMESPACE, + "--wait" + ]) + + print_step(2, TOTAL_STEPS, "trust-manager", "Installed [OK]") + + +# --------------------------------------------------------------------------- +# Step 3: WO extension +# --------------------------------------------------------------------------- + +def _ensure_wo_extension( + cmd, cluster_name, resource_group, extension_name, + extension_version, release_train, no_wait, + kube_config=None, kube_context=None +): + """Check if WO extension is installed; install if missing.""" + # Check existing extensions + try: + extensions = invoke_cli_command( + cmd, + [ + "k8s-extension", "list", + "-g", resource_group, + "--cluster-name", cluster_name, + "--cluster-type", "connectedClusters", + ] + ) + except CLIInternalError: + extensions = [] + + # Find WO extension that is actually working + wo_extensions = [ + ext for ext in (extensions or []) + if (ext.get("extensionType", "") or "").lower() + == DEFAULT_EXTENSION_TYPE.lower() + ] + + if wo_extensions: + ext = wo_extensions[0] + ext_id = ext.get("id", "") + ext_ver = ext.get("version", "unknown") + prov_state = ext.get("provisioningState", "").lower() + + if prov_state == "succeeded": + print_step( + 3, TOTAL_STEPS, "WO extension", + f"Already installed [OK] (version {ext_ver})" + ) + return ext_id + + # Extension exists but is in failed/creating state - delete and reinstall + logger.info( + "WO extension exists but in '%s' state. Deleting and reinstalling...", + prov_state + ) + print_step( + 3, TOTAL_STEPS, + f"WO extension... Found in '{prov_state}' state, reinstalling" + ) + try: + invoke_cli_command(cmd, [ + "k8s-extension", "delete", + "-g", resource_group, + "--cluster-name", cluster_name, + "--cluster-type", "connectedClusters", + "--name", ext.get("name", extension_name), + "--yes", + ], expect_json=False) + import time as _time + _time.sleep(10) # Wait for delete to propagate + except CLIInternalError: + pass # Best effort delete + + # Install extension + version_msg = f" version {extension_version}" if extension_version else "" + print_step( + 3, TOTAL_STEPS, + f"WO extension... Creating '{extension_name}'{version_msg}" + ) + + create_args = [ + "k8s-extension", "create", + "-g", resource_group, + "--cluster-name", cluster_name, + "--cluster-type", "connectedClusters", + "--name", extension_name, + "--extension-type", DEFAULT_EXTENSION_TYPE, + "--scope", DEFAULT_EXTENSION_SCOPE, + "--release-train", release_train, + "--auto-upgrade", "false", + ] + if extension_version: + create_args.extend(["--version", extension_version]) + if no_wait: + create_args.append("--no-wait") + + # Auto-detect storage class and pass as config setting + storage_class = _detect_storage_class(kube_config, kube_context) + if storage_class: + create_args.extend([ + "--configuration-settings", + f"redis.persistentVolume.storageClass={storage_class}", + ]) + + result = invoke_cli_command(cmd, create_args) + ext_id = result.get("id", "") if isinstance(result, dict) else "" + + if no_wait: + print_step(3, TOTAL_STEPS, "WO extension", "Creating (--no-wait) [OK]") + else: + print_step(3, TOTAL_STEPS, "WO extension", "Installed [OK]") + + return ext_id + + +# --------------------------------------------------------------------------- +# Step 4: Custom location +# --------------------------------------------------------------------------- + +def _ensure_custom_location( + cmd, cluster_name, resource_group, location, + custom_location_name, extension_id, connected_cluster_id +): + """Check if custom location exists; create if missing.""" + # Check existing - use REST directly to avoid CLI error output on 404 + sub_id = _get_sub_id(cmd) + cl_arm_url = ( + f"https://management.azure.com/subscriptions" + f"/{sub_id}/resourceGroups/{resource_group}" + f"/providers/Microsoft.ExtendedLocation" + f"/customLocations/{custom_location_name}" + ) + try: + response = send_raw_request( + cmd.cli_ctx, + method="GET", + url=f"{cl_arm_url}?api-version=2021-08-15", + resource="https://management.azure.com" + ) + if response.status_code == 200 and response.text: + cl_info = response.json() + cl_id = cl_info.get("id", "") + if cl_id: + print_step( + 4, TOTAL_STEPS, "Custom location", + f"Already exists [OK] ('{custom_location_name}')" + ) + return cl_id + except Exception: + pass # Not found or error, proceed to create + + if not extension_id: + raise CLIInternalError( + "Cannot create custom location: WO extension ID is not available.", + recommendation=( + "Ensure the WO extension was installed successfully. " + "Re-run without --no-wait." + ) + ) + + print_step( + 4, TOTAL_STEPS, + f"Custom location... Creating '{custom_location_name}'" + ) + + try: + result = invoke_cli_command( + cmd, + [ + "customlocation", "create", + "-g", resource_group, + "-n", custom_location_name, + "--cluster-extension-ids", extension_id, + "--host-resource-id", connected_cluster_id, + "--namespace", DEFAULT_EXTENSION_NAMESPACE, + "--location", location, + ] + ) + cl_id = result.get("id", "") if isinstance(result, dict) else "" + except CLIInternalError as exc: + raise CLIInternalError( + f"Failed to create custom location: {exc}", + recommendation=( + "This can happen if the 'custom-locations' feature is not enabled. " + f"Run: az connectedk8s enable-features -n {cluster_name} " + f"-g {resource_group} --features cluster-connect custom-locations" + ) + ) + + print_step(4, TOTAL_STEPS, "Custom location", "Created [OK]") + return cl_id + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _detect_storage_class(kube_config=None, kube_context=None): + """Auto-detect the default storage class from the cluster.""" + try: + from kubernetes import client, config as k8s_config + k8s_config.load_kube_config( + config_file=kube_config, context=kube_context + ) + storage_v1 = client.StorageV1Api() + scs = storage_v1.list_storage_class() + # Prefer the default storage class + for sc in scs.items: + annotations = sc.metadata.annotations or {} + if annotations.get("storageclass.kubernetes.io/is-default-class") == "true": + logger.info("Auto-detected default storage class: %s", sc.metadata.name) + return sc.metadata.name + # Fallback: first available storage class + if scs.items: + name = scs.items[0].metadata.name + logger.info("No default storage class found, using first: %s", name) + return name + except Exception as exc: + logger.warning("Could not detect storage class: %s", exc) + return None + + +def _print_diagnostic_summary(step_results, cluster_name, resource_group): + """Print a diagnostic summary showing what succeeded/failed. + + This gives the DRI/support engineer a quick picture of where things + went wrong when a customer reports an issue. + """ + from datetime import datetime, timezone + + print("\n" + "=" * 60) + print(" Diagnostic Summary") + print(f" Cluster: {cluster_name}") + print(f" Resource Group: {resource_group}") + print(f" Timestamp: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}") + print("=" * 60) + + for step_name, result in step_results.items(): + if "FAILED" in result: + icon = "[FAIL]" + elif result == "Skipped": + icon = "○" + else: + icon = "[OK]" + print(f" {icon} {step_name}: {result}") + + has_failure = any("FAILED" in v for v in step_results.values()) + if has_failure: + print("\n [WARN] One or more steps failed. See error details above.") + print(" Re-run the command to retry - completed steps will be skipped.") + print("=" * 60 + "\n") + + +def _write_extended_location_file(extended_location): + """Write extended-location.json to the current working directory.""" + filepath = os.path.join(os.getcwd(), "extended-location.json") + with open(filepath, "w", encoding="utf-8") as f: + json.dump(extended_location, f, indent=2) + print(f"\n File written: {filepath}") + + +def _run_kubectl(args, kube_config=None, kube_context=None): + """Run a kubectl command with optional kubeconfig/context.""" + cmd_args = ["kubectl"] + if kube_config: + cmd_args.extend(["--kubeconfig", kube_config]) + if kube_context: + cmd_args.extend(["--context", kube_context]) + cmd_args.extend(args) + + logger.debug("Running: %s", " ".join(cmd_args)) + result = subprocess.run( # pylint: disable=subprocess-run-check + cmd_args, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=600, + ) + if result.returncode != 0: + error_msg = result.stderr.strip() or result.stdout.strip() + raise CLIInternalError( + f"kubectl command failed: {' '.join(args)}\n{error_msg}", + recommendation="Ensure kubectl is installed and cluster is reachable." + ) + return result.stdout + + +def _run_command(cmd_args): + """Run an arbitrary command (e.g., helm).""" + logger.debug("Running: %s", " ".join(cmd_args)) + result = subprocess.run( # pylint: disable=subprocess-run-check + cmd_args, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=600, + ) + if result.returncode != 0: + error_msg = result.stderr.strip() or result.stdout.strip() + raise CLIInternalError( + f"Command failed: {' '.join(cmd_args)}\n{error_msg}" + ) + return result.stdout + + +def _is_helm_available(): + """Check if helm is available in PATH.""" + try: + result = subprocess.run( # pylint: disable=subprocess-run-check + ["helm", "version", "--short"], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except FileNotFoundError: + return False + + +def _get_sub_id(cmd): + """Get subscription ID from CLI context.""" + try: + from azure.cli.core._profile import Profile + profile = Profile(cli_ctx=cmd.cli_ctx) + sub = profile.get_subscription() + return sub.get("id", "") + except Exception: + return "" diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py new file mode 100644 index 00000000000..04f04e322a9 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py @@ -0,0 +1,110 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Service Group link helper — links a target to a service group after creation. + +After creating the ServiceGroupMember relationship, a target update (PUT) is +mandatory to refresh the target's hierarchy info. Without this, the target +appears unlinked in portal. (Confirmed from BVT code LinkServiceGroup().) + +Usage (called internally by target create --service-group): + link_target_to_service_group(cmd, target_id, service_group_name) +""" + +# pylint: disable=broad-exception-caught + +import json +import logging + +from azure.cli.core.azclierror import CLIInternalError + +from azext_workload_orchestration.onboarding.consts import ( + ARM_ENDPOINT, + SG_MEMBER_API_VERSION, + TARGET_API_VERSION, +) +from azext_workload_orchestration.onboarding.utils import ( + invoke_cli_command, +) + +logger = logging.getLogger(__name__) + + +def link_target_to_service_group(cmd, target_id, service_group_name): + """Link a target to a service group and refresh hierarchy. + + Two REST calls: + 1. PUT {targetId}/providers/Microsoft.Relationships/serviceGroupMember/{sgName} + 2. PUT {targetId} (update target to refresh hierarchy — MANDATORY) + """ + sg_member_url = ( + f"{ARM_ENDPOINT}{target_id}" + f"/providers/Microsoft.Relationships/serviceGroupMember/{service_group_name}" + ) + + # Step 1: Create ServiceGroupMember relationship + try: + invoke_cli_command(cmd, [ + "rest", + "--method", "put", + "--url", f"{sg_member_url}?api-version={SG_MEMBER_API_VERSION}", + "--body", json.dumps({ + "properties": { + "targetId": f"/providers/Microsoft.Management/serviceGroups/{service_group_name}" + } + }), + "--resource", ARM_ENDPOINT, + "--header", "Content-Type=application/json", + ], expect_json=False) + logger.info("ServiceGroupMember created: %s -> %s", target_id, service_group_name) + except Exception as exc: + raise CLIInternalError( + f"Failed to link target to service group '{service_group_name}': {exc}", + recommendation=( + f"Try manually:\n" + f" az rest --method put " + f"--url \"{sg_member_url}?api-version={SG_MEMBER_API_VERSION}\" " + f"--body \"{{\\\"properties\\\":{{\\\"targetId\\\":\\\"" + f"/providers/Microsoft.Management/serviceGroups/{service_group_name}" + f"\\\"}}}}\" " + f"--resource {ARM_ENDPOINT} --header Content-Type=application/json" + ) + ) + + # Step 2: Update target to refresh hierarchy (MANDATORY) + try: + # GET current target + target_data = invoke_cli_command(cmd, [ + "rest", + "--method", "get", + "--url", f"{ARM_ENDPOINT}{target_id}?api-version={TARGET_API_VERSION}", + "--resource", ARM_ENDPOINT, + ]) + + # PUT target (update to refresh hierarchy) + if target_data and isinstance(target_data, dict): + # Strip read-only fields + body = { + "location": target_data.get("location", ""), + "properties": target_data.get("properties", {}), + } + if "extendedLocation" in target_data: + body["extendedLocation"] = target_data["extendedLocation"] + + invoke_cli_command(cmd, [ + "rest", + "--method", "put", + "--url", f"{ARM_ENDPOINT}{target_id}?api-version={TARGET_API_VERSION}", + "--body", json.dumps(body), + "--resource", ARM_ENDPOINT, + "--header", "Content-Type=application/json", + ], expect_json=False) + logger.info("Target hierarchy refreshed after SG link") + + except Exception as exc: + logger.warning( + "Target hierarchy refresh after SG link may have failed: %s. " + "Target may appear unlinked until next update.", exc + ) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py new file mode 100644 index 00000000000..06094253cd7 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py @@ -0,0 +1,120 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Shared utilities for onboarding simplification commands. + +Provides REST wrappers (using send_raw_request for automatic auth/retry/throttle), +LRO polling with Retry-After support, CLI command invocation, and progress output. +""" + +# pylint: disable=broad-exception-caught + +import json +import logging + +from azure.cli.core.azclierror import CLIInternalError + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# CmdProxy - bridge between AAZ hooks and helpers expecting cmd.cli_ctx +# --------------------------------------------------------------------------- + +class CmdProxy: + """Lightweight proxy to pass CLI context where a full cmd object is expected. + + AAZ-generated commands don't expose a cmd object in hooks, but many + helper functions expect cmd.cli_ctx. This proxy bridges the gap. + """ + def __init__(self, cli_ctx): + self.cli_ctx = cli_ctx + + +# --------------------------------------------------------------------------- +# CLI command invocation +# --------------------------------------------------------------------------- + +def invoke_cli_command(cmd, command_args, expect_json=True): + """Invoke another az CLI command in-process (shares auth context). + + Uses get_default_cli().invoke() so the child command shares + the same auth session, telemetry, and CLI context. + + Returns parsed JSON result if expect_json=True, raw result otherwise. + Raises CLIInternalError on non-zero exit. + """ + from azure.cli.core import get_default_cli + import io + import sys + + cli = get_default_cli() + if expect_json and "-o" not in command_args and "--output" not in command_args: + command_args = list(command_args) + ["-o", "json"] + + logger.debug("Invoking: az %s", " ".join(command_args)) + + # Suppress stdout/stderr from child command to avoid raw JSON noise + old_stdout = sys.stdout + old_stderr = sys.stderr + captured_out = io.StringIO() + captured_err = io.StringIO() + sys.stdout = captured_out + sys.stderr = captured_err + try: + exit_code = cli.invoke(command_args, out_file=captured_out) + except TypeError: + # Older CLI versions may not support out_file + exit_code = cli.invoke(command_args) + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + + if exit_code != 0: + err_text = captured_err.getvalue().strip() + # cli.result may contain the error object from the CLI framework + cli_error = "" + if hasattr(cli, 'result') and hasattr(cli.result, 'error'): + cli_error = str(cli.result.error) if cli.result.error else "" + full_error = cli_error or err_text or f"exit code {exit_code}" + cmd_str = f"az {' '.join(command_args)}" + raise CLIInternalError(f"{full_error}\nCommand: {cmd_str}") + + result = cli.result.result + if expect_json and isinstance(result, str): + try: + return json.loads(result) + except (json.JSONDecodeError, TypeError): + pass + return result + + +# --------------------------------------------------------------------------- +# Progress output +# --------------------------------------------------------------------------- + +def print_step(step_num, total, message, status=""): + """Print a formatted step indicator. + + Examples: + [1/4] Installing cert-manager... + [1/4] Installing cert-manager... [OK] + [1/4] Installing cert-manager... Already installed [OK] + """ + prefix = f"[{step_num}/{total}]" + if status: + print(f"{prefix} {message}... {status}") + else: + print(f"{prefix} {message}...") + + +def print_success(message): + """Print a success summary line.""" + print(f"\n[OK] {message}") + + +def print_detail(label, value): + """Print a detail line (indented).""" + print(f" {label}: {value}") diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/__init__.py new file mode 100644 index 00000000000..a9989856d22 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/__init__.py @@ -0,0 +1,10 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Unit tests for onboarding simplification commands. + +All tests use mocking — no live Azure/K8s calls. +Run: python -m pytest azext_workload_orchestration/tests/test_onboarding/ -v +""" diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_hierarchy_create.py new file mode 100644 index 00000000000..52369e3db66 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_hierarchy_create.py @@ -0,0 +1,182 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Unit tests for hierarchy create command.""" + +import unittest +from unittest.mock import patch, MagicMock + +from azure.cli.core.azclierror import ValidationError + + +class TestHierarchyCreateValidation(unittest.TestCase): + """Test input validation for hierarchy create.""" + + def _get_mock_cmd(self): + cmd = MagicMock() + cmd.cli_ctx = MagicMock() + return cmd + + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', + return_value='test-sub') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', + return_value='test-tenant') + def test_name_too_long_raises_error(self, _, __): + from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create + cmd = self._get_mock_cmd() + + with self.assertRaises(ValidationError) as ctx: + hierarchy_create( + cmd, name='this-name-is-way-too-long-for-config', # 36 chars + resource_group='rg1', location='eastus', + level_label='Region', skip_context=True, + ) + + self.assertIn('24', str(ctx.exception)) + self.assertIn('36', str(ctx.exception)) + + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', + return_value='test-sub') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', + return_value='test-tenant') + def test_name_exactly_24_passes(self, _, __): + """24-char name should not raise validation error (may fail at API call).""" + from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create + cmd = self._get_mock_cmd() + + # This will pass validation but fail at API call (which we mock) + with patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet'): + with patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command'): + try: + hierarchy_create( + cmd, name='exactly-twenty-four-ch', # 24 chars + resource_group='rg1', location='eastus', + level_label='Region', skip_context=True, + ) + except Exception: + pass # May fail at later steps, that's fine + + +class TestHierarchyCreateFlow(unittest.TestCase): + """Test the SG → Site → Config → ConfigRef flow.""" + + def _get_mock_cmd(self): + cmd = MagicMock() + cmd.cli_ctx = MagicMock() + return cmd + + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', + return_value='test-sub') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', + return_value='test-tenant') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') + @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') + def test_happy_path_skip_context(self, mock_invoke, mock_put, _, __): + from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create + cmd = self._get_mock_cmd() + + result = hierarchy_create( + cmd, name='my-factory', resource_group='rg1', location='eastus', + level_label='Factory', skip_context=True, + ) + + self.assertEqual(result['name'], 'my-factory') + self.assertEqual(result['levelLabel'], 'Factory') + self.assertIn('serviceGroupId', result) + self.assertIn('siteId', result) + self.assertIn('configurationId', result) + # 4 PUT calls: SG, Site, Config, ConfigRef + self.assertEqual(mock_put.call_count, 4) + + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', + return_value='test-sub') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', + return_value='test-tenant') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') + @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') + def test_parent_sets_correct_parent_id(self, mock_invoke, mock_put, _, __): + from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create + cmd = self._get_mock_cmd() + + result = hierarchy_create( + cmd, name='my-factory', resource_group='rg1', location='eastus', + level_label='Factory', parent='my-region', skip_context=True, + ) + + # SG PUT should have parent = /providers/Microsoft.Management/serviceGroups/my-region + sg_call = mock_put.call_args_list[0] + sg_body = sg_call[0][2] # positional: cmd, url, body, api_version + self.assertEqual( + sg_body['properties']['parent']['resourceId'], + '/providers/Microsoft.Management/serviceGroups/my-region' + ) + + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', + return_value='test-sub') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', + return_value='test-tenant') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') + @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') + def test_no_parent_uses_tenant_root(self, mock_invoke, mock_put, _, __): + from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create + cmd = self._get_mock_cmd() + + result = hierarchy_create( + cmd, name='my-region', resource_group='rg1', location='eastus', + level_label='Region', skip_context=True, + ) + + sg_call = mock_put.call_args_list[0] + sg_body = sg_call[0][2] + self.assertEqual( + sg_body['properties']['parent']['resourceId'], + '/providers/Microsoft.Management/serviceGroups/test-tenant' + ) + + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', + return_value='test-sub') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', + return_value='test-tenant') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') + @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') + def test_with_context_auto_creation(self, mock_invoke, mock_put, _, __): + from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create + cmd = self._get_mock_cmd() + + # context current returns existing context + mock_invoke.return_value = {"name": "existing-ctx", "resourceGroup": "ctx-rg"} + + result = hierarchy_create( + cmd, name='my-region', resource_group='rg1', location='eastus', + level_label='Region', + ) + + self.assertEqual(result['contextName'], 'existing-ctx') + # contextAutoCreated is True when context was found (not explicitly provided) + self.assertTrue(result['contextAutoCreated']) + + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', + return_value='test-sub') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', + return_value='test-tenant') + @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') + @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') + def test_site_url_uses_regional_endpoint(self, mock_invoke, mock_put, _, __): + from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create + cmd = self._get_mock_cmd() + + hierarchy_create( + cmd, name='my-region', resource_group='rg1', location='westeurope', + level_label='Region', skip_context=True, + ) + + # Site PUT (2nd call) should use regional URL + site_call = mock_put.call_args_list[1] + site_url = site_call[0][1] # positional: cmd, url, body, api + self.assertIn('westeurope.management.azure.com', site_url) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py new file mode 100644 index 00000000000..003a98c26e2 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py @@ -0,0 +1,95 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Unit tests for service group link helper.""" + +import json +import unittest +from unittest.mock import patch, MagicMock, call + +from azure.cli.core.azclierror import CLIInternalError + + +class TestServiceGroupLink(unittest.TestCase): + """Test target-to-service-group linking.""" + + def _get_mock_cmd(self): + cmd = MagicMock() + cmd.cli_ctx = MagicMock() + return cmd + + @patch('azext_workload_orchestration.onboarding.target_sg_link.invoke_cli_command') + def test_link_creates_member_and_refreshes_target(self, mock_invoke): + from azext_workload_orchestration.onboarding.target_sg_link import ( + link_target_to_service_group + ) + cmd = self._get_mock_cmd() + + # GET target returns existing data + mock_invoke.side_effect = [ + None, # SGMember PUT + { # GET target + "location": "eastus", + "properties": {"displayName": "t1"}, + "extendedLocation": {"name": "cl1", "type": "CustomLocation"}, + }, + None, # PUT target (refresh) + ] + + link_target_to_service_group(cmd, '/sub/rg/targets/t1', 'my-factory') + + # Should have 3 calls: SGMember PUT, GET target, PUT target + self.assertEqual(mock_invoke.call_count, 3) + + # Verify SGMember PUT URL contains service group name + sg_call_args = mock_invoke.call_args_list[0][0][1] + self.assertTrue(any('serviceGroupMember/my-factory' in a for a in sg_call_args)) + + @patch('azext_workload_orchestration.onboarding.target_sg_link.invoke_cli_command') + def test_link_failure_raises_cli_error(self, mock_invoke): + from azext_workload_orchestration.onboarding.target_sg_link import ( + link_target_to_service_group + ) + cmd = self._get_mock_cmd() + + mock_invoke.side_effect = CLIInternalError("SG not found") + + with self.assertRaises(CLIInternalError) as ctx: + link_target_to_service_group(cmd, '/sub/rg/targets/t1', 'bad-sg') + + self.assertIn('bad-sg', str(ctx.exception)) + + +class TestUtils(unittest.TestCase): + """Test shared utilities.""" + + def test_print_step_with_status(self): + from azext_workload_orchestration.onboarding.utils import print_step + # Should not raise + print_step(1, 4, "Installing cert-manager", "✓") + + def test_print_step_without_status(self): + from azext_workload_orchestration.onboarding.utils import print_step + print_step(2, 4, "Installing trust-manager") + + def test_print_success(self): + from azext_workload_orchestration.onboarding.utils import print_success + print_success("All done") + + def test_consts_values(self): + from azext_workload_orchestration.onboarding.consts import ( + MAX_HIERARCHY_NAME_LENGTH, + LRO_TIMEOUT_SECONDS, + DEFAULT_CERT_MANAGER_VERSION, + DEFAULT_EXTENSION_TYPE, + ) + self.assertEqual(MAX_HIERARCHY_NAME_LENGTH, 24) + self.assertEqual(LRO_TIMEOUT_SECONDS, 600) + self.assertEqual(DEFAULT_CERT_MANAGER_VERSION, 'v1.15.3') + self.assertEqual(DEFAULT_EXTENSION_TYPE, 'Microsoft.workloadorchestration') + + +if __name__ == '__main__': + unittest.main() diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py new file mode 100644 index 00000000000..208c4e51959 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py @@ -0,0 +1,152 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Unit tests for target prepare command.""" + +import unittest +from unittest.mock import patch, MagicMock, PropertyMock + +from azure.cli.core.azclierror import CLIInternalError, ValidationError + + +class TestTargetPreparePreFlight(unittest.TestCase): + """Test pre-flight checks for target prepare.""" + + def _get_mock_cmd(self): + cmd = MagicMock() + cmd.cli_ctx = MagicMock() + return cmd + + @patch('azext_workload_orchestration.onboarding.target_prepare.invoke_cli_command') + def test_cluster_not_arc_connected_raises_error(self, mock_invoke): + from azext_workload_orchestration.onboarding.target_prepare import _preflight_checks + cmd = self._get_mock_cmd() + + mock_invoke.side_effect = CLIInternalError("Not found") + + with self.assertRaises(ValidationError) as ctx: + _preflight_checks(cmd, 'my-cluster', 'my-rg') + + self.assertIn('not Arc-connected', str(ctx.exception)) + + @patch('azext_workload_orchestration.onboarding.target_prepare.invoke_cli_command') + def test_arc_connected_returns_cluster_id(self, mock_invoke): + from azext_workload_orchestration.onboarding.target_prepare import _preflight_checks + cmd = self._get_mock_cmd() + + mock_invoke.return_value = { + "id": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Kubernetes/connectedClusters/my-cluster", + "name": "my-cluster", + } + + result = _preflight_checks(cmd, 'my-cluster', 'my-rg') + self.assertIn('connectedClusters/my-cluster', result) + + +class TestTargetPrepareCertManager(unittest.TestCase): + """Test cert-manager detection.""" + + def test_cert_manager_function_exists(self): + """Verify _ensure_cert_manager function is importable.""" + from azext_workload_orchestration.onboarding.target_prepare import _ensure_cert_manager + self.assertTrue(callable(_ensure_cert_manager)) + + +class TestTargetPrepareHelm(unittest.TestCase): + """Test helm detection.""" + + @patch('subprocess.run') + def test_helm_available(self, mock_run): + from azext_workload_orchestration.onboarding.target_prepare import _is_helm_available + mock_run.return_value = MagicMock(returncode=0) + self.assertTrue(_is_helm_available()) + + @patch('subprocess.run') + def test_helm_not_available(self, mock_run): + from azext_workload_orchestration.onboarding.target_prepare import _is_helm_available + mock_run.side_effect = FileNotFoundError() + self.assertFalse(_is_helm_available()) + + +class TestTargetPrepareExtension(unittest.TestCase): + """Test WO extension detection and install.""" + + def _get_mock_cmd(self): + cmd = MagicMock() + cmd.cli_ctx = MagicMock() + return cmd + + @patch('azext_workload_orchestration.onboarding.target_prepare.invoke_cli_command') + @patch('azext_workload_orchestration.onboarding.target_prepare._detect_storage_class', + return_value='default') + def test_extension_already_installed_succeeds_skips(self, _, mock_invoke): + from azext_workload_orchestration.onboarding.target_prepare import _ensure_wo_extension + cmd = self._get_mock_cmd() + + mock_invoke.return_value = [ + { + "extensionType": "microsoft.workloadorchestration", + "id": "/sub/rg/ext/wo-ext", + "version": "2.1.11", + "provisioningState": "Succeeded", + } + ] + + result = _ensure_wo_extension( + cmd, 'cluster1', 'rg1', 'wo-ext', None, 'preview', False + ) + + self.assertEqual(result, '/sub/rg/ext/wo-ext') + # Only list was called, not create + mock_invoke.assert_called_once() + + @patch('azext_workload_orchestration.onboarding.target_prepare.invoke_cli_command') + @patch('azext_workload_orchestration.onboarding.target_prepare._detect_storage_class', + return_value='default') + def test_failed_extension_gets_deleted_and_reinstalled(self, _, mock_invoke): + from azext_workload_orchestration.onboarding.target_prepare import _ensure_wo_extension + cmd = self._get_mock_cmd() + + call_count = [0] + def side_effect(*args, **kwargs): + call_count[0] += 1 + if call_count[0] == 1: + # First call: list returns failed extension + return [{ + "extensionType": "microsoft.workloadorchestration", + "id": "/sub/rg/ext/wo-ext", + "name": "wo-ext", + "version": "2.1.11", + "provisioningState": "Failed", + }] + elif call_count[0] == 2: + # Second call: delete + return None + else: + # Third call: create + return {"id": "/sub/rg/ext/wo-ext-new"} + + mock_invoke.side_effect = side_effect + + result = _ensure_wo_extension( + cmd, 'cluster1', 'rg1', 'wo-ext', None, 'preview', False + ) + + # Should have called: list, delete, create + self.assertEqual(mock_invoke.call_count, 3) + + +class TestTargetPrepareStorageClass(unittest.TestCase): + """Test storage class auto-detection.""" + + def test_detect_returns_none_without_cluster(self): + """Without a real cluster, should return None gracefully.""" + from azext_workload_orchestration.onboarding.target_prepare import _detect_storage_class + result = _detect_storage_class("/nonexistent/kubeconfig", "bad-context") + self.assertIsNone(result) + + +if __name__ == '__main__': + unittest.main() From 5d9fb5a35cfdd029e7b494aa3602a860fa495fdf Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 11:13:31 +0530 Subject: [PATCH 02/91] refactor: extract onboarding handlers into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract _handle_init_context (150 lines) → context_init.py - Extract _handle_init_hierarchy (100 lines) → hierarchy_init.py - Add parse_arm_id() to utils.py (replaces 6 inline ARM ID parsers) - Add invoke_silent() to utils.py (replaces 4 stdout suppression blocks) - Use DEFAULT_TARGET_SPECIFICATION from consts.py - _create.py custom code: 400 lines → 60 lines (orchestration only) - Each onboarding module now has a single clear responsibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_create.py | 414 +----------------- .../onboarding/context_init.py | 283 ++++++++++++ .../onboarding/hierarchy_init.py | 153 +++++++ .../onboarding/utils.py | 50 +++ 4 files changed, 508 insertions(+), 392 deletions(-) create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/context_init.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index 541c359d029..b36e8334718 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -15,6 +15,7 @@ logger = logging.getLogger(__name__) + @register_command( "workload-orchestration target create", ) @@ -233,49 +234,27 @@ def pre_operations(self): "or set a default context using 'az workload-orchestration context use'." ) - # V2: Default target specification (helm.v3) if not provided + # Default target specification (helm.v3) if not provided if not self.ctx.args.target_specification: - self.ctx.args.target_specification = { - "topologies": [{ - "bindings": [{ - "role": "helm.v3", - "provider": "providers.target.helm", - "config": {"inCluster": "true"} - }] - }] - } + from azext_workload_orchestration.onboarding.consts import DEFAULT_TARGET_SPECIFICATION + self.ctx.args.target_specification = DEFAULT_TARGET_SPECIFICATION def _handle_init_extended_location(self): """Auto-prepare cluster (cert-mgr, trust-mgr, extension, custom location).""" from azext_workload_orchestration.onboarding.target_prepare import target_prepare - from azext_workload_orchestration.onboarding.utils import CmdProxy + from azext_workload_orchestration.onboarding.utils import CmdProxy, parse_arm_id cluster_arm_id = str(self.ctx.args.init_extended_location) location = str(self.ctx.args.location) + parts = parse_arm_id(cluster_arm_id) - # Parse cluster name and RG from ARM ID - # Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Kubernetes/connectedClusters/{name} - parts = cluster_arm_id.strip("/").split("/") - if len(parts) < 8: - raise CLIInternalError( - f"Invalid connected cluster ARM ID: {cluster_arm_id}\n" - "Expected format: /subscriptions/{{sub}}/resourceGroups/{{rg}}" - "/providers/Microsoft.Kubernetes/connectedClusters/{{name}}" - ) - - # Extract RG and cluster name from ARM ID - cluster_rg = None - cluster_name = None - for i, part in enumerate(parts): - if part.lower() == "resourcegroups" and i + 1 < len(parts): - cluster_rg = parts[i + 1] - if part.lower() == "connectedclusters" and i + 1 < len(parts): - cluster_name = parts[i + 1] + cluster_rg = parts.get("resourcegroups") + cluster_name = parts.get("connectedclusters") if not cluster_rg or not cluster_name: raise CLIInternalError( - f"Could not extract cluster name/RG from: {cluster_arm_id}\n" - "Expected format: /subscriptions/{{sub}}/resourceGroups/{{rg}}" + f"Invalid connected cluster ARM ID: {cluster_arm_id}\n" + "Expected: /subscriptions/{{sub}}/resourceGroups/{{rg}}" "/providers/Microsoft.Kubernetes/connectedClusters/{{name}}" ) @@ -285,24 +264,17 @@ def _handle_init_extended_location(self): print(f"\n[init-extended-location] Preparing cluster '{cluster_name}' in RG '{cluster_rg}'...") - # Create a cmd proxy for target_prepare - cmd_proxy = CmdProxy(self.ctx.cli_ctx) - result = target_prepare( - cmd=cmd_proxy, + cmd=CmdProxy(self.ctx.cli_ctx), cluster_name=cluster_name, resource_group=cluster_rg, location=location, release_train=release_train, ) - # Set extended_location from the result cl_id = result.get("customLocationId", "") if cl_id: - self.ctx.args.extended_location = { - "name": cl_id, - "type": "CustomLocation" - } + self.ctx.args.extended_location = {"name": cl_id, "type": "CustomLocation"} print(f"[init-extended-location] Cluster prepared, CL: {cl_id} [OK]\n") else: raise CLIInternalError( @@ -310,12 +282,8 @@ def _handle_init_extended_location(self): ) def _handle_init_context(self): - """Auto-create context if none exists, inject hierarchy+capabilities.""" - from azure.cli.core import get_default_cli - from azext_workload_orchestration.onboarding.utils import invoke_cli_command, CmdProxy - import io - import sys - import json + """Auto-create or find a context, inject hierarchy + capabilities.""" + from azext_workload_orchestration.onboarding.context_init import handle_init_context ctx_name = str(self.ctx.args.init_context) rg = str(self.ctx.args.resource_group) @@ -323,362 +291,24 @@ def _handle_init_context(self): hierarchy_level = str(self.ctx.args.hierarchy_level) if self.ctx.args.hierarchy_level else "line" capabilities = [str(c) for c in self.ctx.args.capabilities] if self.ctx.args.capabilities else [] - # Create a cmd proxy for invoke_cli_command - cmd_proxy = CmdProxy(self.ctx.cli_ctx) - cli = get_default_cli() - - # Check if context already exists via config - try: - existing_ctx_id = self.ctx.cli_ctx.config.get('workload_orchestration', 'context_id') - if existing_ctx_id: - logger.info("Context already set: %s", existing_ctx_id) - self.ctx.args.context_id = existing_ctx_id - # Still need to ensure capabilities are present - self._ensure_context_capabilities(cmd_proxy, cli, existing_ctx_id, hierarchy_level, capabilities) - print(f"[init-context] Using existing context [OK]") - return - except (configparser.NoSectionError, configparser.NoOptionError): - pass - - # First, try to find existing context in this RG - try: - existing = invoke_cli_command(cmd_proxy, [ - "workload-orchestration", "context", "list", "-g", rg - ]) - if existing and isinstance(existing, list) and len(existing) > 0: - ctx = existing[0] - ctx_id = ctx.get("id", "") - if ctx_id: - self.ctx.args.context_id = ctx_id - ctx_parts = ctx_id.split("/") - found_name = ctx_parts[-1] if ctx_parts else ctx_name - self._set_context_current(cli, found_name, rg) - self._ensure_context_capabilities(cmd_proxy, cli, ctx_id, hierarchy_level, capabilities) - print(f"[init-context] Using existing context '{found_name}' [OK]") - return - except Exception: - pass # No contexts in this RG, proceed to create - - # Build capabilities args - cap_args = [] - for i, cap in enumerate(capabilities): - cap_args.extend([f"[{i}].name={cap}", f"[{i}].description={cap}"]) - - # Build hierarchies args - hier_args = [f"[0].name={hierarchy_level}", f"[0].description={hierarchy_level}"] - - print(f"[init-context] Creating context '{ctx_name}'...") - - # Try to create the context - create_args = [ - "workload-orchestration", "context", "create", - "-g", rg, "-l", location, "--name", ctx_name, - "--hierarchies", - ] + hier_args - - if cap_args: - create_args.append("--capabilities") - create_args.extend(cap_args) - create_args.extend(["-o", "none"]) - - old_stdout, old_stderr = sys.stdout, sys.stderr - sys.stdout = io.StringIO() - sys.stderr = io.StringIO() - try: - exit_code = cli.invoke(create_args) - finally: - sys.stdout, sys.stderr = old_stdout, old_stderr - - if exit_code == 0: - # Created successfully — set as current - self._set_context_current(cli, ctx_name, rg) - - try: - ctx_id = self.ctx.cli_ctx.config.get('workload_orchestration', 'context_id') - if ctx_id: - self.ctx.args.context_id = ctx_id - print(f"[init-context] Context '{ctx_name}' created [OK]") - return - except (configparser.NoSectionError, configparser.NoOptionError): - pass - - sub_id = self.ctx.subscription_id - ctx_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/contexts/{ctx_name}" - self.ctx.args.context_id = ctx_id - print(f"[init-context] Context '{ctx_name}' created [OK]") - return - - # Context create failed — likely "already exists" in another RG - logger.warning("Context create failed (exit %d). Searching for existing context...", exit_code) - - # Search subscription-wide for existing contexts - from azure.cli.core.util import send_raw_request - sub_id = self.ctx.subscription_id - try: - resp = send_raw_request( - self.ctx.cli_ctx, - method="GET", - url=( - f"https://management.azure.com/subscriptions/{sub_id}" - f"/providers/Microsoft.Edge/contexts?api-version=2025-08-01" - ), - resource="https://management.azure.com" - ) - if resp.status_code == 200: - data = resp.json() - contexts = data.get("value", []) - if contexts: - existing_ctx = contexts[0] - existing_id = existing_ctx.get("id", "") - if existing_id: - self.ctx.args.context_id = existing_id - parts = existing_id.split("/") - found_rg = None - found_name = None - for i, p in enumerate(parts): - if p.lower() == "resourcegroups" and i + 1 < len(parts): - found_rg = parts[i + 1] - if p.lower() == "contexts" and i + 1 < len(parts): - found_name = parts[i + 1] - - if found_rg and found_name: - self._set_context_current(cli, found_name, found_rg) - self._ensure_context_capabilities( - cmd_proxy, cli, existing_id, hierarchy_level, capabilities - ) - - print(f"[init-context] Using existing context '{found_name}' in RG '{found_rg}' [OK]") - return - except Exception as exc: - logger.warning("Failed to search for existing context: %s", exc) - - raise CLIInternalError( - "Could not create or find an existing context. " - "Please provide --context-id explicitly." + ctx_id = handle_init_context( + self.ctx.cli_ctx, ctx_name, rg, location, hierarchy_level, capabilities ) - - def _set_context_current(self, cli, ctx_name, ctx_rg): - """Set a context as the current default (silently).""" - import io - import sys - old_stdout, old_stderr = sys.stdout, sys.stderr - sys.stdout = io.StringIO() - sys.stderr = io.StringIO() - try: - cli.invoke(["workload-orchestration", "context", "use", - "--name", ctx_name, "-g", ctx_rg, "-o", "none"]) - finally: - sys.stdout, sys.stderr = old_stdout, old_stderr - - def _ensure_context_capabilities(self, cmd_proxy, cli, ctx_id, hierarchy_level, capabilities): - """Ensure the context has the required hierarchy level and capabilities.""" - import io - import sys - import json - from azext_workload_orchestration.onboarding.utils import invoke_cli_command - - if not capabilities: - return - - # Get existing context details - parts = ctx_id.split("/") - ctx_rg = None - ctx_name = None - for i, p in enumerate(parts): - if p.lower() == "resourcegroups" and i + 1 < len(parts): - ctx_rg = parts[i + 1] - if p.lower() == "contexts" and i + 1 < len(parts): - ctx_name = parts[i + 1] - - if not ctx_rg or not ctx_name: - return - - try: - ctx_data = invoke_cli_command(cmd_proxy, [ - "workload-orchestration", "context", "show", - "-g", ctx_rg, "--name", ctx_name - ]) - except Exception: - return - - if not ctx_data or not isinstance(ctx_data, dict): - return - - props = ctx_data.get("properties", {}) - existing_caps = {c.get("name", "") for c in (props.get("capabilities") or [])} - existing_hiers = {h.get("name", "") for h in (props.get("hierarchies") or [])} - - # Check if we need to add capabilities or hierarchies - missing_caps = [c for c in capabilities if c not in existing_caps] - missing_hier = hierarchy_level not in existing_hiers - - if not missing_caps and not missing_hier: - return # All present - - # Build update — merge existing + new - all_caps = list(props.get("capabilities") or []) - for cap in missing_caps: - all_caps.append({"name": cap, "description": cap}) - - all_hiers = list(props.get("hierarchies") or []) - if missing_hier: - all_hiers.append({"name": hierarchy_level, "description": hierarchy_level}) - - # Build update args - cap_args = [] - for i, cap in enumerate(all_caps): - cap_args.extend([f"[{i}].name={cap.get('name', '')}", f"[{i}].description={cap.get('description', '')}"]) - - hier_args = [] - for i, h in enumerate(all_hiers): - hier_args.extend([f"[{i}].name={h.get('name', '')}", f"[{i}].description={h.get('description', '')}"]) - - print(f"[init-context] Adding capabilities {missing_caps} to context...") - - # Use REST PUT to update context (no 'context update' CLI command) - from azure.cli.core.util import send_raw_request - parts2 = ctx_id.split("/") - sub_id = None - for i2, p2 in enumerate(parts2): - if p2.lower() == "subscriptions" and i2 + 1 < len(parts2): - sub_id = parts2[i2 + 1] - break - if not sub_id: - sub_id = self.ctx.subscription_id - - # Build updated body from existing context data - location = ctx_data.get("location", str(self.ctx.args.location)) - update_body = { - "location": location, - "properties": { - "capabilities": [{"name": c.get("name", ""), "description": c.get("description", "")} for c in all_caps], - "hierarchies": [{"name": h.get("name", ""), "description": h.get("description", "")} for h in all_hiers], - } - } - - try: - resp = send_raw_request( - self.ctx.cli_ctx, - method="PUT", - url=( - f"https://management.azure.com/subscriptions/{sub_id}" - f"/resourceGroups/{ctx_rg}/providers/Microsoft.Edge" - f"/contexts/{ctx_name}?api-version=2025-08-01" - ), - body=json.dumps(update_body), - resource="https://management.azure.com" - ) - if resp.status_code in (200, 201): - print(f"[init-context] Capabilities updated [OK]") - else: - logger.warning("Context update returned %d: %s", resp.status_code, resp.text) - except Exception as exc: - logger.warning("Failed to update context capabilities: %s", exc) + self.ctx.args.context_id = ctx_id def _handle_init_hierarchy(self): """Auto-create a regular site hierarchy (RG-scoped, no SG).""" - from azure.cli.core import get_default_cli - import io - import sys - import json + from azext_workload_orchestration.onboarding.hierarchy_init import handle_init_hierarchy site_name = str(self.ctx.args.init_hierarchy) rg = str(self.ctx.args.resource_group) location = str(self.ctx.args.location) + hierarchy_level = str(self.ctx.args.hierarchy_level) if self.ctx.args.hierarchy_level else "line" + context_id = str(self.ctx.args.context_id) if self.ctx.args.context_id else None - # Create site in RG scope via az rest - sub_id = self.ctx.subscription_id - site_url = ( - f"https://{location}.management.azure.com" - f"/subscriptions/{sub_id}/resourceGroups/{rg}" - f"/providers/Microsoft.Edge/sites/{site_name}" - f"?api-version=2025-06-01" - ) - site_body = json.dumps({ - "properties": { - "displayName": site_name, - "description": site_name, - "labels": {"level": str(self.ctx.args.hierarchy_level) if self.ctx.args.hierarchy_level else "line"} - } - }) - - print(f"[init-hierarchy] Creating site '{site_name}'...") - - cli = get_default_cli() - - # Helper to invoke CLI silently (suppress stdout/stderr) - def _invoke_silent(args): - old_stdout, old_stderr = sys.stdout, sys.stderr - sys.stdout = io.StringIO() - sys.stderr = io.StringIO() - try: - return cli.invoke(args) - finally: - sys.stdout, sys.stderr = old_stdout, old_stderr - - # Step 1: Create site - _invoke_silent([ - "rest", "--method", "put", "--url", site_url, - "--body", site_body, - "--resource", "https://management.azure.com", - "--header", "Content-Type=application/json", - "-o", "none", - ]) - - # Step 2: Create configuration - config_url = ( - f"https://{location}.management.azure.com" - f"/subscriptions/{sub_id}/resourceGroups/{rg}" - f"/providers/Microsoft.Edge/configurations/{site_name}" - f"?api-version=2025-08-01" + handle_init_hierarchy( + self.ctx.cli_ctx, site_name, rg, location, hierarchy_level, context_id ) - _invoke_silent([ - "rest", "--method", "put", "--url", config_url, - "--body", json.dumps({"location": location}), - "--resource", "https://management.azure.com", - "--header", "Content-Type=application/json", - "-o", "none", - ]) - - # Step 3: Create config reference - site_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/sites/{site_name}" - config_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/configurations/{site_name}" - config_ref_url = ( - f"https://management.azure.com{site_id}" - f"/providers/Microsoft.Edge/configurationreferences/default" - f"?api-version=2025-08-01" - ) - _invoke_silent([ - "rest", "--method", "put", "--url", config_ref_url, - "--body", json.dumps({"properties": {"configurationResourceId": config_id}}), - "--resource", "https://management.azure.com", - "--header", "Content-Type=application/json", - "-o", "none", - ]) - - # Step 4: Create site reference to context - if self.ctx.args.context_id: - parts = str(self.ctx.args.context_id).split("/") - ctx_rg = rg - ctx_name = "default" - for i, p in enumerate(parts): - if p.lower() == "resourcegroups" and i + 1 < len(parts): - ctx_rg = parts[i + 1] - if p.lower() == "contexts" and i + 1 < len(parts): - ctx_name = parts[i + 1] - - try: - _invoke_silent([ - "workload-orchestration", "context", "site-reference", "create", - "-g", ctx_rg, "--context-name", ctx_name, - "--name", f"{site_name}-ref", - "--site-id", site_id, - "-o", "none", - ]) - except Exception: - pass - - print(f"[init-hierarchy] Site '{site_name}' + config + references created [OK]") @register_callback def post_operations(self): diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_init.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_init.py new file mode 100644 index 00000000000..9572851df46 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_init.py @@ -0,0 +1,283 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Context initialization for onboarding simplification. + +Finds or creates a WO context, sets it as current, and ensures the required +capabilities and hierarchy levels are present. + +Usage (called by target create --init-context): + context_id = handle_init_context(cli_ctx, ctx_name, rg, location, + hierarchy_level, capabilities) +""" + +# pylint: disable=broad-exception-caught + +import json +import logging + +from azure.cli.core.azclierror import CLIInternalError + +from azext_workload_orchestration.onboarding.consts import ( + ARM_ENDPOINT, + CONTEXT_API_VERSION, +) +from azext_workload_orchestration.onboarding.utils import ( + CmdProxy, + invoke_cli_command, + invoke_silent, + parse_arm_id, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def handle_init_context(cli_ctx, ctx_name, resource_group, location, + hierarchy_level, capabilities): + """Find or create a WO context and return its ARM resource ID. + + Strategy (in order): + 1. Check if a context is already set in CLI config → use it + 2. List contexts in the target's resource group → use first match + 3. Create a new context with the given name + 4. If create fails (e.g. name conflict), search subscription-wide + + After resolving the context, ensures the required hierarchy level and + capabilities are present (adds them if missing). + + Returns: + str: The ARM resource ID of the context. + + Raises: + CLIInternalError: If no context can be found or created. + """ + import configparser + + cmd = CmdProxy(cli_ctx) + + # ------------------------------------------------------------------ + # 1. Check CLI config for an already-set context + # ------------------------------------------------------------------ + try: + existing_ctx_id = cli_ctx.config.get('workload_orchestration', 'context_id') + if existing_ctx_id: + logger.info("Context already set in config: %s", existing_ctx_id) + _ensure_capabilities(cli_ctx, existing_ctx_id, hierarchy_level, capabilities) + print("[init-context] Using existing context [OK]") + return existing_ctx_id + except (configparser.NoSectionError, configparser.NoOptionError): + pass + + # ------------------------------------------------------------------ + # 2. List contexts in this resource group + # ------------------------------------------------------------------ + try: + existing = invoke_cli_command(cmd, [ + "workload-orchestration", "context", "list", "-g", resource_group + ]) + if existing and isinstance(existing, list) and len(existing) > 0: + ctx_id = existing[0].get("id", "") + if ctx_id: + parts = parse_arm_id(ctx_id) + found_name = parts.get("contexts", ctx_name) + found_rg = parts.get("resourcegroups", resource_group) + _set_current(found_name, found_rg) + _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities) + print(f"[init-context] Using existing context '{found_name}' [OK]") + return ctx_id + except Exception: + pass # No contexts found — proceed to create + + # ------------------------------------------------------------------ + # 3. Create a new context + # ------------------------------------------------------------------ + print(f"[init-context] Creating context '{ctx_name}'...") + + create_args = _build_create_args(ctx_name, resource_group, location, + hierarchy_level, capabilities) + exit_code = invoke_silent(create_args) + + if exit_code == 0: + _set_current(ctx_name, resource_group) + + # Read back the context ID from config (set by 'context use') + try: + ctx_id = cli_ctx.config.get('workload_orchestration', 'context_id') + if ctx_id: + print(f"[init-context] Context '{ctx_name}' created [OK]") + return ctx_id + except (configparser.NoSectionError, configparser.NoOptionError): + pass + + # Fallback: construct the ID manually + sub_id = cli_ctx.data.get('subscription_id', '') + ctx_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/contexts/{ctx_name}") + print(f"[init-context] Context '{ctx_name}' created [OK]") + return ctx_id + + # ------------------------------------------------------------------ + # 4. Create failed — search subscription-wide + # ------------------------------------------------------------------ + logger.warning("Context create returned exit %d. Searching subscription...", exit_code) + ctx_id = _search_subscription(cli_ctx) + if ctx_id: + parts = parse_arm_id(ctx_id) + found_name = parts.get("contexts", "unknown") + found_rg = parts.get("resourcegroups", resource_group) + _set_current(found_name, found_rg) + _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities) + print(f"[init-context] Using existing context '{found_name}' in RG '{found_rg}' [OK]") + return ctx_id + + raise CLIInternalError( + "Could not create or find an existing context. " + "Please provide --context-id explicitly." + ) + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _build_create_args(ctx_name, resource_group, location, + hierarchy_level, capabilities): + """Build the arg list for 'az workload-orchestration context create'.""" + # Capabilities: [0].name=X [0].description=X [1].name=Y ... + cap_args = [] + for i, cap in enumerate(capabilities or []): + cap_args.extend([f"[{i}].name={cap}", f"[{i}].description={cap}"]) + + hier_args = [f"[0].name={hierarchy_level}", f"[0].description={hierarchy_level}"] + + args = [ + "workload-orchestration", "context", "create", + "-g", resource_group, "-l", location, "--name", ctx_name, + "--hierarchies", + ] + hier_args + + if cap_args: + args.append("--capabilities") + args.extend(cap_args) + + args.extend(["-o", "none"]) + return args + + +def _set_current(ctx_name, ctx_rg): + """Set a context as the CLI default (silently).""" + invoke_silent([ + "workload-orchestration", "context", "use", + "--name", ctx_name, "-g", ctx_rg, "-o", "none", + ]) + + +def _search_subscription(cli_ctx): + """Search the entire subscription for any existing context. Returns ID or None.""" + from azure.cli.core.util import send_raw_request + + sub_id = cli_ctx.data.get('subscription_id', '') + try: + resp = send_raw_request( + cli_ctx, + method="GET", + url=(f"{ARM_ENDPOINT}/subscriptions/{sub_id}" + f"/providers/Microsoft.Edge/contexts" + f"?api-version={CONTEXT_API_VERSION}"), + resource=ARM_ENDPOINT, + ) + if resp.status_code == 200: + contexts = resp.json().get("value", []) + if contexts: + return contexts[0].get("id") + except Exception as exc: + logger.warning("Subscription-wide context search failed: %s", exc) + return None + + +def _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities): + """Add missing capabilities/hierarchies to an existing context via PUT.""" + if not capabilities: + return + + cmd = CmdProxy(cli_ctx) + parts = parse_arm_id(ctx_id) + ctx_rg = parts.get("resourcegroups") + ctx_name = parts.get("contexts") + sub_id = parts.get("subscriptions") + + if not ctx_rg or not ctx_name: + return + + # Get current context state + try: + ctx_data = invoke_cli_command(cmd, [ + "workload-orchestration", "context", "show", + "-g", ctx_rg, "--name", ctx_name, + ]) + except Exception: + return + + if not ctx_data or not isinstance(ctx_data, dict): + return + + props = ctx_data.get("properties", {}) + existing_caps = {c.get("name", "") for c in (props.get("capabilities") or [])} + existing_hiers = {h.get("name", "") for h in (props.get("hierarchies") or [])} + + missing_caps = [c for c in capabilities if c not in existing_caps] + missing_hier = hierarchy_level not in existing_hiers + + if not missing_caps and not missing_hier: + return # Nothing to add + + # Merge existing + new + all_caps = list(props.get("capabilities") or []) + for cap in missing_caps: + all_caps.append({"name": cap, "description": cap}) + + all_hiers = list(props.get("hierarchies") or []) + if missing_hier: + all_hiers.append({"name": hierarchy_level, "description": hierarchy_level}) + + print(f"[init-context] Adding capabilities {missing_caps} to context...") + + # PUT updated context + from azure.cli.core.util import send_raw_request + + if not sub_id: + sub_id = cli_ctx.data.get('subscription_id', '') + + location = ctx_data.get("location", "") + body = { + "location": location, + "properties": { + "capabilities": [{"name": c.get("name", ""), "description": c.get("description", "")} + for c in all_caps], + "hierarchies": [{"name": h.get("name", ""), "description": h.get("description", "")} + for h in all_hiers], + } + } + + try: + resp = send_raw_request( + cli_ctx, + method="PUT", + url=(f"{ARM_ENDPOINT}/subscriptions/{sub_id}" + f"/resourceGroups/{ctx_rg}/providers/Microsoft.Edge" + f"/contexts/{ctx_name}?api-version={CONTEXT_API_VERSION}"), + body=json.dumps(body), + resource=ARM_ENDPOINT, + ) + if resp.status_code in (200, 201): + print("[init-context] Capabilities updated [OK]") + else: + logger.warning("Context update returned %d: %s", resp.status_code, resp.text) + except Exception as exc: + logger.warning("Failed to update context capabilities: %s", exc) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py new file mode 100644 index 00000000000..76bcec3701e --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py @@ -0,0 +1,153 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Lightweight hierarchy initialization for target create --init-hierarchy. + +Creates a simple site + configuration + config-reference + site-reference +in a resource group scope (no service group). This is the "RG-scoped" +hierarchy used when a user just wants a quick site without the full +hierarchy_create flow (which requires a Service Group parent). + +Usage (called by target create --init-hierarchy): + handle_init_hierarchy(cli_ctx, site_name, resource_group, location, + hierarchy_level, context_id) +""" + +# pylint: disable=broad-exception-caught + +import json +import logging + +from azure.cli.core.util import send_raw_request + +from azext_workload_orchestration.onboarding.consts import ( + ARM_ENDPOINT, + SITE_API_VERSION, + CONFIGURATION_API_VERSION, + CONFIG_REF_API_VERSION, +) +from azext_workload_orchestration.onboarding.utils import ( + invoke_silent, + parse_arm_id, +) + +logger = logging.getLogger(__name__) + + +def handle_init_hierarchy(cli_ctx, site_name, resource_group, location, + hierarchy_level, context_id=None): + """Create a minimal RG-scoped hierarchy: Site → Configuration → ConfigRef → SiteRef. + + Steps: + 1. PUT site at regional endpoint + 2. PUT configuration at regional endpoint + 3. PUT configuration-reference (links config → site) + 4. Create site-reference via CLI (links site → context) + + All PUTs are idempotent — safe to re-run. + + Args: + cli_ctx: Azure CLI context (from self.ctx.cli_ctx) + site_name: Name for the new site + resource_group: Target resource group + location: Azure region (e.g., eastus2euap) + hierarchy_level: Level label (e.g., "line", "factory") + context_id: Optional ARM ID of the context to link to + """ + sub_id = cli_ctx.data.get('subscription_id', '') + regional_base = f"https://{location}.management.azure.com" + + site_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/sites/{site_name}") + config_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/configurations/{site_name}") + + print(f"[init-hierarchy] Creating site '{site_name}'...") + + # Step 1: Create Site (regional endpoint) + _put_resource( + cli_ctx, + url=f"{regional_base}{site_id}?api-version={SITE_API_VERSION}", + body={ + "properties": { + "displayName": site_name, + "description": site_name, + "labels": {"level": hierarchy_level or "line"}, + } + }, + label="Site", + ) + + # Step 2: Create Configuration (regional endpoint) + _put_resource( + cli_ctx, + url=f"{regional_base}{config_id}?api-version={CONFIGURATION_API_VERSION}", + body={"location": location}, + label="Configuration", + ) + + # Step 3: Create Configuration Reference (links config → site) + config_ref_url = ( + f"{ARM_ENDPOINT}{site_id}" + f"/providers/Microsoft.Edge/configurationreferences/default" + f"?api-version={CONFIG_REF_API_VERSION}" + ) + _put_resource( + cli_ctx, + url=config_ref_url, + body={"properties": {"configurationResourceId": config_id}}, + label="Configuration Reference", + ) + + # Step 4: Create Site Reference (links site → context) + if context_id: + _create_site_reference(context_id, site_name, site_id) + + print(f"[init-hierarchy] Site '{site_name}' + config + references created [OK]") + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _put_resource(cli_ctx, url, body, label): + """PUT a resource via send_raw_request. Logs on failure but doesn't crash.""" + try: + resp = send_raw_request( + cli_ctx, + method="PUT", + url=url, + body=json.dumps(body), + resource=ARM_ENDPOINT, + headers=["Content-Type=application/json"], + ) + if resp.status_code in (200, 201): + logger.info("%s created/updated successfully", label) + else: + logger.warning("%s PUT returned %d: %s", label, resp.status_code, resp.text) + except Exception as exc: + logger.warning("%s creation failed: %s", label, exc) + raise + + +def _create_site_reference(context_id, site_name, site_id): + """Create a site-reference linking the site to the context.""" + parts = parse_arm_id(context_id) + ctx_rg = parts.get("resourcegroups", "") + ctx_name = parts.get("contexts", "default") + + if not ctx_rg: + return + + try: + invoke_silent([ + "workload-orchestration", "context", "site-reference", "create", + "-g", ctx_rg, "--context-name", ctx_name, + "--name", f"{site_name}-ref", + "--site-id", site_id, + "-o", "none", + ]) + except Exception: + pass # Site reference may already exist diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py index 06094253cd7..35463c36f19 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py @@ -33,6 +33,56 @@ def __init__(self, cli_ctx): self.cli_ctx = cli_ctx +# --------------------------------------------------------------------------- +# ARM ID parsing +# --------------------------------------------------------------------------- + +def parse_arm_id(arm_id): + """Parse an ARM resource ID into a dict of segment name → value. + + Example: + parse_arm_id("/subscriptions/abc/resourceGroups/myRG/providers/Microsoft.Edge/contexts/myCtx") + → {"subscriptions": "abc", "resourcegroups": "myRG", "contexts": "myCtx"} + + Keys are lowercased for case-insensitive lookup. + Returns empty dict if arm_id is None or empty. + """ + if not arm_id: + return {} + parts = arm_id.strip("/").split("/") + result = {} + i = 0 + while i < len(parts) - 1: + result[parts[i].lower()] = parts[i + 1] + i += 2 + return result + + +# --------------------------------------------------------------------------- +# Silent CLI invocation +# --------------------------------------------------------------------------- + +def invoke_silent(cli_args): + """Invoke an az CLI command silently (suppress all stdout/stderr). + + Returns the exit code. Useful for fire-and-forget operations + where you don't need the output (e.g., setting config, creating + resources via 'az rest'). + """ + from azure.cli.core import get_default_cli + import io + import sys + + cli = get_default_cli() + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout = io.StringIO() + sys.stderr = io.StringIO() + try: + return cli.invoke(cli_args) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + + # --------------------------------------------------------------------------- # CLI command invocation # --------------------------------------------------------------------------- From 53744064ff459937618e2f7c818c8ab8c36beb9b Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 11:34:07 +0530 Subject: [PATCH 03/91] fix: resolve context_id before init-hierarchy and fix sub_id lookup - Move context_id config resolution before --init-hierarchy (hierarchy needs context_id for site-reference linking) - Extract _resolve_context_id_from_config() for clarity - Fix hierarchy_init.py sub_id: parse from context_id ARM ID or fall back to CLI Profile instead of cli_ctx.data (which returns None) Tested: all 7 flag combinations pass on live AKS cluster Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_create.py | 42 ++++++++++--------- .../onboarding/hierarchy_init.py | 10 ++++- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index b36e8334718..4ded758679c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -210,34 +210,38 @@ def pre_operations(self): if hasattr(self.ctx.args, 'init_context') and self.ctx.args.init_context: self._handle_init_context() + # Resolve context_id from config before hierarchy (hierarchy needs it for site-reference) + if not self.ctx.args.context_id: + self._resolve_context_id_from_config() + # --- V2: --init-hierarchy (auto-create regular site hierarchy) --- if hasattr(self.ctx.args, 'init_hierarchy') and self.ctx.args.init_hierarchy: self._handle_init_hierarchy() - # If context_id is not provided, try to get it from config - if not self.ctx.args.context_id: - try: - context_id = self.ctx.cli_ctx.config.get('workload_orchestration', 'context_id') - if context_id: - self.ctx.args.context_id = context_id - else: - raise CLIInternalError( - "No context-id was provided, and no default context is set. " - "Please provide the --context-id argument, use --init-context, " - "or set a default context using 'az workload-orchestration context use'." - ) - except configparser.NoSectionError as e: - logger.debug("Config section 'workload_orchestration' not found: %s", e) + # Default target specification (helm.v3) if not provided + if not self.ctx.args.target_specification: + from azext_workload_orchestration.onboarding.consts import DEFAULT_TARGET_SPECIFICATION + self.ctx.args.target_specification = DEFAULT_TARGET_SPECIFICATION + + def _resolve_context_id_from_config(self): + """Resolve context_id from CLI config if not already set.""" + try: + context_id = self.ctx.cli_ctx.config.get('workload_orchestration', 'context_id') + if context_id: + self.ctx.args.context_id = context_id + else: raise CLIInternalError( "No context-id was provided, and no default context is set. " "Please provide the --context-id argument, use --init-context, " "or set a default context using 'az workload-orchestration context use'." ) - - # Default target specification (helm.v3) if not provided - if not self.ctx.args.target_specification: - from azext_workload_orchestration.onboarding.consts import DEFAULT_TARGET_SPECIFICATION - self.ctx.args.target_specification = DEFAULT_TARGET_SPECIFICATION + except configparser.NoSectionError as e: + logger.debug("Config section 'workload_orchestration' not found: %s", e) + raise CLIInternalError( + "No context-id was provided, and no default context is set. " + "Please provide the --context-id argument, use --init-context, " + "or set a default context using 'az workload-orchestration context use'." + ) def _handle_init_extended_location(self): """Auto-prepare cluster (cert-mgr, trust-mgr, extension, custom location).""" diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py index 76bcec3701e..4248e6141a0 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py @@ -56,7 +56,15 @@ def handle_init_hierarchy(cli_ctx, site_name, resource_group, location, hierarchy_level: Level label (e.g., "line", "factory") context_id: Optional ARM ID of the context to link to """ - sub_id = cli_ctx.data.get('subscription_id', '') + # Get subscription ID — prefer extracting from context_id, fall back to CLI profile + if context_id: + parts = parse_arm_id(context_id) + sub_id = parts.get("subscriptions", "") + else: + sub_id = "" + if not sub_id: + from azure.cli.core._profile import Profile + sub_id = Profile(cli_ctx=cli_ctx).get_subscription_id() regional_base = f"https://{location}.management.azure.com" site_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" From cc808d49377b0847c3b7b9a2207d1498730836c6 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 11:46:16 +0530 Subject: [PATCH 04/91] refactor: remove standalone 'target prepare' command The cluster preparation logic is now only accessible via 'target create --init-extended-location'. The underlying target_prepare module is retained as an internal dependency. Removed from: commands.py, _params.py, _help.py, custom.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 32 -------- .../azext_workload_orchestration/_params.py | 76 ------------------- .../azext_workload_orchestration/commands.py | 3 - .../azext_workload_orchestration/custom.py | 1 - 4 files changed, 112 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 2a60399b633..2614a31da74 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -47,39 +47,7 @@ text: az workload-orchestration support create-bundle --kube-config ~/.kube/prod-config --kube-context my-cluster """ -helps['workload-orchestration target prepare'] = """ -type: command -short-summary: Prepare an Arc-connected Kubernetes cluster for Workload Orchestration. -long-summary: | - Installs all prerequisites needed to run Workload Orchestration on an Arc-connected - Kubernetes cluster. This is a convenience command that wraps multiple setup steps into one. - - Steps performed: - 1. Install cert-manager (if not already installed) - 2. Install trust-manager via helm (if not already installed) - 3. Install the WO extension (microsoft.workloadorchestration) - 4. Create a custom location linked to the cluster and extension - Prerequisites: - - Cluster must already be Arc-connected (az connectedk8s connect) - - kubectl must be in PATH and configured for the target cluster - - helm must be in PATH (required for trust-manager) - - The command is idempotent - it skips components that are already installed. - On completion, it outputs an extended-location.json file in the current directory - for use with target create. -examples: - - name: Prepare a cluster with defaults - text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus - - name: Prepare with a specific extension version - text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus --extension-version 2.1.18 - - name: Prepare without waiting for extension (fire and forget) - text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus --no-wait - - name: Skip cert-manager (already installed separately) - text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus --skip-cert-manager - - name: Use a specific kubeconfig - text: az workload-orchestration target prepare --cluster-name my-cluster -g my-rg -l eastus --kube-config ~/.kube/prod -""" helps['workload-orchestration hierarchy'] = """ type: group diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 6b94fa46320..36a00d77c3a 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -66,82 +66,6 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Kubernetes context to use. Defaults to current context.', ) - # ----------------------------------------------------------------------- - # target prepare - # ----------------------------------------------------------------------- - with self.argument_context('workload-orchestration target prepare') as c: - c.argument( - 'cluster_name', - options_list=['--cluster-name'], - help='Name of the Arc-connected Kubernetes cluster.', - required=True, - ) - c.argument( - 'resource_group', - options_list=['--resource-group', '-g'], - help='Resource group of the cluster.', - required=True, - ) - c.argument( - 'location', - options_list=['--location', '-l'], - help='Azure region (e.g., eastus, westeurope).', - required=True, - ) - c.argument( - 'extension_name', - options_list=['--extension-name'], - help='Name for the WO extension. Defaults to wo-extension.', - ) - c.argument( - 'custom_location_name', - options_list=['--custom-location-name'], - help='Name for the custom location. Defaults to {cluster-name}-cl.', - ) - c.argument( - 'extension_version', - options_list=['--extension-version'], - help='WO extension version to install (e.g., 2.1.18).', - ) - c.argument( - 'release_train', - options_list=['--release-train'], - help='Extension release train. Defaults to preview.', - ) - c.argument( - 'cert_manager_version', - options_list=['--cert-manager-version'], - help='cert-manager version to install. Defaults to v1.15.3.', - ) - c.argument( - 'skip_cert_manager', - options_list=['--skip-cert-manager'], - action='store_true', - help='Skip cert-manager installation.', - ) - c.argument( - 'skip_trust_manager', - options_list=['--skip-trust-manager'], - action='store_true', - help='Skip trust-manager installation.', - ) - c.argument( - 'kube_config', - options_list=['--kube-config'], - help='Path to kubeconfig file. Defaults to ~/.kube/config.', - ) - c.argument( - 'kube_context', - options_list=['--kube-context'], - help='Kubernetes context to use. Defaults to current context.', - ) - c.argument( - 'no_wait', - options_list=['--no-wait'], - action='store_true', - help="Don't wait for the WO extension to finish installing.", - ) - # ----------------------------------------------------------------------- # hierarchy create # ----------------------------------------------------------------------- diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index ac413568f53..2b8e666d1b9 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -14,8 +14,5 @@ def load_command_table(self, _): # pylint: disable=unused-argument g.custom_command('create-bundle', 'create_support_bundle') # Onboarding simplification commands - with self.command_group('workload-orchestration target', is_preview=True) as g: - g.custom_command('prepare', 'target_prepare') - with self.command_group('workload-orchestration hierarchy', is_preview=True) as g: g.custom_command('create', 'hierarchy_create') diff --git a/src/workload-orchestration/azext_workload_orchestration/custom.py b/src/workload-orchestration/azext_workload_orchestration/custom.py index 1588145a4ee..00efc842d17 100644 --- a/src/workload-orchestration/azext_workload_orchestration/custom.py +++ b/src/workload-orchestration/azext_workload_orchestration/custom.py @@ -9,5 +9,4 @@ from azext_workload_orchestration.support import create_support_bundle # pylint: disable=unused-import # noqa: F401 # Onboarding simplification commands -from azext_workload_orchestration.onboarding import target_prepare # pylint: disable=unused-import # noqa: F401 from azext_workload_orchestration.onboarding import hierarchy_create # pylint: disable=unused-import # noqa: F401 From be0e41929f71a0f090a470a3a93955d52dd0fe3d Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 11:48:44 +0530 Subject: [PATCH 05/91] refactor: remove standalone 'hierarchy create' command Hierarchy creation is now only accessible via 'target create --init-hierarchy'. The underlying modules are retained as internal dependencies. Removed from: commands.py, _params.py, _help.py, custom.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 35 ----------- .../azext_workload_orchestration/_params.py | 63 +------------------ .../azext_workload_orchestration/commands.py | 4 -- .../azext_workload_orchestration/custom.py | 1 - 4 files changed, 1 insertion(+), 102 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 2614a31da74..a2d34f32bcd 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -48,38 +48,3 @@ """ - -helps['workload-orchestration hierarchy'] = """ -type: group -short-summary: Commands for managing WO site hierarchy levels. -""" - -helps['workload-orchestration hierarchy create'] = """ -type: command -short-summary: Create a hierarchy level (Service Group + Site + Configuration) in one command. -long-summary: | - Creates all resources needed for a single hierarchy level in Workload Orchestration. - This replaces 4 separate az rest calls with a single CLI command. - - Resources created: - 1. Service Group (Microsoft.Management/serviceGroups) - 2. Site (Microsoft.Edge/sites) — in the Service Group - 3. Configuration (Microsoft.Edge/configurations) — in the Resource Group - 4. Configuration Reference — links the Configuration to the Site - - If no WO context exists, one is auto-created and set as the current context. - A site-reference is also auto-created to link the site to the context. - - All operations are idempotent (PUT upsert) — safe to re-run. -examples: - - name: Create a top-level Region hierarchy - text: az workload-orchestration hierarchy create --name my-region -g my-rg -l eastus --level-label Region - - name: Create a Factory nested under Region - text: az workload-orchestration hierarchy create --name my-factory -g my-rg -l eastus --level-label Factory --parent my-region - - name: Create with capabilities (auto-added to context) - text: az workload-orchestration hierarchy create --name my-region -g my-rg -l eastus --level-label Region --capabilities soap shampoo - - name: Use an existing context - text: az workload-orchestration hierarchy create --name my-factory -g my-rg -l eastus --level-label Factory --context-name my-context --context-rg context-rg - - name: Skip context auto-creation (manual context management) - text: az workload-orchestration hierarchy create --name my-factory -g my-rg -l eastus --level-label Factory --skip-context -""" diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 36a00d77c3a..3b6b0032555 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -66,68 +66,7 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Kubernetes context to use. Defaults to current context.', ) - # ----------------------------------------------------------------------- - # hierarchy create - # ----------------------------------------------------------------------- - with self.argument_context('workload-orchestration hierarchy create') as c: - c.argument( - 'name', - options_list=['--name', '-n'], - help='Name for this hierarchy level. Used for Service Group, Site, and ' - 'Configuration resources. Maximum 24 characters.', - required=True, - ) - c.argument( - 'resource_group', - options_list=['--resource-group', '-g'], - help='Resource group for the Configuration resource.', - required=True, - ) - c.argument( - 'location', - options_list=['--location', '-l'], - help='Azure region (determines regional API endpoint for Site/Config).', - required=True, - ) - c.argument( - 'level_label', - options_list=['--level-label'], - help='Label for this hierarchy level (e.g., Region, Factory, Line).', - required=True, - ) - c.argument( - 'parent', - options_list=['--parent'], - help='Parent service group name for nesting. ' - 'Omit for top-level (parent defaults to tenant root).', - ) - c.argument( - 'capabilities', - options_list=['--capabilities'], - nargs='+', - help='Capabilities to add to the WO context (e.g., soap shampoo).', - ) - c.argument( - 'description', - options_list=['--description'], - help='Description for the Site resource. Defaults to the name.', - ) - c.argument( - 'context_name', - options_list=['--context-name'], - help='Use an existing context by name (skip auto-create).', - ) - c.argument( - 'context_rg', - options_list=['--context-rg'], - help='Resource group of the existing context.', - ) - c.argument( - 'skip_context', - options_list=['--skip-context'], - action='store_true', - help='Skip auto-creation of context and site-reference.', - ) + c.argument( 'skip_site_reference', options_list=['--skip-site-reference'], diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index 2b8e666d1b9..1f1d9c002a7 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -12,7 +12,3 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration support', is_preview=True) as g: g.custom_command('create-bundle', 'create_support_bundle') - - # Onboarding simplification commands - with self.command_group('workload-orchestration hierarchy', is_preview=True) as g: - g.custom_command('create', 'hierarchy_create') diff --git a/src/workload-orchestration/azext_workload_orchestration/custom.py b/src/workload-orchestration/azext_workload_orchestration/custom.py index 00efc842d17..515e6d71d3c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/custom.py +++ b/src/workload-orchestration/azext_workload_orchestration/custom.py @@ -9,4 +9,3 @@ from azext_workload_orchestration.support import create_support_bundle # pylint: disable=unused-import # noqa: F401 # Onboarding simplification commands -from azext_workload_orchestration.onboarding import hierarchy_create # pylint: disable=unused-import # noqa: F401 From 9b5c319f396b4954af0fd845190ec6c269c1a6ab Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 11:50:40 +0530 Subject: [PATCH 06/91] fix: address PR review comments from Copilot - Fix --release-train help text: 'dev' -> 'stable' to match DEFAULT_RELEASE_TRAIN - Remove unused imports: PropertyMock from test_target_prepare, json/call from test_sg_link_and_utils - Fix hierarchy_create.py docstring: remove claim about updating capabilities - Preserve tags in target_sg_link refresh PUT to avoid dropping metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/target/_create.py | 2 +- .../onboarding/hierarchy_create.py | 2 +- .../azext_workload_orchestration/onboarding/target_sg_link.py | 4 +++- .../tests/test_onboarding/test_sg_link_and_utils.py | 3 +-- .../tests/test_onboarding/test_target_prepare.py | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index 4ded758679c..4b9be28473b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -150,7 +150,7 @@ def _build_arguments_schema(cls, *args, **kwargs): options=["--release-train"], arg_group="Onboarding", help="Release train for WO extension (used with --init-extended-location). " - "Default: 'dev'. Options: dev, preview, stable.", + "Default: 'stable'. Options: dev, preview, stable.", ) capabilities = cls._args_schema.capabilities diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 89020cae6ce..dda13deecc1 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -6,7 +6,7 @@ """Hierarchy create command - creates a hierarchy level in a single command. Wraps 4-6 operations into one: - 0. Auto-create Context + update hierarchies/capabilities (if needed) + 0. Auto-create Context (if needed) 1. Create Service Group 2. Create Site (in Service Group) 3. Create Configuration diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py index 04f04e322a9..19b243a31e5 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py @@ -85,13 +85,15 @@ def link_target_to_service_group(cmd, target_id, service_group_name): # PUT target (update to refresh hierarchy) if target_data and isinstance(target_data, dict): - # Strip read-only fields + # Strip read-only fields, preserve writable top-level fields body = { "location": target_data.get("location", ""), "properties": target_data.get("properties", {}), } if "extendedLocation" in target_data: body["extendedLocation"] = target_data["extendedLocation"] + if "tags" in target_data: + body["tags"] = target_data["tags"] invoke_cli_command(cmd, [ "rest", diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py index 003a98c26e2..a4a203cdf61 100644 --- a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py @@ -5,9 +5,8 @@ """Unit tests for service group link helper.""" -import json import unittest -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, MagicMock from azure.cli.core.azclierror import CLIInternalError diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py index 208c4e51959..2ea32aef82c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py @@ -6,7 +6,7 @@ """Unit tests for target prepare command.""" import unittest -from unittest.mock import patch, MagicMock, PropertyMock +from unittest.mock import patch, MagicMock from azure.cli.core.azclierror import CLIInternalError, ValidationError From bc354b4439694c14645eb7c708090ca7a25e89d6 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 12:06:53 +0530 Subject: [PATCH 07/91] refactor: align with team feedback - separate target init, remove init flags from target create - Remove --init-extended-location, --init-context, --init-hierarchy, --release-train from target create - Add 'target init' as standalone command (wraps target_prepare) - Keep --service-group and default target-spec on target create - Aligns with team decision: cluster setup and hierarchy as separate commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 28 +++++ .../azext_workload_orchestration/_params.py | 62 +++++++++ .../workload_orchestration/target/_create.py | 118 +----------------- .../azext_workload_orchestration/commands.py | 3 + .../azext_workload_orchestration/custom.py | 1 + .../onboarding/__init__.py | 36 +++++- 6 files changed, 133 insertions(+), 115 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index a2d34f32bcd..6df10b6c8df 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -47,4 +47,32 @@ text: az workload-orchestration support create-bundle --kube-config ~/.kube/prod-config --kube-context my-cluster """ +helps['workload-orchestration target init'] = """ +type: command +short-summary: Prepare an Arc-connected Kubernetes cluster for Workload Orchestration. +long-summary: | + Installs all prerequisites on an Arc-connected cluster to make it ready for + Workload Orchestration target creation. This is an idempotent operation that + skips components already installed. + + Steps performed: + 1. Verify cluster is Arc-connected with required features enabled + 2. Install cert-manager (if not present) + 3. Install trust-manager (if not present) + 4. Install WO extension (if not present) + 5. Create custom location (if not present) + + After running this command, use the output custom location ID with + 'az workload-orchestration target create --extended-location'. +examples: + - name: Initialize a cluster with defaults + text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap + - name: Initialize with a specific release train + text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap --release-train preview + - name: Initialize with custom location name + text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl + - name: Skip cert-manager if already managed externally + text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap --skip-cert-manager +""" + diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 3b6b0032555..68b466fa34c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -73,3 +73,65 @@ def load_arguments(self, _): # pylint: disable=unused-argument action='store_true', help='Skip auto-creation of site-reference to context.', ) + + with self.argument_context('workload-orchestration target init') as c: + c.argument( + 'cluster_name', + options_list=['--cluster-name', '-c'], + help='Name of the Arc-connected Kubernetes cluster.', + required=True, + ) + c.argument( + 'resource_group', + options_list=['--resource-group', '-g'], + help='Resource group of the Arc-connected cluster.', + required=True, + ) + c.argument( + 'location', + options_list=['--location', '-l'], + help='Azure region for the custom location (e.g., eastus2euap).', + required=True, + ) + c.argument( + 'release_train', + options_list=['--release-train'], + help='Extension release train. Default: stable.', + ) + c.argument( + 'extension_version', + options_list=['--extension-version'], + help='Specific WO extension version to install.', + ) + c.argument( + 'extension_name', + options_list=['--extension-name'], + help='Name for the WO extension resource. Default: workload-orchestration.', + ) + c.argument( + 'custom_location_name', + options_list=['--custom-location-name'], + help='Name for the custom location. Default: -cl.', + ) + c.argument( + 'skip_cert_manager', + options_list=['--skip-cert-manager'], + action='store_true', + help='Skip cert-manager installation.', + ) + c.argument( + 'skip_trust_manager', + options_list=['--skip-trust-manager'], + action='store_true', + help='Skip trust-manager installation.', + ) + c.argument( + 'kube_config', + options_list=['--kube-config'], + help='Path to kubeconfig file. Defaults to ~/.kube/config.', + ) + c.argument( + 'kube_context', + options_list=['--kube-context'], + help='Kubernetes context to use. Defaults to current context.', + ) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index 4b9be28473b..776e750d767 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -121,37 +121,12 @@ def _build_arguments_schema(cls, *args, **kwargs): ) - # V2: Onboarding simplification arguments - _args_schema.init_context = AAZStrArg( - options=["--init-context"], - arg_group="Onboarding", - help="Auto-create a default context if none exists. Value is the context name (e.g., 'default'). " - "Auto-injects hierarchy level and capabilities into the context.", - ) + # Onboarding simplification arguments _args_schema.service_group = AAZStrArg( options=["--service-group"], arg_group="Onboarding", help="ServiceGroup name to auto-link this target to after creation.", ) - _args_schema.init_hierarchy = AAZStrArg( - options=["--init-hierarchy"], - arg_group="Onboarding", - help="Auto-create a regular site hierarchy. Value is the site name. " - "Creates a Site in the target's resource group and links to context.", - ) - _args_schema.init_extended_location = AAZStrArg( - options=["--init-extended-location"], - arg_group="Onboarding", - help="Auto-prepare an Arc-connected cluster for WO and create a custom location. " - "Value is the connected cluster ARM resource ID. " - "Installs cert-manager, trust-manager, WO extension, and creates custom location.", - ) - _args_schema.release_train = AAZStrArg( - options=["--release-train"], - arg_group="Onboarding", - help="Release train for WO extension (used with --init-extended-location). " - "Default: 'stable'. Options: dev, preview, stable.", - ) capabilities = cls._args_schema.capabilities capabilities.Element = AAZStrArg() @@ -202,22 +177,10 @@ def _execute_operations(self): @register_callback def pre_operations(self): - # --- V2: --init-extended-location (auto-prepare cluster + create CL) --- - if hasattr(self.ctx.args, 'init_extended_location') and self.ctx.args.init_extended_location: - self._handle_init_extended_location() - - # --- V2: --init-context (auto-create context if not exists) --- - if hasattr(self.ctx.args, 'init_context') and self.ctx.args.init_context: - self._handle_init_context() - - # Resolve context_id from config before hierarchy (hierarchy needs it for site-reference) + # Resolve context_id from CLI config if not provided if not self.ctx.args.context_id: self._resolve_context_id_from_config() - # --- V2: --init-hierarchy (auto-create regular site hierarchy) --- - if hasattr(self.ctx.args, 'init_hierarchy') and self.ctx.args.init_hierarchy: - self._handle_init_hierarchy() - # Default target specification (helm.v3) if not provided if not self.ctx.args.target_specification: from azext_workload_orchestration.onboarding.consts import DEFAULT_TARGET_SPECIFICATION @@ -232,91 +195,20 @@ def _resolve_context_id_from_config(self): else: raise CLIInternalError( "No context-id was provided, and no default context is set. " - "Please provide the --context-id argument, use --init-context, " + "Please provide the --context-id argument " "or set a default context using 'az workload-orchestration context use'." ) except configparser.NoSectionError as e: logger.debug("Config section 'workload_orchestration' not found: %s", e) raise CLIInternalError( "No context-id was provided, and no default context is set. " - "Please provide the --context-id argument, use --init-context, " + "Please provide the --context-id argument " "or set a default context using 'az workload-orchestration context use'." ) - def _handle_init_extended_location(self): - """Auto-prepare cluster (cert-mgr, trust-mgr, extension, custom location).""" - from azext_workload_orchestration.onboarding.target_prepare import target_prepare - from azext_workload_orchestration.onboarding.utils import CmdProxy, parse_arm_id - - cluster_arm_id = str(self.ctx.args.init_extended_location) - location = str(self.ctx.args.location) - parts = parse_arm_id(cluster_arm_id) - - cluster_rg = parts.get("resourcegroups") - cluster_name = parts.get("connectedclusters") - - if not cluster_rg or not cluster_name: - raise CLIInternalError( - f"Invalid connected cluster ARM ID: {cluster_arm_id}\n" - "Expected: /subscriptions/{{sub}}/resourceGroups/{{rg}}" - "/providers/Microsoft.Kubernetes/connectedClusters/{{name}}" - ) - - release_train = None - if hasattr(self.ctx.args, 'release_train') and self.ctx.args.release_train: - release_train = str(self.ctx.args.release_train) - - print(f"\n[init-extended-location] Preparing cluster '{cluster_name}' in RG '{cluster_rg}'...") - - result = target_prepare( - cmd=CmdProxy(self.ctx.cli_ctx), - cluster_name=cluster_name, - resource_group=cluster_rg, - location=location, - release_train=release_train, - ) - - cl_id = result.get("customLocationId", "") - if cl_id: - self.ctx.args.extended_location = {"name": cl_id, "type": "CustomLocation"} - print(f"[init-extended-location] Cluster prepared, CL: {cl_id} [OK]\n") - else: - raise CLIInternalError( - "target prepare succeeded but no custom location ID was returned." - ) - - def _handle_init_context(self): - """Auto-create or find a context, inject hierarchy + capabilities.""" - from azext_workload_orchestration.onboarding.context_init import handle_init_context - - ctx_name = str(self.ctx.args.init_context) - rg = str(self.ctx.args.resource_group) - location = str(self.ctx.args.location) - hierarchy_level = str(self.ctx.args.hierarchy_level) if self.ctx.args.hierarchy_level else "line" - capabilities = [str(c) for c in self.ctx.args.capabilities] if self.ctx.args.capabilities else [] - - ctx_id = handle_init_context( - self.ctx.cli_ctx, ctx_name, rg, location, hierarchy_level, capabilities - ) - self.ctx.args.context_id = ctx_id - - def _handle_init_hierarchy(self): - """Auto-create a regular site hierarchy (RG-scoped, no SG).""" - from azext_workload_orchestration.onboarding.hierarchy_init import handle_init_hierarchy - - site_name = str(self.ctx.args.init_hierarchy) - rg = str(self.ctx.args.resource_group) - location = str(self.ctx.args.location) - hierarchy_level = str(self.ctx.args.hierarchy_level) if self.ctx.args.hierarchy_level else "line" - context_id = str(self.ctx.args.context_id) if self.ctx.args.context_id else None - - handle_init_hierarchy( - self.ctx.cli_ctx, site_name, rg, location, hierarchy_level, context_id - ) - @register_callback def post_operations(self): - # --- V2: --service-group (auto-link target to SG after creation) --- + # --service-group: auto-link target to SG after creation if hasattr(self.ctx.args, 'service_group') and self.ctx.args.service_group: self._handle_service_group_link() diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index 1f1d9c002a7..3b86e80a9f2 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -12,3 +12,6 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration support', is_preview=True) as g: g.custom_command('create-bundle', 'create_support_bundle') + + with self.command_group('workload-orchestration target', is_preview=True) as g: + g.custom_command('init', 'target_init') diff --git a/src/workload-orchestration/azext_workload_orchestration/custom.py b/src/workload-orchestration/azext_workload_orchestration/custom.py index 515e6d71d3c..7d1a98e8e4e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/custom.py +++ b/src/workload-orchestration/azext_workload_orchestration/custom.py @@ -9,3 +9,4 @@ from azext_workload_orchestration.support import create_support_bundle # pylint: disable=unused-import # noqa: F401 # Onboarding simplification commands +from azext_workload_orchestration.onboarding import target_init # pylint: disable=unused-import # noqa: F401 diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index cc9875c64d5..5a6748b80de 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -10,6 +10,38 @@ """ from azext_workload_orchestration.onboarding.target_prepare import target_prepare -from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create -__all__ = ['target_prepare', 'hierarchy_create'] + +def target_init( + cmd, + cluster_name, + resource_group, + location, + release_train=None, + extension_version=None, + extension_name=None, + custom_location_name=None, + skip_cert_manager=False, + skip_trust_manager=False, + kube_config=None, + kube_context=None, +): + """Prepare an Arc-connected cluster for Workload Orchestration.""" + result = target_prepare( + cmd=cmd, + cluster_name=cluster_name, + resource_group=resource_group, + location=location, + extension_name=extension_name, + custom_location_name=custom_location_name, + extension_version=extension_version, + release_train=release_train, + skip_cert_manager=skip_cert_manager, + skip_trust_manager=skip_trust_manager, + kube_config=kube_config, + kube_context=kube_context, + ) + return result + + +__all__ = ['target_prepare', 'target_init'] From a9273958e6fbea49eec0be6472750fd7e19b3578 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 12:21:34 +0530 Subject: [PATCH 08/91] =?UTF-8?q?feat:=20add=20target=20deploy=20command?= =?UTF-8?q?=20(review=20=E2=86=92=20publish=20=E2=86=92=20install)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements 'az workload-orchestration target deploy' that chains review, publish, and install into a single command with LRO polling. - Supports --skip-review and --skip-install flags - Supports --config-file for pre-review configuration - Uses send_raw_request with ARM resource auth - Polls LRO via Location/Azure-AsyncOperation headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 31 ++ .../azext_workload_orchestration/_params.py | 108 +++---- .../azext_workload_orchestration/commands.py | 1 + .../azext_workload_orchestration/custom.py | 1 + .../onboarding/__init__.py | 34 ++- .../onboarding/target_deploy.py | 289 ++++++++++++++++++ 6 files changed, 401 insertions(+), 63 deletions(-) create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 6df10b6c8df..c9f95613eae 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -75,4 +75,35 @@ text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap --skip-cert-manager """ +helps['workload-orchestration target deploy'] = """ +type: command +short-summary: Deploy a solution to a target in one step (review → publish → install). +long-summary: | + Chains the three deployment steps into a single command: + 1. Review: validates the solution template version against the target + 2. Publish: publishes the reviewed solution version + 3. Install: installs the published solution on the target + + Optionally set configuration values from a YAML file before review + using --config-file with the config template parameters. + + Use --skip-review if the solution is already reviewed, or + --skip-install to publish without installing. +examples: + - name: Deploy a solution template version to a target + text: > + az workload-orchestration target deploy -g my-rg -n my-target + --solution-template-version-id /subscriptions/sub/resourceGroups/rg/providers/Microsoft.Edge/solutionTemplates/tmpl/versions/1.0.0 + - name: Deploy with configuration file + text: > + az workload-orchestration target deploy -g my-rg -n my-target + --solution-template-version-id /subscriptions/sub/resourceGroups/rg/providers/Microsoft.Edge/solutionTemplates/tmpl/versions/1.0.0 + --config-file values.yaml --config-template-rg rg --config-template-name tmpl --config-template-version 1.0.0 + - name: Skip review (already reviewed) + text: > + az workload-orchestration target deploy -g my-rg -n my-target + --solution-template-version-id /subscriptions/sub/resourceGroups/rg/providers/Microsoft.Edge/solutionVersions/v1 + --skip-review +""" + diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 68b466fa34c..afbf8b2a39b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -65,8 +65,6 @@ def load_arguments(self, _): # pylint: disable=unused-argument options_list=['--kube-context'], help='Kubernetes context to use. Defaults to current context.', ) - - c.argument( 'skip_site_reference', options_list=['--skip-site-reference'], @@ -75,63 +73,49 @@ def load_arguments(self, _): # pylint: disable=unused-argument ) with self.argument_context('workload-orchestration target init') as c: - c.argument( - 'cluster_name', - options_list=['--cluster-name', '-c'], - help='Name of the Arc-connected Kubernetes cluster.', - required=True, - ) - c.argument( - 'resource_group', - options_list=['--resource-group', '-g'], - help='Resource group of the Arc-connected cluster.', - required=True, - ) - c.argument( - 'location', - options_list=['--location', '-l'], - help='Azure region for the custom location (e.g., eastus2euap).', - required=True, - ) - c.argument( - 'release_train', - options_list=['--release-train'], - help='Extension release train. Default: stable.', - ) - c.argument( - 'extension_version', - options_list=['--extension-version'], - help='Specific WO extension version to install.', - ) - c.argument( - 'extension_name', - options_list=['--extension-name'], - help='Name for the WO extension resource. Default: workload-orchestration.', - ) - c.argument( - 'custom_location_name', - options_list=['--custom-location-name'], - help='Name for the custom location. Default: -cl.', - ) - c.argument( - 'skip_cert_manager', - options_list=['--skip-cert-manager'], - action='store_true', - help='Skip cert-manager installation.', - ) - c.argument( - 'skip_trust_manager', - options_list=['--skip-trust-manager'], - action='store_true', - help='Skip trust-manager installation.', - ) - c.argument( - 'kube_config', - options_list=['--kube-config'], - help='Path to kubeconfig file. Defaults to ~/.kube/config.', - ) - c.argument( - 'kube_context', - options_list=['--kube-context'], - help='Kubernetes context to use. Defaults to current context.', - ) + c.argument('cluster_name', options_list=['--cluster-name', '-c'], + help='Name of the Arc-connected Kubernetes cluster.', required=True) + c.argument('resource_group', options_list=['--resource-group', '-g'], + help='Resource group of the Arc-connected cluster.', required=True) + c.argument('location', options_list=['--location', '-l'], + help='Azure region for the custom location (e.g., eastus2euap).', required=True) + c.argument('release_train', options_list=['--release-train'], + help='Extension release train. Default: stable.') + c.argument('extension_version', options_list=['--extension-version'], + help='Specific WO extension version to install.') + c.argument('extension_name', options_list=['--extension-name'], + help='Name for the WO extension resource. Default: workload-orchestration.') + c.argument('custom_location_name', options_list=['--custom-location-name'], + help='Name for the custom location. Default: -cl.') + c.argument('skip_cert_manager', options_list=['--skip-cert-manager'], + action='store_true', help='Skip cert-manager installation.') + c.argument('skip_trust_manager', options_list=['--skip-trust-manager'], + action='store_true', help='Skip trust-manager installation.') + c.argument('kube_config', options_list=['--kube-config'], + help='Path to kubeconfig file. Defaults to ~/.kube/config.') + c.argument('kube_context', options_list=['--kube-context'], + help='Kubernetes context to use. Defaults to current context.') + + with self.argument_context('workload-orchestration target deploy') as c: + c.argument('resource_group', options_list=['--resource-group', '-g'], + help='Resource group of the target.', required=True) + c.argument('target_name', options_list=['--target-name', '-n'], + help='Name of the target to deploy to.', required=True) + c.argument('solution_template_version_id', options_list=['--solution-template-version-id'], + help='ARM resource ID of the solution template version to deploy.', required=True) + c.argument('solution_instance_name', options_list=['--solution-instance-name'], + help='Optional solution instance name for the review step.') + c.argument('skip_review', options_list=['--skip-review'], + action='store_true', help='Skip the review step (use when already reviewed).') + c.argument('skip_install', options_list=['--skip-install'], + action='store_true', help='Skip the install step (publish only).') + c.argument('config_file', options_list=['--config-file'], + help='YAML file with configuration values to set before review.') + c.argument('config_hierarchy_id', options_list=['--config-hierarchy-id'], + help='ARM ID of hierarchy entity for config set. Defaults to target ARM ID.') + c.argument('config_template_rg', options_list=['--config-template-rg'], + help='Resource group of the configuration template (used with --config-file).') + c.argument('config_template_name', options_list=['--config-template-name'], + help='Name of the configuration template (used with --config-file).') + c.argument('config_template_version', options_list=['--config-template-version'], + help='Version of the configuration template (used with --config-file).') diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index 3b86e80a9f2..99dda357cd2 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -15,3 +15,4 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration target', is_preview=True) as g: g.custom_command('init', 'target_init') + g.custom_command('deploy', 'target_deploy') diff --git a/src/workload-orchestration/azext_workload_orchestration/custom.py b/src/workload-orchestration/azext_workload_orchestration/custom.py index 7d1a98e8e4e..cafa4ee5c90 100644 --- a/src/workload-orchestration/azext_workload_orchestration/custom.py +++ b/src/workload-orchestration/azext_workload_orchestration/custom.py @@ -10,3 +10,4 @@ # Onboarding simplification commands from azext_workload_orchestration.onboarding import target_init # pylint: disable=unused-import # noqa: F401 +from azext_workload_orchestration.onboarding import target_deploy # pylint: disable=unused-import # noqa: F401 diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 5a6748b80de..d8d43a95135 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -10,6 +10,7 @@ """ from azext_workload_orchestration.onboarding.target_prepare import target_prepare +from azext_workload_orchestration.onboarding.target_deploy import target_deploy as _target_deploy def target_init( @@ -44,4 +45,35 @@ def target_init( return result -__all__ = ['target_prepare', 'target_init'] +def target_deploy( + cmd, + resource_group, + target_name, + solution_template_version_id, + solution_instance_name=None, + skip_review=False, + skip_install=False, + config_file=None, + config_hierarchy_id=None, + config_template_rg=None, + config_template_name=None, + config_template_version=None, +): + """Deploy a solution to a target: review → publish → install.""" + return _target_deploy( + cmd=cmd, + resource_group=resource_group, + target_name=target_name, + solution_template_version_id=solution_template_version_id, + solution_instance_name=solution_instance_name, + skip_review=skip_review, + skip_install=skip_install, + config_file=config_file, + config_hierarchy_id=config_hierarchy_id, + config_template_rg=config_template_rg, + config_template_name=config_template_name, + config_template_version=config_template_version, + ) + + +__all__ = ['target_prepare', 'target_init', 'target_deploy'] diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py new file mode 100644 index 00000000000..ef404dcc68b --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -0,0 +1,289 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Target deploy command - chains review → publish → install in one step. + +Replaces 3 manual commands: + 1. az workload-orchestration target review + 2. az workload-orchestration target publish + 3. az workload-orchestration target install + +Usage: + az workload-orchestration target deploy \\ + -g my-rg -n my-target \\ + --solution-template-version-id +""" + +import json +import logging + +from azure.cli.core.azclierror import CLIInternalError, ValidationError +from azure.cli.core.util import send_raw_request + +logger = logging.getLogger(__name__) + +TOTAL_STEPS = 3 +API_VERSION = "2025-08-01" +ARM_RESOURCE = "https://management.azure.com" + + +def target_deploy( + cmd, + resource_group, + target_name, + solution_template_version_id, + solution_instance_name=None, + skip_review=False, + skip_install=False, + config_file=None, + config_hierarchy_id=None, + config_template_rg=None, + config_template_name=None, + config_template_version=None, +): + """Deploy a solution to a target: review → publish → install. + + Chains the 3 LRO operations, passing the solution-version-id + from review output into publish and install automatically. + """ + sub_id = cmd.cli_ctx.data.get('subscription_id') + if not sub_id: + from azure.cli.core._profile import Profile + sub_id = Profile(cli_ctx=cmd.cli_ctx).get_subscription_id() + + base_url = ( + f"https://management.azure.com/subscriptions/{sub_id}" + f"/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/targets/{target_name}" + ) + + results = {} + + # Optional Step 0: Set configuration before review + if config_file: + _handle_config_set( + cmd, config_file, config_hierarchy_id, config_template_rg, + config_template_name, config_template_version, + resource_group, target_name, sub_id, + ) + + # Step 1: Review + if skip_review: + _print_step(1, "Review", "Skipped (--skip-review)") + results["review"] = "Skipped" + # Without review, user must know the solution-version-id already + # We'll use the solution-template-version-id for publish directly + solution_version_id = solution_template_version_id + else: + _print_step(1, "Review") + review_result = _do_review( + cmd, base_url, solution_template_version_id, solution_instance_name, + ) + results["review"] = review_result + solution_version_id = _extract_solution_version_id(review_result) + _print_step(1, "Review", f"[OK] → version: {_short_id(solution_version_id)}") + + # Step 2: Publish + _print_step(2, "Publish") + publish_result = _do_publish(cmd, base_url, solution_version_id) + results["publish"] = publish_result + _print_step(2, "Publish", "[OK]") + + # Step 3: Install + if skip_install: + _print_step(3, "Install", "Skipped (--skip-install)") + results["install"] = "Skipped" + else: + _print_step(3, "Install") + install_result = _do_install(cmd, base_url, solution_version_id) + results["install"] = install_result + _print_step(3, "Install", "[OK]") + + print(f"\n[OK] Deployment complete for target '{target_name}'") + return { + "targetName": target_name, + "solutionVersionId": solution_version_id, + "steps": results, + } + + +# --------------------------------------------------------------------------- +# Step implementations +# --------------------------------------------------------------------------- + +def _do_review(cmd, base_url, solution_template_version_id, solution_instance_name): + """POST .../reviewSolutionVersion — validates config and creates a solution version.""" + url = f"{base_url}/reviewSolutionVersion?api-version={API_VERSION}" + body = { + "solutionTemplateVersionId": solution_template_version_id, + } + if solution_instance_name: + body["solutionInstanceName"] = solution_instance_name + + resp = send_raw_request( + cmd.cli_ctx, "POST", url, + body=json.dumps(body), + headers=["Content-Type=application/json"], + resource=ARM_RESOURCE, + ) + return _parse_response(resp, "review", cmd=cmd) + + +def _do_publish(cmd, base_url, solution_version_id): + """POST .../publishSolutionVersion — publishes the reviewed solution.""" + url = f"{base_url}/publishSolutionVersion?api-version={API_VERSION}" + body = {"solutionVersionId": solution_version_id} + + resp = send_raw_request( + cmd.cli_ctx, "POST", url, + body=json.dumps(body), + headers=["Content-Type=application/json"], + resource=ARM_RESOURCE, + ) + return _parse_response(resp, "publish", cmd=cmd) + + +def _do_install(cmd, base_url, solution_version_id): + """POST .../installSolution — installs the published solution on target.""" + url = f"{base_url}/installSolution?api-version={API_VERSION}" + body = {"solutionVersionId": solution_version_id} + + resp = send_raw_request( + cmd.cli_ctx, "POST", url, + body=json.dumps(body), + headers=["Content-Type=application/json"], + resource=ARM_RESOURCE, + ) + return _parse_response(resp, "install", cmd=cmd) + + +def _handle_config_set( + cmd, config_file, hierarchy_id, template_rg, + template_name, template_version, + resource_group, target_name, sub_id, +): + """Optional: set configuration values from file before review.""" + from azext_workload_orchestration.onboarding.utils import invoke_cli_command + + if not hierarchy_id: + hierarchy_id = ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/targets/{target_name}" + ) + + if not template_rg or not template_name or not template_version: + raise ValidationError( + "When using --config-file, you must also provide " + "--config-template-rg, --config-template-name, and --config-template-version." + ) + + print(f"[0/{TOTAL_STEPS}] Setting configuration from '{config_file}'...") + invoke_cli_command(cmd, [ + "workload-orchestration", "configuration", "set", + "--hierarchy-id", hierarchy_id, + "--template-rg", template_rg, + "--template-name", template_name, + "--version", template_version, + "--file", config_file, + ], expect_json=False) + print(f"[0/{TOTAL_STEPS}] Configuration set [OK]") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _parse_response(resp, step_name, cmd=None): + """Parse a REST response, handling 200/202 LRO patterns.""" + status = resp.status_code + if status in (200, 201): + try: + return resp.json() + except Exception: + return {"status": "Succeeded"} + if status == 202: + return _poll_lro(resp, step_name, cmd=cmd) + + # Error + try: + error_body = resp.text + except Exception: + error_body = f"HTTP {status}" + raise CLIInternalError(f"{step_name} failed: {error_body}") + + +def _poll_lro(resp, step_name, cmd=None): + """Poll an LRO until terminal state.""" + import time + + location = resp.headers.get("Location") or resp.headers.get("Azure-AsyncOperation") + if not location: + logger.warning("No LRO polling URL in %s response headers", step_name) + return {"status": "Accepted"} + + retry_after = int(resp.headers.get("Retry-After", "10")) + max_polls = 60 # ~10 min max + + for i in range(max_polls): + time.sleep(retry_after) + try: + poll_resp = send_raw_request(cmd.cli_ctx, "GET", location, resource=ARM_RESOURCE) + except Exception: + logger.debug("LRO poll attempt %d failed for %s", i, step_name) + continue + + try: + body = poll_resp.json() + except Exception: + continue + + status = body.get("status", "").lower() + if status in ("succeeded", "completed"): + return body + if status in ("failed", "canceled", "cancelled"): + raise CLIInternalError( + f"{step_name} LRO failed: {json.dumps(body, indent=2)}" + ) + + raise CLIInternalError(f"{step_name} LRO timed out after {max_polls * retry_after}s") + + +def _extract_solution_version_id(review_result): + """Extract solution-version-id from review response.""" + if not review_result or not isinstance(review_result, dict): + raise CLIInternalError("Review returned no result — cannot determine solution version ID.") + + # The review response may have the ID at different paths + sv_id = ( + review_result.get("solutionVersionId") + or review_result.get("properties", {}).get("solutionVersionId") + or review_result.get("id") + ) + if not sv_id: + logger.warning("Could not extract solutionVersionId from review result: %s", + json.dumps(review_result, indent=2)[:500]) + raise CLIInternalError( + "Review succeeded but no solutionVersionId found in response. " + "Pass --skip-review and provide the solution-version-id directly via " + "--solution-template-version-id if you already have a reviewed version." + ) + return sv_id + + +def _short_id(arm_id): + """Return just the resource name from an ARM ID for display.""" + if not arm_id: + return "" + parts = arm_id.strip("/").split("/") + return parts[-1] if parts else arm_id + + +def _print_step(step_num, name, status=""): + """Print formatted step output.""" + prefix = f"[{step_num}/{TOTAL_STEPS}]" + if status: + print(f"{prefix} {name}... {status}") + else: + print(f"{prefix} {name}...") From 3bb524c50e5a857cd7c6e86132554bd651512c5e Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 12:32:44 +0530 Subject: [PATCH 09/91] feat: enhance target deploy with friendly name, resume-from, config-set - Add --solution-template-name + --solution-template-version (friendly name) as alternative to --solution-template-version-id (ARM ID) - Add --solution-template-rg for cross-RG templates - Add --resume-from publish|install with --solution-version-id - Add --solution-dependencies pass-through to review - Add --config for pre-review configuration set - Proper mutual exclusivity validation between ARM ID and friendly name - Dynamic step counter adjusts based on active steps Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 48 ++- .../azext_workload_orchestration/_params.py | 49 ++- .../onboarding/__init__.py | 20 +- .../onboarding/target_deploy.py | 307 +++++++++++++----- 4 files changed, 319 insertions(+), 105 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index c9f95613eae..44b5f7ef4c9 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -79,31 +79,49 @@ type: command short-summary: Deploy a solution to a target in one step (review → publish → install). long-summary: | - Chains the three deployment steps into a single command: - 1. Review: validates the solution template version against the target - 2. Publish: publishes the reviewed solution version - 3. Install: installs the published solution on the target + Chains up to four steps into a single command: + 0. Config Set (optional): applies configuration values from a YAML/JSON file + 1. Review: validates the solution template version against the target + 2. Publish: publishes the reviewed solution version + 3. Install: installs the published solution on the target - Optionally set configuration values from a YAML file before review - using --config-file with the config template parameters. + You can identify the solution template by either: + - ARM ID: --solution-template-version-id + - Friendly name: --solution-template-name --solution-template-version - Use --skip-review if the solution is already reviewed, or - --skip-install to publish without installing. + Use --resume-from to restart a partially completed deployment. + Use --skip-review or --skip-install to skip specific steps. + Use --config to set configuration values before the review step. examples: - - name: Deploy a solution template version to a target + - name: Deploy using friendly name text: > az workload-orchestration target deploy -g my-rg -n my-target - --solution-template-version-id /subscriptions/sub/resourceGroups/rg/providers/Microsoft.Edge/solutionTemplates/tmpl/versions/1.0.0 + --solution-template-name sofi-hotmelt-template --solution-template-version 1.0.0 + - name: Deploy using ARM ID + text: > + az workload-orchestration target deploy -g my-rg -n my-target + --solution-template-version-id /subscriptions/.../solutionTemplates/tmpl/versions/1.0.0 - name: Deploy with configuration file text: > az workload-orchestration target deploy -g my-rg -n my-target - --solution-template-version-id /subscriptions/sub/resourceGroups/rg/providers/Microsoft.Edge/solutionTemplates/tmpl/versions/1.0.0 - --config-file values.yaml --config-template-rg rg --config-template-name tmpl --config-template-version 1.0.0 - - name: Skip review (already reviewed) + --solution-template-name tmpl --solution-template-version 1.0.0 + --config values.yaml --config-template-rg rg --config-template-name cfg --config-template-version 1.0.0 + - name: Resume from publish (review already done) + text: > + az workload-orchestration target deploy -g my-rg -n my-target + --resume-from publish --solution-version-id /subscriptions/.../solutionVersions/sv1 + - name: Resume from install (review + publish already done) + text: > + az workload-orchestration target deploy -g my-rg -n my-target + --resume-from install --solution-version-id /subscriptions/.../solutionVersions/sv1 + - name: Skip install (review + publish only) + text: > + az workload-orchestration target deploy -g my-rg -n my-target + --solution-template-name tmpl --solution-template-version 1.0.0 --skip-install + - name: Deploy without waiting for install text: > az workload-orchestration target deploy -g my-rg -n my-target - --solution-template-version-id /subscriptions/sub/resourceGroups/rg/providers/Microsoft.Edge/solutionVersions/v1 - --skip-review + --solution-template-name tmpl --solution-template-version 1.0.0 --no-wait """ diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index afbf8b2a39b..4403f28e5aa 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -97,25 +97,56 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Kubernetes context to use. Defaults to current context.') with self.argument_context('workload-orchestration target deploy') as c: + # Required: target identification c.argument('resource_group', options_list=['--resource-group', '-g'], help='Resource group of the target.', required=True) c.argument('target_name', options_list=['--target-name', '-n'], help='Name of the target to deploy to.', required=True) + + # Solution template: ARM ID (option A) c.argument('solution_template_version_id', options_list=['--solution-template-version-id'], - help='ARM resource ID of the solution template version to deploy.', required=True) + help='Full ARM resource ID of the solution template version. ' + 'Mutually exclusive with --solution-template-name.') + + # Solution template: friendly name (option B) + c.argument('solution_template_name', options_list=['--solution-template-name'], + help='Name of the solution template. ' + 'Use with --solution-template-version. ' + 'Mutually exclusive with --solution-template-version-id.') + c.argument('solution_template_version', options_list=['--solution-template-version'], + help='Version of the solution template (e.g., 1.0.0). ' + 'Required when using --solution-template-name.') + c.argument('solution_template_rg', options_list=['--solution-template-rg'], + help='Resource group of the solution template. ' + 'Defaults to --resource-group if omitted.') + + # Optional review args c.argument('solution_instance_name', options_list=['--solution-instance-name'], - help='Optional solution instance name for the review step.') + help='Custom solution instance name for the review step.') + c.argument('solution_dependencies', options_list=['--solution-dependencies'], + help='JSON string of solution dependency definitions.') + + # Resume / skip + c.argument('resume_from', options_list=['--resume-from'], + help='Resume deployment from a specific step. ' + 'Choices: publish, install. Requires --solution-version-id.', + choices=['publish', 'install']) + c.argument('solution_version_id', options_list=['--solution-version-id'], + help='Solution version ARM ID. Required with --resume-from.') c.argument('skip_review', options_list=['--skip-review'], - action='store_true', help='Skip the review step (use when already reviewed).') + action='store_true', + help='Skip review (use solution-template-version-id directly for publish).') c.argument('skip_install', options_list=['--skip-install'], - action='store_true', help='Skip the install step (publish only).') - c.argument('config_file', options_list=['--config-file'], - help='YAML file with configuration values to set before review.') + action='store_true', help='Skip install step (review + publish only).') + + # Config set (step 0) + c.argument('config', options_list=['--config'], + help='Path to YAML/JSON file with configuration values to set before review.') c.argument('config_hierarchy_id', options_list=['--config-hierarchy-id'], help='ARM ID of hierarchy entity for config set. Defaults to target ARM ID.') c.argument('config_template_rg', options_list=['--config-template-rg'], - help='Resource group of the configuration template (used with --config-file).') + help='Resource group of the configuration template (with --config).') c.argument('config_template_name', options_list=['--config-template-name'], - help='Name of the configuration template (used with --config-file).') + help='Name of the configuration template (with --config).') c.argument('config_template_version', options_list=['--config-template-version'], - help='Version of the configuration template (used with --config-file).') + help='Version of the configuration template (with --config).') diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index d8d43a95135..f858a52a64d 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -49,15 +49,22 @@ def target_deploy( cmd, resource_group, target_name, - solution_template_version_id, + solution_template_version_id=None, + solution_template_name=None, + solution_template_version=None, + solution_template_rg=None, solution_instance_name=None, + solution_dependencies=None, + solution_version_id=None, + resume_from=None, skip_review=False, skip_install=False, - config_file=None, + config=None, config_hierarchy_id=None, config_template_rg=None, config_template_name=None, config_template_version=None, + no_wait=False, ): """Deploy a solution to a target: review → publish → install.""" return _target_deploy( @@ -65,14 +72,21 @@ def target_deploy( resource_group=resource_group, target_name=target_name, solution_template_version_id=solution_template_version_id, + solution_template_name=solution_template_name, + solution_template_version=solution_template_version, + solution_template_rg=solution_template_rg, solution_instance_name=solution_instance_name, + solution_dependencies=solution_dependencies, + solution_version_id=solution_version_id, + resume_from=resume_from, skip_review=skip_review, skip_install=skip_install, - config_file=config_file, + config=config, config_hierarchy_id=config_hierarchy_id, config_template_rg=config_template_rg, config_template_name=config_template_name, config_template_version=config_template_version, + no_wait=no_wait, ) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index ef404dcc68b..62cf39a3bc8 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -3,17 +3,37 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Target deploy command - chains review → publish → install in one step. +"""Target deploy command - chains review -> publish -> install in one step. Replaces 3 manual commands: 1. az workload-orchestration target review 2. az workload-orchestration target publish 3. az workload-orchestration target install +Optionally prepends config-set (step 0) when --config is provided. + Usage: + # Friendly name + az workload-orchestration target deploy \\ + -g my-rg -n my-target \\ + --solution-template-name tmpl --solution-template-version 1.0.0 + + # ARM ID az workload-orchestration target deploy \\ -g my-rg -n my-target \\ --solution-template-version-id + + # With config + az workload-orchestration target deploy \\ + -g my-rg -n my-target \\ + --solution-template-version-id \\ + --config values.yaml \\ + --config-template-rg rg --config-template-name tmpl --config-template-version 1.0.0 + + # Resume from publish (already reviewed) + az workload-orchestration target deploy \\ + -g my-rg -n my-target \\ + --resume-from publish --solution-version-id """ import json @@ -24,7 +44,6 @@ logger = logging.getLogger(__name__) -TOTAL_STEPS = 3 API_VERSION = "2025-08-01" ARM_RESOURCE = "https://management.azure.com" @@ -33,94 +52,226 @@ def target_deploy( cmd, resource_group, target_name, - solution_template_version_id, + solution_template_version_id=None, + solution_template_name=None, + solution_template_version=None, + solution_template_rg=None, solution_instance_name=None, + solution_dependencies=None, + solution_version_id=None, + resume_from=None, skip_review=False, skip_install=False, - config_file=None, + config=None, config_hierarchy_id=None, config_template_rg=None, config_template_name=None, config_template_version=None, + no_wait=False, ): - """Deploy a solution to a target: review → publish → install. + """Deploy a solution to a target: review -> publish -> install. - Chains the 3 LRO operations, passing the solution-version-id - from review output into publish and install automatically. + Chains up to 4 steps (config-set + review + publish + install), + passing the solution-version-id from review into publish and install. """ - sub_id = cmd.cli_ctx.data.get('subscription_id') - if not sub_id: - from azure.cli.core._profile import Profile - sub_id = Profile(cli_ctx=cmd.cli_ctx).get_subscription_id() + sub_id = _get_subscription_id(cmd) + + # --- Validate resume-from (before template resolution) --- + if resume_from: + resume_from = resume_from.lower() + if resume_from not in ("publish", "install"): + raise ValidationError("--resume-from must be 'publish' or 'install'.") + if not solution_version_id: + raise ValidationError( + "--solution-version-id is required when using --resume-from." + ) + + # --- Resolve solution-template-version-id (not needed for resume) --- + if not resume_from: + solution_template_version_id = _resolve_template_version_id( + solution_template_version_id, solution_template_name, + solution_template_version, solution_template_rg, + resource_group, sub_id, + ) + elif not solution_template_version_id: + # resume_from is set, template version not required + solution_template_version_id = None base_url = ( - f"https://management.azure.com/subscriptions/{sub_id}" + f"{ARM_RESOURCE}/subscriptions/{sub_id}" f"/resourceGroups/{resource_group}" f"/providers/Microsoft.Edge/targets/{target_name}" ) + # Figure out which steps to run + do_config = config is not None + do_review = (resume_from is None) and (not skip_review) + do_publish = resume_from in (None, "publish") or skip_review + do_install = (not skip_install) and (resume_from != "install" or resume_from == "install") + + # If resume_from == "install", skip review and publish + if resume_from == "install": + do_review = False + do_publish = False + elif resume_from == "publish": + do_review = False + + total = sum([do_config, do_review, do_publish, do_install]) + step = [0] # mutable counter + + def _step(name, status=""): + step[0] += 1 + prefix = f"[{step[0]}/{total}]" + if status: + print(f"{prefix} {name}... {status}") + else: + print(f"{prefix} {name}...") + results = {} + sv_id = solution_version_id # may be set by review or passed via --solution-version-id - # Optional Step 0: Set configuration before review - if config_file: + # --- Step 0: Config set --- + if do_config: + _step("Config Set") _handle_config_set( - cmd, config_file, config_hierarchy_id, config_template_rg, + cmd, config, config_hierarchy_id, config_template_rg, config_template_name, config_template_version, resource_group, target_name, sub_id, ) + _step("Config Set", "[OK]") + step[0] -= 1 # _step incremented twice; fix + results["configSet"] = "Succeeded" - # Step 1: Review - if skip_review: - _print_step(1, "Review", "Skipped (--skip-review)") - results["review"] = "Skipped" - # Without review, user must know the solution-version-id already - # We'll use the solution-template-version-id for publish directly - solution_version_id = solution_template_version_id - else: - _print_step(1, "Review") + # --- Step 1: Review --- + if do_review: + _step("Review") review_result = _do_review( - cmd, base_url, solution_template_version_id, solution_instance_name, + cmd, base_url, solution_template_version_id, + solution_instance_name, solution_dependencies, ) results["review"] = review_result - solution_version_id = _extract_solution_version_id(review_result) - _print_step(1, "Review", f"[OK] → version: {_short_id(solution_version_id)}") - - # Step 2: Publish - _print_step(2, "Publish") - publish_result = _do_publish(cmd, base_url, solution_version_id) - results["publish"] = publish_result - _print_step(2, "Publish", "[OK]") - - # Step 3: Install - if skip_install: - _print_step(3, "Install", "Skipped (--skip-install)") - results["install"] = "Skipped" + sv_id = _extract_solution_version_id(review_result) + _step("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") + step[0] -= 1 + elif not resume_from: + print(f"[~] Review skipped (--skip-review)") + results["review"] = "Skipped" + sv_id = solution_template_version_id + else: + print(f"[~] Review skipped (--resume-from {resume_from})") + results["review"] = "Skipped" + + if not sv_id: + raise CLIInternalError("No solution-version-id available. Cannot proceed with publish.") + + # --- Step 2: Publish --- + if do_publish: + _step("Publish") + publish_result = _do_publish(cmd, base_url, sv_id) + results["publish"] = publish_result + _step("Publish", "[OK]") + step[0] -= 1 else: - _print_step(3, "Install") - install_result = _do_install(cmd, base_url, solution_version_id) + print(f"[~] Publish skipped (--resume-from install)") + results["publish"] = "Skipped" + + # --- Step 3: Install --- + if do_install: + _step("Install") + install_result = _do_install(cmd, base_url, sv_id, no_wait=no_wait) results["install"] = install_result - _print_step(3, "Install", "[OK]") + if no_wait: + _step("Install", "[Accepted] (--no-wait)") + else: + _step("Install", "[OK]") + step[0] -= 1 + else: + print(f"[~] Install skipped (--skip-install)") + results["install"] = "Skipped" + + print(f"\n{'=' * 50}") + print(f"Deployment complete for target '{target_name}'") + print(f"Solution Version ID: {sv_id}") + print(f"{'=' * 50}") - print(f"\n[OK] Deployment complete for target '{target_name}'") return { "targetName": target_name, - "solutionVersionId": solution_version_id, + "resourceGroup": resource_group, + "solutionVersionId": sv_id, + "solutionTemplateVersionId": solution_template_version_id, "steps": results, } +# --------------------------------------------------------------------------- +# Resolution helpers +# --------------------------------------------------------------------------- + +def _get_subscription_id(cmd): + """Get subscription ID from CLI context.""" + sub_id = cmd.cli_ctx.data.get('subscription_id') + if not sub_id: + from azure.cli.core._profile import Profile + sub_id = Profile(cli_ctx=cmd.cli_ctx).get_subscription_id() + return sub_id + + +def _resolve_template_version_id( + arm_id, template_name, template_version, template_rg, + default_rg, sub_id, +): + """Resolve solution-template-version-id from friendly name or ARM ID. + + Mutual exclusivity: + - Provide --solution-template-version-id (full ARM ID) + - OR --solution-template-name + --solution-template-version (friendly) + """ + if arm_id and template_name: + raise ValidationError( + "Provide either --solution-template-version-id OR " + "(--solution-template-name + --solution-template-version), not both." + ) + + if arm_id: + return arm_id + + if template_name: + if not template_version: + raise ValidationError( + "--solution-template-version is required when using --solution-template-name." + ) + rg = template_rg or default_rg + return ( + f"/subscriptions/{sub_id}/resourceGroups/{rg}" + f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" + f"/versions/{template_version}" + ) + + raise ValidationError( + "Provide either --solution-template-version-id or " + "(--solution-template-name + --solution-template-version)." + ) + + # --------------------------------------------------------------------------- # Step implementations # --------------------------------------------------------------------------- -def _do_review(cmd, base_url, solution_template_version_id, solution_instance_name): - """POST .../reviewSolutionVersion — validates config and creates a solution version.""" +def _do_review(cmd, base_url, solution_template_version_id, + solution_instance_name=None, solution_dependencies=None): + """POST .../reviewSolutionVersion""" url = f"{base_url}/reviewSolutionVersion?api-version={API_VERSION}" body = { "solutionTemplateVersionId": solution_template_version_id, } if solution_instance_name: body["solutionInstanceName"] = solution_instance_name + if solution_dependencies: + body["solutionDependencies"] = ( + json.loads(solution_dependencies) + if isinstance(solution_dependencies, str) + else solution_dependencies + ) resp = send_raw_request( cmd.cli_ctx, "POST", url, @@ -128,11 +279,11 @@ def _do_review(cmd, base_url, solution_template_version_id, solution_instance_na headers=["Content-Type=application/json"], resource=ARM_RESOURCE, ) - return _parse_response(resp, "review", cmd=cmd) + return _parse_response(resp, "Review", cmd=cmd) def _do_publish(cmd, base_url, solution_version_id): - """POST .../publishSolutionVersion — publishes the reviewed solution.""" + """POST .../publishSolutionVersion""" url = f"{base_url}/publishSolutionVersion?api-version={API_VERSION}" body = {"solutionVersionId": solution_version_id} @@ -142,11 +293,11 @@ def _do_publish(cmd, base_url, solution_version_id): headers=["Content-Type=application/json"], resource=ARM_RESOURCE, ) - return _parse_response(resp, "publish", cmd=cmd) + return _parse_response(resp, "Publish", cmd=cmd) -def _do_install(cmd, base_url, solution_version_id): - """POST .../installSolution — installs the published solution on target.""" +def _do_install(cmd, base_url, solution_version_id, no_wait=False): + """POST .../installSolution""" url = f"{base_url}/installSolution?api-version={API_VERSION}" body = {"solutionVersionId": solution_version_id} @@ -156,7 +307,17 @@ def _do_install(cmd, base_url, solution_version_id): headers=["Content-Type=application/json"], resource=ARM_RESOURCE, ) - return _parse_response(resp, "install", cmd=cmd) + + if no_wait: + # Return 202 without polling + if resp.status_code == 202: + return {"status": "Accepted", "message": "Install triggered (no-wait)"} + try: + return resp.json() + except Exception: + return {"status": "Accepted"} + + return _parse_response(resp, "Install", cmd=cmd) def _handle_config_set( @@ -164,9 +325,10 @@ def _handle_config_set( template_name, template_version, resource_group, target_name, sub_id, ): - """Optional: set configuration values from file before review.""" - from azext_workload_orchestration.onboarding.utils import invoke_cli_command + """Set configuration values from file before review. + Delegates to: az workload-orchestration configuration set + """ if not hierarchy_id: hierarchy_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" @@ -175,11 +337,11 @@ def _handle_config_set( if not template_rg or not template_name or not template_version: raise ValidationError( - "When using --config-file, you must also provide " + "When using --config, you must also provide " "--config-template-rg, --config-template-name, and --config-template-version." ) - print(f"[0/{TOTAL_STEPS}] Setting configuration from '{config_file}'...") + from azext_workload_orchestration.onboarding.utils import invoke_cli_command invoke_cli_command(cmd, [ "workload-orchestration", "configuration", "set", "--hierarchy-id", hierarchy_id, @@ -188,15 +350,14 @@ def _handle_config_set( "--version", template_version, "--file", config_file, ], expect_json=False) - print(f"[0/{TOTAL_STEPS}] Configuration set [OK]") # --------------------------------------------------------------------------- -# Helpers +# LRO and response helpers # --------------------------------------------------------------------------- def _parse_response(resp, step_name, cmd=None): - """Parse a REST response, handling 200/202 LRO patterns.""" + """Parse REST response, handling 200/201/202 LRO patterns.""" status = resp.status_code if status in (200, 201): try: @@ -211,11 +372,11 @@ def _parse_response(resp, step_name, cmd=None): error_body = resp.text except Exception: error_body = f"HTTP {status}" - raise CLIInternalError(f"{step_name} failed: {error_body}") + raise CLIInternalError(f"{step_name} failed (HTTP {status}): {error_body}") def _poll_lro(resp, step_name, cmd=None): - """Poll an LRO until terminal state.""" + """Poll an LRO via Location or Azure-AsyncOperation header.""" import time location = resp.headers.get("Location") or resp.headers.get("Azure-AsyncOperation") @@ -231,7 +392,7 @@ def _poll_lro(resp, step_name, cmd=None): try: poll_resp = send_raw_request(cmd.cli_ctx, "GET", location, resource=ARM_RESOURCE) except Exception: - logger.debug("LRO poll attempt %d failed for %s", i, step_name) + logger.debug("LRO poll attempt %d failed for %s", i + 1, step_name) continue try: @@ -239,10 +400,10 @@ def _poll_lro(resp, step_name, cmd=None): except Exception: continue - status = body.get("status", "").lower() - if status in ("succeeded", "completed"): + poll_status = body.get("status", "").lower() + if poll_status in ("succeeded", "completed"): return body - if status in ("failed", "canceled", "cancelled"): + if poll_status in ("failed", "canceled", "cancelled"): raise CLIInternalError( f"{step_name} LRO failed: {json.dumps(body, indent=2)}" ) @@ -253,9 +414,9 @@ def _poll_lro(resp, step_name, cmd=None): def _extract_solution_version_id(review_result): """Extract solution-version-id from review response.""" if not review_result or not isinstance(review_result, dict): - raise CLIInternalError("Review returned no result — cannot determine solution version ID.") + raise CLIInternalError("Review returned no result - cannot determine solution version ID.") - # The review response may have the ID at different paths + # Try multiple response shapes sv_id = ( review_result.get("solutionVersionId") or review_result.get("properties", {}).get("solutionVersionId") @@ -266,24 +427,14 @@ def _extract_solution_version_id(review_result): json.dumps(review_result, indent=2)[:500]) raise CLIInternalError( "Review succeeded but no solutionVersionId found in response. " - "Pass --skip-review and provide the solution-version-id directly via " - "--solution-template-version-id if you already have a reviewed version." + "Use --resume-from publish --solution-version-id to continue manually." ) return sv_id def _short_id(arm_id): - """Return just the resource name from an ARM ID for display.""" + """Return the last segment of an ARM ID for display.""" if not arm_id: return "" parts = arm_id.strip("/").split("/") return parts[-1] if parts else arm_id - - -def _print_step(step_num, name, status=""): - """Print formatted step output.""" - prefix = f"[{step_num}/{TOTAL_STEPS}]" - if status: - print(f"{prefix} {name}... {status}") - else: - print(f"{prefix} {name}...") From d88dc6562dfcb9d1c87af423bf7110e63f6ec75a Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 13:27:12 +0530 Subject: [PATCH 10/91] fix: extract solutionVersionId from properties.id in LRO response The review LRO response nests the solution version ARM ID at properties.id, not properties.properties.id or the top-level id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/target_deploy.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 62cf39a3bc8..615e3428f53 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -416,15 +416,22 @@ def _extract_solution_version_id(review_result): if not review_result or not isinstance(review_result, dict): raise CLIInternalError("Review returned no result - cannot determine solution version ID.") - # Try multiple response shapes + # The LRO response structure: + # {id, name, status, properties: {id: , properties: {...}, ...}} + # The solution version ARM ID is at properties.id (NOT properties.properties.id) + props = review_result.get("properties", {}) + sv_id = ( - review_result.get("solutionVersionId") - or review_result.get("properties", {}).get("solutionVersionId") - or review_result.get("id") + props.get("id") # properties.id (most common) + or review_result.get("solutionVersionId") # top-level fallback + or props.get("solutionVersionId") # properties.solutionVersionId + or (props.get("properties", {}) or {}).get("id") # properties.properties.id ) if not sv_id: - logger.warning("Could not extract solutionVersionId from review result: %s", - json.dumps(review_result, indent=2)[:500]) + logger.warning("Could not extract solutionVersionId. Keys at top: %s, inner keys: %s, full (truncated): %s", + list(review_result.keys()), + list(inner.keys()) if isinstance(inner, dict) else "N/A", + json.dumps(review_result, indent=2)[:800]) raise CLIInternalError( "Review succeeded but no solutionVersionId found in response. " "Use --resume-from publish --solution-version-id to continue manually." From 3ca0b7714cd33aa5b1dd404b3d680cefb004045a Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 13:33:25 +0530 Subject: [PATCH 11/91] fix: add --solution flag to config-set and improve logging - Config set needs --solution flag to use solution templates (not config templates) - Improved solutionVersionId extraction debug logging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/onboarding/target_deploy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 615e3428f53..75e65649c25 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -349,6 +349,7 @@ def _handle_config_set( "--template-name", template_name, "--version", template_version, "--file", config_file, + "--solution", ], expect_json=False) From 066689ca6a4a833427242f1dfb644ab08db56f10 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 14:05:38 +0530 Subject: [PATCH 12/91] refactor: remove skip flags, fix linter errors, add short aliases - Remove --skip-review, --skip-install, --no-wait from target deploy - Fix disallowed HTML tags in help (wrap placeholders in backticks) - Add short aliases: --stv-id, --stv, --ct-version for long options - Remove --no-wait example from help Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 12 ++----- .../azext_workload_orchestration/_params.py | 13 +++----- .../onboarding/__init__.py | 6 ---- .../onboarding/target_deploy.py | 31 +++++-------------- 4 files changed, 14 insertions(+), 48 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 44b5f7ef4c9..dafa9d015f2 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -86,8 +86,8 @@ 3. Install: installs the published solution on the target You can identify the solution template by either: - - ARM ID: --solution-template-version-id - - Friendly name: --solution-template-name --solution-template-version + - ARM ID: --solution-template-version-id `` + - Friendly name: --solution-template-name `` --solution-template-version `` Use --resume-from to restart a partially completed deployment. Use --skip-review or --skip-install to skip specific steps. @@ -114,14 +114,6 @@ text: > az workload-orchestration target deploy -g my-rg -n my-target --resume-from install --solution-version-id /subscriptions/.../solutionVersions/sv1 - - name: Skip install (review + publish only) - text: > - az workload-orchestration target deploy -g my-rg -n my-target - --solution-template-name tmpl --solution-template-version 1.0.0 --skip-install - - name: Deploy without waiting for install - text: > - az workload-orchestration target deploy -g my-rg -n my-target - --solution-template-name tmpl --solution-template-version 1.0.0 --no-wait """ diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 4403f28e5aa..aac955fb4e3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -86,7 +86,7 @@ def load_arguments(self, _): # pylint: disable=unused-argument c.argument('extension_name', options_list=['--extension-name'], help='Name for the WO extension resource. Default: workload-orchestration.') c.argument('custom_location_name', options_list=['--custom-location-name'], - help='Name for the custom location. Default: -cl.') + help='Name for the custom location. Default: `-cl`.') c.argument('skip_cert_manager', options_list=['--skip-cert-manager'], action='store_true', help='Skip cert-manager installation.') c.argument('skip_trust_manager', options_list=['--skip-trust-manager'], @@ -104,7 +104,7 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Name of the target to deploy to.', required=True) # Solution template: ARM ID (option A) - c.argument('solution_template_version_id', options_list=['--solution-template-version-id'], + c.argument('solution_template_version_id', options_list=['--solution-template-version-id', '--stv-id'], help='Full ARM resource ID of the solution template version. ' 'Mutually exclusive with --solution-template-name.') @@ -113,7 +113,7 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Name of the solution template. ' 'Use with --solution-template-version. ' 'Mutually exclusive with --solution-template-version-id.') - c.argument('solution_template_version', options_list=['--solution-template-version'], + c.argument('solution_template_version', options_list=['--solution-template-version', '--stv'], help='Version of the solution template (e.g., 1.0.0). ' 'Required when using --solution-template-name.') c.argument('solution_template_rg', options_list=['--solution-template-rg'], @@ -133,11 +133,6 @@ def load_arguments(self, _): # pylint: disable=unused-argument choices=['publish', 'install']) c.argument('solution_version_id', options_list=['--solution-version-id'], help='Solution version ARM ID. Required with --resume-from.') - c.argument('skip_review', options_list=['--skip-review'], - action='store_true', - help='Skip review (use solution-template-version-id directly for publish).') - c.argument('skip_install', options_list=['--skip-install'], - action='store_true', help='Skip install step (review + publish only).') # Config set (step 0) c.argument('config', options_list=['--config'], @@ -148,5 +143,5 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Resource group of the configuration template (with --config).') c.argument('config_template_name', options_list=['--config-template-name'], help='Name of the configuration template (with --config).') - c.argument('config_template_version', options_list=['--config-template-version'], + c.argument('config_template_version', options_list=['--config-template-version', '--ct-version'], help='Version of the configuration template (with --config).') diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index f858a52a64d..7ee008baa0f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -57,14 +57,11 @@ def target_deploy( solution_dependencies=None, solution_version_id=None, resume_from=None, - skip_review=False, - skip_install=False, config=None, config_hierarchy_id=None, config_template_rg=None, config_template_name=None, config_template_version=None, - no_wait=False, ): """Deploy a solution to a target: review → publish → install.""" return _target_deploy( @@ -79,14 +76,11 @@ def target_deploy( solution_dependencies=solution_dependencies, solution_version_id=solution_version_id, resume_from=resume_from, - skip_review=skip_review, - skip_install=skip_install, config=config, config_hierarchy_id=config_hierarchy_id, config_template_rg=config_template_rg, config_template_name=config_template_name, config_template_version=config_template_version, - no_wait=no_wait, ) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 75e65649c25..a717c7abc2b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -60,14 +60,11 @@ def target_deploy( solution_dependencies=None, solution_version_id=None, resume_from=None, - skip_review=False, - skip_install=False, config=None, config_hierarchy_id=None, config_template_rg=None, config_template_name=None, config_template_version=None, - no_wait=False, ): """Deploy a solution to a target: review -> publish -> install. @@ -105,9 +102,9 @@ def target_deploy( # Figure out which steps to run do_config = config is not None - do_review = (resume_from is None) and (not skip_review) - do_publish = resume_from in (None, "publish") or skip_review - do_install = (not skip_install) and (resume_from != "install" or resume_from == "install") + do_review = resume_from is None + do_publish = resume_from in (None, "publish") + do_install = True # If resume_from == "install", skip review and publish if resume_from == "install": @@ -154,7 +151,7 @@ def _step(name, status=""): _step("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") step[0] -= 1 elif not resume_from: - print(f"[~] Review skipped (--skip-review)") + # Should not reach here — do_review is True when resume_from is None results["review"] = "Skipped" sv_id = solution_template_version_id else: @@ -178,15 +175,12 @@ def _step(name, status=""): # --- Step 3: Install --- if do_install: _step("Install") - install_result = _do_install(cmd, base_url, sv_id, no_wait=no_wait) + install_result = _do_install(cmd, base_url, sv_id) results["install"] = install_result - if no_wait: - _step("Install", "[Accepted] (--no-wait)") - else: - _step("Install", "[OK]") + _step("Install", "[OK]") step[0] -= 1 else: - print(f"[~] Install skipped (--skip-install)") + print(f"[~] Install skipped (--resume-from install)") results["install"] = "Skipped" print(f"\n{'=' * 50}") @@ -296,7 +290,7 @@ def _do_publish(cmd, base_url, solution_version_id): return _parse_response(resp, "Publish", cmd=cmd) -def _do_install(cmd, base_url, solution_version_id, no_wait=False): +def _do_install(cmd, base_url, solution_version_id): """POST .../installSolution""" url = f"{base_url}/installSolution?api-version={API_VERSION}" body = {"solutionVersionId": solution_version_id} @@ -308,15 +302,6 @@ def _do_install(cmd, base_url, solution_version_id, no_wait=False): resource=ARM_RESOURCE, ) - if no_wait: - # Return 202 without polling - if resp.status_code == 202: - return {"status": "Accepted", "message": "Install triggered (no-wait)"} - try: - return resp.json() - except Exception: - return {"status": "Accepted"} - return _parse_response(resp, "Install", cmd=cmd) From e5e330cb30c4a1dd4ef45df142b31a033c7db531 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 14:13:27 +0530 Subject: [PATCH 13/91] cleanup: remove duplicate output from target init - Remove 'Next steps' and 'Extended Location JSON' print lines - Keep diagnostic summary and success message - CLI framework already prints the return dict as JSON Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/target_prepare.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index c0329ef3f03..1b327537538 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -204,12 +204,6 @@ def target_prepare( print_success(f"Cluster '{cluster_name}' is ready for Workload Orchestration") print_detail("Custom Location ID", cl_id) - print_detail("Extended Location JSON", json.dumps(extended_location)) - print() - print(" Next steps:") - print(" 1. Create hierarchy: az workload-orchestration hierarchy create ...") - print(" 2. Create target: az workload-orchestration target create " - "--extended-location '@extended-location.json' ...") print() return { From 84a10dcc2185adeff44c7c4c3f7d419bcbcd66ab Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 14:35:36 +0530 Subject: [PATCH 14/91] cleanup: remove preview tags from target and support command groups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index 99dda357cd2..c6c4621cbb9 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -10,9 +10,9 @@ def load_command_table(self, _): # pylint: disable=unused-argument - with self.command_group('workload-orchestration support', is_preview=True) as g: + with self.command_group('workload-orchestration support') as g: g.custom_command('create-bundle', 'create_support_bundle') - with self.command_group('workload-orchestration target', is_preview=True) as g: + with self.command_group('workload-orchestration target') as g: g.custom_command('init', 'target_init') g.custom_command('deploy', 'target_deploy') From d81f4bc79ec015de1b536ea86a7ae1cbb3c70a1d Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 14:57:40 +0530 Subject: [PATCH 15/91] fix: step counter display in target deploy Counter now increments only on step start, not on status update. Shows [1/3]...[1/3] OK, [2/3]...[2/3] OK, [3/3]...[3/3] OK. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/target_deploy.py | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index a717c7abc2b..27ff0f21ae3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -114,42 +114,39 @@ def target_deploy( do_review = False total = sum([do_config, do_review, do_publish, do_install]) - step = [0] # mutable counter + current = [0] # mutable counter - def _step(name, status=""): - step[0] += 1 - prefix = f"[{step[0]}/{total}]" + def _log(step_name, status=""): if status: - print(f"{prefix} {name}... {status}") + print(f"[{current[0]}/{total}] {step_name}... {status}") else: - print(f"{prefix} {name}...") + current[0] += 1 + print(f"[{current[0]}/{total}] {step_name}...") results = {} sv_id = solution_version_id # may be set by review or passed via --solution-version-id # --- Step 0: Config set --- if do_config: - _step("Config Set") + _log("Config Set") _handle_config_set( cmd, config, config_hierarchy_id, config_template_rg, config_template_name, config_template_version, resource_group, target_name, sub_id, ) - _step("Config Set", "[OK]") - step[0] -= 1 # _step incremented twice; fix + _log("Config Set", "[OK]") results["configSet"] = "Succeeded" # --- Step 1: Review --- if do_review: - _step("Review") + _log("Review") review_result = _do_review( cmd, base_url, solution_template_version_id, solution_instance_name, solution_dependencies, ) results["review"] = review_result sv_id = _extract_solution_version_id(review_result) - _step("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") - step[0] -= 1 + _log("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") elif not resume_from: # Should not reach here — do_review is True when resume_from is None results["review"] = "Skipped" @@ -163,22 +160,20 @@ def _step(name, status=""): # --- Step 2: Publish --- if do_publish: - _step("Publish") + _log("Publish") publish_result = _do_publish(cmd, base_url, sv_id) results["publish"] = publish_result - _step("Publish", "[OK]") - step[0] -= 1 + _log("Publish", "[OK]") else: print(f"[~] Publish skipped (--resume-from install)") results["publish"] = "Skipped" # --- Step 3: Install --- if do_install: - _step("Install") + _log("Install") install_result = _do_install(cmd, base_url, sv_id) results["install"] = install_result - _step("Install", "[OK]") - step[0] -= 1 + _log("Install", "[OK]") else: print(f"[~] Install skipped (--resume-from install)") results["install"] = "Skipped" From 230ea5c49b8f97af107fe20a5c16fb67a1a3d67d Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 14 Apr 2026 15:04:33 +0530 Subject: [PATCH 16/91] fix: return install LRO result matching native target install format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/target_deploy.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 27ff0f21ae3..582cb733b09 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -183,13 +183,11 @@ def _log(step_name, status=""): print(f"Solution Version ID: {sv_id}") print(f"{'=' * 50}") - return { - "targetName": target_name, - "resourceGroup": resource_group, - "solutionVersionId": sv_id, - "solutionTemplateVersionId": solution_template_version_id, - "steps": results, - } + # Return the install LRO result (same format as `az wo target install`) + return results.get("install", { + "status": "Succeeded", + "resourceId": f"{base_url}", + }) # --------------------------------------------------------------------------- From fa2bc600850b1df9b6e854a42919c5ff4b2c0f97 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 09:46:06 +0530 Subject: [PATCH 17/91] cleanup: remove diagnostic summary from target init success output Keep it only on error paths for debugging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/onboarding/target_prepare.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 1b327537538..905fdea9768 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -199,9 +199,6 @@ def target_prepare( extended_location = {"name": cl_id, "type": "CustomLocation"} _write_extended_location_file(extended_location) - # Print diagnostic summary (all steps succeeded) - _print_diagnostic_summary(step_results, cluster_name, resource_group) - print_success(f"Cluster '{cluster_name}' is ready for Workload Orchestration") print_detail("Custom Location ID", cl_id) print() From 51e988e5bd11a6becea8e7e6acc51c7943a134e9 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 09:54:04 +0530 Subject: [PATCH 18/91] feat: context create accepts --site-id to auto-create site reference When --site-id is provided, automatically creates a site-reference linking the site to the context after creation. Site reference name is derived from the site name (e.g., mySite -> mySite-ref). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/context/_create.py | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py index 6292ad711b4..957d55b7ac7 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py @@ -9,6 +9,7 @@ # flake8: noqa from azure.cli.core.aaz import * +from azure.cli.core.azclierror import CLIInternalError as CLIError @register_command( @@ -128,6 +129,14 @@ def _build_arguments_schema(cls, *args, **kwargs): tags = cls._args_schema.tags tags.Element = AAZStrArg() + + # Custom arg: --site-id (not sent to ARM, used in post_operations) + _args_schema.site_id = AAZStrArg( + options=["--site-id"], + arg_group="Onboarding", + help="ARM resource ID of a Site to auto-create a site reference after context creation.", + ) + return cls._args_schema def _execute_operations(self): @@ -141,7 +150,47 @@ def pre_operations(self): @register_callback def post_operations(self): - pass + if hasattr(self.ctx.args, 'site_id') and self.ctx.args.site_id: + self._create_site_reference() + + def _create_site_reference(self): + """Auto-create a site reference linking the site to this context.""" + import logging + import re + logger = logging.getLogger(__name__) + + site_id = str(self.ctx.args.site_id) + context_name = str(self.ctx.args.context_name) + rg = str(self.ctx.args.resource_group) + + # Extract site name from ARM ID for the reference name + site_name = site_id.rstrip("/").split("/")[-1] + ref_name = f"{site_name}-ref" + # Sanitize: only alphanumeric and hyphens, 3-61 chars + ref_name = re.sub(r'[^a-zA-Z0-9-]', '-', ref_name)[:61] + + logger.info("Creating site reference '%s' -> %s", ref_name, site_id) + + try: + from azext_workload_orchestration.onboarding.utils import invoke_cli_command, CmdProxy + cmd_proxy = CmdProxy(self.ctx.cli_ctx) + invoke_cli_command(cmd_proxy, [ + "workload-orchestration", "context", "site-reference", "create", + "-g", rg, + "--context-name", context_name, + "--site-reference-name", ref_name, + "--site-id", site_id, + ]) + logger.info("Site reference '%s' created successfully", ref_name) + except Exception as exc: + logger.warning("Site reference creation failed: %s", exc) + raise CLIError( + f"Context created successfully, but site reference creation failed: {exc}\n" + f"Run manually:\n" + f" az workload-orchestration context site-reference create " + f"-g {rg} --context-name {context_name} " + f"--site-reference-name {ref_name} --site-id {site_id}" + ) def _output(self, *args, **kwargs): result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) From 45ad4485e72f3b390510aa69b692b78bc64c00e7 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 10:03:31 +0530 Subject: [PATCH 19/91] refactor: remove --resume-from and --solution-version-id from target deploy Users can use individual commands (target review/publish/install) for partial operations. Deploy is now always a full chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 10 -- .../azext_workload_orchestration/_params.py | 8 -- .../onboarding/__init__.py | 4 - .../onboarding/target_deploy.py | 100 +++++------------- 4 files changed, 25 insertions(+), 97 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index dafa9d015f2..78576a43ec1 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -89,8 +89,6 @@ - ARM ID: --solution-template-version-id `` - Friendly name: --solution-template-name `` --solution-template-version `` - Use --resume-from to restart a partially completed deployment. - Use --skip-review or --skip-install to skip specific steps. Use --config to set configuration values before the review step. examples: - name: Deploy using friendly name @@ -106,14 +104,6 @@ az workload-orchestration target deploy -g my-rg -n my-target --solution-template-name tmpl --solution-template-version 1.0.0 --config values.yaml --config-template-rg rg --config-template-name cfg --config-template-version 1.0.0 - - name: Resume from publish (review already done) - text: > - az workload-orchestration target deploy -g my-rg -n my-target - --resume-from publish --solution-version-id /subscriptions/.../solutionVersions/sv1 - - name: Resume from install (review + publish already done) - text: > - az workload-orchestration target deploy -g my-rg -n my-target - --resume-from install --solution-version-id /subscriptions/.../solutionVersions/sv1 """ diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index aac955fb4e3..e7a5ffe62e3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -126,14 +126,6 @@ def load_arguments(self, _): # pylint: disable=unused-argument c.argument('solution_dependencies', options_list=['--solution-dependencies'], help='JSON string of solution dependency definitions.') - # Resume / skip - c.argument('resume_from', options_list=['--resume-from'], - help='Resume deployment from a specific step. ' - 'Choices: publish, install. Requires --solution-version-id.', - choices=['publish', 'install']) - c.argument('solution_version_id', options_list=['--solution-version-id'], - help='Solution version ARM ID. Required with --resume-from.') - # Config set (step 0) c.argument('config', options_list=['--config'], help='Path to YAML/JSON file with configuration values to set before review.') diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 7ee008baa0f..21ce3b79cef 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -55,8 +55,6 @@ def target_deploy( solution_template_rg=None, solution_instance_name=None, solution_dependencies=None, - solution_version_id=None, - resume_from=None, config=None, config_hierarchy_id=None, config_template_rg=None, @@ -74,8 +72,6 @@ def target_deploy( solution_template_rg=solution_template_rg, solution_instance_name=solution_instance_name, solution_dependencies=solution_dependencies, - solution_version_id=solution_version_id, - resume_from=resume_from, config=config, config_hierarchy_id=config_hierarchy_id, config_template_rg=config_template_rg, diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 582cb733b09..ca3984129dc 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -29,11 +29,6 @@ --solution-template-version-id \\ --config values.yaml \\ --config-template-rg rg --config-template-name tmpl --config-template-version 1.0.0 - - # Resume from publish (already reviewed) - az workload-orchestration target deploy \\ - -g my-rg -n my-target \\ - --resume-from publish --solution-version-id """ import json @@ -58,8 +53,6 @@ def target_deploy( solution_template_rg=None, solution_instance_name=None, solution_dependencies=None, - solution_version_id=None, - resume_from=None, config=None, config_hierarchy_id=None, config_template_rg=None, @@ -73,26 +66,12 @@ def target_deploy( """ sub_id = _get_subscription_id(cmd) - # --- Validate resume-from (before template resolution) --- - if resume_from: - resume_from = resume_from.lower() - if resume_from not in ("publish", "install"): - raise ValidationError("--resume-from must be 'publish' or 'install'.") - if not solution_version_id: - raise ValidationError( - "--solution-version-id is required when using --resume-from." - ) - - # --- Resolve solution-template-version-id (not needed for resume) --- - if not resume_from: - solution_template_version_id = _resolve_template_version_id( - solution_template_version_id, solution_template_name, - solution_template_version, solution_template_rg, - resource_group, sub_id, - ) - elif not solution_template_version_id: - # resume_from is set, template version not required - solution_template_version_id = None + # --- Resolve solution-template-version-id --- + solution_template_version_id = _resolve_template_version_id( + solution_template_version_id, solution_template_name, + solution_template_version, solution_template_rg, + resource_group, sub_id, + ) base_url = ( f"{ARM_RESOURCE}/subscriptions/{sub_id}" @@ -102,18 +81,8 @@ def target_deploy( # Figure out which steps to run do_config = config is not None - do_review = resume_from is None - do_publish = resume_from in (None, "publish") - do_install = True - - # If resume_from == "install", skip review and publish - if resume_from == "install": - do_review = False - do_publish = False - elif resume_from == "publish": - do_review = False - - total = sum([do_config, do_review, do_publish, do_install]) + + total = sum([do_config, True, True, True]) # config(opt) + review + publish + install current = [0] # mutable counter def _log(step_name, status=""): @@ -124,7 +93,7 @@ def _log(step_name, status=""): print(f"[{current[0]}/{total}] {step_name}...") results = {} - sv_id = solution_version_id # may be set by review or passed via --solution-version-id + sv_id = None # --- Step 0: Config set --- if do_config: @@ -138,45 +107,26 @@ def _log(step_name, status=""): results["configSet"] = "Succeeded" # --- Step 1: Review --- - if do_review: - _log("Review") - review_result = _do_review( - cmd, base_url, solution_template_version_id, - solution_instance_name, solution_dependencies, - ) - results["review"] = review_result - sv_id = _extract_solution_version_id(review_result) - _log("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") - elif not resume_from: - # Should not reach here — do_review is True when resume_from is None - results["review"] = "Skipped" - sv_id = solution_template_version_id - else: - print(f"[~] Review skipped (--resume-from {resume_from})") - results["review"] = "Skipped" - - if not sv_id: - raise CLIInternalError("No solution-version-id available. Cannot proceed with publish.") + _log("Review") + review_result = _do_review( + cmd, base_url, solution_template_version_id, + solution_instance_name, solution_dependencies, + ) + results["review"] = review_result + sv_id = _extract_solution_version_id(review_result) + _log("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") # --- Step 2: Publish --- - if do_publish: - _log("Publish") - publish_result = _do_publish(cmd, base_url, sv_id) - results["publish"] = publish_result - _log("Publish", "[OK]") - else: - print(f"[~] Publish skipped (--resume-from install)") - results["publish"] = "Skipped" + _log("Publish") + publish_result = _do_publish(cmd, base_url, sv_id) + results["publish"] = publish_result + _log("Publish", "[OK]") # --- Step 3: Install --- - if do_install: - _log("Install") - install_result = _do_install(cmd, base_url, sv_id) - results["install"] = install_result - _log("Install", "[OK]") - else: - print(f"[~] Install skipped (--resume-from install)") - results["install"] = "Skipped" + _log("Install") + install_result = _do_install(cmd, base_url, sv_id) + results["install"] = install_result + _log("Install", "[OK]") print(f"\n{'=' * 50}") print(f"Deployment complete for target '{target_name}'") From bca3b13f47dbcc2003779d11eb3df3c7bdb2cb59 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 10:49:53 +0530 Subject: [PATCH 20/91] feat: merge deploy into target install, remove standalone deploy command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit target install now supports two flows: 1. Full deploy: --solution-template-name + --stv (or --stv-id) Runs: config-set (opt) → review → publish → install 2. Direct install: --solution-version-id (old flow) Runs: install only Removed: target deploy command, _params, _help, commands.py registration. Kept: target_deploy.py module (used internally by install pre_operations). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 32 ---- .../azext_workload_orchestration/_params.py | 42 ----- .../workload_orchestration/target/_install.py | 156 +++++++++++++++--- .../azext_workload_orchestration/commands.py | 1 - .../azext_workload_orchestration/custom.py | 1 - .../onboarding/target_deploy.py | 82 ++++++++- 6 files changed, 208 insertions(+), 106 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 78576a43ec1..720ce3ca2c2 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -75,35 +75,3 @@ text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap --skip-cert-manager """ -helps['workload-orchestration target deploy'] = """ -type: command -short-summary: Deploy a solution to a target in one step (review → publish → install). -long-summary: | - Chains up to four steps into a single command: - 0. Config Set (optional): applies configuration values from a YAML/JSON file - 1. Review: validates the solution template version against the target - 2. Publish: publishes the reviewed solution version - 3. Install: installs the published solution on the target - - You can identify the solution template by either: - - ARM ID: --solution-template-version-id `` - - Friendly name: --solution-template-name `` --solution-template-version `` - - Use --config to set configuration values before the review step. -examples: - - name: Deploy using friendly name - text: > - az workload-orchestration target deploy -g my-rg -n my-target - --solution-template-name sofi-hotmelt-template --solution-template-version 1.0.0 - - name: Deploy using ARM ID - text: > - az workload-orchestration target deploy -g my-rg -n my-target - --solution-template-version-id /subscriptions/.../solutionTemplates/tmpl/versions/1.0.0 - - name: Deploy with configuration file - text: > - az workload-orchestration target deploy -g my-rg -n my-target - --solution-template-name tmpl --solution-template-version 1.0.0 - --config values.yaml --config-template-rg rg --config-template-name cfg --config-template-version 1.0.0 -""" - - diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index e7a5ffe62e3..947e529634a 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -95,45 +95,3 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Path to kubeconfig file. Defaults to ~/.kube/config.') c.argument('kube_context', options_list=['--kube-context'], help='Kubernetes context to use. Defaults to current context.') - - with self.argument_context('workload-orchestration target deploy') as c: - # Required: target identification - c.argument('resource_group', options_list=['--resource-group', '-g'], - help='Resource group of the target.', required=True) - c.argument('target_name', options_list=['--target-name', '-n'], - help='Name of the target to deploy to.', required=True) - - # Solution template: ARM ID (option A) - c.argument('solution_template_version_id', options_list=['--solution-template-version-id', '--stv-id'], - help='Full ARM resource ID of the solution template version. ' - 'Mutually exclusive with --solution-template-name.') - - # Solution template: friendly name (option B) - c.argument('solution_template_name', options_list=['--solution-template-name'], - help='Name of the solution template. ' - 'Use with --solution-template-version. ' - 'Mutually exclusive with --solution-template-version-id.') - c.argument('solution_template_version', options_list=['--solution-template-version', '--stv'], - help='Version of the solution template (e.g., 1.0.0). ' - 'Required when using --solution-template-name.') - c.argument('solution_template_rg', options_list=['--solution-template-rg'], - help='Resource group of the solution template. ' - 'Defaults to --resource-group if omitted.') - - # Optional review args - c.argument('solution_instance_name', options_list=['--solution-instance-name'], - help='Custom solution instance name for the review step.') - c.argument('solution_dependencies', options_list=['--solution-dependencies'], - help='JSON string of solution dependency definitions.') - - # Config set (step 0) - c.argument('config', options_list=['--config'], - help='Path to YAML/JSON file with configuration values to set before review.') - c.argument('config_hierarchy_id', options_list=['--config-hierarchy-id'], - help='ARM ID of hierarchy entity for config set. Defaults to target ARM ID.') - c.argument('config_template_rg', options_list=['--config-template-rg'], - help='Resource group of the configuration template (with --config).') - c.argument('config_template_name', options_list=['--config-template-name'], - help='Name of the configuration template (with --config).') - c.argument('config_template_version', options_list=['--config-template-version', '--ct-version'], - help='Version of the configuration template (with --config).') diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 6a1a0e35238..e96282f7757 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -9,15 +9,26 @@ # flake8: noqa from azure.cli.core.aaz import * +from azure.cli.core.azclierror import CLIInternalError, ValidationError @register_command( "workload-orchestration target install", ) class Install(AAZCommand): - """Post request to install a solution - :example: Install a solution to a target - az workload-orchestration target install -g rg1 -n target1 --solution-version-id /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRG/providers/Microsoft.Edge/solutionVersions/mySolutionVersion + """Install a solution on a target. + + When invoked with --solution-template-version-id (or --solution-template-name + --solution-template-version), + runs the full deployment chain: config-set (optional) → review → publish → install. + + When invoked with --solution-version-id only (old flow), runs direct install. + + :example: Full deploy (friendly name) + az workload-orchestration target install -g rg1 -n target1 --solution-template-name tmpl --solution-template-version 1.0.0 + :example: Full deploy with config + az workload-orchestration target install -g rg1 -n target1 --solution-template-name tmpl --stv 1.0.0 --config values.yaml --config-template-rg rg1 --config-template-name tmpl --ct-version 1.0.0 + :example: Direct install (old flow) + az workload-orchestration target install -g rg1 -n target1 --solution-version-id /subscriptions/.../solutionVersions/sv1 """ _aaz_info = { @@ -41,8 +52,6 @@ def _build_arguments_schema(cls, *args, **kwargs): return cls._args_schema cls._args_schema = super()._build_arguments_schema(*args, **kwargs) - # define Arg Group "" - _args_schema = cls._args_schema _args_schema.resource_group = AAZResourceGroupNameArg( required=True, @@ -59,31 +68,72 @@ def _build_arguments_schema(cls, *args, **kwargs): ), ) - # define Arg Group "Body" - _args_schema = cls._args_schema - - # Remove these parameters (v2025_06_01) - # _args_schema.solution = AAZStrArg( - # options=["--solution"], - # arg_group="Body", - # help="Solution Name", - # required=True, - # ) - # _args_schema.solution_version = AAZStrArg( - # options=["--solution-version"], - # arg_group="Body", - # help="Solution Version Name", - # required=True, - # ) - - # Add new parameter (v2025_06_01) + # Old flow: direct install with solution-version-id _args_schema.solution_version_id = AAZStrArg( options=["--solution-version-id"], arg_group="Body", - help="Solution Version ARM Id", - required=True, + help="Solution Version ARM ID (direct install, skips review/publish).", + ) + + # New flow: full deploy chain + _args_schema.solution_template_version_id = AAZStrArg( + options=["--solution-template-version-id", "--stv-id"], + arg_group="Deploy", + help="Full ARM ID of the solution template version. Triggers full deploy chain.", + ) + _args_schema.solution_template_name = AAZStrArg( + options=["--solution-template-name"], + arg_group="Deploy", + help="Name of the solution template. Use with --solution-template-version.", + ) + _args_schema.solution_template_version = AAZStrArg( + options=["--solution-template-version", "--stv"], + arg_group="Deploy", + help="Version of the solution template (e.g., 1.0.0).", + ) + _args_schema.solution_template_rg = AAZStrArg( + options=["--solution-template-rg"], + arg_group="Deploy", + help="Resource group of the solution template. Defaults to -g.", + ) + _args_schema.solution_instance_name = AAZStrArg( + options=["--solution-instance-name"], + arg_group="Deploy", + help="Custom solution instance name for the review step.", + ) + _args_schema.solution_dependencies = AAZStrArg( + options=["--solution-dependencies"], + arg_group="Deploy", + help="JSON string of solution dependency definitions.", + ) + + # Config set args + _args_schema.config = AAZStrArg( + options=["--config"], + arg_group="Config", + help="Path to YAML/JSON config file to set before review.", + ) + _args_schema.config_hierarchy_id = AAZStrArg( + options=["--config-hierarchy-id"], + arg_group="Config", + help="ARM ID of hierarchy entity for config set. Defaults to target ARM ID.", ) - + _args_schema.config_template_rg = AAZStrArg( + options=["--config-template-rg"], + arg_group="Config", + help="Resource group of the configuration template.", + ) + _args_schema.config_template_name = AAZStrArg( + options=["--config-template-name"], + arg_group="Config", + help="Name of the configuration template.", + ) + _args_schema.config_template_version = AAZStrArg( + options=["--config-template-version", "--ct-version"], + arg_group="Config", + help="Version of the configuration template.", + ) + return cls._args_schema def _execute_operations(self): @@ -93,7 +143,59 @@ def _execute_operations(self): @register_callback def pre_operations(self): - pass + """If template args provided, run config-set → review → publish before install.""" + args = self.ctx.args + has_template = ( + args.solution_template_version_id + or args.solution_template_name + ) + has_direct = args.solution_version_id + + # Validate: need either template args OR solution-version-id + if not has_template and not has_direct: + raise ValidationError( + "Provide either --solution-template-version-id (or --solution-template-name + " + "--solution-template-version) for full deploy, or --solution-version-id for direct install." + ) + + if has_template and has_direct: + raise ValidationError( + "Provide either solution template args (for full deploy) or " + "--solution-version-id (for direct install), not both." + ) + + if has_template: + self._run_deploy_chain() + + def _run_deploy_chain(self): + """Run config-set → review → publish, then let the AAZ install handle the final step.""" + from azext_workload_orchestration.onboarding.target_deploy import ( + target_deploy_pre_install, + ) + from azext_workload_orchestration.onboarding.utils import CmdProxy + + args = self.ctx.args + cmd_proxy = CmdProxy(self.ctx.cli_ctx) + + sv_id = target_deploy_pre_install( + cmd=cmd_proxy, + resource_group=str(args.resource_group), + target_name=str(args.target_name), + solution_template_version_id=str(args.solution_template_version_id) if args.solution_template_version_id else None, + solution_template_name=str(args.solution_template_name) if args.solution_template_name else None, + solution_template_version=str(args.solution_template_version) if args.solution_template_version else None, + solution_template_rg=str(args.solution_template_rg) if args.solution_template_rg else None, + solution_instance_name=str(args.solution_instance_name) if args.solution_instance_name else None, + solution_dependencies=str(args.solution_dependencies) if args.solution_dependencies else None, + config=str(args.config) if args.config else None, + config_hierarchy_id=str(args.config_hierarchy_id) if args.config_hierarchy_id else None, + config_template_rg=str(args.config_template_rg) if args.config_template_rg else None, + config_template_name=str(args.config_template_name) if args.config_template_name else None, + config_template_version=str(args.config_template_version) if args.config_template_version else None, + ) + + # Set the solution_version_id for the AAZ install step + args.solution_version_id = sv_id @register_callback def post_operations(self): diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index c6c4621cbb9..e4b12f21157 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -15,4 +15,3 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration target') as g: g.custom_command('init', 'target_init') - g.custom_command('deploy', 'target_deploy') diff --git a/src/workload-orchestration/azext_workload_orchestration/custom.py b/src/workload-orchestration/azext_workload_orchestration/custom.py index cafa4ee5c90..7d1a98e8e4e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/custom.py +++ b/src/workload-orchestration/azext_workload_orchestration/custom.py @@ -10,4 +10,3 @@ # Onboarding simplification commands from azext_workload_orchestration.onboarding import target_init # pylint: disable=unused-import # noqa: F401 -from azext_workload_orchestration.onboarding import target_deploy # pylint: disable=unused-import # noqa: F401 diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index ca3984129dc..a0e99073927 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -59,10 +59,10 @@ def target_deploy( config_template_name=None, config_template_version=None, ): - """Deploy a solution to a target: review -> publish -> install. + """Deploy a solution to a target: config-set → review → publish → install. - Chains up to 4 steps (config-set + review + publish + install), - passing the solution-version-id from review into publish and install. + Standalone deploy command (kept for backward compat). + Prefer using `target install` with template args instead. """ sub_id = _get_subscription_id(cmd) @@ -140,6 +140,82 @@ def _log(step_name, status=""): }) +def target_deploy_pre_install( + cmd, + resource_group, + target_name, + solution_template_version_id=None, + solution_template_name=None, + solution_template_version=None, + solution_template_rg=None, + solution_instance_name=None, + solution_dependencies=None, + config=None, + config_hierarchy_id=None, + config_template_rg=None, + config_template_name=None, + config_template_version=None, +): + """Run config-set → review → publish and return the solution-version-id. + + Called by the enhanced `target install` command before the AAZ install step. + Does NOT run install — that's handled by the AAZ LRO. + """ + sub_id = _get_subscription_id(cmd) + + solution_template_version_id = _resolve_template_version_id( + solution_template_version_id, solution_template_name, + solution_template_version, solution_template_rg, + resource_group, sub_id, + ) + + base_url = ( + f"{ARM_RESOURCE}/subscriptions/{sub_id}" + f"/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/targets/{target_name}" + ) + + do_config = config is not None + total = sum([do_config, True, True, True]) # config + review + publish + install(AAZ) + current = [0] + + def _log(step_name, status=""): + if status: + print(f"[{current[0]}/{total}] {step_name}... {status}") + else: + current[0] += 1 + print(f"[{current[0]}/{total}] {step_name}...") + + # --- Step 0: Config set --- + if do_config: + _log("Config Set") + _handle_config_set( + cmd, config, config_hierarchy_id, config_template_rg, + config_template_name, config_template_version, + resource_group, target_name, sub_id, + ) + _log("Config Set", "[OK]") + + # --- Step 1: Review --- + _log("Review") + review_result = _do_review( + cmd, base_url, solution_template_version_id, + solution_instance_name, solution_dependencies, + ) + sv_id = _extract_solution_version_id(review_result) + _log("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") + + # --- Step 2: Publish --- + _log("Publish") + _do_publish(cmd, base_url, sv_id) + _log("Publish", "[OK]") + + # Step 3 (Install) is handled by AAZ LRO + _log("Install") + + return sv_id + + # --------------------------------------------------------------------------- # Resolution helpers # --------------------------------------------------------------------------- From c5e06bb60cc8129a2779734f24d855028fc1f797 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 11:07:34 +0530 Subject: [PATCH 21/91] refactor: remove config-template args, auto-derive from solution template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config-set now auto-derives template info from solution template args: config-template-rg → solution-template-rg or --resource-group config-template-name → solution-template-name config-template-version → solution-template-version Usage simplified to: az wo target install -g rg -n target --solution-template-name X --stv 1.0.0 --configuration values.yaml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_install.py | 26 +--------------- .../onboarding/target_deploy.py | 30 +++++++++++++++---- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index e96282f7757..ad0711ef6b8 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -109,30 +109,10 @@ def _build_arguments_schema(cls, *args, **kwargs): # Config set args _args_schema.config = AAZStrArg( - options=["--config"], + options=["--config", "--configuration"], arg_group="Config", help="Path to YAML/JSON config file to set before review.", ) - _args_schema.config_hierarchy_id = AAZStrArg( - options=["--config-hierarchy-id"], - arg_group="Config", - help="ARM ID of hierarchy entity for config set. Defaults to target ARM ID.", - ) - _args_schema.config_template_rg = AAZStrArg( - options=["--config-template-rg"], - arg_group="Config", - help="Resource group of the configuration template.", - ) - _args_schema.config_template_name = AAZStrArg( - options=["--config-template-name"], - arg_group="Config", - help="Name of the configuration template.", - ) - _args_schema.config_template_version = AAZStrArg( - options=["--config-template-version", "--ct-version"], - arg_group="Config", - help="Version of the configuration template.", - ) return cls._args_schema @@ -188,10 +168,6 @@ def _run_deploy_chain(self): solution_instance_name=str(args.solution_instance_name) if args.solution_instance_name else None, solution_dependencies=str(args.solution_dependencies) if args.solution_dependencies else None, config=str(args.config) if args.config else None, - config_hierarchy_id=str(args.config_hierarchy_id) if args.config_hierarchy_id else None, - config_template_rg=str(args.config_template_rg) if args.config_template_rg else None, - config_template_name=str(args.config_template_name) if args.config_template_name else None, - config_template_version=str(args.config_template_version) if args.config_template_version else None, ) # Set the solution_version_id for the AAZ install step diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index a0e99073927..5e19708fa13 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -151,15 +151,16 @@ def target_deploy_pre_install( solution_instance_name=None, solution_dependencies=None, config=None, - config_hierarchy_id=None, - config_template_rg=None, - config_template_name=None, - config_template_version=None, ): """Run config-set → review → publish and return the solution-version-id. Called by the enhanced `target install` command before the AAZ install step. Does NOT run install — that's handled by the AAZ LRO. + + Config-template args are auto-derived from solution template args: + config-template-rg → solution_template_rg or resource_group + config-template-name → solution_template_name + config-template-version → solution_template_version """ sub_id = _get_subscription_id(cmd) @@ -189,9 +190,26 @@ def _log(step_name, status=""): # --- Step 0: Config set --- if do_config: _log("Config Set") + # Auto-derive config template args from solution template args + ct_rg = solution_template_rg or resource_group + ct_name = solution_template_name + ct_version = solution_template_version + + # If using ARM ID, extract name/version/rg from it + if not ct_name and solution_template_version_id: + parts = solution_template_version_id.strip("/").split("/") + # .../resourceGroups/{rg}/providers/Microsoft.Edge/solutionTemplates/{name}/versions/{ver} + for i, part in enumerate(parts): + if part.lower() == "resourcegroups" and i + 1 < len(parts): + ct_rg = parts[i + 1] + elif part.lower() == "solutiontemplates" and i + 1 < len(parts): + ct_name = parts[i + 1] + elif part.lower() == "versions" and i + 1 < len(parts): + ct_version = parts[i + 1] + _handle_config_set( - cmd, config, config_hierarchy_id, config_template_rg, - config_template_name, config_template_version, + cmd, config, None, ct_rg, + ct_name, ct_version, resource_group, target_name, sub_id, ) _log("Config Set", "[OK]") From ba7bd266954ad58adbd32dc59dc3ffa7e1233e36 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 12:08:10 +0530 Subject: [PATCH 22/91] refactor: rename 'target init' to 'cluster init' Better reflects the command's purpose - it prepares the cluster, not a specific target. Command: az workload-orchestration cluster init -c cluster -g rg -l region Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 14 +++++++------- .../azext_workload_orchestration/_params.py | 2 +- .../azext_workload_orchestration/commands.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 720ce3ca2c2..7a9fdb836df 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -47,13 +47,13 @@ text: az workload-orchestration support create-bundle --kube-config ~/.kube/prod-config --kube-context my-cluster """ -helps['workload-orchestration target init'] = """ +helps['workload-orchestration cluster init'] = """ type: command short-summary: Prepare an Arc-connected Kubernetes cluster for Workload Orchestration. long-summary: | Installs all prerequisites on an Arc-connected cluster to make it ready for - Workload Orchestration target creation. This is an idempotent operation that - skips components already installed. + Workload Orchestration. This is an idempotent operation that skips components + already installed. Steps performed: 1. Verify cluster is Arc-connected with required features enabled @@ -66,12 +66,12 @@ 'az workload-orchestration target create --extended-location'. examples: - name: Initialize a cluster with defaults - text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap - name: Initialize with a specific release train - text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap --release-train preview + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --release-train preview - name: Initialize with custom location name - text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl - name: Skip cert-manager if already managed externally - text: az workload-orchestration target init -c my-cluster -g my-rg -l eastus2euap --skip-cert-manager + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --skip-cert-manager """ diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 947e529634a..7b50dcf404f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -72,7 +72,7 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Skip auto-creation of site-reference to context.', ) - with self.argument_context('workload-orchestration target init') as c: + with self.argument_context('workload-orchestration cluster init') as c: c.argument('cluster_name', options_list=['--cluster-name', '-c'], help='Name of the Arc-connected Kubernetes cluster.', required=True) c.argument('resource_group', options_list=['--resource-group', '-g'], diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index e4b12f21157..fdc5bb5a98e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -13,5 +13,5 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration support') as g: g.custom_command('create-bundle', 'create_support_bundle') - with self.command_group('workload-orchestration target') as g: + with self.command_group('workload-orchestration cluster') as g: g.custom_command('init', 'target_init') From d98e2aa4f5597d0a4c99db9e7abb7bca09226fed Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 12:10:05 +0530 Subject: [PATCH 23/91] refactor: remove default target-specification auto-injection Per Shubham's feedback: don't touch --target-specification. Upcoming non-K8s workloads may change this, marked as future work. Users must provide --target-specification explicitly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/target/_create.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index 776e750d767..a68e1ec1b3f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -181,11 +181,6 @@ def pre_operations(self): if not self.ctx.args.context_id: self._resolve_context_id_from_config() - # Default target specification (helm.v3) if not provided - if not self.ctx.args.target_specification: - from azext_workload_orchestration.onboarding.consts import DEFAULT_TARGET_SPECIFICATION - self.ctx.args.target_specification = DEFAULT_TARGET_SPECIFICATION - def _resolve_context_id_from_config(self): """Resolve context_id from CLI config if not already set.""" try: From 7250fe9180e0c705def5597292be22ab4d41ff1f Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 14:57:04 +0530 Subject: [PATCH 24/91] refactor: remove --solution-template-rg per Shubham's feedback Solution templates can be in any RG, so friendly name resolution uses the target's --resource-group. For cross-RG templates, use the full ARM ID via --solution-template-version-id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_install.py | 6 ----- .../onboarding/target_deploy.py | 22 ++++++++----------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index ad0711ef6b8..f0adbbdb2f5 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -91,11 +91,6 @@ def _build_arguments_schema(cls, *args, **kwargs): arg_group="Deploy", help="Version of the solution template (e.g., 1.0.0).", ) - _args_schema.solution_template_rg = AAZStrArg( - options=["--solution-template-rg"], - arg_group="Deploy", - help="Resource group of the solution template. Defaults to -g.", - ) _args_schema.solution_instance_name = AAZStrArg( options=["--solution-instance-name"], arg_group="Deploy", @@ -164,7 +159,6 @@ def _run_deploy_chain(self): solution_template_version_id=str(args.solution_template_version_id) if args.solution_template_version_id else None, solution_template_name=str(args.solution_template_name) if args.solution_template_name else None, solution_template_version=str(args.solution_template_version) if args.solution_template_version else None, - solution_template_rg=str(args.solution_template_rg) if args.solution_template_rg else None, solution_instance_name=str(args.solution_instance_name) if args.solution_instance_name else None, solution_dependencies=str(args.solution_dependencies) if args.solution_dependencies else None, config=str(args.config) if args.config else None, diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 5e19708fa13..87fa23550ca 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -50,7 +50,6 @@ def target_deploy( solution_template_version_id=None, solution_template_name=None, solution_template_version=None, - solution_template_rg=None, solution_instance_name=None, solution_dependencies=None, config=None, @@ -61,15 +60,14 @@ def target_deploy( ): """Deploy a solution to a target: config-set → review → publish → install. - Standalone deploy command (kept for backward compat). - Prefer using `target install` with template args instead. + Standalone deploy function (used internally). """ sub_id = _get_subscription_id(cmd) # --- Resolve solution-template-version-id --- solution_template_version_id = _resolve_template_version_id( solution_template_version_id, solution_template_name, - solution_template_version, solution_template_rg, + solution_template_version, None, resource_group, sub_id, ) @@ -147,7 +145,6 @@ def target_deploy_pre_install( solution_template_version_id=None, solution_template_name=None, solution_template_version=None, - solution_template_rg=None, solution_instance_name=None, solution_dependencies=None, config=None, @@ -157,16 +154,14 @@ def target_deploy_pre_install( Called by the enhanced `target install` command before the AAZ install step. Does NOT run install — that's handled by the AAZ LRO. - Config-template args are auto-derived from solution template args: - config-template-rg → solution_template_rg or resource_group - config-template-name → solution_template_name - config-template-version → solution_template_version + When using friendly name, the target's resource_group is used for the ST. + Config-template args are auto-derived from solution template args. """ sub_id = _get_subscription_id(cmd) solution_template_version_id = _resolve_template_version_id( solution_template_version_id, solution_template_name, - solution_template_version, solution_template_rg, + solution_template_version, None, resource_group, sub_id, ) @@ -248,7 +243,7 @@ def _get_subscription_id(cmd): def _resolve_template_version_id( - arm_id, template_name, template_version, template_rg, + arm_id, template_name, template_version, _unused, default_rg, sub_id, ): """Resolve solution-template-version-id from friendly name or ARM ID. @@ -256,6 +251,8 @@ def _resolve_template_version_id( Mutual exclusivity: - Provide --solution-template-version-id (full ARM ID) - OR --solution-template-name + --solution-template-version (friendly) + + When using friendly name, the target's resource group is used. """ if arm_id and template_name: raise ValidationError( @@ -271,9 +268,8 @@ def _resolve_template_version_id( raise ValidationError( "--solution-template-version is required when using --solution-template-name." ) - rg = template_rg or default_rg return ( - f"/subscriptions/{sub_id}/resourceGroups/{rg}" + f"/subscriptions/{sub_id}/resourceGroups/{default_rg}" f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" f"/versions/{template_version}" ) From 24992ebe08db87f7523651bb1a8e16bdfac1a631 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 14:58:38 +0530 Subject: [PATCH 25/91] cleanup: remove short aliases, use full option names only Removed --stv-id and --stv aliases. Use full names: --solution-template-version-id --solution-template-version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/target/_install.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index f0adbbdb2f5..365bf0cc1d6 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -77,7 +77,7 @@ def _build_arguments_schema(cls, *args, **kwargs): # New flow: full deploy chain _args_schema.solution_template_version_id = AAZStrArg( - options=["--solution-template-version-id", "--stv-id"], + options=["--solution-template-version-id"], arg_group="Deploy", help="Full ARM ID of the solution template version. Triggers full deploy chain.", ) @@ -87,7 +87,7 @@ def _build_arguments_schema(cls, *args, **kwargs): help="Name of the solution template. Use with --solution-template-version.", ) _args_schema.solution_template_version = AAZStrArg( - options=["--solution-template-version", "--stv"], + options=["--solution-template-version"], arg_group="Deploy", help="Version of the solution template (e.g., 1.0.0).", ) From 79276639f5c753c48ad8be74712b95c0ae502e74 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 15 Apr 2026 15:15:37 +0530 Subject: [PATCH 26/91] fix: remove leftover solution_template_rg reference in config path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/onboarding/target_deploy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 87fa23550ca..2a620c7f216 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -186,7 +186,7 @@ def _log(step_name, status=""): if do_config: _log("Config Set") # Auto-derive config template args from solution template args - ct_rg = solution_template_rg or resource_group + ct_rg = resource_group ct_name = solution_template_name ct_version = solution_template_version From e600c9a99610646d9e9802802fc34ff9811039a6 Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 16 Apr 2026 09:30:26 +0530 Subject: [PATCH 27/91] refactor: remove skip, kube, and reinstall options from cluster init Removed: - --skip-cert-manager, --skip-trust-manager - --kube-config, --kube-context - Failed extension auto-reinstall logic Kept args: -c, -g, -l, --release-train, --extension-version, --extension-name, --custom-location-name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 8 +++---- .../azext_workload_orchestration/_params.py | 10 +------- .../onboarding/__init__.py | 8 ------- .../onboarding/target_prepare.py | 23 ------------------- 4 files changed, 5 insertions(+), 44 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 7a9fdb836df..f21be0c3880 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -68,10 +68,10 @@ - name: Initialize a cluster with defaults text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap - name: Initialize with a specific release train - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --release-train preview - - name: Initialize with custom location name + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --release-train dev + - name: Pin a specific extension version + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-version 2.1.28 + - name: Custom location name text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl - - name: Skip cert-manager if already managed externally - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --skip-cert-manager """ diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 7b50dcf404f..eb5ed4356a8 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -84,14 +84,6 @@ def load_arguments(self, _): # pylint: disable=unused-argument c.argument('extension_version', options_list=['--extension-version'], help='Specific WO extension version to install.') c.argument('extension_name', options_list=['--extension-name'], - help='Name for the WO extension resource. Default: workload-orchestration.') + help='Name for the WO extension resource. Default: wo-extension.') c.argument('custom_location_name', options_list=['--custom-location-name'], help='Name for the custom location. Default: `-cl`.') - c.argument('skip_cert_manager', options_list=['--skip-cert-manager'], - action='store_true', help='Skip cert-manager installation.') - c.argument('skip_trust_manager', options_list=['--skip-trust-manager'], - action='store_true', help='Skip trust-manager installation.') - c.argument('kube_config', options_list=['--kube-config'], - help='Path to kubeconfig file. Defaults to ~/.kube/config.') - c.argument('kube_context', options_list=['--kube-context'], - help='Kubernetes context to use. Defaults to current context.') diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 21ce3b79cef..0f5d481acd3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -22,10 +22,6 @@ def target_init( extension_version=None, extension_name=None, custom_location_name=None, - skip_cert_manager=False, - skip_trust_manager=False, - kube_config=None, - kube_context=None, ): """Prepare an Arc-connected cluster for Workload Orchestration.""" result = target_prepare( @@ -37,10 +33,6 @@ def target_init( custom_location_name=custom_location_name, extension_version=extension_version, release_train=release_train, - skip_cert_manager=skip_cert_manager, - skip_trust_manager=skip_trust_manager, - kube_config=kube_config, - kube_context=kube_context, ) return result diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 905fdea9768..7f3204a64d9 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -450,29 +450,6 @@ def _ensure_wo_extension( ) return ext_id - # Extension exists but is in failed/creating state - delete and reinstall - logger.info( - "WO extension exists but in '%s' state. Deleting and reinstalling...", - prov_state - ) - print_step( - 3, TOTAL_STEPS, - f"WO extension... Found in '{prov_state}' state, reinstalling" - ) - try: - invoke_cli_command(cmd, [ - "k8s-extension", "delete", - "-g", resource_group, - "--cluster-name", cluster_name, - "--cluster-type", "connectedClusters", - "--name", ext.get("name", extension_name), - "--yes", - ], expect_json=False) - import time as _time - _time.sleep(10) # Wait for delete to propagate - except CLIInternalError: - pass # Best effort delete - # Install extension version_msg = f" version {extension_version}" if extension_version else "" print_step( From 4642f2dfd9ce8666ef20c1af3bb77de54ee299ad Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 16 Apr 2026 12:16:35 +0530 Subject: [PATCH 28/91] feat: add hierarchy create command (ResourceGroup + ServiceGroup) Creates full resource stack in one command: 1. Site (with level label) 2. Configuration (in specified region) 3. ConfigurationReference (links site to config) Supports: - ResourceGroup: single site via --resource-group + shorthand/YAML - ServiceGroup: nested hierarchy up to 3 levels via YAML file Usage: az wo hierarchy create -g rg --configuration-location eastus2euap --hierarchy-spec 'name=X level=factory' az wo hierarchy create --configuration-location eastus2euap --hierarchy-spec hierarchy.yaml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 20 ++ .../azext_workload_orchestration/_params.py | 40 +++ .../azext_workload_orchestration/commands.py | 3 + .../azext_workload_orchestration/custom.py | 1 + .../onboarding/__init__.py | 13 +- .../onboarding/hierarchy_create_v2.py | 327 ++++++++++++++++++ 6 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create_v2.py diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index f21be0c3880..dbfc3d8192f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -75,3 +75,23 @@ text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl """ +helps['workload-orchestration hierarchy create'] = """ +type: command +short-summary: Create a hierarchy (Site + Configuration + ConfigurationReference) in one command. +long-summary: | + Creates the full resource stack for a hierarchy level: + 1. Site (with level label) + 2. Configuration (in specified region) + 3. ConfigurationReference (links site to configuration) + + Supports two types: + - ResourceGroup (default): single site in a resource group + - ServiceGroup: nested sites under a service group (up to 3 levels) +examples: + - name: Create RG hierarchy from YAML file + text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "@hierarchy.yaml" + - name: Create RG hierarchy with shorthand + text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "name=Mehoopany level=factory" + - name: Create ServiceGroup hierarchy from YAML + text: az workload-orchestration hierarchy create --configuration-location eastus2euap --hierarchy-spec "@sg-hierarchy.yaml" +""" diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index eb5ed4356a8..20973bdb9f2 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -87,3 +87,43 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Name for the WO extension resource. Default: wo-extension.') c.argument('custom_location_name', options_list=['--custom-location-name'], help='Name for the custom location. Default: `-cl`.') + + with self.argument_context('workload-orchestration hierarchy create') as c: + c.argument('resource_group', options_list=['--resource-group', '-g'], + help='Resource group (required for ResourceGroup type hierarchy).') + c.argument('configuration_location', options_list=['--configuration-location'], + help='Azure region for the Configuration resource (e.g., eastus2euap).', required=True) + c.argument('hierarchy_spec', options_list=['--hierarchy-spec'], + help='Hierarchy specification as YAML/JSON file (@file.yaml) or shorthand syntax.', + required=True, type=_parse_hierarchy_spec) + + +def _parse_hierarchy_spec(value): + """Parse hierarchy spec from file path or shorthand syntax.""" + import os + + # Handle @file syntax (@ may be stripped by CLI framework) + filepath = value.lstrip('@') + if os.path.exists(filepath): + try: + import yaml + except ImportError: + import json as yaml_fallback + with open(filepath, 'r', encoding='utf-8') as f: + return yaml_fallback.load(f) + with open(filepath, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + + # Shorthand: name=X level=Y type=Z + result = {} + for pair in value.split(): + if '=' in pair: + k, v = pair.split('=', 1) + result[k] = v + if not result: + from azure.cli.core.azclierror import ValidationError + raise ValidationError( + f"Invalid hierarchy-spec: '{value}'. " + "Use a YAML file path or shorthand: name=X level=Y" + ) + return result diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index fdc5bb5a98e..7c1fa55ced1 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -15,3 +15,6 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration cluster') as g: g.custom_command('init', 'target_init') + + with self.command_group('workload-orchestration hierarchy') as g: + g.custom_command('create', 'hierarchy_create') diff --git a/src/workload-orchestration/azext_workload_orchestration/custom.py b/src/workload-orchestration/azext_workload_orchestration/custom.py index 7d1a98e8e4e..94e8a97cbbd 100644 --- a/src/workload-orchestration/azext_workload_orchestration/custom.py +++ b/src/workload-orchestration/azext_workload_orchestration/custom.py @@ -10,3 +10,4 @@ # Onboarding simplification commands from azext_workload_orchestration.onboarding import target_init # pylint: disable=unused-import # noqa: F401 +from azext_workload_orchestration.onboarding import hierarchy_create # pylint: disable=unused-import # noqa: F401 diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 0f5d481acd3..e9a7cc811a2 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -11,6 +11,7 @@ from azext_workload_orchestration.onboarding.target_prepare import target_prepare from azext_workload_orchestration.onboarding.target_deploy import target_deploy as _target_deploy +from azext_workload_orchestration.onboarding.hierarchy_create_v2 import hierarchy_create as _hierarchy_create def target_init( @@ -72,4 +73,14 @@ def target_deploy( ) -__all__ = ['target_prepare', 'target_init', 'target_deploy'] +__all__ = ['target_prepare', 'target_init', 'target_deploy', 'hierarchy_create'] + + +def hierarchy_create(cmd, resource_group=None, configuration_location=None, hierarchy_spec=None): + """Create a hierarchy: Site + Configuration + ConfigurationReference.""" + return _hierarchy_create( + cmd=cmd, + resource_group=resource_group, + configuration_location=configuration_location, + hierarchy_spec=hierarchy_spec, + ) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create_v2.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create_v2.py new file mode 100644 index 00000000000..64fc7085c30 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create_v2.py @@ -0,0 +1,327 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Hierarchy create command - creates Site + Configuration + ConfigurationReference. + +Supports two hierarchy types: + - ResourceGroup: Single site in a resource group (no children) + - ServiceGroup: Nested sites under a service group (up to 3 levels) + +For ResourceGroup: + az workload-orchestration hierarchy create \\ + --resource-group rg --configuration-location eastus2euap \\ + --hierarchy-spec "@hierarchy.yaml" + + hierarchy.yaml: + name: Mehoopany + level: factory + +For ServiceGroup: + az workload-orchestration hierarchy create \\ + --configuration-location eastus2euap \\ + --hierarchy-spec "@hierarchy.yaml" + + hierarchy.yaml: + type: ServiceGroup + name: India + level: country + children: + name: Karnataka + level: region + children: + - name: BangaloreSouth + level: factory +""" + +# pylint: disable=broad-exception-caught +# pylint: disable=too-many-locals + +import json +import logging + +from azure.cli.core.azclierror import ( + CLIInternalError, + ValidationError, +) +from azure.cli.core.util import send_raw_request + +from azext_workload_orchestration.onboarding.consts import ( + ARM_ENDPOINT, + SERVICE_GROUP_API_VERSION, + SITE_API_VERSION, + CONFIGURATION_API_VERSION, + CONFIG_REF_API_VERSION, + EDGE_RP_NAMESPACE, +) + +logger = logging.getLogger(__name__) + +MAX_SG_DEPTH = 3 + + +def hierarchy_create(cmd, resource_group=None, configuration_location=None, hierarchy_spec=None): + """Create a hierarchy: Site + Configuration + ConfigurationReference. + + Parses the hierarchy spec (YAML/JSON or shorthand) and creates + the full resource stack. + """ + if not hierarchy_spec: + raise ValidationError("--hierarchy-spec is required.") + if not configuration_location: + raise ValidationError("--configuration-location is required.") + + # Parse spec (could be dict from shorthand or file) + spec = hierarchy_spec if isinstance(hierarchy_spec, dict) else hierarchy_spec + + name = spec.get("name") + level = spec.get("level") + hierarchy_type = spec.get("type", "ResourceGroup") + + if not name: + raise ValidationError("hierarchy-spec must include 'name'.") + if not level: + raise ValidationError("hierarchy-spec must include 'level'.") + + if hierarchy_type == "ServiceGroup": + return _create_sg_hierarchy(cmd, spec, configuration_location) + else: + # ResourceGroup (default) + if not resource_group: + raise ValidationError("--resource-group is required for ResourceGroup hierarchy.") + return _create_rg_hierarchy(cmd, resource_group, configuration_location, name, level) + + +# --------------------------------------------------------------------------- +# ResourceGroup hierarchy +# --------------------------------------------------------------------------- + +def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): + """Create Site + Configuration + ConfigurationReference in a resource group.""" + sub_id = _get_sub_id(cmd) + total = 3 + step = [0] + + def _log(msg, status=""): + if status: + print(f"[{step[0]}/{total}] {msg}... {status}") + else: + step[0] += 1 + print(f"[{step[0]}/{total}] {msg}...") + + site_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + config_name = f"{name}Config" + config_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" + + print(f"\nCreating hierarchy '{name}' (level: {level}) in RG '{resource_group}'...\n") + + # Step 1: Create Site + _log(f"Site '{name}' ({level})") + _arm_put(cmd, f"{ARM_ENDPOINT}{site_id}", { + "properties": { + "displayName": name, + "description": name, + "labels": {"level": level}, + } + }, SITE_API_VERSION) + _log(f"Site '{name}'", "[OK]") + + # Step 2: Create Configuration + _log(f"Configuration '{config_name}'") + _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { + "location": config_location, + }, CONFIGURATION_API_VERSION) + _log(f"Configuration '{config_name}'", "[OK]") + + # Step 3: Create ConfigurationReference (links site → config) + config_ref_url = f"{ARM_ENDPOINT}{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" + _log("ConfigurationReference") + _arm_put(cmd, config_ref_url, { + "properties": { + "configurationResourceId": config_id, + } + }, CONFIG_REF_API_VERSION) + _log("ConfigurationReference", "[OK]") + + print(f"\nHierarchy '{name}' created successfully (3 resources).\n") + + return { + "type": "ResourceGroup", + "name": name, + "level": level, + "resourceGroup": resource_group, + "siteId": site_id, + "configurationId": config_id, + } + + +# --------------------------------------------------------------------------- +# ServiceGroup hierarchy (recursive, max 3 levels) +# --------------------------------------------------------------------------- + +def _create_sg_hierarchy(cmd, spec, config_location): + """Create ServiceGroup + nested Sites + Configurations recursively.""" + sub_id = _get_sub_id(cmd) + tenant_id = _get_tenant_id(cmd) + + # Count total nodes + nodes = _count_nodes(spec) + if nodes > MAX_SG_DEPTH: + raise ValidationError( + f"ServiceGroup hierarchy has {nodes} levels. Maximum is {MAX_SG_DEPTH}." + ) + + print(f"\nCreating ServiceGroup hierarchy '{spec['name']}' ({nodes} levels)...\n") + + results = [] + _create_sg_level(cmd, spec, config_location, sub_id, tenant_id, parent_sg=None, results=results, depth=0) + + print(f"\nHierarchy created successfully ({nodes} levels, {len(results)} resources).\n") + + return { + "type": "ServiceGroup", + "name": spec["name"], + "levels": nodes, + "resources": results, + } + + +def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, parent_sg, results, depth): + """Recursively create SG + Site + Config + ConfigRef at each level.""" + name = node["name"] + level = node["level"] + + # Create or reference the ServiceGroup + if parent_sg: + parent_id = f"/providers/Microsoft.Management/serviceGroups/{parent_sg}" + else: + parent_id = f"/providers/Microsoft.Management/serviceGroups/{tenant_id}" + + sg_id = f"/providers/Microsoft.Management/serviceGroups/{name}" + indent = " " * depth + + # 1. Create ServiceGroup + print(f"{indent}[+] ServiceGroup '{name}'...") + try: + _arm_put(cmd, f"{ARM_ENDPOINT}{sg_id}", { + "properties": { + "displayName": name, + "parent": {"resourceId": parent_id}, + } + }, SERVICE_GROUP_API_VERSION) + print(f"{indent}[+] ServiceGroup '{name}'... [OK]") + results.append({"type": "ServiceGroup", "name": name, "id": sg_id}) + except Exception as exc: + logger.warning("ServiceGroup creation failed: %s", exc) + raise CLIInternalError(f"ServiceGroup '{name}' creation failed: {exc}") + + # 2. Create Site under ServiceGroup + site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + print(f"{indent} [+] Site '{name}' ({level})...") + _arm_put_regional(cmd, config_location, site_id, { + "properties": { + "displayName": name, + "description": name, + "labels": {"level": level}, + } + }, SITE_API_VERSION) + print(f"{indent} [+] Site '{name}'... [OK]") + results.append({"type": "Site", "name": name, "level": level, "id": site_id}) + + # 3. Create Configuration + config_name = f"{name}Config" + config_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" + print(f"{indent} [+] Configuration '{config_name}'...") + _arm_put_regional(cmd, config_location, config_id, { + "location": config_location, + }, CONFIGURATION_API_VERSION) + print(f"{indent} [+] Configuration '{config_name}'... [OK]") + results.append({"type": "Configuration", "name": config_name, "id": config_id}) + + # 4. Create ConfigurationReference + config_ref_id = f"{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" + print(f"{indent} [+] ConfigurationReference...") + _arm_put_regional(cmd, config_location, config_ref_id, { + "properties": { + "configurationResourceId": config_id, + } + }, CONFIG_REF_API_VERSION) + print(f"{indent} [+] ConfigurationReference... [OK]") + results.append({"type": "ConfigurationReference", "siteId": site_id}) + + # Recurse into children + children = node.get("children") + if children: + if isinstance(children, dict): + children = [children] + for child in children: + _create_sg_level(cmd, child, config_location, sub_id, tenant_id, + parent_sg=name, results=results, depth=depth + 1) + + +def _count_nodes(node): + """Count total depth of hierarchy tree.""" + children = node.get("children") + if not children: + return 1 + if isinstance(children, dict): + return 1 + _count_nodes(children) + return 1 + max(_count_nodes(c) for c in children) + + +# --------------------------------------------------------------------------- +# ARM helpers +# --------------------------------------------------------------------------- + +def _arm_put(cmd, url, body, api_version): + """PUT to ARM endpoint.""" + full_url = f"{url}?api-version={api_version}" + send_raw_request( + cmd.cli_ctx, "PUT", full_url, + body=json.dumps(body), + headers=["Content-Type=application/json"], + resource=ARM_ENDPOINT, + ) + + +def _arm_put_regional(cmd, location, resource_id, body, api_version): + """PUT to regional ARM endpoint (for SG-scoped resources).""" + from azure.cli.core._profile import Profile + + full_url = f"https://{location}.management.azure.com{resource_id}?api-version={api_version}" + body_str = json.dumps(body) + + profile = Profile(cli_ctx=cmd.cli_ctx) + token_info, _, _ = profile.get_raw_token( + resource="https://management.azure.com", + subscription=profile.get_subscription_id() + ) + token_type, token, _ = token_info + + send_raw_request( + cmd.cli_ctx, "PUT", full_url, + body=body_str, + headers=[ + f"Authorization={token_type} {token}", + "Content-Type=application/json", + ], + skip_authorization_header=True, + ) + + +def _get_sub_id(cmd): + """Get subscription ID.""" + sub_id = cmd.cli_ctx.data.get('subscription_id') + if not sub_id: + from azure.cli.core._profile import Profile + sub_id = Profile(cli_ctx=cmd.cli_ctx).get_subscription_id() + return sub_id + + +def _get_tenant_id(cmd): + """Get tenant ID.""" + from azure.cli.core._profile import Profile + profile = Profile(cli_ctx=cmd.cli_ctx) + _, _, tenant_id = profile.get_raw_token(resource="https://management.azure.com") + return tenant_id From e735464b9836b5b8257d0ec3895d8fce264ca675 Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 16 Apr 2026 12:43:34 +0530 Subject: [PATCH 29/91] feat: hierarchy create with SG support, RBAC propagation, RG-scoped configs - ResourceGroup flow: Site + Config + ConfigRef (3 resources) - ServiceGroup flow: recursive SG + Site + Config + ConfigRef (up to 3 levels, 12 resources) - Configs are always RG-scoped (--resource-group required) - RBAC propagation wait after each SG creation (polls site list) - Removed old hierarchy_create.py, using clean v2 E2E tested: 3-level SG hierarchy created successfully. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_params.py | 2 +- .../onboarding/__init__.py | 2 +- .../onboarding/hierarchy_create.py | 757 +++++++----------- .../onboarding/hierarchy_create_v2.py | 327 -------- 4 files changed, 290 insertions(+), 798 deletions(-) delete mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create_v2.py diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 20973bdb9f2..33fe188aa8b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -90,7 +90,7 @@ def load_arguments(self, _): # pylint: disable=unused-argument with self.argument_context('workload-orchestration hierarchy create') as c: c.argument('resource_group', options_list=['--resource-group', '-g'], - help='Resource group (required for ResourceGroup type hierarchy).') + help='Resource group for Configuration resources.', required=True) c.argument('configuration_location', options_list=['--configuration-location'], help='Azure region for the Configuration resource (e.g., eastus2euap).', required=True) c.argument('hierarchy_spec', options_list=['--hierarchy-spec'], diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index e9a7cc811a2..2bdcc95085d 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -11,7 +11,7 @@ from azext_workload_orchestration.onboarding.target_prepare import target_prepare from azext_workload_orchestration.onboarding.target_deploy import target_deploy as _target_deploy -from azext_workload_orchestration.onboarding.hierarchy_create_v2 import hierarchy_create as _hierarchy_create +from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create as _hierarchy_create def target_init( diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index dda13deecc1..c9b1dd5eba9 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -3,31 +3,43 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Hierarchy create command - creates a hierarchy level in a single command. +"""Hierarchy create command - creates Site + Configuration + ConfigurationReference. -Wraps 4-6 operations into one: - 0. Auto-create Context (if needed) - 1. Create Service Group - 2. Create Site (in Service Group) - 3. Create Configuration - 4. Create Configuration Reference (links Config to Site) - 5. Create Site Reference (links Site to Context) +Supports two hierarchy types: + - ResourceGroup: Single site in a resource group (no children) + - ServiceGroup: Nested sites under a service group (up to 3 levels) -Usage: +For ResourceGroup: az workload-orchestration hierarchy create \\ - --name my-factory -g my-rg -l eastus --level-label Factory \\ - --parent my-region --capabilities soap shampoo + --resource-group rg --configuration-location eastus2euap \\ + --hierarchy-spec "@hierarchy.yaml" + + hierarchy.yaml: + name: Mehoopany + level: factory + +For ServiceGroup: + az workload-orchestration hierarchy create \\ + --configuration-location eastus2euap \\ + --hierarchy-spec "@hierarchy.yaml" + + hierarchy.yaml: + type: ServiceGroup + name: India + level: country + children: + name: Karnataka + level: region + children: + - name: BangaloreSouth + level: factory """ # pylint: disable=broad-exception-caught # pylint: disable=too-many-locals -# pylint: disable=too-many-statements -# pylint: disable=too-many-branches -# pylint: disable=import-outside-toplevel import json import logging -from datetime import datetime, timezone from azure.cli.core.azclierror import ( CLIInternalError, @@ -42,460 +54,252 @@ CONFIGURATION_API_VERSION, CONFIG_REF_API_VERSION, EDGE_RP_NAMESPACE, - MAX_HIERARCHY_NAME_LENGTH, -) -from azext_workload_orchestration.onboarding.utils import ( - invoke_cli_command, - print_step, - print_success, - print_detail, ) logger = logging.getLogger(__name__) -TOTAL_STEPS_WITH_CONTEXT = 6 -TOTAL_STEPS_NO_CONTEXT = 4 - - -def hierarchy_create( - cmd, - name, - resource_group, - location, - level_label, - parent=None, - capabilities=None, - description=None, - context_name=None, - context_rg=None, - skip_context=False, - skip_site_reference=False, -): - """Create a hierarchy level (ServiceGroup + Site + Config + ConfigRef) in one command. - - Optionally auto-creates a default Context and SiteReference if none exists. - All PUT operations are idempotent (safe to re-run). +MAX_SG_DEPTH = 3 + + +def hierarchy_create(cmd, resource_group=None, configuration_location=None, hierarchy_spec=None): + """Create a hierarchy: Site + Configuration + ConfigurationReference. + + Parses the hierarchy spec (YAML/JSON or shorthand) and creates + the full resource stack. """ - # ----------------------------------------------------------------------- - # Pre-flight validation - # ----------------------------------------------------------------------- - if len(name) > MAX_HIERARCHY_NAME_LENGTH: - raise ValidationError( - f"Name '{name}' is {len(name)} characters. " - f"Maximum is {MAX_HIERARCHY_NAME_LENGTH} " - "(limited by the Configuration resource name constraint)." - ) + if not hierarchy_spec: + raise ValidationError("--hierarchy-spec is required.") + if not configuration_location: + raise ValidationError("--configuration-location is required.") + if not resource_group: + raise ValidationError("--resource-group is required (used for Configuration resources).") - description = description or name - sub_id = _get_sub_id(cmd) - tenant_id = _get_tenant_id(cmd) + # Parse spec (could be dict from shorthand or file) + spec = hierarchy_spec if isinstance(hierarchy_spec, dict) else hierarchy_spec - total_steps = TOTAL_STEPS_NO_CONTEXT if skip_context else TOTAL_STEPS_WITH_CONTEXT - step = 0 - step_results = {} + name = spec.get("name") + level = spec.get("level") + hierarchy_type = spec.get("type", "ResourceGroup") - print(f"\nCreating hierarchy level '{name}' ({level_label})...\n") + if not name: + raise ValidationError("hierarchy-spec must include 'name'.") + if not level: + raise ValidationError("hierarchy-spec must include 'level'.") - # ----------------------------------------------------------------------- - # Step 0: Auto-create / detect Context (if not skipped) - # ----------------------------------------------------------------------- - ctx_name = None - ctx_rg = None + if hierarchy_type == "ServiceGroup": + return _create_sg_hierarchy(cmd, spec, configuration_location, resource_group) + else: + return _create_rg_hierarchy(cmd, resource_group, configuration_location, name, level) - if not skip_context: - step += 1 - try: - ctx_name, ctx_rg = _ensure_context( - cmd, resource_group, location, context_name, context_rg, - level_label, capabilities, step, total_steps - ) - step_results["context"] = "Succeeded" - except Exception as exc: - step_results["context"] = f"FAILED: {exc}" - _print_hierarchy_diagnostic(step_results, name, resource_group) - raise CLIInternalError( - f"Context setup failed: {exc}", - recommendation=( - "Try creating context manually:\n" - f" az workload-orchestration context create -g {resource_group} " - f"-l {location} --name {resource_group}-context " - "--capabilities [] --hierarchies []" - ) - ) - # ----------------------------------------------------------------------- - # Step 1: Create Service Group - # ----------------------------------------------------------------------- - step += 1 - try: - parent_id = ( - f"/providers/Microsoft.Management/serviceGroups/{parent}" - if parent - else f"/providers/Microsoft.Management/serviceGroups/{tenant_id}" - ) - sg_id = f"/providers/Microsoft.Management/serviceGroups/{name}" +# --------------------------------------------------------------------------- +# ResourceGroup hierarchy +# --------------------------------------------------------------------------- - _arm_put_quiet(cmd, f"{ARM_ENDPOINT}{sg_id}", { - "properties": { - "displayName": name, - "parent": {"resourceId": parent_id} - } - }, SERVICE_GROUP_API_VERSION) +def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): + """Create Site + Configuration + ConfigurationReference in a resource group.""" + sub_id = _get_sub_id(cmd) + total = 3 + step = [0] - print_step(step, total_steps, "Service Group", "[OK] Created") - step_results["service-group"] = "Succeeded" - except Exception as exc: - step_results["service-group"] = f"FAILED: {exc}" - _print_hierarchy_diagnostic(step_results, name, resource_group) - raise CLIInternalError( - f"Service Group creation failed: {exc}", - recommendation=( - f"Try manually: az rest --method put " - f"--url \"{ARM_ENDPOINT}{sg_id}?api-version={SERVICE_GROUP_API_VERSION}\" " - f"--header Content-Type=application/json " - f"--body \"{{\\\"properties\\\":{{\\\"displayName\\\":\\\"{name}\\\"," - f"\\\"parent\\\":{{\\\"resourceId\\\":\\\"{parent_id}\\\"}}}}}}\" " - f"--resource {ARM_ENDPOINT}" - ) - ) + def _log(msg, status=""): + if status: + print(f"[{step[0]}/{total}] {msg}... {status}") + else: + step[0] += 1 + print(f"[{step[0]}/{total}] {msg}...") + + site_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + config_name = f"{name}Config" + config_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" + + print(f"\nCreating hierarchy '{name}' (level: {level}) in RG '{resource_group}'...\n") + + # Step 1: Create Site + _log(f"Site '{name}' ({level})") + _arm_put(cmd, f"{ARM_ENDPOINT}{site_id}", { + "properties": { + "displayName": name, + "description": name, + "labels": {"level": level}, + } + }, SITE_API_VERSION) + _log(f"Site '{name}'", "[OK]") + + # Step 2: Create Configuration + _log(f"Configuration '{config_name}'") + _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { + "location": config_location, + }, CONFIGURATION_API_VERSION) + _log(f"Configuration '{config_name}'", "[OK]") + + # Step 3: Create ConfigurationReference (links site → config) + config_ref_url = f"{ARM_ENDPOINT}{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" + _log("ConfigurationReference") + _arm_put(cmd, config_ref_url, { + "properties": { + "configurationResourceId": config_id, + } + }, CONFIG_REF_API_VERSION) + _log("ConfigurationReference", "[OK]") + + print(f"\nHierarchy '{name}' created successfully (3 resources).\n") - # ----------------------------------------------------------------------- - # Step 2: Create Site (in Service Group, regional endpoint) - # Retry with backoff - RBAC on new ServiceGroup scope takes time to propagate - # ----------------------------------------------------------------------- - step += 1 - site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" - try: - regional_url = f"https://{location}.management.azure.com{site_id}" - - max_retries = 4 - retry_delay = 10 # seconds - last_err = None - for attempt in range(max_retries): - try: - _arm_put_quiet(cmd, regional_url, { - "properties": { - "displayName": name, - "description": description, - "labels": {"level": level_label} - } - }, SITE_API_VERSION) - last_err = None - break - except Exception as exc: - last_err = exc - err_str = str(exc).lower() - is_auth_error = any(x in err_str for x in [ - "authorizationfailed", "forbidden", "403", - "does not have authorization" - ]) - if is_auth_error and attempt < max_retries - 1: - wait = retry_delay * (attempt + 1) - logger.info( - "Site creation got 403 (RBAC propagation). " - "Retry %d/%d in %ds...", attempt + 1, max_retries - 1, wait - ) - print_step(step, total_steps, "Site", - f"Waiting for permissions ({wait}s)...") - import time - time.sleep(wait) - else: - raise - - if last_err: - raise last_err - - print_step(step, total_steps, "Site", "[OK] Created") - step_results["site"] = "Succeeded" - except Exception as exc: - step_results["site"] = f"FAILED: {exc}" - _print_hierarchy_diagnostic(step_results, name, resource_group) - raise CLIInternalError( - f"Site creation failed: {exc}", - recommendation=( - "Check that the region supports the Sites API. " - f"Region used: {location}. " - "Try eastus2euap for canary testing." - ) - ) + return { + "type": "ResourceGroup", + "name": name, + "level": level, + "resourceGroup": resource_group, + "siteId": site_id, + "configurationId": config_id, + } - # ----------------------------------------------------------------------- - # Step 3: Create Configuration - # ----------------------------------------------------------------------- - step += 1 - config_id = ( - f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" - f"/providers/{EDGE_RP_NAMESPACE}/configurations/{name}" - ) - try: - regional_config_url = f"https://{location}.management.azure.com{config_id}" - _arm_put_quiet(cmd, regional_config_url, { - "location": location - }, CONFIGURATION_API_VERSION) +# --------------------------------------------------------------------------- +# ServiceGroup hierarchy (recursive, max 3 levels) +# --------------------------------------------------------------------------- - print_step(step, total_steps, "Configuration", "[OK] Created") - step_results["configuration"] = "Succeeded" - except Exception as exc: - step_results["configuration"] = f"FAILED: {exc}" - _print_hierarchy_diagnostic(step_results, name, resource_group) - raise CLIInternalError( - f"Configuration creation failed: {exc}", - recommendation=( - f"Configuration name must be ≤{MAX_HIERARCHY_NAME_LENGTH} chars. " - f"Current name: '{name}' ({len(name)} chars)." - ) - ) +def _create_sg_hierarchy(cmd, spec, config_location, resource_group): + """Create ServiceGroup + nested Sites + Configurations recursively.""" + sub_id = _get_sub_id(cmd) + tenant_id = _get_tenant_id(cmd) - # ----------------------------------------------------------------------- - # Step 4: Create Configuration Reference (links Config → Site) - # ----------------------------------------------------------------------- - step += 1 - try: - config_ref_url = ( - f"{ARM_ENDPOINT}{site_id}" - f"/providers/{EDGE_RP_NAMESPACE}/configurationreferences/default" + # Count total nodes + nodes = _count_nodes(spec) + if nodes > MAX_SG_DEPTH: + raise ValidationError( + f"ServiceGroup hierarchy has {nodes} levels. Maximum is {MAX_SG_DEPTH}." ) - _arm_put_quiet(cmd, config_ref_url, { - "properties": { - "configurationResourceId": config_id - } - }, CONFIG_REF_API_VERSION) + print(f"\nCreating ServiceGroup hierarchy '{spec['name']}' ({nodes} levels)...\n") - print_step(step, total_steps, "Configuration Reference", "[OK] Linked") - step_results["config-reference"] = "Succeeded" - except Exception as exc: - step_results["config-reference"] = f"FAILED: {exc}" - _print_hierarchy_diagnostic(step_results, name, resource_group) - raise CLIInternalError( - f"Configuration Reference creation failed: {exc}", - recommendation="This links the Configuration to the Site. Check ARM access." - ) + results = [] + _create_sg_level(cmd, spec, config_location, sub_id, tenant_id, + resource_group, parent_sg=None, results=results, depth=0) - # ----------------------------------------------------------------------- - # Step 5: Create Site Reference (links Site → Context) - # ----------------------------------------------------------------------- - if not skip_context and not skip_site_reference and ctx_name: - step += 1 - try: - invoke_cli_command(cmd, [ - "workload-orchestration", "context", "site-reference", "create", - "-g", ctx_rg or resource_group, - "--context-name", ctx_name, - "--name", f"{name}-ref", - "--site-id", site_id, - ], expect_json=False) - - print_step(step, total_steps, "Site Reference", "[OK] Linked to context") - step_results["site-reference"] = "Succeeded" - except Exception as exc: - # Site reference may already exist (not critical) - if "already exists" in str(exc).lower() or "conflict" in str(exc).lower(): - print_step(step, total_steps, "Site Reference", "Already exists [OK]") - step_results["site-reference"] = "Already exists" - else: - step_results["site-reference"] = f"FAILED: {exc}" - logger.warning("Site reference creation failed (non-critical): %s", exc) - print_step(step, total_steps, "Site Reference", f"[WARN] Warning: {exc}") - - # ----------------------------------------------------------------------- - # Output - # ----------------------------------------------------------------------- - _print_hierarchy_diagnostic(step_results, name, resource_group) - - print_success(f"Hierarchy level '{name}' created") - print_detail("Service Group", sg_id) - print_detail("Site ID", site_id) - print_detail("Configuration ID", config_id) - if ctx_name: - print_detail("Context", ctx_name) - print() + print(f"\nHierarchy created successfully ({nodes} levels, {len(results)} resources).\n") return { - "name": name, - "levelLabel": level_label, - "serviceGroupId": sg_id, - "siteId": site_id, - "configurationId": config_id, - "contextName": ctx_name, - "contextAutoCreated": ctx_name is not None and not context_name, + "type": "ServiceGroup", + "name": spec["name"], + "levels": nodes, + "resources": results, } -# --------------------------------------------------------------------------- -# Context helpers -# --------------------------------------------------------------------------- +def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_group, parent_sg, results, depth): + """Recursively create SG + Site + Config + ConfigRef at each level.""" + import time -def _ensure_context( - cmd, resource_group, location, context_name, context_rg, - level_label, capabilities, step, total_steps -): - """Ensure a WO context exists. Auto-create if needed. + name = node["name"] + level = node["level"] - Returns (context_name, context_rg) tuple. - """ - # Check if context is already set in CLI config - try: - current = invoke_cli_command(cmd, [ - "workload-orchestration", "context", "current", - ]) - if current and isinstance(current, dict): - existing_name = current.get("name") or current.get("contextName") - existing_rg = current.get("resourceGroup") - if existing_name: - print_step(step, total_steps, "Context", - f"Using existing '{existing_name}' [OK]") - - # Update context with new hierarchy level and capabilities if needed - _update_context_if_needed( - cmd, existing_name, existing_rg or resource_group, - level_label, capabilities - ) - return existing_name, existing_rg or resource_group - except Exception: - pass # No context set, try to find or create one - - # Try to use explicitly provided context - if context_name: - ctx_rg = context_rg or resource_group - print_step(step, total_steps, "Context", - f"Using specified '{context_name}' [OK]") - _update_context_if_needed(cmd, context_name, ctx_rg, level_label, capabilities) - - # Set as current - try: - invoke_cli_command(cmd, [ - "workload-orchestration", "context", "use", - "--name", context_name, "-g", ctx_rg, - ], expect_json=False) - except Exception: - pass - return context_name, ctx_rg - - # Auto-create default context - default_ctx_name = f"{resource_group}-context" - ctx_rg = context_rg or resource_group - print_step(step, total_steps, "Context", - f"Creating default '{default_ctx_name}'") - - # Build hierarchies and capabilities for context create - hierarchies_args = [ - f"[0].name={level_label.lower()}", - f"[0].description={level_label}", - ] - capabilities_args = [] - if capabilities: - for i, cap in enumerate(capabilities): - capabilities_args.extend([ - f"[{i}].name={cap}", - f"[{i}].description={cap}", - ]) - - create_args = [ - "workload-orchestration", "context", "create", - "-g", ctx_rg, - "-l", location, - "--name", default_ctx_name, - "--hierarchies", - ] + hierarchies_args - - if capabilities_args: - create_args.append("--capabilities") - create_args.extend(capabilities_args) - - invoke_cli_command(cmd, create_args, expect_json=False) - - # Set as current - try: - invoke_cli_command(cmd, [ - "workload-orchestration", "context", "use", - "--name", default_ctx_name, "-g", ctx_rg, - ], expect_json=False) - except Exception: - pass - - print_step(step, total_steps, "Context", - f"Created '{default_ctx_name}' [OK]") - return default_ctx_name, ctx_rg + # Create or reference the ServiceGroup + if parent_sg: + parent_id = f"/providers/Microsoft.Management/serviceGroups/{parent_sg}" + else: + parent_id = f"/providers/Microsoft.Management/serviceGroups/{tenant_id}" + sg_id = f"/providers/Microsoft.Management/serviceGroups/{name}" + indent = " " * depth -def _update_context_if_needed(cmd, context_name, context_rg, level_label, capabilities): - """Update existing context with new hierarchy level or capabilities if not already present.""" + # 1. Create ServiceGroup + print(f"{indent}[+] ServiceGroup '{name}'...") try: - ctx = invoke_cli_command(cmd, [ - "workload-orchestration", "context", "show", - "-g", context_rg, "--name", context_name, - ]) - if not ctx or not isinstance(ctx, dict): - return - - props = ctx.get("properties", {}) - existing_hierarchies = [ - h.get("name", "").lower() - for h in (props.get("hierarchies") or []) - ] - existing_capabilities = [ - c.get("name", "").lower() - for c in (props.get("capabilities") or []) - ] - - needs_update = False - - # Check if hierarchy level needs adding - if level_label.lower() not in existing_hierarchies: - logger.info("Adding hierarchy level '%s' to context", level_label) - needs_update = True - - # Check if capabilities need adding - if capabilities: - new_caps = [c for c in capabilities if c.lower() not in existing_capabilities] - if new_caps: - logger.info("Adding capabilities %s to context", new_caps) - needs_update = True - - if needs_update: - # Context update with hierarchies/capabilities is complex, - # log the need but don't auto-update to avoid breaking existing config - logger.info( - "Context '%s' may need hierarchy/capability updates. " - "Run: az workload-orchestration context update ...", - context_name - ) + _arm_put(cmd, f"{ARM_ENDPOINT}{sg_id}", { + "properties": { + "displayName": name, + "parent": {"resourceId": parent_id}, + } + }, SERVICE_GROUP_API_VERSION) + print(f"{indent}[+] ServiceGroup '{name}'... [OK]") + results.append({"type": "ServiceGroup", "name": name, "id": sg_id}) except Exception as exc: - logger.debug("Could not check/update context: %s", exc) + logger.warning("ServiceGroup creation failed: %s", exc) + raise CLIInternalError(f"ServiceGroup '{name}' creation failed: {exc}") + + # Wait for RBAC propagation on new SG scope + print(f"{indent} Waiting for RBAC propagation...") + _wait_for_sg_rbac(cmd, config_location, sg_id, name) + + # 2. Create Site under ServiceGroup (regional endpoint) + site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + print(f"{indent} [+] Site '{name}' ({level})...") + _arm_put_regional(cmd, config_location, site_id, { + "properties": { + "displayName": name, + "description": name, + "labels": {"level": level}, + } + }, SITE_API_VERSION) + print(f"{indent} [+] Site '{name}'... [OK]") + results.append({"type": "Site", "name": name, "level": level, "id": site_id}) + + # 3. Create Configuration (RG-scoped, NOT under SG) + config_name = f"{name}Config" + config_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" + print(f"{indent} [+] Configuration '{config_name}' (in RG: {resource_group})...") + _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { + "location": config_location, + }, CONFIGURATION_API_VERSION) + print(f"{indent} [+] Configuration '{config_name}'... [OK]") + results.append({"type": "Configuration", "name": config_name, "id": config_id}) + + # 4. Create ConfigurationReference on Site (regional, links site -> RG config) + config_ref_id = f"{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" + print(f"{indent} [+] ConfigurationReference...") + _arm_put_regional(cmd, config_location, config_ref_id, { + "properties": { + "configurationResourceId": config_id, + } + }, CONFIG_REF_API_VERSION) + print(f"{indent} [+] ConfigurationReference... [OK]") + results.append({"type": "ConfigurationReference", "siteId": site_id}) + + # Recurse into children + children = node.get("children") + if children: + if isinstance(children, dict): + children = [children] + for child in children: + _create_sg_level(cmd, child, config_location, sub_id, tenant_id, + resource_group, parent_sg=name, results=results, depth=depth + 1) + + +def _count_nodes(node): + """Count total depth of hierarchy tree.""" + children = node.get("children") + if not children: + return 1 + if isinstance(children, dict): + return 1 + _count_nodes(children) + return 1 + max(_count_nodes(c) for c in children) # --------------------------------------------------------------------------- -# ARM helper (quiet - no output) +# ARM helpers # --------------------------------------------------------------------------- -def _arm_put_quiet(cmd, url, body, api_version): - """PUT request using send_raw_request with manual token for regional endpoints. +def _arm_put(cmd, url, body, api_version): + """PUT to ARM endpoint.""" + full_url = f"{url}?api-version={api_version}" + send_raw_request( + cmd.cli_ctx, "PUT", full_url, + body=json.dumps(body), + headers=["Content-Type=application/json"], + resource=ARM_ENDPOINT, + ) - We manually acquire the token with the correct subscription context and - pass it as an Authorization header. This bypasses send_raw_request's - built-in auth which fails on regional URLs (eastus2euap.management.azure.com) - because it can't match them to a known cloud endpoint. - """ - from azure.cli.core._profile import Profile - full_url = f"{url}?api-version={api_version}" - body_str = json.dumps(body) if isinstance(body, dict) else body - logger.debug("PUT %s", full_url) +def _arm_put_regional(cmd, location, resource_id, body, api_version): + """PUT to regional ARM endpoint (for SG-scoped resources).""" + full_url = f"https://{location}.management.azure.com{resource_id}?api-version={api_version}" + body_str = json.dumps(body) - # Get token manually with correct subscription - profile = Profile(cli_ctx=cmd.cli_ctx) - token_info, _, _ = profile.get_raw_token( - resource="https://management.azure.com", - subscription=profile.get_subscription_id() - ) - token_type, token, _ = token_info + token_type, token = _get_token(cmd) send_raw_request( - cmd.cli_ctx, - method="PUT", - url=full_url, + cmd.cli_ctx, "PUT", full_url, body=body_str, headers=[ f"Authorization={token_type} {token}", @@ -505,56 +309,71 @@ def _arm_put_quiet(cmd, url, body, api_version): ) -# --------------------------------------------------------------------------- -# Diagnostics -# --------------------------------------------------------------------------- +def _arm_get_regional(cmd, location, resource_id, api_version): + """GET from regional ARM endpoint.""" + full_url = f"https://{location}.management.azure.com{resource_id}?api-version={api_version}" -def _print_hierarchy_diagnostic(step_results, name, resource_group): - """Print diagnostic summary for hierarchy creation.""" - print("\n" + "=" * 60) - print(" Hierarchy Creation - Diagnostic Summary") - print(f" Name: {name}") - print(f" Resource Group: {resource_group}") - print(f" Timestamp: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}") - print("=" * 60) - - for step_name, result in step_results.items(): - if "FAILED" in result: - icon = "[FAIL]" - elif "Warning" in result: - icon = "[WARN]" - else: - icon = "[OK]" - print(f" {icon} {step_name}: {result}") + token_type, token = _get_token(cmd) - has_failure = any("FAILED" in v for v in step_results.values()) - if has_failure: - print("\n [WARN] One or more steps failed.") - print(" Re-run the command to retry - PUTs are idempotent (safe to re-run).") - print("=" * 60 + "\n") + resp = send_raw_request( + cmd.cli_ctx, "GET", full_url, + headers=[ + f"Authorization={token_type} {token}", + ], + skip_authorization_header=True, + ) + return resp -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- +def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=18, wait_sec=10): + """Wait for RBAC to propagate on a newly created ServiceGroup. + + After SG creation, it takes time for permissions to propagate. + We poll by trying to list sites under the SG until it succeeds. + """ + import time + + site_list_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites" + + for attempt in range(max_retries): + try: + _arm_get_regional(cmd, location, site_list_id, SITE_API_VERSION) + logger.info("RBAC propagated for SG '%s' after %ds", sg_name, attempt * wait_sec) + return + except Exception: + if attempt < max_retries - 1: + logger.debug("RBAC not ready (attempt %d/%d), waiting %ds...", attempt + 1, max_retries, wait_sec) + time.sleep(wait_sec) + else: + logger.warning( + "RBAC propagation timeout for SG '%s' after %ds. Continuing anyway...", + sg_name, max_retries * wait_sec + ) + + +def _get_token(cmd): + """Get ARM bearer token.""" + from azure.cli.core._profile import Profile + profile = Profile(cli_ctx=cmd.cli_ctx) + token_info, _, _ = profile.get_raw_token( + resource="https://management.azure.com", + subscription=profile.get_subscription_id() + ) + return token_info[0], token_info[1] # token_type, token + def _get_sub_id(cmd): - """Get subscription ID from CLI context.""" - try: + """Get subscription ID.""" + sub_id = cmd.cli_ctx.data.get('subscription_id') + if not sub_id: from azure.cli.core._profile import Profile - profile = Profile(cli_ctx=cmd.cli_ctx) - sub = profile.get_subscription() - return sub.get("id", "") - except Exception: - return "" + sub_id = Profile(cli_ctx=cmd.cli_ctx).get_subscription_id() + return sub_id def _get_tenant_id(cmd): - """Get tenant ID from CLI context.""" - try: - from azure.cli.core._profile import Profile - profile = Profile(cli_ctx=cmd.cli_ctx) - sub = profile.get_subscription() - return sub.get("tenantId", "") - except Exception: - return "" + """Get tenant ID.""" + from azure.cli.core._profile import Profile + profile = Profile(cli_ctx=cmd.cli_ctx) + _, _, tenant_id = profile.get_raw_token(resource="https://management.azure.com") + return tenant_id diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create_v2.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create_v2.py deleted file mode 100644 index 64fc7085c30..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create_v2.py +++ /dev/null @@ -1,327 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Hierarchy create command - creates Site + Configuration + ConfigurationReference. - -Supports two hierarchy types: - - ResourceGroup: Single site in a resource group (no children) - - ServiceGroup: Nested sites under a service group (up to 3 levels) - -For ResourceGroup: - az workload-orchestration hierarchy create \\ - --resource-group rg --configuration-location eastus2euap \\ - --hierarchy-spec "@hierarchy.yaml" - - hierarchy.yaml: - name: Mehoopany - level: factory - -For ServiceGroup: - az workload-orchestration hierarchy create \\ - --configuration-location eastus2euap \\ - --hierarchy-spec "@hierarchy.yaml" - - hierarchy.yaml: - type: ServiceGroup - name: India - level: country - children: - name: Karnataka - level: region - children: - - name: BangaloreSouth - level: factory -""" - -# pylint: disable=broad-exception-caught -# pylint: disable=too-many-locals - -import json -import logging - -from azure.cli.core.azclierror import ( - CLIInternalError, - ValidationError, -) -from azure.cli.core.util import send_raw_request - -from azext_workload_orchestration.onboarding.consts import ( - ARM_ENDPOINT, - SERVICE_GROUP_API_VERSION, - SITE_API_VERSION, - CONFIGURATION_API_VERSION, - CONFIG_REF_API_VERSION, - EDGE_RP_NAMESPACE, -) - -logger = logging.getLogger(__name__) - -MAX_SG_DEPTH = 3 - - -def hierarchy_create(cmd, resource_group=None, configuration_location=None, hierarchy_spec=None): - """Create a hierarchy: Site + Configuration + ConfigurationReference. - - Parses the hierarchy spec (YAML/JSON or shorthand) and creates - the full resource stack. - """ - if not hierarchy_spec: - raise ValidationError("--hierarchy-spec is required.") - if not configuration_location: - raise ValidationError("--configuration-location is required.") - - # Parse spec (could be dict from shorthand or file) - spec = hierarchy_spec if isinstance(hierarchy_spec, dict) else hierarchy_spec - - name = spec.get("name") - level = spec.get("level") - hierarchy_type = spec.get("type", "ResourceGroup") - - if not name: - raise ValidationError("hierarchy-spec must include 'name'.") - if not level: - raise ValidationError("hierarchy-spec must include 'level'.") - - if hierarchy_type == "ServiceGroup": - return _create_sg_hierarchy(cmd, spec, configuration_location) - else: - # ResourceGroup (default) - if not resource_group: - raise ValidationError("--resource-group is required for ResourceGroup hierarchy.") - return _create_rg_hierarchy(cmd, resource_group, configuration_location, name, level) - - -# --------------------------------------------------------------------------- -# ResourceGroup hierarchy -# --------------------------------------------------------------------------- - -def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): - """Create Site + Configuration + ConfigurationReference in a resource group.""" - sub_id = _get_sub_id(cmd) - total = 3 - step = [0] - - def _log(msg, status=""): - if status: - print(f"[{step[0]}/{total}] {msg}... {status}") - else: - step[0] += 1 - print(f"[{step[0]}/{total}] {msg}...") - - site_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" - config_name = f"{name}Config" - config_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" - - print(f"\nCreating hierarchy '{name}' (level: {level}) in RG '{resource_group}'...\n") - - # Step 1: Create Site - _log(f"Site '{name}' ({level})") - _arm_put(cmd, f"{ARM_ENDPOINT}{site_id}", { - "properties": { - "displayName": name, - "description": name, - "labels": {"level": level}, - } - }, SITE_API_VERSION) - _log(f"Site '{name}'", "[OK]") - - # Step 2: Create Configuration - _log(f"Configuration '{config_name}'") - _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { - "location": config_location, - }, CONFIGURATION_API_VERSION) - _log(f"Configuration '{config_name}'", "[OK]") - - # Step 3: Create ConfigurationReference (links site → config) - config_ref_url = f"{ARM_ENDPOINT}{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" - _log("ConfigurationReference") - _arm_put(cmd, config_ref_url, { - "properties": { - "configurationResourceId": config_id, - } - }, CONFIG_REF_API_VERSION) - _log("ConfigurationReference", "[OK]") - - print(f"\nHierarchy '{name}' created successfully (3 resources).\n") - - return { - "type": "ResourceGroup", - "name": name, - "level": level, - "resourceGroup": resource_group, - "siteId": site_id, - "configurationId": config_id, - } - - -# --------------------------------------------------------------------------- -# ServiceGroup hierarchy (recursive, max 3 levels) -# --------------------------------------------------------------------------- - -def _create_sg_hierarchy(cmd, spec, config_location): - """Create ServiceGroup + nested Sites + Configurations recursively.""" - sub_id = _get_sub_id(cmd) - tenant_id = _get_tenant_id(cmd) - - # Count total nodes - nodes = _count_nodes(spec) - if nodes > MAX_SG_DEPTH: - raise ValidationError( - f"ServiceGroup hierarchy has {nodes} levels. Maximum is {MAX_SG_DEPTH}." - ) - - print(f"\nCreating ServiceGroup hierarchy '{spec['name']}' ({nodes} levels)...\n") - - results = [] - _create_sg_level(cmd, spec, config_location, sub_id, tenant_id, parent_sg=None, results=results, depth=0) - - print(f"\nHierarchy created successfully ({nodes} levels, {len(results)} resources).\n") - - return { - "type": "ServiceGroup", - "name": spec["name"], - "levels": nodes, - "resources": results, - } - - -def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, parent_sg, results, depth): - """Recursively create SG + Site + Config + ConfigRef at each level.""" - name = node["name"] - level = node["level"] - - # Create or reference the ServiceGroup - if parent_sg: - parent_id = f"/providers/Microsoft.Management/serviceGroups/{parent_sg}" - else: - parent_id = f"/providers/Microsoft.Management/serviceGroups/{tenant_id}" - - sg_id = f"/providers/Microsoft.Management/serviceGroups/{name}" - indent = " " * depth - - # 1. Create ServiceGroup - print(f"{indent}[+] ServiceGroup '{name}'...") - try: - _arm_put(cmd, f"{ARM_ENDPOINT}{sg_id}", { - "properties": { - "displayName": name, - "parent": {"resourceId": parent_id}, - } - }, SERVICE_GROUP_API_VERSION) - print(f"{indent}[+] ServiceGroup '{name}'... [OK]") - results.append({"type": "ServiceGroup", "name": name, "id": sg_id}) - except Exception as exc: - logger.warning("ServiceGroup creation failed: %s", exc) - raise CLIInternalError(f"ServiceGroup '{name}' creation failed: {exc}") - - # 2. Create Site under ServiceGroup - site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" - print(f"{indent} [+] Site '{name}' ({level})...") - _arm_put_regional(cmd, config_location, site_id, { - "properties": { - "displayName": name, - "description": name, - "labels": {"level": level}, - } - }, SITE_API_VERSION) - print(f"{indent} [+] Site '{name}'... [OK]") - results.append({"type": "Site", "name": name, "level": level, "id": site_id}) - - # 3. Create Configuration - config_name = f"{name}Config" - config_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" - print(f"{indent} [+] Configuration '{config_name}'...") - _arm_put_regional(cmd, config_location, config_id, { - "location": config_location, - }, CONFIGURATION_API_VERSION) - print(f"{indent} [+] Configuration '{config_name}'... [OK]") - results.append({"type": "Configuration", "name": config_name, "id": config_id}) - - # 4. Create ConfigurationReference - config_ref_id = f"{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" - print(f"{indent} [+] ConfigurationReference...") - _arm_put_regional(cmd, config_location, config_ref_id, { - "properties": { - "configurationResourceId": config_id, - } - }, CONFIG_REF_API_VERSION) - print(f"{indent} [+] ConfigurationReference... [OK]") - results.append({"type": "ConfigurationReference", "siteId": site_id}) - - # Recurse into children - children = node.get("children") - if children: - if isinstance(children, dict): - children = [children] - for child in children: - _create_sg_level(cmd, child, config_location, sub_id, tenant_id, - parent_sg=name, results=results, depth=depth + 1) - - -def _count_nodes(node): - """Count total depth of hierarchy tree.""" - children = node.get("children") - if not children: - return 1 - if isinstance(children, dict): - return 1 + _count_nodes(children) - return 1 + max(_count_nodes(c) for c in children) - - -# --------------------------------------------------------------------------- -# ARM helpers -# --------------------------------------------------------------------------- - -def _arm_put(cmd, url, body, api_version): - """PUT to ARM endpoint.""" - full_url = f"{url}?api-version={api_version}" - send_raw_request( - cmd.cli_ctx, "PUT", full_url, - body=json.dumps(body), - headers=["Content-Type=application/json"], - resource=ARM_ENDPOINT, - ) - - -def _arm_put_regional(cmd, location, resource_id, body, api_version): - """PUT to regional ARM endpoint (for SG-scoped resources).""" - from azure.cli.core._profile import Profile - - full_url = f"https://{location}.management.azure.com{resource_id}?api-version={api_version}" - body_str = json.dumps(body) - - profile = Profile(cli_ctx=cmd.cli_ctx) - token_info, _, _ = profile.get_raw_token( - resource="https://management.azure.com", - subscription=profile.get_subscription_id() - ) - token_type, token, _ = token_info - - send_raw_request( - cmd.cli_ctx, "PUT", full_url, - body=body_str, - headers=[ - f"Authorization={token_type} {token}", - "Content-Type=application/json", - ], - skip_authorization_header=True, - ) - - -def _get_sub_id(cmd): - """Get subscription ID.""" - sub_id = cmd.cli_ctx.data.get('subscription_id') - if not sub_id: - from azure.cli.core._profile import Profile - sub_id = Profile(cli_ctx=cmd.cli_ctx).get_subscription_id() - return sub_id - - -def _get_tenant_id(cmd): - """Get tenant ID.""" - from azure.cli.core._profile import Profile - profile = Profile(cli_ctx=cmd.cli_ctx) - _, _, tenant_id = profile.get_raw_token(resource="https://management.azure.com") - return tenant_id From 5014bb0ca0bd94a74835f9ca6f1906b6e3fcb9ab Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 20 Apr 2026 10:11:42 +0530 Subject: [PATCH 30/91] refactor: remove --solution-instance-name and --solution-dependencies from target install These are review-specific args. Users can use standalone 'target review' for advanced scenarios. Keeps target install clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_install.py | 12 --------- .../onboarding/target_deploy.py | 25 +++---------------- 2 files changed, 3 insertions(+), 34 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 365bf0cc1d6..38953dbdc5d 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -91,16 +91,6 @@ def _build_arguments_schema(cls, *args, **kwargs): arg_group="Deploy", help="Version of the solution template (e.g., 1.0.0).", ) - _args_schema.solution_instance_name = AAZStrArg( - options=["--solution-instance-name"], - arg_group="Deploy", - help="Custom solution instance name for the review step.", - ) - _args_schema.solution_dependencies = AAZStrArg( - options=["--solution-dependencies"], - arg_group="Deploy", - help="JSON string of solution dependency definitions.", - ) # Config set args _args_schema.config = AAZStrArg( @@ -159,8 +149,6 @@ def _run_deploy_chain(self): solution_template_version_id=str(args.solution_template_version_id) if args.solution_template_version_id else None, solution_template_name=str(args.solution_template_name) if args.solution_template_name else None, solution_template_version=str(args.solution_template_version) if args.solution_template_version else None, - solution_instance_name=str(args.solution_instance_name) if args.solution_instance_name else None, - solution_dependencies=str(args.solution_dependencies) if args.solution_dependencies else None, config=str(args.config) if args.config else None, ) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 2a620c7f216..527c3713eaa 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -50,8 +50,6 @@ def target_deploy( solution_template_version_id=None, solution_template_name=None, solution_template_version=None, - solution_instance_name=None, - solution_dependencies=None, config=None, config_hierarchy_id=None, config_template_rg=None, @@ -106,10 +104,7 @@ def _log(step_name, status=""): # --- Step 1: Review --- _log("Review") - review_result = _do_review( - cmd, base_url, solution_template_version_id, - solution_instance_name, solution_dependencies, - ) + review_result = _do_review(cmd, base_url, solution_template_version_id) results["review"] = review_result sv_id = _extract_solution_version_id(review_result) _log("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") @@ -145,8 +140,6 @@ def target_deploy_pre_install( solution_template_version_id=None, solution_template_name=None, solution_template_version=None, - solution_instance_name=None, - solution_dependencies=None, config=None, ): """Run config-set → review → publish and return the solution-version-id. @@ -211,10 +204,7 @@ def _log(step_name, status=""): # --- Step 1: Review --- _log("Review") - review_result = _do_review( - cmd, base_url, solution_template_version_id, - solution_instance_name, solution_dependencies, - ) + review_result = _do_review(cmd, base_url, solution_template_version_id) sv_id = _extract_solution_version_id(review_result) _log("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") @@ -284,21 +274,12 @@ def _resolve_template_version_id( # Step implementations # --------------------------------------------------------------------------- -def _do_review(cmd, base_url, solution_template_version_id, - solution_instance_name=None, solution_dependencies=None): +def _do_review(cmd, base_url, solution_template_version_id): """POST .../reviewSolutionVersion""" url = f"{base_url}/reviewSolutionVersion?api-version={API_VERSION}" body = { "solutionTemplateVersionId": solution_template_version_id, } - if solution_instance_name: - body["solutionInstanceName"] = solution_instance_name - if solution_dependencies: - body["solutionDependencies"] = ( - json.loads(solution_dependencies) - if isinstance(solution_dependencies, str) - else solution_dependencies - ) resp = send_raw_request( cmd.cli_ctx, "POST", url, From 5f91bc635a0c7a62cd3df1abfcd378f029ab08d1 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 20 Apr 2026 11:22:06 +0530 Subject: [PATCH 31/91] fix: resolve all pylint/flake8 lint errors - Fix undefined 'inner' variable in solutionVersionId extraction - Replace broad Exception catches with specific types - Remove stale args from __init__.py (solution_template_rg, solution_instance_name, solution_dependencies) - Fix line-too-long in hierarchy_create.py - Remove unnecessary else after return - Remove unused import time - Fix ungrouped imports in target_prepare.py - Add pylint disable for too-few-public-methods on CmdProxy - Add pylint disable for unused-argument on invoke_cli_command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/__init__.py | 6 ------ .../onboarding/hierarchy_create.py | 20 ++++++++++++------- .../onboarding/target_deploy.py | 20 +++++++++---------- .../onboarding/target_prepare.py | 3 +-- .../onboarding/utils.py | 10 +++------- 5 files changed, 27 insertions(+), 32 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 2bdcc95085d..2cba955a72b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -45,9 +45,6 @@ def target_deploy( solution_template_version_id=None, solution_template_name=None, solution_template_version=None, - solution_template_rg=None, - solution_instance_name=None, - solution_dependencies=None, config=None, config_hierarchy_id=None, config_template_rg=None, @@ -62,9 +59,6 @@ def target_deploy( solution_template_version_id=solution_template_version_id, solution_template_name=solution_template_name, solution_template_version=solution_template_version, - solution_template_rg=solution_template_rg, - solution_instance_name=solution_instance_name, - solution_dependencies=solution_dependencies, config=config, config_hierarchy_id=config_hierarchy_id, config_template_rg=config_template_rg, diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index c9b1dd5eba9..73257d3379f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -88,8 +88,7 @@ def hierarchy_create(cmd, resource_group=None, configuration_location=None, hier if hierarchy_type == "ServiceGroup": return _create_sg_hierarchy(cmd, spec, configuration_location, resource_group) - else: - return _create_rg_hierarchy(cmd, resource_group, configuration_location, name, level) + return _create_rg_hierarchy(cmd, resource_group, configuration_location, name, level) # --------------------------------------------------------------------------- @@ -109,9 +108,15 @@ def _log(msg, status=""): step[0] += 1 print(f"[{step[0]}/{total}] {msg}...") - site_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + site_id = ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + ) config_name = f"{name}Config" - config_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" + config_id = ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" + ) print(f"\nCreating hierarchy '{name}' (level: {level}) in RG '{resource_group}'...\n") @@ -189,8 +194,6 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_group, parent_sg, results, depth): """Recursively create SG + Site + Config + ConfigRef at each level.""" - import time - name = node["name"] level = node["level"] @@ -237,7 +240,10 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro # 3. Create Configuration (RG-scoped, NOT under SG) config_name = f"{name}Config" - config_id = f"/subscriptions/{sub_id}/resourceGroups/{resource_group}/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" + config_id = ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" + ) print(f"{indent} [+] Configuration '{config_name}' (in RG: {resource_group})...") _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { "location": config_location, diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 527c3713eaa..6e37acae2fc 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -362,7 +362,7 @@ def _parse_response(resp, step_name, cmd=None): if status in (200, 201): try: return resp.json() - except Exception: + except (ValueError, AttributeError): return {"status": "Succeeded"} if status == 202: return _poll_lro(resp, step_name, cmd=cmd) @@ -370,7 +370,7 @@ def _parse_response(resp, step_name, cmd=None): # Error try: error_body = resp.text - except Exception: + except (ValueError, AttributeError): error_body = f"HTTP {status}" raise CLIInternalError(f"{step_name} failed (HTTP {status}): {error_body}") @@ -391,13 +391,13 @@ def _poll_lro(resp, step_name, cmd=None): time.sleep(retry_after) try: poll_resp = send_raw_request(cmd.cli_ctx, "GET", location, resource=ARM_RESOURCE) - except Exception: + except (CLIInternalError, ValueError, ConnectionError): logger.debug("LRO poll attempt %d failed for %s", i + 1, step_name) continue try: body = poll_resp.json() - except Exception: + except (ValueError, AttributeError): continue poll_status = body.get("status", "").lower() @@ -428,13 +428,13 @@ def _extract_solution_version_id(review_result): or (props.get("properties", {}) or {}).get("id") # properties.properties.id ) if not sv_id: - logger.warning("Could not extract solutionVersionId. Keys at top: %s, inner keys: %s, full (truncated): %s", - list(review_result.keys()), - list(inner.keys()) if isinstance(inner, dict) else "N/A", - json.dumps(review_result, indent=2)[:800]) + logger.warning( + "Could not extract solutionVersionId. Keys: %s, full (truncated): %s", + list(review_result.keys()), + json.dumps(review_result, indent=2)[:800] + ) raise CLIInternalError( - "Review succeeded but no solutionVersionId found in response. " - "Use --resume-from publish --solution-version-id to continue manually." + "Review succeeded but no solutionVersionId found in response." ) return sv_id diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 7f3204a64d9..8bda52c6242 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -28,6 +28,7 @@ CLIInternalError, ValidationError, ) +from azure.cli.core.util import send_raw_request from azext_workload_orchestration.onboarding.consts import ( DEFAULT_CERT_MANAGER_VERSION, @@ -53,8 +54,6 @@ print_detail, ) -from azure.cli.core.util import send_raw_request - logger = logging.getLogger(__name__) TOTAL_STEPS = 4 diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py index 35463c36f19..1deead42a88 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py @@ -23,12 +23,8 @@ # CmdProxy - bridge between AAZ hooks and helpers expecting cmd.cli_ctx # --------------------------------------------------------------------------- -class CmdProxy: - """Lightweight proxy to pass CLI context where a full cmd object is expected. - - AAZ-generated commands don't expose a cmd object in hooks, but many - helper functions expect cmd.cli_ctx. This proxy bridges the gap. - """ +class CmdProxy: # pylint: disable=too-few-public-methods + """Lightweight proxy to pass CLI context where a full cmd object is expected.""" def __init__(self, cli_ctx): self.cli_ctx = cli_ctx @@ -87,7 +83,7 @@ def invoke_silent(cli_args): # CLI command invocation # --------------------------------------------------------------------------- -def invoke_cli_command(cmd, command_args, expect_json=True): +def invoke_cli_command(cmd, command_args, expect_json=True): # pylint: disable=unused-argument """Invoke another az CLI command in-process (shares auth context). Uses get_default_cli().invoke() so the child command shares From 3bec477a53bccf3f861fb6cb9729ebb9d3e600b2 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 20 Apr 2026 11:28:49 +0530 Subject: [PATCH 32/91] chore: bump version to 5.2.0, update HISTORY.rst CLI Onboarding Simplification release: - cluster init, hierarchy create (new commands) - context create --site-id, target create --service-group (enhanced) - target install with full deploy chain (enhanced) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/workload-orchestration/HISTORY.rst | 20 ++++++++++++++++++++ src/workload-orchestration/setup.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/workload-orchestration/HISTORY.rst b/src/workload-orchestration/HISTORY.rst index 49671d6fc4a..10227419f6d 100644 --- a/src/workload-orchestration/HISTORY.rst +++ b/src/workload-orchestration/HISTORY.rst @@ -2,6 +2,26 @@ Release History =============== +5.2.0 +++++++ +* **CLI Onboarding Simplification** — reduces onboarding from 11 commands to 4: +* Added ``az workload-orchestration cluster init`` command: + * Prepares Arc-connected clusters for WO (cert-manager, trust-manager, extension, custom location) + * Idempotent — safely skips components already installed + * Supports ``--release-train``, ``--extension-version``, ``--custom-location-name`` +* Added ``az workload-orchestration hierarchy create`` command: + * Creates full hierarchy stack (Site + Configuration + ConfigurationReference) in one command + * Supports ResourceGroup (shorthand or YAML) and ServiceGroup (up to 3 levels, recursive) + * Handles RBAC propagation waits for ServiceGroup hierarchies +* Enhanced ``az workload-orchestration context create``: + * Added ``--site-id`` argument to auto-create site-reference after context creation +* Enhanced ``az workload-orchestration target create``: + * Added ``--service-group`` argument to auto-link target to a Service Group after creation +* Enhanced ``az workload-orchestration target install``: + * Added ``--solution-template-version-id``, ``--solution-template-name``, ``--solution-template-version`` for full deploy chain (review → publish → install) + * Added ``--configuration`` to set config values before review (auto-derives config template args) + * Existing ``--solution-version-id`` direct install flow unchanged + 5.1.1 ++++++ * Resolved solution template name to uniqueIdentifier for ``az workload-orchestration target solution-revision-list`` and ``az workload-orchestration target solution-instance-list`` diff --git a/src/workload-orchestration/setup.py b/src/workload-orchestration/setup.py index 7a0cfc1110b..67a7a051800 100644 --- a/src/workload-orchestration/setup.py +++ b/src/workload-orchestration/setup.py @@ -10,7 +10,7 @@ # HISTORY.rst entry. -VERSION = '5.1.1' +VERSION = '5.2.0' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 288136b9d8053b0a2939faf34aa9a73f53a0cf72 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 20 Apr 2026 11:48:43 +0530 Subject: [PATCH 33/91] fix: linter errors - add group help, fix example, add short aliases - Add help for 'cluster' and 'hierarchy' command groups - Fix hierarchy create SG example to include -g - Add -v alias for --solution-template-version-id - Add --version alias for --solution-template-version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 12 +++++++++++- .../latest/workload_orchestration/target/_install.py | 4 ++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index dbfc3d8192f..e0adb310506 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -93,5 +93,15 @@ - name: Create RG hierarchy with shorthand text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "name=Mehoopany level=factory" - name: Create ServiceGroup hierarchy from YAML - text: az workload-orchestration hierarchy create --configuration-location eastus2euap --hierarchy-spec "@sg-hierarchy.yaml" + text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "@sg-hierarchy.yaml" +""" + +helps['workload-orchestration cluster'] = """ +type: group +short-summary: Commands for cluster preparation for workload orchestration. +""" + +helps['workload-orchestration hierarchy'] = """ +type: group +short-summary: Commands for managing workload orchestration hierarchies. """ diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 38953dbdc5d..61399a1abc0 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -77,7 +77,7 @@ def _build_arguments_schema(cls, *args, **kwargs): # New flow: full deploy chain _args_schema.solution_template_version_id = AAZStrArg( - options=["--solution-template-version-id"], + options=["--solution-template-version-id", "-v"], arg_group="Deploy", help="Full ARM ID of the solution template version. Triggers full deploy chain.", ) @@ -87,7 +87,7 @@ def _build_arguments_schema(cls, *args, **kwargs): help="Name of the solution template. Use with --solution-template-version.", ) _args_schema.solution_template_version = AAZStrArg( - options=["--solution-template-version"], + options=["--solution-template-version", "--version"], arg_group="Deploy", help="Version of the solution template (e.g., 1.0.0).", ) From bc14ab848cb6204ea93e3b85fa3d28735e55e0f7 Mon Sep 17 00:00:00 2001 From: Atharva Date: Mon, 20 Apr 2026 13:22:40 +0530 Subject: [PATCH 34/91] fix: handle @file.yaml in hierarchy-spec parser (P1 bug) When user passes @file.yaml, Azure CLI framework pre-loads the file content and passes YAML text as the value. Parser now handles 3 modes: 1. File path: hierarchy.yaml (reads file) 2. YAML content: @hierarchy.yaml (CLI pre-loads, parser detects YAML) 3. Shorthand: name=X level=Y (key=value pairs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_params.py | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 33fe188aa8b..3f1ccd6af5b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -99,22 +99,39 @@ def load_arguments(self, _): # pylint: disable=unused-argument def _parse_hierarchy_spec(value): - """Parse hierarchy spec from file path or shorthand syntax.""" + """Parse hierarchy spec from file path, YAML content, or shorthand syntax. + + Handles three input modes: + 1. File path: 'hierarchy.yaml' or '@hierarchy.yaml' (@ stripped by CLI) + 2. YAML content: when CLI framework pre-loads @file, we get raw YAML text + 3. Shorthand: 'name=X level=Y type=Z' + """ import os - # Handle @file syntax (@ may be stripped by CLI framework) + # Mode 1: File path (with or without @) filepath = value.lstrip('@') if os.path.exists(filepath): try: import yaml except ImportError: - import json as yaml_fallback + import json with open(filepath, 'r', encoding='utf-8') as f: - return yaml_fallback.load(f) + return json.load(f) with open(filepath, 'r', encoding='utf-8') as f: return yaml.safe_load(f) - # Shorthand: name=X level=Y type=Z + # Mode 2: YAML content (CLI framework pre-loaded @file) + # Detect YAML by checking for colon-separated key-value or newlines + if ':' in value and ('\n' in value or 'name:' in value or 'level:' in value): + try: + import yaml + parsed = yaml.safe_load(value) + if isinstance(parsed, dict): + return parsed + except Exception: + pass + + # Mode 3: Shorthand syntax: name=X level=Y type=Z result = {} for pair in value.split(): if '=' in pair: From 688c9bf6545348fce035915fe46fc75e3361e6cb Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 09:14:14 +0530 Subject: [PATCH 35/91] feat: add -l alias for --configuration-location on hierarchy create Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 3f1ccd6af5b..33ec8c9ef7b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -91,7 +91,7 @@ def load_arguments(self, _): # pylint: disable=unused-argument with self.argument_context('workload-orchestration hierarchy create') as c: c.argument('resource_group', options_list=['--resource-group', '-g'], help='Resource group for Configuration resources.', required=True) - c.argument('configuration_location', options_list=['--configuration-location'], + c.argument('configuration_location', options_list=['--configuration-location', '-l'], help='Azure region for the Configuration resource (e.g., eastus2euap).', required=True) c.argument('hierarchy_spec', options_list=['--hierarchy-spec'], help='Hierarchy specification as YAML/JSON file (@file.yaml) or shorthand syntax.', From 40242c8fa0756f11bce2a1e800f612a85a14639b Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 09:48:54 +0530 Subject: [PATCH 36/91] fix: move all progress output to stderr for clean -o json/table/tsv All print() calls in cluster init, hierarchy create, and target install now write to stderr. The -o flag output (json, table, tsv, yaml) is clean with no progress messages mixed in. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/hierarchy_create.py | 36 +++++++++++-------- .../onboarding/target_deploy.py | 22 +++++++----- .../onboarding/target_prepare.py | 32 ++++++++++------- .../onboarding/utils.py | 30 ++++++++-------- 4 files changed, 70 insertions(+), 50 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 73257d3379f..9f494c2f413 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -56,8 +56,13 @@ EDGE_RP_NAMESPACE, ) +import sys + logger = logging.getLogger(__name__) +def _eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + MAX_SG_DEPTH = 3 @@ -103,10 +108,10 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): def _log(msg, status=""): if status: - print(f"[{step[0]}/{total}] {msg}... {status}") + _eprint(f"[{step[0]}/{total}] {msg}... {status}") else: step[0] += 1 - print(f"[{step[0]}/{total}] {msg}...") + _eprint(f"[{step[0]}/{total}] {msg}...") site_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" @@ -118,7 +123,7 @@ def _log(msg, status=""): f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) - print(f"\nCreating hierarchy '{name}' (level: {level}) in RG '{resource_group}'...\n") + _eprint(f"\nCreating hierarchy '{name}' (level: {level}) in RG '{resource_group}'...\n") # Step 1: Create Site _log(f"Site '{name}' ({level})") @@ -148,7 +153,7 @@ def _log(msg, status=""): }, CONFIG_REF_API_VERSION) _log("ConfigurationReference", "[OK]") - print(f"\nHierarchy '{name}' created successfully (3 resources).\n") + _eprint(f"\nHierarchy '{name}' created successfully (3 resources).\n") return { "type": "ResourceGroup", @@ -176,13 +181,13 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): f"ServiceGroup hierarchy has {nodes} levels. Maximum is {MAX_SG_DEPTH}." ) - print(f"\nCreating ServiceGroup hierarchy '{spec['name']}' ({nodes} levels)...\n") + _eprint(f"\nCreating ServiceGroup hierarchy '{spec['name']}' ({nodes} levels)...\n") results = [] _create_sg_level(cmd, spec, config_location, sub_id, tenant_id, resource_group, parent_sg=None, results=results, depth=0) - print(f"\nHierarchy created successfully ({nodes} levels, {len(results)} resources).\n") + _eprint(f"\nHierarchy created successfully ({nodes} levels, {len(results)} resources).\n") return { "type": "ServiceGroup", @@ -207,7 +212,7 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro indent = " " * depth # 1. Create ServiceGroup - print(f"{indent}[+] ServiceGroup '{name}'...") + _eprint(f"{indent}[+] ServiceGroup '{name}'...") try: _arm_put(cmd, f"{ARM_ENDPOINT}{sg_id}", { "properties": { @@ -215,19 +220,19 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro "parent": {"resourceId": parent_id}, } }, SERVICE_GROUP_API_VERSION) - print(f"{indent}[+] ServiceGroup '{name}'... [OK]") + _eprint(f"{indent}[+] ServiceGroup '{name}'... [OK]") results.append({"type": "ServiceGroup", "name": name, "id": sg_id}) except Exception as exc: logger.warning("ServiceGroup creation failed: %s", exc) raise CLIInternalError(f"ServiceGroup '{name}' creation failed: {exc}") # Wait for RBAC propagation on new SG scope - print(f"{indent} Waiting for RBAC propagation...") + _eprint(f"{indent} Waiting for RBAC propagation...") _wait_for_sg_rbac(cmd, config_location, sg_id, name) # 2. Create Site under ServiceGroup (regional endpoint) site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" - print(f"{indent} [+] Site '{name}' ({level})...") + _eprint(f"{indent} [+] Site '{name}' ({level})...") _arm_put_regional(cmd, config_location, site_id, { "properties": { "displayName": name, @@ -235,7 +240,7 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro "labels": {"level": level}, } }, SITE_API_VERSION) - print(f"{indent} [+] Site '{name}'... [OK]") + _eprint(f"{indent} [+] Site '{name}'... [OK]") results.append({"type": "Site", "name": name, "level": level, "id": site_id}) # 3. Create Configuration (RG-scoped, NOT under SG) @@ -244,22 +249,22 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) - print(f"{indent} [+] Configuration '{config_name}' (in RG: {resource_group})...") + _eprint(f"{indent} [+] Configuration '{config_name}' (in RG: {resource_group})...") _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { "location": config_location, }, CONFIGURATION_API_VERSION) - print(f"{indent} [+] Configuration '{config_name}'... [OK]") + _eprint(f"{indent} [+] Configuration '{config_name}'... [OK]") results.append({"type": "Configuration", "name": config_name, "id": config_id}) # 4. Create ConfigurationReference on Site (regional, links site -> RG config) config_ref_id = f"{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" - print(f"{indent} [+] ConfigurationReference...") + _eprint(f"{indent} [+] ConfigurationReference...") _arm_put_regional(cmd, config_location, config_ref_id, { "properties": { "configurationResourceId": config_id, } }, CONFIG_REF_API_VERSION) - print(f"{indent} [+] ConfigurationReference... [OK]") + _eprint(f"{indent} [+] ConfigurationReference... [OK]") results.append({"type": "ConfigurationReference", "siteId": site_id}) # Recurse into children @@ -383,3 +388,4 @@ def _get_tenant_id(cmd): profile = Profile(cli_ctx=cmd.cli_ctx) _, _, tenant_id = profile.get_raw_token(resource="https://management.azure.com") return tenant_id + diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 6e37acae2fc..8979532f79e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -37,8 +37,13 @@ from azure.cli.core.azclierror import CLIInternalError, ValidationError from azure.cli.core.util import send_raw_request +import sys + logger = logging.getLogger(__name__) +def _eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + API_VERSION = "2025-08-01" ARM_RESOURCE = "https://management.azure.com" @@ -83,10 +88,10 @@ def target_deploy( def _log(step_name, status=""): if status: - print(f"[{current[0]}/{total}] {step_name}... {status}") + _eprint(f"[{current[0]}/{total}] {step_name}... {status}") else: current[0] += 1 - print(f"[{current[0]}/{total}] {step_name}...") + _eprint(f"[{current[0]}/{total}] {step_name}...") results = {} sv_id = None @@ -121,10 +126,10 @@ def _log(step_name, status=""): results["install"] = install_result _log("Install", "[OK]") - print(f"\n{'=' * 50}") - print(f"Deployment complete for target '{target_name}'") - print(f"Solution Version ID: {sv_id}") - print(f"{'=' * 50}") + _eprint(f"\n{'=' * 50}") + _eprint(f"Deployment complete for target '{target_name}'") + _eprint(f"Solution Version ID: {sv_id}") + _eprint(f"{'=' * 50}") # Return the install LRO result (same format as `az wo target install`) return results.get("install", { @@ -170,10 +175,10 @@ def target_deploy_pre_install( def _log(step_name, status=""): if status: - print(f"[{current[0]}/{total}] {step_name}... {status}") + _eprint(f"[{current[0]}/{total}] {step_name}... {status}") else: current[0] += 1 - print(f"[{current[0]}/{total}] {step_name}...") + _eprint(f"[{current[0]}/{total}] {step_name}...") # --- Step 0: Config set --- if do_config: @@ -445,3 +450,4 @@ def _short_id(arm_id): return "" parts = arm_id.strip("/").split("/") return parts[-1] if parts else arm_id + diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 8bda52c6242..8a11108bd86 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -23,6 +23,7 @@ import os import subprocess import logging +import sys from azure.cli.core.azclierror import ( CLIInternalError, @@ -56,6 +57,10 @@ logger = logging.getLogger(__name__) + +def _eprint(*args, **kwargs): + print(*args, file=sys.stderr, **kwargs) + TOTAL_STEPS = 4 @@ -85,7 +90,7 @@ def target_prepare( release_train = release_train or DEFAULT_RELEASE_TRAIN cert_manager_version = cert_manager_version or DEFAULT_CERT_MANAGER_VERSION - print(f"\nPreparing cluster '{cluster_name}' for Workload Orchestration...\n") + _eprint(f"\nPreparing cluster '{cluster_name}' for Workload Orchestration...\n") # Track step results for diagnostic summary step_results = {} @@ -200,7 +205,7 @@ def target_prepare( print_success(f"Cluster '{cluster_name}' is ready for Workload Orchestration") print_detail("Custom Location ID", cl_id) - print() + _eprint() return { "clusterName": cluster_name, @@ -606,12 +611,12 @@ def _print_diagnostic_summary(step_results, cluster_name, resource_group): """ from datetime import datetime, timezone - print("\n" + "=" * 60) - print(" Diagnostic Summary") - print(f" Cluster: {cluster_name}") - print(f" Resource Group: {resource_group}") - print(f" Timestamp: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}") - print("=" * 60) + _eprint("\n" + "=" * 60) + _eprint(" Diagnostic Summary") + _eprint(f" Cluster: {cluster_name}") + _eprint(f" Resource Group: {resource_group}") + _eprint(f" Timestamp: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}") + _eprint("=" * 60) for step_name, result in step_results.items(): if "FAILED" in result: @@ -620,13 +625,13 @@ def _print_diagnostic_summary(step_results, cluster_name, resource_group): icon = "○" else: icon = "[OK]" - print(f" {icon} {step_name}: {result}") + _eprint(f" {icon} {step_name}: {result}") has_failure = any("FAILED" in v for v in step_results.values()) if has_failure: - print("\n [WARN] One or more steps failed. See error details above.") - print(" Re-run the command to retry - completed steps will be skipped.") - print("=" * 60 + "\n") + _eprint("\n [WARN] One or more steps failed. See error details above.") + _eprint(" Re-run the command to retry - completed steps will be skipped.") + _eprint("=" * 60 + "\n") def _write_extended_location_file(extended_location): @@ -634,7 +639,7 @@ def _write_extended_location_file(extended_location): filepath = os.path.join(os.getcwd(), "extended-location.json") with open(filepath, "w", encoding="utf-8") as f: json.dump(extended_location, f, indent=2) - print(f"\n File written: {filepath}") + _eprint(f"\n File written: {filepath}") def _run_kubectl(args, kube_config=None, kube_context=None): @@ -704,3 +709,4 @@ def _get_sub_id(cmd): return sub.get("id", "") except Exception: return "" + diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py index 1deead42a88..05eb1458155 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py @@ -138,29 +138,31 @@ def invoke_cli_command(cmd, command_args, expect_json=True): # pylint: disable= # --------------------------------------------------------------------------- -# Progress output +# Progress output (uses stderr so -o json/table/tsv is clean) # --------------------------------------------------------------------------- -def print_step(step_num, total, message, status=""): - """Print a formatted step indicator. +import sys - Examples: - [1/4] Installing cert-manager... - [1/4] Installing cert-manager... [OK] - [1/4] Installing cert-manager... Already installed [OK] - """ + +def _eprint(*args, **kwargs): + """Print to stderr.""" + print(*args, file=sys.stderr, **kwargs) + + +def print_step(step_num, total, message, status=""): + """Print a formatted step indicator to stderr.""" prefix = f"[{step_num}/{total}]" if status: - print(f"{prefix} {message}... {status}") + _eprint(f"{prefix} {message}... {status}") else: - print(f"{prefix} {message}...") + _eprint(f"{prefix} {message}...") def print_success(message): - """Print a success summary line.""" - print(f"\n[OK] {message}") + """Print a success summary line to stderr.""" + _eprint(f"\n[OK] {message}") def print_detail(label, value): - """Print a detail line (indented).""" - print(f" {label}: {value}") + """Print a detail line (indented) to stderr.""" + _eprint(f" {label}: {value}") From 705d480f7f6cc37b3279ef6be35272bef26310c6 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 09:51:34 +0530 Subject: [PATCH 37/91] feat: tree-style hierarchy output, remove RBAC waiting message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RG output: 📁 MyFactory (factory) ├── Site 'MyFactory' ✓ ├── Configuration 'MyFactoryConfig' ✓ └── ConfigurationReference ✓ SG output: └── 📁 MyCountry (country) ├── Site 'MyCountry' ✓ ├── Configuration 'MyCountryConfig' ✓ └── ConfigurationReference ✓ └── 📁 MyRegion (region) ... Removed 'Waiting for RBAC propagation...' message (still waits silently). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/hierarchy_create.py | 69 +++++++++---------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 9f494c2f413..feb1e362d23 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -103,15 +103,6 @@ def hierarchy_create(cmd, resource_group=None, configuration_location=None, hier def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): """Create Site + Configuration + ConfigurationReference in a resource group.""" sub_id = _get_sub_id(cmd) - total = 3 - step = [0] - - def _log(msg, status=""): - if status: - _eprint(f"[{step[0]}/{total}] {msg}... {status}") - else: - step[0] += 1 - _eprint(f"[{step[0]}/{total}] {msg}...") site_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" @@ -123,10 +114,10 @@ def _log(msg, status=""): f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) - _eprint(f"\nCreating hierarchy '{name}' (level: {level}) in RG '{resource_group}'...\n") + _eprint(f"\nCreating hierarchy in RG '{resource_group}'...\n") # Step 1: Create Site - _log(f"Site '{name}' ({level})") + _eprint(f"📁 {name} ({level})") _arm_put(cmd, f"{ARM_ENDPOINT}{site_id}", { "properties": { "displayName": name, @@ -134,26 +125,27 @@ def _log(msg, status=""): "labels": {"level": level}, } }, SITE_API_VERSION) - _log(f"Site '{name}'", "[OK]") + _eprint(f"├── Site '{name}' ✓") # Step 2: Create Configuration - _log(f"Configuration '{config_name}'") _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { "location": config_location, }, CONFIGURATION_API_VERSION) - _log(f"Configuration '{config_name}'", "[OK]") + _eprint(f"├── Configuration '{config_name}' ✓") # Step 3: Create ConfigurationReference (links site → config) - config_ref_url = f"{ARM_ENDPOINT}{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" - _log("ConfigurationReference") + config_ref_url = ( + f"{ARM_ENDPOINT}{site_id}/providers/" + f"{EDGE_RP_NAMESPACE}/configurationReferences/default" + ) _arm_put(cmd, config_ref_url, { "properties": { "configurationResourceId": config_id, } }, CONFIG_REF_API_VERSION) - _log("ConfigurationReference", "[OK]") + _eprint(f"└── ConfigurationReference ✓") - _eprint(f"\nHierarchy '{name}' created successfully (3 resources).\n") + _eprint(f"\n✅ Hierarchy created (3 resources)\n") return { "type": "ResourceGroup", @@ -185,9 +177,10 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): results = [] _create_sg_level(cmd, spec, config_location, sub_id, tenant_id, - resource_group, parent_sg=None, results=results, depth=0) + resource_group, parent_sg=None, results=results, + depth=0, is_last=True) - _eprint(f"\nHierarchy created successfully ({nodes} levels, {len(results)} resources).\n") + _eprint(f"\n✅ Hierarchy created ({nodes} levels, {len(results)} resources)\n") return { "type": "ServiceGroup", @@ -197,22 +190,27 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): } -def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_group, parent_sg, results, depth): +def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_group, parent_sg, results, depth, is_last=True): """Recursively create SG + Site + Config + ConfigRef at each level.""" name = node["name"] level = node["level"] - # Create or reference the ServiceGroup if parent_sg: parent_id = f"/providers/Microsoft.Management/serviceGroups/{parent_sg}" else: parent_id = f"/providers/Microsoft.Management/serviceGroups/{tenant_id}" sg_id = f"/providers/Microsoft.Management/serviceGroups/{name}" - indent = " " * depth + + # Tree drawing characters + prefix = "" + for d in range(depth): + prefix += "│ " + connector = "└── " if is_last else "├── " + child_prefix = prefix + (" " if is_last else "│ ") # 1. Create ServiceGroup - _eprint(f"{indent}[+] ServiceGroup '{name}'...") + _eprint(f"{prefix}{connector}📁 {name} ({level})") try: _arm_put(cmd, f"{ARM_ENDPOINT}{sg_id}", { "properties": { @@ -220,19 +218,16 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro "parent": {"resourceId": parent_id}, } }, SERVICE_GROUP_API_VERSION) - _eprint(f"{indent}[+] ServiceGroup '{name}'... [OK]") results.append({"type": "ServiceGroup", "name": name, "id": sg_id}) except Exception as exc: logger.warning("ServiceGroup creation failed: %s", exc) raise CLIInternalError(f"ServiceGroup '{name}' creation failed: {exc}") - # Wait for RBAC propagation on new SG scope - _eprint(f"{indent} Waiting for RBAC propagation...") + # Wait for RBAC propagation silently _wait_for_sg_rbac(cmd, config_location, sg_id, name) # 2. Create Site under ServiceGroup (regional endpoint) site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" - _eprint(f"{indent} [+] Site '{name}' ({level})...") _arm_put_regional(cmd, config_location, site_id, { "properties": { "displayName": name, @@ -240,31 +235,29 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro "labels": {"level": level}, } }, SITE_API_VERSION) - _eprint(f"{indent} [+] Site '{name}'... [OK]") + _eprint(f"{child_prefix}├── Site '{name}' ✓") results.append({"type": "Site", "name": name, "level": level, "id": site_id}) - # 3. Create Configuration (RG-scoped, NOT under SG) + # 3. Create Configuration (RG-scoped) config_name = f"{name}Config" config_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) - _eprint(f"{indent} [+] Configuration '{config_name}' (in RG: {resource_group})...") _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { "location": config_location, }, CONFIGURATION_API_VERSION) - _eprint(f"{indent} [+] Configuration '{config_name}'... [OK]") + _eprint(f"{child_prefix}├── Configuration '{config_name}' ✓") results.append({"type": "Configuration", "name": config_name, "id": config_id}) - # 4. Create ConfigurationReference on Site (regional, links site -> RG config) + # 4. Create ConfigurationReference config_ref_id = f"{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" - _eprint(f"{indent} [+] ConfigurationReference...") _arm_put_regional(cmd, config_location, config_ref_id, { "properties": { "configurationResourceId": config_id, } }, CONFIG_REF_API_VERSION) - _eprint(f"{indent} [+] ConfigurationReference... [OK]") + _eprint(f"{child_prefix}└── ConfigurationReference ✓") results.append({"type": "ConfigurationReference", "siteId": site_id}) # Recurse into children @@ -272,9 +265,11 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro if children: if isinstance(children, dict): children = [children] - for child in children: + for i, child in enumerate(children): + child_is_last = (i == len(children) - 1) _create_sg_level(cmd, child, config_location, sub_id, tenant_id, - resource_group, parent_sg=name, results=results, depth=depth + 1) + resource_group, parent_sg=name, results=results, + depth=depth + 1, is_last=child_is_last) def _count_nodes(node): From 09c270e6b8e89052136a3009924d19641b5be6bd Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 09:53:11 +0530 Subject: [PATCH 38/91] cleanup: remove folder emoji from hierarchy output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/hierarchy_create.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index feb1e362d23..d613dbd07b1 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -117,7 +117,7 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): _eprint(f"\nCreating hierarchy in RG '{resource_group}'...\n") # Step 1: Create Site - _eprint(f"📁 {name} ({level})") + _eprint(f"{name} ({level})") _arm_put(cmd, f"{ARM_ENDPOINT}{site_id}", { "properties": { "displayName": name, @@ -210,7 +210,7 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro child_prefix = prefix + (" " if is_last else "│ ") # 1. Create ServiceGroup - _eprint(f"{prefix}{connector}📁 {name} ({level})") + _eprint(f"{prefix}{connector}{name} ({level})") try: _arm_put(cmd, f"{ARM_ENDPOINT}{sg_id}", { "properties": { @@ -384,3 +384,4 @@ def _get_tenant_id(cmd): _, _, tenant_id = profile.get_raw_token(resource="https://management.azure.com") return tenant_id + From 4be5606615102bea23c735a1d60dedbb96affe63 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 10:17:44 +0530 Subject: [PATCH 39/91] fix: tree output, add --solution-template-rg, clean deploy output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Fix tree characters in SG hierarchy (proper │├└ nesting) 2. Add --solution-template-rg to target install (ST can be in different RG) 3. Clean deploy completion message (no === banner) 4. Resources shown under parent node correctly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_install.py | 6 ++++ .../onboarding/hierarchy_create.py | 32 +++++++++++-------- .../onboarding/target_deploy.py | 22 ++++++------- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 61399a1abc0..fb4c18168a3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -91,6 +91,11 @@ def _build_arguments_schema(cls, *args, **kwargs): arg_group="Deploy", help="Version of the solution template (e.g., 1.0.0).", ) + _args_schema.solution_template_rg = AAZStrArg( + options=["--solution-template-rg"], + arg_group="Deploy", + help="Resource group of the solution template. Defaults to target's -g.", + ) # Config set args _args_schema.config = AAZStrArg( @@ -149,6 +154,7 @@ def _run_deploy_chain(self): solution_template_version_id=str(args.solution_template_version_id) if args.solution_template_version_id else None, solution_template_name=str(args.solution_template_name) if args.solution_template_name else None, solution_template_version=str(args.solution_template_version) if args.solution_template_version else None, + solution_template_rg=str(args.solution_template_rg) if args.solution_template_rg else None, config=str(args.config) if args.config else None, ) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index d613dbd07b1..40ad44a8949 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -190,7 +190,7 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): } -def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_group, parent_sg, results, depth, is_last=True): +def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_group, parent_sg, results, depth, is_last=True, parent_prefix=""): """Recursively create SG + Site + Config + ConfigRef at each level.""" name = node["name"] level = node["level"] @@ -202,15 +202,12 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro sg_id = f"/providers/Microsoft.Management/serviceGroups/{name}" - # Tree drawing characters - prefix = "" - for d in range(depth): - prefix += "│ " + # Tree drawing connector = "└── " if is_last else "├── " - child_prefix = prefix + (" " if is_last else "│ ") + child_prefix = parent_prefix + (" " if is_last else "│ ") # 1. Create ServiceGroup - _eprint(f"{prefix}{connector}{name} ({level})") + _eprint(f"{parent_prefix}{connector}{name} ({level})") try: _arm_put(cmd, f"{ARM_ENDPOINT}{sg_id}", { "properties": { @@ -226,7 +223,7 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro # Wait for RBAC propagation silently _wait_for_sg_rbac(cmd, config_location, sg_id, name) - # 2. Create Site under ServiceGroup (regional endpoint) + # 2. Create Site site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" _arm_put_regional(cmd, config_location, site_id, { "properties": { @@ -235,10 +232,9 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro "labels": {"level": level}, } }, SITE_API_VERSION) - _eprint(f"{child_prefix}├── Site '{name}' ✓") results.append({"type": "Site", "name": name, "level": level, "id": site_id}) - # 3. Create Configuration (RG-scoped) + # 3. Create Configuration config_name = f"{name}Config" config_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" @@ -247,7 +243,6 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { "location": config_location, }, CONFIGURATION_API_VERSION) - _eprint(f"{child_prefix}├── Configuration '{config_name}' ✓") results.append({"type": "Configuration", "name": config_name, "id": config_id}) # 4. Create ConfigurationReference @@ -257,11 +252,19 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro "configurationResourceId": config_id, } }, CONFIG_REF_API_VERSION) - _eprint(f"{child_prefix}└── ConfigurationReference ✓") results.append({"type": "ConfigurationReference", "siteId": site_id}) - # Recurse into children + # Show resources created under this node children = node.get("children") + has_children = children is not None + _eprint(f"{child_prefix}├── Site ✓") + _eprint(f"{child_prefix}├── Configuration ✓") + if has_children: + _eprint(f"{child_prefix}├── ConfigurationReference ✓") + else: + _eprint(f"{child_prefix}└── ConfigurationReference ✓") + + # Recurse into children if children: if isinstance(children, dict): children = [children] @@ -269,7 +272,8 @@ def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_gro child_is_last = (i == len(children) - 1) _create_sg_level(cmd, child, config_location, sub_id, tenant_id, resource_group, parent_sg=name, results=results, - depth=depth + 1, is_last=child_is_last) + depth=depth + 1, is_last=child_is_last, + parent_prefix=child_prefix) def _count_nodes(node): diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 8979532f79e..e26b6a83a4f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -126,10 +126,8 @@ def _log(step_name, status=""): results["install"] = install_result _log("Install", "[OK]") - _eprint(f"\n{'=' * 50}") - _eprint(f"Deployment complete for target '{target_name}'") - _eprint(f"Solution Version ID: {sv_id}") - _eprint(f"{'=' * 50}") + _eprint(f"\n✅ Deployment complete for target '{target_name}'") + _eprint(f" Solution Version: {_short_id(sv_id)}") # Return the install LRO result (same format as `az wo target install`) return results.get("install", { @@ -145,6 +143,7 @@ def target_deploy_pre_install( solution_template_version_id=None, solution_template_name=None, solution_template_version=None, + solution_template_rg=None, config=None, ): """Run config-set → review → publish and return the solution-version-id. @@ -152,14 +151,14 @@ def target_deploy_pre_install( Called by the enhanced `target install` command before the AAZ install step. Does NOT run install — that's handled by the AAZ LRO. - When using friendly name, the target's resource_group is used for the ST. + When using friendly name, solution_template_rg defaults to resource_group. Config-template args are auto-derived from solution template args. """ sub_id = _get_subscription_id(cmd) solution_template_version_id = _resolve_template_version_id( solution_template_version_id, solution_template_name, - solution_template_version, None, + solution_template_version, solution_template_rg, resource_group, sub_id, ) @@ -238,16 +237,12 @@ def _get_subscription_id(cmd): def _resolve_template_version_id( - arm_id, template_name, template_version, _unused, + arm_id, template_name, template_version, template_rg, default_rg, sub_id, ): """Resolve solution-template-version-id from friendly name or ARM ID. - Mutual exclusivity: - - Provide --solution-template-version-id (full ARM ID) - - OR --solution-template-name + --solution-template-version (friendly) - - When using friendly name, the target's resource group is used. + When using friendly name, template_rg defaults to default_rg (target's RG). """ if arm_id and template_name: raise ValidationError( @@ -263,8 +258,9 @@ def _resolve_template_version_id( raise ValidationError( "--solution-template-version is required when using --solution-template-name." ) + rg = template_rg or default_rg return ( - f"/subscriptions/{sub_id}/resourceGroups/{default_rg}" + f"/subscriptions/{sub_id}/resourceGroups/{rg}" f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" f"/versions/{template_version}" ) From f534322f1c4cc692ba2d84f32fca83b5718fdf18 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 11:23:21 +0530 Subject: [PATCH 40/91] fix: target create service-group messages to stderr Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/target/_create.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index a68e1ec1b3f..cdc0620193e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -226,14 +226,15 @@ def _handle_service_group_link(self): name = str(self.ctx.args.target_name) target_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/targets/{name}" - print(f"[service-group] Linking target to '{sg_name}'...") + import sys + print(f"[service-group] Linking target to '{sg_name}'...", file=sys.stderr) try: cmd_proxy = CmdProxy(self.ctx.cli_ctx) link_target_to_service_group(cmd_proxy, target_id, sg_name) - print(f"[service-group] Linked [OK]") + print(f"[service-group] Linked [OK]", file=sys.stderr) except Exception as exc: logger.warning("Service group link failed (non-critical): %s", exc) - print(f"[service-group] Link failed (non-critical): {exc}") + print(f"[service-group] Link failed (non-critical): {exc}", file=sys.stderr) def _output(self, *args, **kwargs): result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) From 3352feabfb72087177a5d08ff4474d72324e8821 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 11:27:19 +0530 Subject: [PATCH 41/91] =?UTF-8?q?style:=20use=20tree=20characters=20(?= =?UTF-8?q?=E2=94=9C=E2=94=80=E2=94=80=20=E2=94=94=E2=94=80=E2=94=80)=20an?= =?UTF-8?q?d=20=E2=9C=93=20for=20all=20command=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardize output across all commands: - cluster init: tree-style step indicators - hierarchy create: already had tree output - target create --service-group: tree-style link message - target install deploy chain: tree-style progress - Replace all [OK] with ✓ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_create.py | 6 ++--- .../onboarding/target_deploy.py | 26 +++++++++++-------- .../onboarding/target_prepare.py | 22 ++++++++-------- .../onboarding/utils.py | 10 +++---- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index cdc0620193e..85fe38e4dcf 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -227,14 +227,14 @@ def _handle_service_group_link(self): target_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/targets/{name}" import sys - print(f"[service-group] Linking target to '{sg_name}'...", file=sys.stderr) + print(f"└── service-group Linking to '{sg_name}'...", file=sys.stderr) try: cmd_proxy = CmdProxy(self.ctx.cli_ctx) link_target_to_service_group(cmd_proxy, target_id, sg_name) - print(f"[service-group] Linked [OK]", file=sys.stderr) + print(f"└── service-group Linked ✓", file=sys.stderr) except Exception as exc: logger.warning("Service group link failed (non-critical): %s", exc) - print(f"[service-group] Link failed (non-critical): {exc}", file=sys.stderr) + print(f"└── service-group Link failed (non-critical): {exc}", file=sys.stderr) def _output(self, *args, **kwargs): result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index e26b6a83a4f..155f799cda9 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -88,10 +88,12 @@ def target_deploy( def _log(step_name, status=""): if status: - _eprint(f"[{current[0]}/{total}] {step_name}... {status}") + connector = "└──" if current[0] == total else "├──" + _eprint(f"{connector} {step_name} {status}") else: current[0] += 1 - _eprint(f"[{current[0]}/{total}] {step_name}...") + connector = "└──" if current[0] == total else "├──" + _eprint(f"{connector} {step_name}...") results = {} sv_id = None @@ -104,7 +106,7 @@ def _log(step_name, status=""): config_template_name, config_template_version, resource_group, target_name, sub_id, ) - _log("Config Set", "[OK]") + _log("Config Set", "✓") results["configSet"] = "Succeeded" # --- Step 1: Review --- @@ -112,19 +114,19 @@ def _log(step_name, status=""): review_result = _do_review(cmd, base_url, solution_template_version_id) results["review"] = review_result sv_id = _extract_solution_version_id(review_result) - _log("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") + _log("Review", f"✓ solutionVersionId: {_short_id(sv_id)}") # --- Step 2: Publish --- _log("Publish") publish_result = _do_publish(cmd, base_url, sv_id) results["publish"] = publish_result - _log("Publish", "[OK]") + _log("Publish", "✓") # --- Step 3: Install --- _log("Install") install_result = _do_install(cmd, base_url, sv_id) results["install"] = install_result - _log("Install", "[OK]") + _log("Install", "✓") _eprint(f"\n✅ Deployment complete for target '{target_name}'") _eprint(f" Solution Version: {_short_id(sv_id)}") @@ -174,10 +176,12 @@ def target_deploy_pre_install( def _log(step_name, status=""): if status: - _eprint(f"[{current[0]}/{total}] {step_name}... {status}") + connector = "└──" if current[0] == total else "├──" + _eprint(f"{connector} {step_name} {status}") else: current[0] += 1 - _eprint(f"[{current[0]}/{total}] {step_name}...") + connector = "└──" if current[0] == total else "├──" + _eprint(f"{connector} {step_name}...") # --- Step 0: Config set --- if do_config: @@ -204,18 +208,18 @@ def _log(step_name, status=""): ct_name, ct_version, resource_group, target_name, sub_id, ) - _log("Config Set", "[OK]") + _log("Config Set", "✓") # --- Step 1: Review --- _log("Review") review_result = _do_review(cmd, base_url, solution_template_version_id) sv_id = _extract_solution_version_id(review_result) - _log("Review", f"[OK] -> solutionVersionId: {_short_id(sv_id)}") + _log("Review", f"✓ solutionVersionId: {_short_id(sv_id)}") # --- Step 2: Publish --- _log("Publish") _do_publish(cmd, base_url, sv_id) - _log("Publish", "[OK]") + _log("Publish", "✓") # Step 3 (Install) is handled by AAZ LRO _log("Install") diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 8a11108bd86..904e6cad67d 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -310,7 +310,7 @@ def _ensure_cert_manager(version, kube_config, kube_context): if len(running) >= CERT_MANAGER_MIN_PODS: print_step( 1, TOTAL_STEPS, "cert-manager", - f"Already installed [OK] ({len(running)} pods running)" + f"Already installed ✓ ({len(running)} pods running)" ) return logger.info( @@ -338,7 +338,7 @@ def _ensure_cert_manager(version, kube_config, kube_context): f"--timeout={CERT_MANAGER_WAIT_TIMEOUT}" ], kube_config, kube_context) - print_step(1, TOTAL_STEPS, "cert-manager", f"Installed {version} [OK]") + print_step(1, TOTAL_STEPS, "cert-manager", f"Installed {version} ✓") # --------------------------------------------------------------------------- @@ -372,7 +372,7 @@ def _ensure_trust_manager(kube_config, kube_context): apps_v1.read_namespaced_deployment( TRUST_MANAGER_DEPLOYMENT, CERT_MANAGER_NAMESPACE ) - print_step(2, TOTAL_STEPS, "trust-manager", "Already installed [OK]") + print_step(2, TOTAL_STEPS, "trust-manager", "Already installed ✓") return except ApiException as exc: if exc.status != 404: @@ -407,7 +407,7 @@ def _ensure_trust_manager(kube_config, kube_context): "--wait" ]) - print_step(2, TOTAL_STEPS, "trust-manager", "Installed [OK]") + print_step(2, TOTAL_STEPS, "trust-manager", "Installed ✓") # --------------------------------------------------------------------------- @@ -450,7 +450,7 @@ def _ensure_wo_extension( if prov_state == "succeeded": print_step( 3, TOTAL_STEPS, "WO extension", - f"Already installed [OK] (version {ext_ver})" + f"Already installed ✓ (version {ext_ver})" ) return ext_id @@ -489,9 +489,9 @@ def _ensure_wo_extension( ext_id = result.get("id", "") if isinstance(result, dict) else "" if no_wait: - print_step(3, TOTAL_STEPS, "WO extension", "Creating (--no-wait) [OK]") + print_step(3, TOTAL_STEPS, "WO extension", "Creating (--no-wait) ✓") else: - print_step(3, TOTAL_STEPS, "WO extension", "Installed [OK]") + print_step(3, TOTAL_STEPS, "WO extension", "Installed ✓") return ext_id @@ -526,7 +526,7 @@ def _ensure_custom_location( if cl_id: print_step( 4, TOTAL_STEPS, "Custom location", - f"Already exists [OK] ('{custom_location_name}')" + f"Already exists ✓ ('{custom_location_name}')" ) return cl_id except Exception: @@ -570,7 +570,7 @@ def _ensure_custom_location( ) ) - print_step(4, TOTAL_STEPS, "Custom location", "Created [OK]") + print_step(4, TOTAL_STEPS, "Custom location", "Created ✓") return cl_id @@ -620,11 +620,11 @@ def _print_diagnostic_summary(step_results, cluster_name, resource_group): for step_name, result in step_results.items(): if "FAILED" in result: - icon = "[FAIL]" + icon = "✗" elif result == "Skipped": icon = "○" else: - icon = "[OK]" + icon = "✓" _eprint(f" {icon} {step_name}: {result}") has_failure = any("FAILED" in v for v in step_results.values()) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py index 05eb1458155..4f6d1906cb5 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py @@ -150,17 +150,17 @@ def _eprint(*args, **kwargs): def print_step(step_num, total, message, status=""): - """Print a formatted step indicator to stderr.""" - prefix = f"[{step_num}/{total}]" + """Print a formatted step indicator to stderr using tree characters.""" + connector = "└──" if step_num == total else "├──" if status: - _eprint(f"{prefix} {message}... {status}") + _eprint(f"{connector} {message} {status}") else: - _eprint(f"{prefix} {message}...") + _eprint(f"{connector} {message}...") def print_success(message): """Print a success summary line to stderr.""" - _eprint(f"\n[OK] {message}") + _eprint(f"\n✅ {message}") def print_detail(label, value): From ed0afe4b45731a9e46f26992297c267e00d81547 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 11:36:39 +0530 Subject: [PATCH 42/91] clean: remove all recommendation/mitigation text from errors Errors now show only what failed, no verbose suggestions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/target_prepare.py | 78 +++---------------- .../onboarding/target_sg_link.py | 11 +-- 2 files changed, 13 insertions(+), 76 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 904e6cad67d..07c9c434b77 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -117,13 +117,7 @@ def target_prepare( logger.error("Step 1/4 failed (cert-manager): %s", exc) _print_diagnostic_summary(step_results, cluster_name, resource_group) raise CLIInternalError( - f"cert-manager installation failed: {exc}", - recommendation=( - "Check cluster connectivity and kubectl access. " - "Verify the cluster has internet access to github.com. " - "Try manually: kubectl apply -f https://github.com/cert-manager/" - f"cert-manager/releases/download/{cert_manager_version}/cert-manager.yaml" - ) + f"cert-manager installation failed: {exc}" ) # Step 2: trust-manager @@ -141,12 +135,7 @@ def target_prepare( logger.error("Step 2/4 failed (trust-manager): %s", exc) _print_diagnostic_summary(step_results, cluster_name, resource_group) raise CLIInternalError( - f"trust-manager installation failed: {exc}", - recommendation=( - "Ensure helm is installed and the cluster can reach charts.jetstack.io. " - "Try manually: helm upgrade trust-manager jetstack/trust-manager " - "--install --namespace cert-manager --wait" - ) + f"trust-manager installation failed: {exc}" ) # Step 3: WO extension @@ -162,17 +151,7 @@ def target_prepare( logger.error("Step 3/4 failed (WO extension): %s", exc) _print_diagnostic_summary(step_results, cluster_name, resource_group) raise CLIInternalError( - f"WO extension installation failed: {exc}", - recommendation=( - "Common causes:\n" - " - Wrong release train for this region (try --release-train preview or dev)\n" - " - Insufficient cluster resources (need 2+ CPU cores, 4Gi+ memory)\n" - " - Storage class not available (check: kubectl get sc)\n" - "Try manually: az k8s-extension create -g {rg} --cluster-name {cluster} " - "--cluster-type connectedClusters --name {ext} " - "--extension-type Microsoft.workloadorchestration --scope cluster " - f"--release-train {release_train}" - ).format(rg=resource_group, cluster=cluster_name, ext=extension_name) + f"WO extension installation failed: {exc}" ) # Step 4: Custom location @@ -187,16 +166,7 @@ def target_prepare( logger.error("Step 4/4 failed (Custom location): %s", exc) _print_diagnostic_summary(step_results, cluster_name, resource_group) raise CLIInternalError( - f"Custom location creation failed: {exc}", - recommendation=( - "Ensure custom-locations feature is enabled:\n" - f" az connectedk8s enable-features -n {cluster_name} " - f"-g {resource_group} --features cluster-connect custom-locations\n" - "Also verify the extension is in 'Succeeded' state:\n" - f" az k8s-extension show -g {resource_group} " - f"--cluster-name {cluster_name} --cluster-type connectedClusters " - f"--name {extension_name}" - ) + f"Custom location creation failed: {exc}" ) # Output extended-location.json @@ -231,11 +201,7 @@ def _preflight_checks(cmd, cluster_name, resource_group): except CLIInternalError: raise ValidationError( f"Cluster '{cluster_name}' is not Arc-connected or not found " - f"in resource group '{resource_group}'.", - recommendation=( - f"Run: az connectedk8s connect -g {resource_group} " - f"-n {cluster_name} -l " - ) + f"in resource group '{resource_group}'." ) connected_cluster_id = cluster_info.get("id", "") @@ -278,8 +244,7 @@ def _ensure_cert_manager(version, kube_config, kube_context): from kubernetes.client.rest import ApiException except ImportError: raise CLIInternalError( - "kubernetes Python package is required.", - recommendation="Run: pip install kubernetes" + "kubernetes Python package is required." ) # Load kubeconfig @@ -290,11 +255,7 @@ def _ensure_cert_manager(version, kube_config, kube_context): ) except Exception as exc: raise CLIInternalError( - f"Failed to load kubeconfig: {exc}", - recommendation=( - "Ensure kubectl is configured. " - "Use --kube-config and --kube-context if needed." - ) + f"Failed to load kubeconfig: {exc}" ) v1 = client.CoreV1Api() @@ -352,8 +313,7 @@ def _ensure_trust_manager(kube_config, kube_context): from kubernetes.client.rest import ApiException except ImportError: raise CLIInternalError( - "kubernetes Python package is required.", - recommendation="Run: pip install kubernetes" + "kubernetes Python package is required." ) # Load kubeconfig (may already be loaded from cert-manager step) @@ -382,11 +342,7 @@ def _ensure_trust_manager(kube_config, kube_context): # Check if helm is available if not _is_helm_available(): raise CLIInternalError( - "helm is required to install trust-manager.", - recommendation=( - "Install helm from https://helm.sh/docs/intro/install/ " - "and try again." - ) + "helm is required to install trust-manager." ) # Install trust-manager via helm @@ -534,11 +490,7 @@ def _ensure_custom_location( if not extension_id: raise CLIInternalError( - "Cannot create custom location: WO extension ID is not available.", - recommendation=( - "Ensure the WO extension was installed successfully. " - "Re-run without --no-wait." - ) + "Cannot create custom location: WO extension ID is not available." ) print_step( @@ -562,12 +514,7 @@ def _ensure_custom_location( cl_id = result.get("id", "") if isinstance(result, dict) else "" except CLIInternalError as exc: raise CLIInternalError( - f"Failed to create custom location: {exc}", - recommendation=( - "This can happen if the 'custom-locations' feature is not enabled. " - f"Run: az connectedk8s enable-features -n {cluster_name} " - f"-g {resource_group} --features cluster-connect custom-locations" - ) + f"Failed to create custom location: {exc}" ) print_step(4, TOTAL_STEPS, "Custom location", "Created ✓") @@ -662,8 +609,7 @@ def _run_kubectl(args, kube_config=None, kube_context=None): if result.returncode != 0: error_msg = result.stderr.strip() or result.stdout.strip() raise CLIInternalError( - f"kubectl command failed: {' '.join(args)}\n{error_msg}", - recommendation="Ensure kubectl is installed and cluster is reachable." + f"kubectl command failed: {' '.join(args)}\n{error_msg}" ) return result.stdout diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py index 19b243a31e5..89a27db4d94 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py @@ -61,16 +61,7 @@ def link_target_to_service_group(cmd, target_id, service_group_name): logger.info("ServiceGroupMember created: %s -> %s", target_id, service_group_name) except Exception as exc: raise CLIInternalError( - f"Failed to link target to service group '{service_group_name}': {exc}", - recommendation=( - f"Try manually:\n" - f" az rest --method put " - f"--url \"{sg_member_url}?api-version={SG_MEMBER_API_VERSION}\" " - f"--body \"{{\\\"properties\\\":{{\\\"targetId\\\":\\\"" - f"/providers/Microsoft.Management/serviceGroups/{service_group_name}" - f"\\\"}}}}\" " - f"--resource {ARM_ENDPOINT} --header Content-Type=application/json" - ) + f"Failed to link target to service group '{service_group_name}': {exc}" ) # Step 2: Update target to refresh hierarchy (MANDATORY) From e6e240771da01d78afa69c5119460ded98a36c18 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 21 Apr 2026 13:24:12 +0530 Subject: [PATCH 43/91] fix: reduce RBAC wait to 3 retries (30s max) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/onboarding/hierarchy_create.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 40ad44a8949..09b3716ee5e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -335,7 +335,7 @@ def _arm_get_regional(cmd, location, resource_id, api_version): return resp -def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=18, wait_sec=10): +def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=3, wait_sec=10): """Wait for RBAC to propagate on a newly created ServiceGroup. After SG creation, it takes time for permissions to propagate. From 76ca49fd02f6a8583c6854643d2bb44aa3aa4d97 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 22 Apr 2026 08:50:30 +0530 Subject: [PATCH 44/91] =?UTF-8?q?fix:=20add=20Install=20=E2=9C=93=20tick?= =?UTF-8?q?=20after=20AAZ=20LRO=20completes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - post_operations prints └── Install ✓ when deploy chain finishes - Removed premature Install... line from pre_install - Shows ✅ Deployment complete message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_install.py | 13 ++++++++++++- .../onboarding/target_deploy.py | 3 +-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index fb4c18168a3..436a43b734c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -163,7 +163,18 @@ def _run_deploy_chain(self): @register_callback def post_operations(self): - pass + # Print Install ✓ after AAZ LRO completes (only when deploy chain was used) + args = self.ctx.args + has_template = ( + args.solution_template_version_id + or args.solution_template_name + ) + if has_template: + import sys + print("└── Install ✓\n", file=sys.stderr) + + target_name = str(args.target_name) if args.target_name else "" + print(f"✅ Deployment complete for target '{target_name}'", file=sys.stderr) def _output(self, *args, **kwargs): result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 155f799cda9..4c08c8ff656 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -221,8 +221,7 @@ def _log(step_name, status=""): _do_publish(cmd, base_url, sv_id) _log("Publish", "✓") - # Step 3 (Install) is handled by AAZ LRO - _log("Install") + # Step 3 (Install) is handled by AAZ LRO — tick printed in post_operations return sv_id From a92175bf8db619237c13e4268f30383b148f16c4 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 22 Apr 2026 09:21:55 +0530 Subject: [PATCH 45/91] fix: increase RBAC retry to 6 (60s max) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/onboarding/hierarchy_create.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 09b3716ee5e..4c50634066c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -335,7 +335,7 @@ def _arm_get_regional(cmd, location, resource_id, api_version): return resp -def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=3, wait_sec=10): +def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=6, wait_sec=10): """Wait for RBAC to propagate on a newly created ServiceGroup. After SG creation, it takes time for permissions to propagate. From cba547cd4ac2927f43fee262ad9d5e738668bc58 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 22 Apr 2026 09:59:01 +0530 Subject: [PATCH 46/91] fix: increase RBAC wait to 120s, fail instead of continue - 12 retries x 10s = 120s max wait per SG level - Fail with clear message instead of silently continuing - Tested: 2-level SG hierarchy succeeds on first try Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/hierarchy_create.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 4c50634066c..d42214327a5 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -335,11 +335,12 @@ def _arm_get_regional(cmd, location, resource_id, api_version): return resp -def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=6, wait_sec=10): +def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=12, wait_sec=10): """Wait for RBAC to propagate on a newly created ServiceGroup. After SG creation, it takes time for permissions to propagate. We poll by trying to list sites under the SG until it succeeds. + Waits up to 120s (12 x 10s). """ import time @@ -355,9 +356,9 @@ def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=6, wait_sec=10) logger.debug("RBAC not ready (attempt %d/%d), waiting %ds...", attempt + 1, max_retries, wait_sec) time.sleep(wait_sec) else: - logger.warning( - "RBAC propagation timeout for SG '%s' after %ds. Continuing anyway...", - sg_name, max_retries * wait_sec + raise CLIInternalError( + f"RBAC propagation timeout for ServiceGroup '{sg_name}' after {max_retries * wait_sec}s. " + f"Retry the command — the ServiceGroup exists, RBAC just needs more time." ) From d379e38d061de1189e64d6f986d7f10f6ced62de Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 22 Apr 2026 11:00:47 +0530 Subject: [PATCH 47/91] fix: add stderr progress for context create --site-id Shows tree-style progress when auto-creating site reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/context/_create.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py index 957d55b7ac7..d1f6dec2517 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py @@ -157,6 +157,7 @@ def _create_site_reference(self): """Auto-create a site reference linking the site to this context.""" import logging import re + import sys logger = logging.getLogger(__name__) site_id = str(self.ctx.args.site_id) @@ -169,7 +170,7 @@ def _create_site_reference(self): # Sanitize: only alphanumeric and hyphens, 3-61 chars ref_name = re.sub(r'[^a-zA-Z0-9-]', '-', ref_name)[:61] - logger.info("Creating site reference '%s' -> %s", ref_name, site_id) + print(f"├── site-reference Creating '{ref_name}'...", file=sys.stderr) try: from azext_workload_orchestration.onboarding.utils import invoke_cli_command, CmdProxy @@ -181,7 +182,7 @@ def _create_site_reference(self): "--site-reference-name", ref_name, "--site-id", site_id, ]) - logger.info("Site reference '%s' created successfully", ref_name) + print(f"└── site-reference Created ✓", file=sys.stderr) except Exception as exc: logger.warning("Site reference creation failed: %s", exc) raise CLIError( From b4523237577ee1348896fd1890366805cdb9a3df Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 22 Apr 2026 15:37:43 +0530 Subject: [PATCH 48/91] fix: cross-RG config-set uses --solution-template-rg correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-001: When target and ST are in different RGs, config-set was using the target's RG instead of the ST's RG. Fix: ct_rg = solution_template_rg or resource_group (was: ct_rg = resource_group) Tested: cross-RG install with --config → Config Set ✓ → Succeeded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/onboarding/target_deploy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 4c08c8ff656..38949f3a633 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -187,7 +187,7 @@ def _log(step_name, status=""): if do_config: _log("Config Set") # Auto-derive config template args from solution template args - ct_rg = resource_group + ct_rg = solution_template_rg or resource_group ct_name = solution_template_name ct_version = solution_template_version From 2219331a6603eaf6d910cf196992a7a072d82307 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 22 Apr 2026 15:51:41 +0530 Subject: [PATCH 49/91] feat: add client-side name validation in hierarchy create Validates names before making REST calls: - Pattern: alphanumeric + hyphens, 2-63 chars - Recursively validates all children in SG hierarchy - Prevents confusing API errors from broken URLs BUG-002 (Low): Special chars in names gave MissingApiVersionParameter Note: Server-side validation also catches this, this is defense-in-depth Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/hierarchy_create.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index d42214327a5..7280f2a1b81 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -40,6 +40,7 @@ import json import logging +import re from azure.cli.core.azclierror import ( CLIInternalError, @@ -91,11 +92,38 @@ def hierarchy_create(cmd, resource_group=None, configuration_location=None, hier if not level: raise ValidationError("hierarchy-spec must include 'level'.") + # Validate all names in the hierarchy + _validate_hierarchy_names(spec) + if hierarchy_type == "ServiceGroup": return _create_sg_hierarchy(cmd, spec, configuration_location, resource_group) return _create_rg_hierarchy(cmd, resource_group, configuration_location, name, level) +_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9]$') + + +def _validate_hierarchy_names(node): + """Validate resource names in hierarchy spec before making REST calls.""" + name = node.get("name", "") + if len(name) < 2 or len(name) > 63: + raise ValidationError( + f"Name '{name}' must be between 2 and 63 characters." + ) + if not _NAME_PATTERN.match(name): + raise ValidationError( + f"Name '{name}' contains invalid characters. " + f"Use only letters, numbers, and hyphens. Must start and end with alphanumeric." + ) + children = node.get("children") + if children: + if isinstance(children, dict): + _validate_hierarchy_names(children) + elif isinstance(children, list): + for child in children: + _validate_hierarchy_names(child) + + # --------------------------------------------------------------------------- # ResourceGroup hierarchy # --------------------------------------------------------------------------- From de55d7d15c826a7e8dc46bccc46efebaebce6e0a Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 23 Apr 2026 10:39:13 +0530 Subject: [PATCH 50/91] fix: all pylint and flake8 linter errors - _params.py: disable broad-exception-caught for YAML parse fallback - hierarchy_create.py: move import sys to top, fix f-string without interpolation, fix line-too-long, fix blank lines, trailing newline - target_deploy.py: fix blank lines after function def, trailing newline - target_prepare.py: fix blank lines, unused-argument suppress, trailing newline - utils.py: move import sys to top, remove local reimports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_params.py | 2 +- .../onboarding/hierarchy_create.py | 17 ++++++++++------- .../onboarding/target_deploy.py | 3 ++- .../onboarding/target_prepare.py | 4 ++-- .../onboarding/utils.py | 5 +---- 5 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 33ec8c9ef7b..af6024095f3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -128,7 +128,7 @@ def _parse_hierarchy_spec(value): parsed = yaml.safe_load(value) if isinstance(parsed, dict): return parsed - except Exception: + except Exception: # pylint: disable=broad-exception-caught pass # Mode 3: Shorthand syntax: name=X level=Y type=Z diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 7280f2a1b81..d331cc5db9c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -41,6 +41,7 @@ import json import logging import re +import sys from azure.cli.core.azclierror import ( CLIInternalError, @@ -57,13 +58,13 @@ EDGE_RP_NAMESPACE, ) -import sys - logger = logging.getLogger(__name__) + def _eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) + MAX_SG_DEPTH = 3 @@ -171,9 +172,9 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): "configurationResourceId": config_id, } }, CONFIG_REF_API_VERSION) - _eprint(f"└── ConfigurationReference ✓") + _eprint("└── ConfigurationReference ✓") - _eprint(f"\n✅ Hierarchy created (3 resources)\n") + _eprint("\n✅ Hierarchy created (3 resources)\n") return { "type": "ResourceGroup", @@ -218,7 +219,11 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): } -def _create_sg_level(cmd, node, config_location, sub_id, tenant_id, resource_group, parent_sg, results, depth, is_last=True, parent_prefix=""): +def _create_sg_level( # pylint: disable=too-many-arguments + cmd, node, config_location, sub_id, tenant_id, + resource_group, parent_sg, results, depth, + is_last=True, parent_prefix="", +): """Recursively create SG + Site + Config + ConfigRef at each level.""" name = node["name"] level = node["level"] @@ -416,5 +421,3 @@ def _get_tenant_id(cmd): profile = Profile(cli_ctx=cmd.cli_ctx) _, _, tenant_id = profile.get_raw_token(resource="https://management.azure.com") return tenant_id - - diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 38949f3a633..2e785bf671f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -41,9 +41,11 @@ logger = logging.getLogger(__name__) + def _eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) + API_VERSION = "2025-08-01" ARM_RESOURCE = "https://management.azure.com" @@ -449,4 +451,3 @@ def _short_id(arm_id): return "" parts = arm_id.strip("/").split("/") return parts[-1] if parts else arm_id - diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 07c9c434b77..604733b12da 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -61,6 +61,7 @@ def _eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) + TOTAL_STEPS = 4 @@ -457,7 +458,7 @@ def _ensure_wo_extension( # --------------------------------------------------------------------------- def _ensure_custom_location( - cmd, cluster_name, resource_group, location, + cmd, cluster_name, resource_group, location, # pylint: disable=unused-argument custom_location_name, extension_id, connected_cluster_id ): """Check if custom location exists; create if missing.""" @@ -655,4 +656,3 @@ def _get_sub_id(cmd): return sub.get("id", "") except Exception: return "" - diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py index 4f6d1906cb5..f517ded8024 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py @@ -13,6 +13,7 @@ import json import logging +import sys from azure.cli.core.azclierror import CLIInternalError @@ -67,7 +68,6 @@ def invoke_silent(cli_args): """ from azure.cli.core import get_default_cli import io - import sys cli = get_default_cli() old_stdout, old_stderr = sys.stdout, sys.stderr @@ -94,7 +94,6 @@ def invoke_cli_command(cmd, command_args, expect_json=True): # pylint: disable= """ from azure.cli.core import get_default_cli import io - import sys cli = get_default_cli() if expect_json and "-o" not in command_args and "--output" not in command_args: @@ -141,8 +140,6 @@ def invoke_cli_command(cmd, command_args, expect_json=True): # pylint: disable= # Progress output (uses stderr so -o json/table/tsv is clean) # --------------------------------------------------------------------------- -import sys - def _eprint(*args, **kwargs): """Print to stderr.""" From ba47c31807684b2e3a83bc7f208ab0be3fde26e7 Mon Sep 17 00:00:00 2001 From: Harshit Gupta Date: Thu, 23 Apr 2026 23:54:09 +0530 Subject: [PATCH 51/91] Added sync commands --- _sync.py | 0 .../latest/workload_orchestration/__init__.py | 1 + .../_resync_target_helper.py | 818 ++++++++++++++++++ .../latest/workload_orchestration/_sync.py | 233 +++++ 4 files changed, 1052 insertions(+) create mode 100644 _sync.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/_resync_target_helper.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/_sync.py diff --git a/_sync.py b/_sync.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py index 5a9d61963d6..5c73bb449ca 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py @@ -9,3 +9,4 @@ # flake8: noqa from .__cmd_group import * +from ._sync import * diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/_resync_target_helper.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/_resync_target_helper.py new file mode 100644 index 00000000000..29c3b348070 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/_resync_target_helper.py @@ -0,0 +1,818 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * +import logging +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +logger = logging.getLogger(__name__) + + +def _log_step(msg, *args): + """Print a step message only when --verbose is active. + + Azure CLI sets the level of the 'azure-cli' logger to INFO when --verbose + is passed. Extension loggers live under 'azext_*', a completely separate + hierarchy, so logger.info() here is silently dropped even with --verbose. + We check the 'azure-cli' logger as a reliable proxy for verbose mode and + write directly to stdout so the message is always visible to the user. + """ + if logging.getLogger('azure-cli').isEnabledFor(logging.INFO): + print(msg % args if args else msg) + + + +class TargetPut(AAZHttpOperation): + """PUT a target to re-sync it to the new cluster.""" + CLIENT_TYPE = "MgmtClient" + + def __init__(self, ctx, target): + super().__init__(ctx) + self._target = target + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code == 200: + return self.on_200_201(session) + if session.http_response.status_code in [201, 202]: + poller = self.client.build_lro_polling( + False, + session, + self.on_200_201, + self.on_error, + lro_options={"final-state-via": "azure-async-operation"}, + path_format_arguments=self.url_parameters, + ) + return poller.run() + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "{targetId}", + **self.url_parameters + ) + + @property + def url_parameters(self): + return { + **self.serialize_url_param( + "targetId", self._target["id"], + required=True, + skip_quote=True, + ), + } + + @property + def method(self): + return "PUT" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def query_parameters(self): + return { + **self.serialize_query_param("api-version", "2025-08-01", required=True), + } + + @property + def header_parameters(self): + return { + **self.serialize_header_param("Content-Type", "application/json"), + **self.serialize_header_param("Accept", "application/json"), + } + + @property + def content(self): + body = { + "location": self._target.get("location"), + "extendedLocation": self._target.get("extendedLocation"), + "properties": self._target.get("properties"), + "tags": self._target.get("tags"), + } + return self.serialize_content(body) + + def on_200_201(self, session): + pass + + +class TargetGet(AAZHttpOperation): + """GET a target to check its provisioning state.""" + CLIENT_TYPE = "MgmtClient" + + def __init__(self, ctx, target): + super().__init__(ctx) + self._target = target + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code == 200: + return self.on_200(session) + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url("{targetId}", **self.url_parameters) + + @property + def url_parameters(self): + return { + **self.serialize_url_param( + "targetId", self._target["id"], + required=True, + skip_quote=True, + ), + } + + @property + def method(self): + return "GET" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def query_parameters(self): + return {**self.serialize_query_param("api-version", "2025-08-01", required=True)} + + @property + def header_parameters(self): + return {**self.serialize_header_param("Accept", "application/json")} + + def on_200(self, session): + data = self.deserialize_http_content(session) + self._result = data + + +class TargetSolutionVersionsArgQuery(AAZHttpOperation): + """Fetch all solution versions installed on a target via ARG.""" + CLIENT_TYPE = "MgmtClient" + + def __init__(self, ctx, target_id): + super().__init__(ctx) + self._target_id = target_id + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code == 200: + return self.on_200(session) + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url("/providers/Microsoft.ResourceGraph/resources") + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def query_parameters(self): + return { + **self.serialize_query_param("api-version", "2022-10-01", required=True), + } + + @property + def header_parameters(self): + return { + **self.serialize_header_param("Content-Type", "application/json"), + **self.serialize_header_param("Accept", "application/json"), + } + + @property + def content(self): + body = { + "query": ( + "ExtensibilityResources" + " | where type =~ 'microsoft.edge/targets/solutions/versions'" + f" | where tolower(id) startswith tolower('{self._target_id}')" + " | project id, name, location, resourceGroup, subscriptionId," + " provisioningState = tostring(properties.provisioningState)," + " state = tostring(properties.state)," + " specification = properties.specification" + ), + "options": { + "resultFormat": "objectArray" + } + } + return self.serialize_content(body) + + def on_200(self, session): + data = self.deserialize_http_content(session) + self._result = data.get("data", []) + + +class TargetInstallSolution(AAZHttpOperation): + """POST installSolution for a specific solution version on a target.""" + CLIENT_TYPE = "MgmtClient" + + def __init__(self, ctx, target, solution_version_id): + super().__init__(ctx) + self._target = target + self._solution_version_id = solution_version_id + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code == 202: + poller = self.client.build_lro_polling( + False, + session, + self.on_200, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + return poller.run() + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}" + "/providers/Microsoft.Edge/targets/{targetName}/installSolution", + **self.url_parameters + ) + + @property + def url_parameters(self): + target_id = self._target["id"] + parts = target_id.split("/") + return { + **self.serialize_url_param("subscriptionId", parts[2], required=True), + **self.serialize_url_param("resourceGroupName", parts[4], required=True), + **self.serialize_url_param("targetName", parts[8], required=True), + } + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def query_parameters(self): + return { + **self.serialize_query_param("api-version", "2025-08-01", required=True), + } + + @property + def header_parameters(self): + return { + **self.serialize_header_param("Content-Type", "application/json"), + **self.serialize_header_param("Accept", "application/json"), + } + + @property + def content(self): + body = {"solutionVersionId": self._solution_version_id} + return self.serialize_content(body) + + def on_200(self, session): + pass + + +class UpdateConfigWithRegistryIp(AAZHttpOperation): + """Fetch the DynamicConfigurationVersion for a staged solution and update LocalConnectedRegistryIP. + + Steps performed in __call__: + 1. GET the target solution version to resolve ``solutionTemplateVersionId``. + 2. Derive the config URL from the target's configurationReference. + 3. GET the existing DynamicConfigurationVersion (or prepare a new one if absent). + 4. Set LocalConnectedRegistryIP in the YAML values. + 5. PUT the updated object back. + """ + CLIENT_TYPE = "MgmtClient" + + def __init__(self, ctx, target_id, solution_version_id, local_ip): + super().__init__(ctx) + self._target_id = target_id + self._solution_version_id = solution_version_id + self._local_ip = local_ip + self.solution_template_version_id = None # populated during __call__ + + def __call__(self, *args, **kwargs): + import json + import yaml + from azext_workload_orchestration.aaz.latest.workload_orchestration.configuration._config_helper import ConfigurationHelper + + # Step 1: GET the target solution version to find solutionTemplateVersionId + sv_url = self.client.format_url("{svId}", svId=self._solution_version_id) + sv_req = self.client._request( + "GET", sv_url, {"api-version": "2025-08-01"}, {"Accept": "application/json"}, None, {}, None + ) + sv_resp = self.client.send_request(request=sv_req, stream=False) + if sv_resp.http_response.status_code != 200: + raise Exception( + f"Failed to GET solution version {self._solution_version_id}: " + f"HTTP {sv_resp.http_response.status_code}" + ) + sv_data = json.loads(sv_resp.http_response.text()) + self.solution_template_version_id = sv_data.get("properties", {}).get("solutionTemplateVersionId") + if not self.solution_template_version_id: + raise Exception( + f"solutionTemplateVersionId not found on solution version {self._solution_version_id}" + ) + + # Extract solution template version (last segment of the ARM ID, e.g. "1.0.0") + template_version = self.solution_template_version_id.rstrip("/").split("/")[-1] + + # Extract solutionUniqueId from solution version path: + # .../solutions/{uniqueId}/versions/{version} + sv_parts = self._solution_version_id.split("/") + solution_unique_id = sv_parts[10] + + # Step 2: Get the configuration ID for this target + config_id = ConfigurationHelper.getConfigurationId(self._target_id, self.client) + + # Step 3: GET the current DynamicConfigurationVersion + # URL pattern (mirrors _config_set.py): {configId}/dynamicConfigurations/{uniqueId}/versions/{version} + dcv_url = f"{config_id}/dynamicConfigurations/{solution_unique_id}/versions/{template_version}" + dcv_req = self.client._request( + "GET", dcv_url, {"api-version": "2025-08-01"}, {"Accept": "application/json"}, None, {}, None + ) + dcv_resp = self.client.send_request(request=dcv_req, stream=False) + + if dcv_resp.http_response.status_code == 200: + dcv_data = json.loads(dcv_resp.http_response.text()) + raw_values = dcv_data.get("properties", {}).get("values", "") + try: + values = yaml.safe_load(raw_values) or {} + except Exception: + values = {} + values["LocalConnectedRegistryIP"] = self._local_ip + # Only send properties — strip all read-only top-level fields + body = { + "properties": { + "values": yaml.dump(values, default_flow_style=False) + } + } + elif dcv_resp.http_response.status_code == 404: + body = { + "properties": { + "values": yaml.dump({"LocalConnectedRegistryIP": self._local_ip}, default_flow_style=False) + } + } + else: + raise Exception( + f"Failed to GET DynamicConfigurationVersion: HTTP {dcv_resp.http_response.status_code}" + ) + + # Step 4: PUT the updated DynamicConfigurationVersion + serialized_body = self.serialize_content(body) + put_req = self.client._request( + "PUT", dcv_url, + {"api-version": "2025-08-01"}, + {"Content-Type": "application/json", "Accept": "application/json"}, + serialized_body, {}, None + ) + put_resp = self.client.send_request(request=put_req, stream=False) + if put_resp.http_response.status_code not in [200, 201]: + raise Exception( + f"Failed to PUT DynamicConfigurationVersion: HTTP {put_resp.http_response.status_code}" + f" - {put_resp.http_response.text()}" + ) + + # The following properties are required by the base class but are not used + # because __call__ is fully overridden. + @property + def url(self): + raise NotImplementedError + + @property + def method(self): + raise NotImplementedError + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def query_parameters(self): + return {} + + @property + def header_parameters(self): + return {} + + +class ReviewStagedSolutionVersion(AAZHttpOperation): + """POST reviewSolutionVersion for a staged solution and wait for completion.""" + CLIENT_TYPE = "MgmtClient" + + def __init__(self, ctx, target, solution_template_version_id): + super().__init__(ctx) + self._target = target + self._solution_template_version_id = solution_template_version_id + self._result = None + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200, 202]: + poller = self.client.build_lro_polling( + False, + session, + self.on_200, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + return poller.run() + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}" + "/providers/Microsoft.Edge/targets/{targetName}/reviewSolutionVersion", + **self.url_parameters + ) + + @property + def url_parameters(self): + parts = self._target["id"].split("/") + return { + **self.serialize_url_param("subscriptionId", parts[2], required=True), + **self.serialize_url_param("resourceGroupName", parts[4], required=True), + **self.serialize_url_param("targetName", parts[8], required=True), + } + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def query_parameters(self): + return {**self.serialize_query_param("api-version", "2025-08-01", required=True)} + + @property + def header_parameters(self): + return { + **self.serialize_header_param("Content-Type", "application/json"), + **self.serialize_header_param("Accept", "application/json"), + } + + @property + def content(self): + return self.serialize_content({"solutionTemplateVersionId": self._solution_template_version_id}) + + def on_200(self, session): + self._result = self.deserialize_http_content(session) + + +class PublishStagedSolutionVersion(AAZHttpOperation): + """POST publishSolutionVersion for a staged solution and wait for staging to complete.""" + CLIENT_TYPE = "MgmtClient" + + def __init__(self, ctx, target, solution_version_id): + super().__init__(ctx) + self._target = target + self._solution_version_id = solution_version_id + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code in [200, 202]: + poller = self.client.build_lro_polling( + False, + session, + self.on_200, + self.on_error, + lro_options={"final-state-via": "location"}, + path_format_arguments=self.url_parameters, + ) + return poller.run() + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url( + "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}" + "/providers/Microsoft.Edge/targets/{targetName}/publishSolutionVersion", + **self.url_parameters + ) + + @property + def url_parameters(self): + parts = self._target["id"].split("/") + return { + **self.serialize_url_param("subscriptionId", parts[2], required=True), + **self.serialize_url_param("resourceGroupName", parts[4], required=True), + **self.serialize_url_param("targetName", parts[8], required=True), + } + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def query_parameters(self): + return {**self.serialize_query_param("api-version", "2025-08-01", required=True)} + + @property + def header_parameters(self): + return { + **self.serialize_header_param("Content-Type", "application/json"), + **self.serialize_header_param("Accept", "application/json"), + } + + @property + def content(self): + return self.serialize_content({"solutionVersionId": self._solution_version_id}) + + def on_200(self, session): + pass + + +_RETRY_DELAYS = [5, 10, 20] # seconds between retries (up to 3 retries) + +_PROVISIONING_POLL_INTERVAL = 10 # seconds between provisioning state polls +_PROVISIONING_TIMEOUT = 300 # maximum seconds to wait for provisioning to settle +_TRANSITIONAL_STATES = {"accepted", "updating", "creating", "deleting"} + + +def _wait_for_target_provisioned(ctx, target): + """Poll target GET until provisioningState leaves transitional states or timeout. + + Raises TimeoutError if the target has not settled within _PROVISIONING_TIMEOUT seconds. + Returns the final provisioningState string. + """ + target_name = target.get("name", target.get("id", "")) + deadline = time.time() + _PROVISIONING_TIMEOUT + while time.time() < deadline: + get_op = TargetGet(ctx=ctx, target=target) + get_op() + state = (get_op._result.get("properties", {}).get("provisioningState") or "").lower() + _log_step("[%s] Waiting for provisioning to settle, current state: '%s'.", target_name, state) + if state not in _TRANSITIONAL_STATES: + return state + time.sleep(_PROVISIONING_POLL_INTERVAL) + raise TimeoutError( + f"Timed out after {_PROVISIONING_TIMEOUT}s waiting for target '{target_name}' " + "to leave transitional provisioning state." + ) + + +def _is_staging_enabled(sv): + """Return True if the solution version has at least one component with staged images. + + Mirrors the C# IsValidStageProperties logic: + checks specification.components[*].properties.staged.images + """ + try: + specification = sv.get("specification") or {} + components = specification.get("components", []) or [] + for component in components: + if not isinstance(component, dict): + continue + staged = component.get("properties", {}).get("staged") + if staged and staged.get("images"): + return True + return False + except Exception: + return False + + +def _retry_with_backoff(fn): + """Call fn(), retrying up to 3 times with delays of 5s, 10s, 20s on failure.""" + last_exc = None + for delay in _RETRY_DELAYS: + try: + return fn() + except Exception as exc: + last_exc = exc + time.sleep(delay) + # Final attempt after all retries exhausted + try: + return fn() + except Exception as exc: + raise exc from last_exc + + +def process_staged_solution(ctx, target, sv, local_connected_registry_ip): + """Orchestrate a staged solution sync: update config → review → publish → install. + + Args: + ctx: AAZCommandCtx from the parent Sync command. + target (dict): Target object. + sv (dict): Solution version object with staging enabled. + local_connected_registry_ip (str): IP of the local connected registry on the new cluster. + """ + sv_id = sv.get("id", "") + target_id = target.get("id", "") + + target_name = target.get("name", target_id) + + # Step 1: Fetch config and update LocalConnectedRegistryIP + _log_step("[%s] Staged '%s': Updating configuration with local registry IP...", target_name, sv_id.rstrip('/').split('/')[-1]) + config_op = UpdateConfigWithRegistryIp( + ctx=ctx, target_id=target_id, solution_version_id=sv_id, local_ip=local_connected_registry_ip + ) + try: + _retry_with_backoff(config_op) + _log_step("[%s] Staged: Configuration update succeeded.", target_name) + except Exception as exc: + _log_step("[%s] Staged: Configuration update failed: %s", target_name, exc) + raise + solution_template_version_id = config_op.solution_template_version_id + + # Step 2: Review — creates/updates the target solution version on the new cluster + _log_step("[%s] Staged: Reviewing solution version...", target_name) + review_op = ReviewStagedSolutionVersion( + ctx=ctx, target=target, solution_template_version_id=solution_template_version_id + ) + try: + _retry_with_backoff(review_op) + _log_step("[%s] Staged: Review succeeded.", target_name) + except Exception as exc: + _log_step("[%s] Staged: Review failed: %s", target_name, exc) + raise + # Use the solution version ID returned by review; fall back to the existing one + new_sv_id = ((review_op._result or {}).get("id")) or sv_id + + # Step 3: Publish — triggers staging on the new cluster (downloads images) + _log_step("[%s] Staged: Publishing solution version...", target_name) + publish_op = PublishStagedSolutionVersion(ctx=ctx, target=target, solution_version_id=new_sv_id) + try: + _retry_with_backoff(publish_op) + _log_step("[%s] Staged: Publish succeeded.", target_name) + except Exception as exc: + _log_step("[%s] Staged: Publish failed: %s", target_name, exc) + raise + + # Step 4: Install + _log_step("[%s] Staged: Installing solution version...", target_name) + install_op = TargetInstallSolution(ctx=ctx, target=target, solution_version_id=new_sv_id) + try: + _retry_with_backoff(install_op) + _log_step("[%s] Staged: Install succeeded.", target_name) + except Exception as exc: + _log_step("[%s] Staged: Install failed: %s", target_name, exc) + raise + return new_sv_id + + +def process_target(ctx, target, local_connected_registry_ip=None): + """Orchestrate per-target sync: PUT the target, fetch its solution versions, + and trigger install for each version in 'Deployed' state. + + Args: + ctx: AAZCommandCtx from the parent Sync command. + target (dict): A target object from the ARG query. + local_connected_registry_ip (str|None): IP of the local connected registry on the + new cluster. When provided, solution versions with staging enabled are detected + and separated out for special handling. + + Returns: + dict with keys: target, put_error, installed, install_errors, staged_solutions + """ + target_id = target.get("id", "") + target_name = target.get("name", target_id) + result = { + "target": target_name, + "put_error": None, + "installed": [], + "staged_installs": [], # list of (original_name, new_sv_id) for staged solutions + "install_errors": [], + "skipped_staged": [], # list of solution names skipped: staging enabled but no registry IP + } + + # Step A: Re-sync the target via PUT (with retries) + _log_step("[%s] Step A: Re-syncing target via PUT...", target_name) + try: + _retry_with_backoff(lambda: TargetPut(ctx=ctx, target=target)()) + _log_step("[%s] Step A: Target PUT succeeded.", target_name) + except Exception as exc: + _log_step("[%s] Step A: Target PUT failed: %s", target_name, exc) + result["put_error"] = str(exc) + return result + + # Wait for the target to leave transitional provisioning states before proceeding. + # The PUT may return 200/201 immediately while the service still transitions through + # 'Accepted' -> 'Updating' -> 'Succeeded' asynchronously. Subsequent operations such + # as reviewSolutionVersion will fail with InvalidResourceOperation if we proceed too + # early. + _log_step("[%s] Step A: Waiting for target provisioning to settle...", target_name) + try: + final_state = _wait_for_target_provisioned(ctx, target) + _log_step("[%s] Step A: Target provisioning settled with state '%s'.", target_name, final_state) + except Exception as exc: + _log_step("[%s] Step A: Error waiting for target provisioning: %s", target_name, exc) + result["put_error"] = str(exc) + return result + + # Step B: Fetch installed solution versions for this target + _log_step("[%s] Step B: Fetching installed solution versions...", target_name) + try: + query_op = TargetSolutionVersionsArgQuery(ctx=ctx, target_id=target_id) + query_op() + solution_versions = getattr(query_op, "_result", []) + _log_step("[%s] Step B: Found %d solution version(s).", target_name, len(solution_versions)) + except Exception as exc: + _log_step("[%s] Step B: Failed to fetch solution versions: %s", target_name, exc) + result["put_error"] = f"Failed to fetch solution versions: {exc}" + return result + + # Step C: Trigger install for each solution version in 'Deployed' state + deployed = [sv for sv in solution_versions if sv.get("state", "").lower() == "deployed"] + _log_step("[%s] Step C: Installing %d deployed solution(s)...", target_name, len(deployed)) + for sv in deployed: + sv_id = sv.get("id", "") + sv_name = sv.get("name", sv_id) + if _is_staging_enabled(sv): + if not local_connected_registry_ip: + _log_step( + "[%s] Skipping solution '%s': staging enabled but --local-connected-registry-ip not provided.", + target_name, sv_name + ) + result["skipped_staged"].append(sv_name) + continue + # Staged solution: update config then review → publish → install + _log_step("[%s] Installing staged solution '%s'...", target_name, sv_name) + sv_captured = sv + try: + new_sv_id = process_staged_solution(ctx, target, sv_captured, local_connected_registry_ip) + result["staged_installs"].append((sv_name, new_sv_id or sv_id)) + _log_step("[%s] Staged solution '%s' installed successfully.", target_name, sv_name) + except Exception as exc: + _log_step("[%s] Staged solution '%s' failed: %s", target_name, sv_name, exc) + result["install_errors"].append((sv_name, str(exc))) + time.sleep(5) + continue + _log_step("[%s] Installing solution '%s'...", target_name, sv_name) + try: + _retry_with_backoff( + lambda: TargetInstallSolution(ctx=ctx, target=target, solution_version_id=sv_id)() + ) + result["installed"].append(sv_name) + _log_step("[%s] Solution '%s' installed successfully.", target_name, sv_name) + except Exception as exc: + _log_step("[%s] Solution '%s' failed: %s", target_name, sv_name, exc) + result["install_errors"].append((sv_name, str(exc))) + time.sleep(5) + + return result + + +def process_targets_in_parallel(ctx, targets, batch_size=10, local_connected_registry_ip=None): + """Process targets in parallel batches and return a summary of any errors. + + Args: + ctx: AAZCommandCtx from the parent Sync command. + targets (list): List of target objects to process. + batch_size (int): Number of targets to process concurrently. + local_connected_registry_ip (str|None): IP of the local connected registry on the + new cluster, forwarded to process_target for staging detection. + + Returns: + list[dict]: Results for all targets. + """ + all_results = [] + for i in range(0, len(targets), batch_size): + batch = targets[i:i + batch_size] + with ThreadPoolExecutor(max_workers=batch_size) as executor: + futures = { + executor.submit(process_target, ctx, target, local_connected_registry_ip): target + for target in batch + } + for future in as_completed(futures): + target = futures[future] + try: + all_results.append(future.result()) + except Exception as exc: + all_results.append({ + "target": target.get("name", target.get("id", "")), + "put_error": str(exc), + "installed": [], + "staged_installs": [], + "install_errors": [], + "skipped_staged": [], + }) + return all_results diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/_sync.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/_sync.py new file mode 100644 index 00000000000..95fb41553aa --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/_sync.py @@ -0,0 +1,233 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * +import logging +from azext_workload_orchestration.aaz.latest.workload_orchestration._resync_target_helper import process_targets_in_parallel + +logger = logging.getLogger(__name__) + + +def _log_step(msg, *args): + """Print a step message only when --verbose is active. + + Azure CLI sets the level of the 'azure-cli' logger to INFO when --verbose + is passed. Extension loggers live under 'azext_*', a completely separate + hierarchy, so logger.info() here is silently dropped even with --verbose. + We check the 'azure-cli' logger as a reliable proxy for verbose mode and + write directly to stdout so the message is always visible to the user. + """ + if logging.getLogger('azure-cli').isEnabledFor(logging.INFO): + print(msg % args if args else msg) + + +@register_command( + "workload-orchestration sync", +) +class Sync(AAZCommand): + """Sync workload orchestration resources for a custom location + + :example: Sync resources for a custom location + az workload-orchestration sync --custom-location /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myResourceGroup/providers/Microsoft.ExtendedLocation/customLocations/myCustomLocation + """ + + _aaz_info = { + "version": "2025-06-01", + } + + def _handler(self, command_args): + super()._handler(command_args) + self._execute_operations() + return self._output() + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + + _args_schema = cls._args_schema + _args_schema.custom_location = AAZStrArg( + options=["--custom-location"], + help="The resource ID of the custom location.", + required=True, + ) + _args_schema.local_connected_registry_ip = AAZStrArg( + options=["--local-connected-registry-ip"], + help=( + "IP address of the local connected registry on the new cluster. " + "Not required if staging is not enabled for any solutions. " + "If staging is enabled and this value is not provided, syncing those solutions will fail. " + "When provided, the value is set in the configuration and a new revision for the solutions with staging enabled is installed." + ), + required=False, + ) + return cls._args_schema + + def _execute_operations(self): + self.pre_operations() + + # Step 1: Get all succeeded targets associated with the custom location via ARG + _log_step("[Step 1/3] Querying targets for the given custom location...") + query_op = self.TargetsArgQuery(ctx=self.ctx) + try: + query_op() + self._targets = query_op._result + _log_step("[Step 1/3] Query succeeded: found %d target(s).", len(self._targets)) + except Exception as exc: + _log_step("[Step 1/3] Query failed: %s", exc) + raise + + if not self._targets: + logger.warning("No targets found for the given custom location.") + self.post_operations() + return + + # Display targets to the user + print(f"\nFound {len(self._targets)} target(s) for the custom location:") + print(f" {'#':<4} {'Subscription':<38} {'Resource Group':<30} {'Target Name'}") + print(f" {'-'*4} {'-'*38} {'-'*30} {'-'*30}") + for i, target in enumerate(self._targets): + print(f" [{i + 1}] {target.get('subscriptionId', ''):<38} {target.get('resourceGroup', ''):<30} {target.get('name', '')}") + + # Step 2: Determine which targets to sync + _log_step("[Step 2/3] Determining which targets to sync...") + selected_targets = self._targets + from knack.prompting import prompt + user_input = prompt( + "\nEnter the numbers of the targets to sync (e.g. 1,3) or press Enter to sync all: " + ) + if user_input.strip(): + from azure.cli.core.azclierror import InvalidArgumentValueError + try: + indices = [int(x.strip()) - 1 for x in user_input.split(",")] + selected_targets = [self._targets[i] for i in indices if 0 <= i < len(self._targets)] + if not selected_targets: + raise InvalidArgumentValueError("No valid targets selected.") + except ValueError: + raise InvalidArgumentValueError( + "Invalid input. Please enter comma-separated numbers from the list." + ) + _log_step("[Step 2/3] %d target(s) selected for sync.", len(selected_targets)) + + # Step 3: Re-sync selected targets in parallel (10 at a time) + _log_step("[Step 3/3] Starting sync for %d selected target(s)...", len(selected_targets)) + local_connected_registry_ip = str(self.ctx.args.local_connected_registry_ip) if self.ctx.args.local_connected_registry_ip else None + results = process_targets_in_parallel(self.ctx, selected_targets, local_connected_registry_ip=local_connected_registry_ip) + _log_step("[Step 3/3] Sync completed.") + + # Final summary + print("\nSync Summary:") + print(f" {'Target':<35} {'Solution':<45} {'Status'}") + print(f" {'-'*35} {'-'*45} {'-'*20}") + for r in results: + target_name = r["target"] + if r["put_error"]: + print(f" {target_name:<35} {'-':<45} FAILED (sync): {r['put_error']}") + continue + failed_map = {name: msg for name, msg in r["install_errors"]} + staged_map = {name: new_id for name, new_id in r.get("staged_installs", [])} + skipped_staged = set(r.get("skipped_staged", [])) + all_solutions = ( + r["installed"] + + [name for name, _ in r.get("staged_installs", [])] + + [name for name, _ in r["install_errors"]] + + r.get("skipped_staged", []) + ) + if not all_solutions: + print(f" {target_name:<35} {'(no deployed solutions)':<45} OK") + continue + for i, sol in enumerate(all_solutions): + label = target_name if i == 0 else "" + if sol in skipped_staged: + status = "SKIPPED (staging enabled, --local-connected-registry-ip not provided)" + elif sol in staged_map: + new_id = staged_map[sol] + new_ver = new_id.rstrip("/").split("/")[-1] if new_id else "?" + status = f"OK (staged, new version: {new_ver})" + elif sol in failed_map: + status = f"FAILED: {failed_map[sol]}" + else: + status = "OK" + print(f" {label:<35} {sol:<45} {status}") + + self._synced_targets = selected_targets + self.post_operations() + + class TargetsArgQuery(AAZHttpOperation): + CLIENT_TYPE = "MgmtClient" + + def __call__(self, *args, **kwargs): + request = self.make_request() + session = self.client.send_request(request=request, stream=False, **kwargs) + if session.http_response.status_code == 200: + return self.on_200(session) + return self.on_error(session.http_response) + + @property + def url(self): + return self.client.format_url("/providers/Microsoft.ResourceGraph/resources") + + @property + def method(self): + return "POST" + + @property + def error_format(self): + return "MgmtErrorFormat" + + @property + def query_parameters(self): + return { + **self.serialize_query_param("api-version", "2022-10-01", required=True), + } + + @property + def header_parameters(self): + return { + **self.serialize_header_param("Content-Type", "application/json"), + **self.serialize_header_param("Accept", "application/json"), + } + + @property + def content(self): + custom_location = self.ctx.args.custom_location.to_serialized_data() + body = { + "query": ( + "Resources" + " | where type =~ 'Microsoft.Edge/targets'" + f" | where extendedLocation.name =~ '{custom_location}'" + " | where properties.provisioningState =~ 'Succeeded'" + " | project id, name, location, resourceGroup, subscriptionId," + " extendedLocation, properties, tags" + ), + "options": { + "resultFormat": "objectArray" + } + } + return self.serialize_content(body) + + def on_200(self, session): + data = self.deserialize_http_content(session) + self._result = data.get("data", []) + + @register_callback + def pre_operations(self): + pass + + @register_callback + def post_operations(self): + pass + + def _output(self, *args, **kwargs): + return None + + +__all__ = ["Sync"] From f51abf602b414ab57a24b233193ae7129bf41a1a Mon Sep 17 00:00:00 2001 From: Athrva Udapure <34984229+atharvau@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:21:03 +0530 Subject: [PATCH 52/91] Delete _sync.py --- _sync.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 _sync.py diff --git a/_sync.py b/_sync.py deleted file mode 100644 index e69de29bb2d..00000000000 From 9260c54905deb43ca9a5e92ee3e382cb31da9adf Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 09:24:33 +0530 Subject: [PATCH 53/91] Simplify cluster init to AIO-only cert-manager path Install cert-manager + trust-manager exclusively via the AIO platform Arc extension (microsoft.iotoperations.platform, --name aio-certmgr), matching the official MS Learn doc. Removes all helm/kubectl fallback code paths and associated CLI flags. Removed flags: --cert-manager-install-mode --trust-manager-version --storage-class --storage-size --skip-cert-manager --skip-trust-manager Remaining flags (7): -c/--cluster-name, -g/--resource-group, -l/--location, --release-train, --extension-version, --extension-name, --custom-location-name, --cert-manager-version Simplifications: - consts.py: dropped helm/kubectl/manifest/webhook/mode constants - target_prepare.py: removed _ensure_cert_manager/_ensure_trust_manager/ _run_kubectl/_run_command/_is_helm_available; single AIO install path - _ensure_wo_extension: auto-detects storage class, uses 20Gi default - Verified live on cluster audapure-ob-fresh2 (fresh install) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 12 +- .../azext_workload_orchestration/_params.py | 2 + .../onboarding/__init__.py | 2 + .../onboarding/consts.py | 22 +- .../onboarding/target_prepare.py | 300 +++++------------- .../test_onboarding/test_target_prepare.py | 28 +- 6 files changed, 112 insertions(+), 254 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index e0adb310506..68cdc77cb8e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -57,10 +57,10 @@ Steps performed: 1. Verify cluster is Arc-connected with required features enabled - 2. Install cert-manager (if not present) - 3. Install trust-manager (if not present) - 4. Install WO extension (if not present) - 5. Create custom location (if not present) + 2. Install cert-manager + trust-manager via the AIO platform Arc extension + (microsoft.iotoperations.platform) + 3. Install WO extension (if not present) + 4. Create custom location (if not present) After running this command, use the output custom location ID with 'az workload-orchestration target create --extended-location'. @@ -69,8 +69,10 @@ text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap - name: Initialize with a specific release train text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --release-train dev - - name: Pin a specific extension version + - name: Pin a specific WO extension version text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-version 2.1.28 + - name: Pin a specific AIO platform (cert-manager bundle) extension version + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --cert-manager-version 0.7.6 - name: Custom location name text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl """ diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index af6024095f3..ebc9113a582 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -87,6 +87,8 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Name for the WO extension resource. Default: wo-extension.') c.argument('custom_location_name', options_list=['--custom-location-name'], help='Name for the custom location. Default: `-cl`.') + c.argument('cert_manager_version', options_list=['--cert-manager-version'], + help='Version of the AIO platform extension (cert-manager + trust-manager bundle) to install. Default: latest.') with self.argument_context('workload-orchestration hierarchy create') as c: c.argument('resource_group', options_list=['--resource-group', '-g'], diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 2cba955a72b..24fadb5bc64 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -23,6 +23,7 @@ def target_init( extension_version=None, extension_name=None, custom_location_name=None, + cert_manager_version=None, ): """Prepare an Arc-connected cluster for Workload Orchestration.""" result = target_prepare( @@ -34,6 +35,7 @@ def target_init( custom_location_name=custom_location_name, extension_version=extension_version, release_train=release_train, + cert_manager_version=cert_manager_version, ) return result diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py index e683127701f..60d27e61ac1 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py @@ -32,24 +32,18 @@ RELATIONSHIPS_RP = "Microsoft.Relationships" # --------------------------------------------------------------------------- -# cert-manager Defaults +# cert-manager + trust-manager Defaults (installed via AIO Platform extension) # --------------------------------------------------------------------------- -DEFAULT_CERT_MANAGER_VERSION = "v1.15.3" -CERT_MANAGER_MANIFEST_URL = ( - "https://github.com/cert-manager/cert-manager/releases/download" - "/{version}/cert-manager.yaml" -) +DEFAULT_CERT_MANAGER_VERSION = None # None = AIO extension default CERT_MANAGER_NAMESPACE = "cert-manager" -CERT_MANAGER_WEBHOOK_DEPLOYMENT = "cert-manager-webhook" -CERT_MANAGER_MIN_PODS = 3 # webhook, controller, cainjector # --------------------------------------------------------------------------- -# trust-manager Defaults +# AIO Platform Extension (bundles cert-manager + trust-manager) # --------------------------------------------------------------------------- -TRUST_MANAGER_DEPLOYMENT = "trust-manager" -TRUST_MANAGER_HELM_REPO = "https://charts.jetstack.io" -TRUST_MANAGER_HELM_REPO_NAME = "jetstack" -TRUST_MANAGER_HELM_CHART = "jetstack/trust-manager" +AIO_PLATFORM_EXTENSION_TYPE = "microsoft.iotoperations.platform" +AIO_PLATFORM_EXTENSION_NAME = "aio-certmgr" +AIO_PLATFORM_EXTENSION_NAMESPACE = "cert-manager" +AIO_PLATFORM_EXTENSION_SCOPE = "cluster" # --------------------------------------------------------------------------- # WO Extension Defaults @@ -59,6 +53,7 @@ DEFAULT_RELEASE_TRAIN = "stable" DEFAULT_EXTENSION_NAMESPACE = "workloadorchestration" DEFAULT_EXTENSION_SCOPE = "cluster" +DEFAULT_STORAGE_SIZE = "20Gi" # --------------------------------------------------------------------------- # Limits & Timeouts @@ -66,7 +61,6 @@ MAX_HIERARCHY_NAME_LENGTH = 24 # Configuration resource name limit LRO_TIMEOUT_SECONDS = 600 # 10 minutes per LRO step LRO_DEFAULT_POLL_INTERVAL = 15 # seconds, overridden by Retry-After header -CERT_MANAGER_WAIT_TIMEOUT = "300s" # --------------------------------------------------------------------------- # Default Target Specification (helm.v3) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 604733b12da..3763ea18f59 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -21,7 +21,6 @@ import json import os -import subprocess import logging import sys @@ -33,20 +32,16 @@ from azext_workload_orchestration.onboarding.consts import ( DEFAULT_CERT_MANAGER_VERSION, - CERT_MANAGER_MANIFEST_URL, - CERT_MANAGER_NAMESPACE, - CERT_MANAGER_WEBHOOK_DEPLOYMENT, - CERT_MANAGER_MIN_PODS, - CERT_MANAGER_WAIT_TIMEOUT, - TRUST_MANAGER_DEPLOYMENT, - TRUST_MANAGER_HELM_REPO, - TRUST_MANAGER_HELM_REPO_NAME, - TRUST_MANAGER_HELM_CHART, + AIO_PLATFORM_EXTENSION_TYPE, + AIO_PLATFORM_EXTENSION_NAME, + AIO_PLATFORM_EXTENSION_NAMESPACE, + AIO_PLATFORM_EXTENSION_SCOPE, DEFAULT_EXTENSION_TYPE, DEFAULT_EXTENSION_NAME, DEFAULT_RELEASE_TRAIN, DEFAULT_EXTENSION_NAMESPACE, DEFAULT_EXTENSION_SCOPE, + DEFAULT_STORAGE_SIZE, ) from azext_workload_orchestration.onboarding.utils import ( invoke_cli_command, @@ -75,16 +70,15 @@ def target_prepare( extension_version=None, release_train=None, cert_manager_version=None, - skip_cert_manager=False, - skip_trust_manager=False, kube_config=None, kube_context=None, no_wait=False, ): """Prepare an Arc-connected K8s cluster for Workload Orchestration. - Installs cert-manager, trust-manager, WO extension, and creates a custom - location. Skips components that are already installed (idempotent). + Installs cert-manager + trust-manager (via the AIO platform Arc + extension), the WO extension, and creates a custom location. + Idempotent: skips components that are already installed. """ extension_name = extension_name or DEFAULT_EXTENSION_NAME custom_location_name = custom_location_name or f"{cluster_name}-cl" @@ -105,38 +99,26 @@ def target_prepare( _print_diagnostic_summary(step_results, cluster_name, resource_group) raise - # Step 1: cert-manager + # Step 1+2: cert-manager + trust-manager (single AIO Arc extension) try: - if skip_cert_manager: - print_step(1, TOTAL_STEPS, "cert-manager", "Skipped (--skip-cert-manager)") - step_results["cert-manager"] = "Skipped" - else: - _ensure_cert_manager(cert_manager_version, kube_config, kube_context) - step_results["cert-manager"] = "Succeeded" + _ensure_cert_trust_manager_via_aio_extension( + cmd, cluster_name, resource_group, + cert_manager_version, no_wait, + ) + step_results["cert-manager"] = "Succeeded" + step_results["trust-manager"] = "Succeeded (bundled)" + print_step( + 2, TOTAL_STEPS, "trust-manager", + "Bundled with cert-manager ✓" + ) except Exception as exc: step_results["cert-manager"] = f"FAILED: {exc}" - logger.error("Step 1/4 failed (cert-manager): %s", exc) - _print_diagnostic_summary(step_results, cluster_name, resource_group) - raise CLIInternalError( - f"cert-manager installation failed: {exc}" + logger.error( + "Steps 1-2/4 failed (AIO cert/trust-manager): %s", exc ) - - # Step 2: trust-manager - try: - if skip_trust_manager: - print_step(2, TOTAL_STEPS, "trust-manager", "Skipped (--skip-trust-manager)") - step_results["trust-manager"] = "Skipped" - else: - _ensure_trust_manager(kube_config, kube_context) - step_results["trust-manager"] = "Succeeded" - except CLIInternalError: - raise # Already has good error message (e.g., helm not installed) - except Exception as exc: - step_results["trust-manager"] = f"FAILED: {exc}" - logger.error("Step 2/4 failed (trust-manager): %s", exc) _print_diagnostic_summary(step_results, cluster_name, resource_group) raise CLIInternalError( - f"trust-manager installation failed: {exc}" + f"cert-manager/trust-manager installation failed: {exc}" ) # Step 3: WO extension @@ -144,7 +126,7 @@ def target_prepare( extension_id = _ensure_wo_extension( cmd, cluster_name, resource_group, extension_name, extension_version, release_train, no_wait, - kube_config, kube_context + kube_config, kube_context, ) step_results["wo-extension"] = "Succeeded" except Exception as exc: @@ -235,136 +217,81 @@ def _preflight_checks(cmd, cluster_name, resource_group): # --------------------------------------------------------------------------- -# Step 1: cert-manager +# Step 1+2: cert-manager + trust-manager via AIO Platform extension # --------------------------------------------------------------------------- -def _ensure_cert_manager(version, kube_config, kube_context): - """Check if cert-manager is installed; install if missing.""" - try: - from kubernetes import client, config as k8s_config - from kubernetes.client.rest import ApiException - except ImportError: - raise CLIInternalError( - "kubernetes Python package is required." - ) +def _ensure_cert_trust_manager_via_aio_extension( + cmd, cluster_name, resource_group, version, no_wait +): + """Install cert-manager + trust-manager as an Arc k8s-extension. - # Load kubeconfig + Uses microsoft.iotoperations.platform which bundles cert-manager and + trust-manager. Idempotent: skips if an extension of that type already + exists on the cluster. + """ + # Check existing extensions for a matching AIO platform extension try: - k8s_config.load_kube_config( - config_file=kube_config, - context=kube_context - ) - except Exception as exc: - raise CLIInternalError( - f"Failed to load kubeconfig: {exc}" + extensions = invoke_cli_command( + cmd, + [ + "k8s-extension", "list", + "-g", resource_group, + "--cluster-name", cluster_name, + "--cluster-type", "connectedClusters", + ] ) + except CLIInternalError: + extensions = [] - v1 = client.CoreV1Api() + existing = None + for ext in (extensions or []): + ext_type = (ext.get("extensionType", "") or "").lower() + if ext_type == AIO_PLATFORM_EXTENSION_TYPE.lower(): + existing = ext + break - # Check if cert-manager namespace exists with running pods - try: - v1.read_namespace(CERT_MANAGER_NAMESPACE) - pods = v1.list_namespaced_pod(CERT_MANAGER_NAMESPACE) - running = [ - p for p in pods.items - if p.status and p.status.phase == "Running" - ] - if len(running) >= CERT_MANAGER_MIN_PODS: + if existing: + ext_ver = existing.get("version", "unknown") + prov_state = (existing.get("provisioningState", "") or "").lower() + if prov_state == "succeeded": print_step( - 1, TOTAL_STEPS, "cert-manager", - f"Already installed ✓ ({len(running)} pods running)" + 1, TOTAL_STEPS, "cert-manager + trust-manager", + f"Already installed ✓ (AIO platform ext {ext_ver})" ) return logger.info( - "cert-manager namespace exists but only %d/%d pods running. Reinstalling.", - len(running), CERT_MANAGER_MIN_PODS - ) - except ApiException as exc: - if exc.status != 404: - raise CLIInternalError(f"Failed to check cert-manager: {exc}") - # 404 = namespace doesn't exist, proceed with install - - # Install cert-manager - print_step(1, TOTAL_STEPS, f"cert-manager... Installing {version}") - _run_kubectl([ - "apply", "-f", - CERT_MANAGER_MANIFEST_URL.format(version=version), - "--wait" - ], kube_config, kube_context) - - # Wait for webhook to be ready - _run_kubectl([ - "wait", "--for=condition=Available", - f"deployment/{CERT_MANAGER_WEBHOOK_DEPLOYMENT}", - "-n", CERT_MANAGER_NAMESPACE, - f"--timeout={CERT_MANAGER_WAIT_TIMEOUT}" - ], kube_config, kube_context) - - print_step(1, TOTAL_STEPS, "cert-manager", f"Installed {version} ✓") - - -# --------------------------------------------------------------------------- -# Step 2: trust-manager -# --------------------------------------------------------------------------- - -def _ensure_trust_manager(kube_config, kube_context): - """Check if trust-manager is installed; install via helm if missing.""" - try: - from kubernetes import client, config as k8s_config - from kubernetes.client.rest import ApiException - except ImportError: - raise CLIInternalError( - "kubernetes Python package is required." - ) - - # Load kubeconfig (may already be loaded from cert-manager step) - try: - k8s_config.load_kube_config( - config_file=kube_config, - context=kube_context + "Existing AIO platform extension in state '%s'; reinstalling.", + prov_state, ) - except Exception: - pass # Already loaded, or will fail below - apps_v1 = client.AppsV1Api() - - # Check if trust-manager deployment exists - try: - apps_v1.read_namespaced_deployment( - TRUST_MANAGER_DEPLOYMENT, CERT_MANAGER_NAMESPACE - ) - print_step(2, TOTAL_STEPS, "trust-manager", "Already installed ✓") - return - except ApiException as exc: - if exc.status != 404: - raise CLIInternalError(f"Failed to check trust-manager: {exc}") - # 404 = not found, proceed with install - - # Check if helm is available - if not _is_helm_available(): - raise CLIInternalError( - "helm is required to install trust-manager." - ) - - # Install trust-manager via helm - print_step(2, TOTAL_STEPS, "trust-manager... Installing via helm") + version_msg = f" version {version}" if version else "" + print_step( + 1, TOTAL_STEPS, + f"cert-manager + trust-manager... Installing AIO platform ext{version_msg}" + ) - _run_command([ - "helm", "repo", "add", - TRUST_MANAGER_HELM_REPO_NAME, - TRUST_MANAGER_HELM_REPO, - "--force-update" - ]) + create_args = [ + "k8s-extension", "create", + "--resource-group", resource_group, + "--cluster-name", cluster_name, + "--name", AIO_PLATFORM_EXTENSION_NAME, + "--cluster-type", "connectedClusters", + "--extension-type", AIO_PLATFORM_EXTENSION_TYPE, + "--scope", AIO_PLATFORM_EXTENSION_SCOPE, + "--release-namespace", AIO_PLATFORM_EXTENSION_NAMESPACE, + ] + if version: + create_args.extend(["--version", version, "--auto-upgrade", "false"]) + if no_wait: + create_args.append("--no-wait") - _run_command([ - "helm", "upgrade", TRUST_MANAGER_DEPLOYMENT, - TRUST_MANAGER_HELM_CHART, - "--install", - "--namespace", CERT_MANAGER_NAMESPACE, - "--wait" - ]) + invoke_cli_command(cmd, create_args) - print_step(2, TOTAL_STEPS, "trust-manager", "Installed ✓") + suffix = " (--no-wait)" if no_wait else "" + print_step( + 1, TOTAL_STEPS, "cert-manager + trust-manager", + f"Installed via AIO platform extension{suffix} ✓" + ) # --------------------------------------------------------------------------- @@ -374,7 +301,7 @@ def _ensure_trust_manager(kube_config, kube_context): def _ensure_wo_extension( cmd, cluster_name, resource_group, extension_name, extension_version, release_train, no_wait, - kube_config=None, kube_context=None + kube_config=None, kube_context=None, ): """Check if WO extension is installed; install if missing.""" # Check existing extensions @@ -434,12 +361,14 @@ def _ensure_wo_extension( if no_wait: create_args.append("--no-wait") - # Auto-detect storage class and pass as config setting + # Auto-detect storage class and pass redis PVC config storage_class = _detect_storage_class(kube_config, kube_context) if storage_class: create_args.extend([ "--configuration-settings", f"redis.persistentVolume.storageClass={storage_class}", + "--configuration-settings", + f"redis.persistentVolume.size={DEFAULT_STORAGE_SIZE}", ]) result = invoke_cli_command(cmd, create_args) @@ -590,63 +519,6 @@ def _write_extended_location_file(extended_location): _eprint(f"\n File written: {filepath}") -def _run_kubectl(args, kube_config=None, kube_context=None): - """Run a kubectl command with optional kubeconfig/context.""" - cmd_args = ["kubectl"] - if kube_config: - cmd_args.extend(["--kubeconfig", kube_config]) - if kube_context: - cmd_args.extend(["--context", kube_context]) - cmd_args.extend(args) - - logger.debug("Running: %s", " ".join(cmd_args)) - result = subprocess.run( # pylint: disable=subprocess-run-check - cmd_args, - capture_output=True, - encoding="utf-8", - errors="replace", - timeout=600, - ) - if result.returncode != 0: - error_msg = result.stderr.strip() or result.stdout.strip() - raise CLIInternalError( - f"kubectl command failed: {' '.join(args)}\n{error_msg}" - ) - return result.stdout - - -def _run_command(cmd_args): - """Run an arbitrary command (e.g., helm).""" - logger.debug("Running: %s", " ".join(cmd_args)) - result = subprocess.run( # pylint: disable=subprocess-run-check - cmd_args, - capture_output=True, - encoding="utf-8", - errors="replace", - timeout=600, - ) - if result.returncode != 0: - error_msg = result.stderr.strip() or result.stdout.strip() - raise CLIInternalError( - f"Command failed: {' '.join(cmd_args)}\n{error_msg}" - ) - return result.stdout - - -def _is_helm_available(): - """Check if helm is available in PATH.""" - try: - result = subprocess.run( # pylint: disable=subprocess-run-check - ["helm", "version", "--short"], - capture_output=True, - text=True, - timeout=10, - ) - return result.returncode == 0 - except FileNotFoundError: - return False - - def _get_sub_id(cmd): """Get subscription ID from CLI context.""" try: diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py index 2ea32aef82c..eecf29252c8 100644 --- a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py @@ -46,28 +46,14 @@ def test_arc_connected_returns_cluster_id(self, mock_invoke): class TestTargetPrepareCertManager(unittest.TestCase): - """Test cert-manager detection.""" + """Test cert-manager + trust-manager AIO extension install.""" - def test_cert_manager_function_exists(self): - """Verify _ensure_cert_manager function is importable.""" - from azext_workload_orchestration.onboarding.target_prepare import _ensure_cert_manager - self.assertTrue(callable(_ensure_cert_manager)) - - -class TestTargetPrepareHelm(unittest.TestCase): - """Test helm detection.""" - - @patch('subprocess.run') - def test_helm_available(self, mock_run): - from azext_workload_orchestration.onboarding.target_prepare import _is_helm_available - mock_run.return_value = MagicMock(returncode=0) - self.assertTrue(_is_helm_available()) - - @patch('subprocess.run') - def test_helm_not_available(self, mock_run): - from azext_workload_orchestration.onboarding.target_prepare import _is_helm_available - mock_run.side_effect = FileNotFoundError() - self.assertFalse(_is_helm_available()) + def test_aio_extension_function_exists(self): + """Verify _ensure_cert_trust_manager_via_aio_extension is importable.""" + from azext_workload_orchestration.onboarding.target_prepare import ( + _ensure_cert_trust_manager_via_aio_extension, + ) + self.assertTrue(callable(_ensure_cert_trust_manager_via_aio_extension)) class TestTargetPrepareExtension(unittest.TestCase): From d9efcf982c9a18cba7ba080146bc1feb3bf54fbd Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 09:35:58 +0530 Subject: [PATCH 54/91] Add linter exclusion for sync --local-connected-registry-ip The option --local-connected-registry-ip exceeds the 22-char threshold for option_length_too_long but has no meaningful shorter abbreviation. Adding rule_exclusion so azdev linter passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/workload-orchestration/linter_exclusions.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/workload-orchestration/linter_exclusions.yml b/src/workload-orchestration/linter_exclusions.yml index d2e221c3189..2e486058648 100644 --- a/src/workload-orchestration/linter_exclusions.yml +++ b/src/workload-orchestration/linter_exclusions.yml @@ -31,5 +31,11 @@ workload-orchestration target review: rule_exclusions: - option_length_too_long solution_template_version_id: + rule_exclusions: + - option_length_too_long + +workload-orchestration sync: + parameters: + local_connected_registry_ip: rule_exclusions: - option_length_too_long \ No newline at end of file From a920bc42fb5e772401035dc9f4c69d9360f1cb2a Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 09:41:13 +0530 Subject: [PATCH 55/91] Fix C0301 line-too-long in _params.py for --cert-manager-version help Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_params.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index ebc9113a582..5fd8274bed3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -88,7 +88,8 @@ def load_arguments(self, _): # pylint: disable=unused-argument c.argument('custom_location_name', options_list=['--custom-location-name'], help='Name for the custom location. Default: `-cl`.') c.argument('cert_manager_version', options_list=['--cert-manager-version'], - help='Version of the AIO platform extension (cert-manager + trust-manager bundle) to install. Default: latest.') + help='Version of the AIO platform extension ' + '(cert-manager + trust-manager bundle) to install. Default: latest.') with self.argument_context('workload-orchestration hierarchy create') as c: c.argument('resource_group', options_list=['--resource-group', '-g'], From d125500057f47692df0139f80fae5c73d60b40be Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 10:59:51 +0530 Subject: [PATCH 56/91] Replace --cert-manager-version with --extension-dependency-version Replace the specific --cert-manager-version parameter on cluster init with a generic --extension-dependency-version that accepts key=value pairs (e.g., iotplatform=1.6.1). This decouples the CLI surface from specific dependency names so the CLI doesn't need to change when dependencies evolve. Changes: - _params.py: New --extension-dependency-version arg with nargs='+' - __init__.py: Add _parse_dependency_versions() with strict validation (unknown keys, missing =, empty version, duplicates all rejected) - consts.py: Add EXTENSION_DEPENDENCIES registry mapping iotplatform to microsoft.iotoperations.platform extension - _help.py: Update example to show new syntax - linter_exclusions.yml: Add exclusion for long option name Internally routes iotplatform=X to the existing cert_manager_version parameter in target_prepare(), so no changes needed downstream. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 4 +- .../azext_workload_orchestration/_params.py | 8 ++-- .../onboarding/__init__.py | 45 +++++++++++++++++-- .../onboarding/consts.py | 12 +++++ .../linter_exclusions.yml | 8 +++- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 68cdc77cb8e..5c8ec0b830f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -71,8 +71,8 @@ text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --release-train dev - name: Pin a specific WO extension version text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-version 2.1.28 - - name: Pin a specific AIO platform (cert-manager bundle) extension version - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --cert-manager-version 0.7.6 + - name: Pin a dependency extension version (e.g., AIO platform / cert-manager) + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version iotplatform=0.7.6 - name: Custom location name text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl """ diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 5fd8274bed3..cff28a7c6d3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -87,9 +87,11 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Name for the WO extension resource. Default: wo-extension.') c.argument('custom_location_name', options_list=['--custom-location-name'], help='Name for the custom location. Default: `-cl`.') - c.argument('cert_manager_version', options_list=['--cert-manager-version'], - help='Version of the AIO platform extension ' - '(cert-manager + trust-manager bundle) to install. Default: latest.') + c.argument('extension_dependency_version', + options_list=['--extension-dependency-version'], + nargs='+', + help='Pin dependency extension versions. Use key=value ' + 'pairs (e.g., iotplatform=1.6.1).') with self.argument_context('workload-orchestration hierarchy create') as c: c.argument('resource_group', options_list=['--resource-group', '-g'], diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 24fadb5bc64..55b83148d30 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -14,6 +14,40 @@ from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create as _hierarchy_create +def _parse_dependency_versions(raw_pairs): + """Parse ``key=value`` pairs into ``{key: version}`` dict.""" + from azext_workload_orchestration.onboarding.consts import EXTENSION_DEPENDENCIES + from azure.cli.core.azclierror import ValidationError + + if not raw_pairs: + return {} + + result = {} + for pair in raw_pairs: + if '=' not in pair: + raise ValidationError( + f"Invalid dependency format: '{pair}'. " + "Expected key=value (e.g., iotplatform=1.6.1)." + ) + key, version = pair.split('=', 1) + key = key.strip().lower() + version = version.strip() + if key not in EXTENSION_DEPENDENCIES: + supported = ', '.join(sorted(EXTENSION_DEPENDENCIES)) + raise ValidationError( + f"Unsupported dependency '{key}'. " + f"Supported values: {supported}." + ) + if key in result: + raise ValidationError(f"Duplicate dependency key '{key}'.") + if not version: + raise ValidationError( + f"Empty version for dependency '{key}'." + ) + result[key] = version + return result + + def target_init( cmd, cluster_name, @@ -23,9 +57,14 @@ def target_init( extension_version=None, extension_name=None, custom_location_name=None, - cert_manager_version=None, + extension_dependency_version=None, ): """Prepare an Arc-connected cluster for Workload Orchestration.""" + dep_versions = _parse_dependency_versions( + extension_dependency_version + ) + iot_platform_version = dep_versions.get("iotplatform") + result = target_prepare( cmd=cmd, cluster_name=cluster_name, @@ -35,7 +74,7 @@ def target_init( custom_location_name=custom_location_name, extension_version=extension_version, release_train=release_train, - cert_manager_version=cert_manager_version, + cert_manager_version=iot_platform_version, ) return result @@ -53,7 +92,7 @@ def target_deploy( config_template_name=None, config_template_version=None, ): - """Deploy a solution to a target: review → publish → install.""" + """Deploy a solution to a target: review -> publish -> install.""" return _target_deploy( cmd=cmd, resource_group=resource_group, diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py index 60d27e61ac1..d01abd131b6 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py @@ -37,6 +37,18 @@ DEFAULT_CERT_MANAGER_VERSION = None # None = AIO extension default CERT_MANAGER_NAMESPACE = "cert-manager" +# Registry of extension dependencies for `--extension-dependency-version`. +# Keys are the user-facing names; values configure the Arc extension install. +EXTENSION_DEPENDENCIES = { + "iotplatform": { + "extension_type": "microsoft.iotoperations.platform", + "extension_name": "aio-certmgr", + "namespace": "cert-manager", + "scope": "cluster", + "default_version": None, + }, +} + # --------------------------------------------------------------------------- # AIO Platform Extension (bundles cert-manager + trust-manager) # --------------------------------------------------------------------------- diff --git a/src/workload-orchestration/linter_exclusions.yml b/src/workload-orchestration/linter_exclusions.yml index 2e486058648..72c114b4188 100644 --- a/src/workload-orchestration/linter_exclusions.yml +++ b/src/workload-orchestration/linter_exclusions.yml @@ -38,4 +38,10 @@ workload-orchestration sync: parameters: local_connected_registry_ip: rule_exclusions: - - option_length_too_long \ No newline at end of file + - option_length_too_long + +workload-orchestration cluster init: + parameters: + extension-dependency-version: + rule_exclusions: + - option_length_too_long From d1820c597b6eeb4d6a0217932cb582448a5a8348 Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 11:24:24 +0530 Subject: [PATCH 57/91] Migrate --extension-dependency-version and --hierarchy-spec to native Azure CLI shorthand syntax Replaces two hand-rolled parsers with Azure CLI's built-in AAZ shorthand syntax parser, bringing our hand-written commands in line with the conventions at https://learn.microsoft.com/cli/azure/use-azure-cli-successfully-shorthand. What changed: - New onboarding/_shorthand.py wraps AAZShortHandSyntaxParser. Precedence: @file (JSON/YAML) -> partial value (key=value) -> strict JSON -> AAZ shorthand ({k:v}) -> YAML content. - --extension-dependency-version: dropped nargs='+' custom parser; now accepts iotplatform=1.6.1, {iotplatform:1.6.1}, or @deps.json. - --hierarchy-spec: removed fragile _parse_hierarchy_spec (YAML detection heuristic + space-shorthand). Single path: @file.yaml, @file.json, or inline {name:X,level:Y}. - Dropped dead isinstance check in hierarchy_create. - Updated help examples. Tests: 22/22 end-to-end pass via real az CLI, including all happy-path shorthand forms, validation failures (unknown key, empty value, duplicate key, non-object, missing file), old --cert-manager-version flag rejection, and a real cluster init smoke test on audapure-ob-fresh2 with all three input forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 16 +- .../azext_workload_orchestration/_params.py | 60 +----- .../onboarding/__init__.py | 62 +++--- .../onboarding/_shorthand.py | 187 ++++++++++++++++++ .../onboarding/hierarchy_create.py | 4 +- 5 files changed, 238 insertions(+), 91 deletions(-) create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/_shorthand.py diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 5c8ec0b830f..0d567a0e4df 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -71,8 +71,12 @@ text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --release-train dev - name: Pin a specific WO extension version text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-version 2.1.28 - - name: Pin a dependency extension version (e.g., AIO platform / cert-manager) + - name: Pin a dependency extension version (partial-value shorthand) text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version iotplatform=0.7.6 + - name: Pin a dependency extension version (full-value shorthand) + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version "{iotplatform:0.7.6}" + - name: Pin dependencies from a JSON file + text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version @deps.json - name: Custom location name text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl """ @@ -91,11 +95,11 @@ - ServiceGroup: nested sites under a service group (up to 3 levels) examples: - name: Create RG hierarchy from YAML file - text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "@hierarchy.yaml" - - name: Create RG hierarchy with shorthand - text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "name=Mehoopany level=factory" - - name: Create ServiceGroup hierarchy from YAML - text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "@sg-hierarchy.yaml" + text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec @hierarchy.yaml + - name: Create RG hierarchy with inline shorthand + text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "{name:Mehoopany,level:factory}" + - name: Create ServiceGroup hierarchy from JSON file + text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec @sg-hierarchy.json """ helps['workload-orchestration cluster'] = """ diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index cff28a7c6d3..90d1a65fce8 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -89,9 +89,9 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Name for the custom location. Default: `-cl`.') c.argument('extension_dependency_version', options_list=['--extension-dependency-version'], - nargs='+', - help='Pin dependency extension versions. Use key=value ' - 'pairs (e.g., iotplatform=1.6.1).') + help='Pin dependency extension versions. Supports Azure CLI ' + 'shorthand syntax: "iotplatform=1.6.1", ' + '"{iotplatform:1.6.1}", or "@deps.json".') with self.argument_context('workload-orchestration hierarchy create') as c: c.argument('resource_group', options_list=['--resource-group', '-g'], @@ -99,53 +99,7 @@ def load_arguments(self, _): # pylint: disable=unused-argument c.argument('configuration_location', options_list=['--configuration-location', '-l'], help='Azure region for the Configuration resource (e.g., eastus2euap).', required=True) c.argument('hierarchy_spec', options_list=['--hierarchy-spec'], - help='Hierarchy specification as YAML/JSON file (@file.yaml) or shorthand syntax.', - required=True, type=_parse_hierarchy_spec) - - -def _parse_hierarchy_spec(value): - """Parse hierarchy spec from file path, YAML content, or shorthand syntax. - - Handles three input modes: - 1. File path: 'hierarchy.yaml' or '@hierarchy.yaml' (@ stripped by CLI) - 2. YAML content: when CLI framework pre-loads @file, we get raw YAML text - 3. Shorthand: 'name=X level=Y type=Z' - """ - import os - - # Mode 1: File path (with or without @) - filepath = value.lstrip('@') - if os.path.exists(filepath): - try: - import yaml - except ImportError: - import json - with open(filepath, 'r', encoding='utf-8') as f: - return json.load(f) - with open(filepath, 'r', encoding='utf-8') as f: - return yaml.safe_load(f) - - # Mode 2: YAML content (CLI framework pre-loaded @file) - # Detect YAML by checking for colon-separated key-value or newlines - if ':' in value and ('\n' in value or 'name:' in value or 'level:' in value): - try: - import yaml - parsed = yaml.safe_load(value) - if isinstance(parsed, dict): - return parsed - except Exception: # pylint: disable=broad-exception-caught - pass - - # Mode 3: Shorthand syntax: name=X level=Y type=Z - result = {} - for pair in value.split(): - if '=' in pair: - k, v = pair.split('=', 1) - result[k] = v - if not result: - from azure.cli.core.azclierror import ValidationError - raise ValidationError( - f"Invalid hierarchy-spec: '{value}'. " - "Use a YAML file path or shorthand: name=X level=Y" - ) - return result + help='Hierarchy specification. Supports Azure CLI shorthand ' + 'syntax: "{name:X,level:Y}", ' + '"@hierarchy.yaml", or "@hierarchy.json".', + required=True) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 55b83148d30..eaa740b5697 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -12,40 +12,33 @@ from azext_workload_orchestration.onboarding.target_prepare import target_prepare from azext_workload_orchestration.onboarding.target_deploy import target_deploy as _target_deploy from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create as _hierarchy_create +from azext_workload_orchestration.onboarding._shorthand import ( + parse_shorthand, + validate_allowed_keys, +) -def _parse_dependency_versions(raw_pairs): - """Parse ``key=value`` pairs into ``{key: version}`` dict.""" +def _parse_dependency_versions(raw): + """Parse ``--extension-dependency-version`` input into a validated dict. + + Accepts any Azure CLI shorthand form (partial, full, file) and enforces + the registry in ``EXTENSION_DEPENDENCIES``. + """ from azext_workload_orchestration.onboarding.consts import EXTENSION_DEPENDENCIES - from azure.cli.core.azclierror import ValidationError - if not raw_pairs: + if raw is None: return {} - - result = {} - for pair in raw_pairs: - if '=' not in pair: - raise ValidationError( - f"Invalid dependency format: '{pair}'. " - "Expected key=value (e.g., iotplatform=1.6.1)." - ) - key, version = pair.split('=', 1) - key = key.strip().lower() - version = version.strip() - if key not in EXTENSION_DEPENDENCIES: - supported = ', '.join(sorted(EXTENSION_DEPENDENCIES)) - raise ValidationError( - f"Unsupported dependency '{key}'. " - f"Supported values: {supported}." - ) - if key in result: - raise ValidationError(f"Duplicate dependency key '{key}'.") - if not version: - raise ValidationError( - f"Empty version for dependency '{key}'." - ) - result[key] = version - return result + data = parse_shorthand( + raw, + arg_name="extension-dependency-version", + allow_yaml_files=False, + require_object=True, + ) + return validate_allowed_keys( + data, + allowed_keys=EXTENSION_DEPENDENCIES.keys(), + arg_name="extension-dependency-version", + ) def target_init( @@ -113,9 +106,18 @@ def target_deploy( def hierarchy_create(cmd, resource_group=None, configuration_location=None, hierarchy_spec=None): """Create a hierarchy: Site + Configuration + ConfigurationReference.""" + from azext_workload_orchestration.onboarding._shorthand import parse_shorthand + + parsed_spec = parse_shorthand( + hierarchy_spec, + arg_name="hierarchy-spec", + allow_yaml_files=True, + require_object=True, + ) if hierarchy_spec is not None else None + return _hierarchy_create( cmd=cmd, resource_group=resource_group, configuration_location=configuration_location, - hierarchy_spec=hierarchy_spec, + hierarchy_spec=parsed_spec, ) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/_shorthand.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/_shorthand.py new file mode 100644 index 00000000000..ac726a73e75 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/_shorthand.py @@ -0,0 +1,187 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Shared shorthand-syntax parser aligned with Azure CLI conventions. + +Wraps the native ``AAZShortHandSyntaxParser`` so our hand-written commands can +accept the same input forms as AAZ-generated commands: + +* Full value: ``"{key:value,key2:value2}"`` +* Partial value: ``"key=value"`` +* Null/help: ``"null"`` / ``"??"`` +* File input: ``"@path/to/file.json"`` or ``"@path/to/file.yaml"`` + +The parser returns a plain ``dict`` for object-shaped inputs. Consumers apply +their own semantic validation on the result. +""" + +import json +import os + +from azure.cli.core.azclierror import InvalidArgumentValueError + +try: + from azure.cli.core.aaz._utils import AAZShortHandSyntaxParser + _AAZ_PARSER = AAZShortHandSyntaxParser() +except ImportError: # pragma: no cover - defensive guard for older core + _AAZ_PARSER = None + + +def _load_file(path, allow_yaml): + """Load a JSON (or YAML when allowed) file into a dict.""" + if not os.path.exists(path): + raise InvalidArgumentValueError(f"File not found: {path}") + with open(path, "r", encoding="utf-8") as fh: + text = fh.read() + suffix = os.path.splitext(path)[1].lower() + if allow_yaml and suffix in (".yaml", ".yml"): + try: + import yaml # pylint: disable=import-outside-toplevel + except ImportError as ex: + raise InvalidArgumentValueError( + "PyYAML is required to read YAML files. Install it with 'pip install pyyaml'." + ) from ex + return yaml.safe_load(text) + try: + return json.loads(text) + except json.JSONDecodeError as ex: + if allow_yaml: + try: + import yaml # pylint: disable=import-outside-toplevel + return yaml.safe_load(text) + except Exception as yex: # pylint: disable=broad-exception-caught + raise InvalidArgumentValueError( + f"Failed to parse '{path}' as JSON or YAML: {yex}" + ) from yex + raise InvalidArgumentValueError( + f"Failed to parse '{path}' as JSON: {ex}" + ) from ex + + +def parse_shorthand( + raw, + arg_name="argument", + allow_yaml_files=False, + require_object=True, +): + """Parse a shorthand/JSON/file input into a Python value. + + Parameters + ---------- + raw : str + The raw CLI argument value. + arg_name : str + Display name used in error messages (e.g. ``hierarchy-spec``). + allow_yaml_files : bool + If True, ``@file.yaml`` / ``@file.yml`` are loaded via PyYAML, and + pre-expanded YAML content strings are accepted. + require_object : bool + If True, the final parsed value must be a ``dict``. + + Returns + ------- + dict | list | str | int | float | bool | None + """ + if raw is None: + raise InvalidArgumentValueError(f"--{arg_name}: value is required") + if not isinstance(raw, str): + value = raw + else: + stripped = raw.strip() + if not stripped: + raise InvalidArgumentValueError(f"--{arg_name}: empty value") + value = _parse_string(stripped, allow_yaml_files, arg_name) + + if require_object and not isinstance(value, dict): + raise InvalidArgumentValueError( + f"--{arg_name}: expected an object, got {type(value).__name__}" + ) + return value + + +def _parse_string(stripped, allow_yaml, arg_name): + """Dispatch a stripped, non-empty argument string to the right parser.""" + # 1. Explicit @file (not yet pre-expanded by CLI framework) + if stripped.startswith("@"): + return _load_file(stripped[1:], allow_yaml=allow_yaml) + + # 2. Partial-value shorthand: "key=value" (top-level only) + if _AAZ_PARSER is not None and _AAZ_PARSER.partial_value_key_pattern.match(stripped): + key, _, val_str = stripped.partition("=") + try: + val = _AAZ_PARSER(val_str, is_simple=True) if val_str else "" + except Exception as ex: # pylint: disable=broad-exception-caught + raise InvalidArgumentValueError( + f"--{arg_name}: failed to parse value for '{key}': {ex}" + ) from ex + return {key: val} + + # 3. Strict JSON (catches @file contents that the CLI pre-expanded, and + # explicitly quoted JSON inputs). + try: + return json.loads(stripped) + except json.JSONDecodeError: + pass + + # 4. AAZ shorthand syntax: {k:v}, [a,b], 'quoted'. + # Tried BEFORE YAML because YAML flow-style greedily parses + # "{name:X}" as "{'name:X': None}" (YAML requires ': ' with space). + if _AAZ_PARSER is not None and (stripped[0] in "{[" or stripped.startswith("'")): + try: + return _AAZ_PARSER(stripped) + except Exception as ex: # pylint: disable=broad-exception-caught + raise InvalidArgumentValueError( + f"--{arg_name}: failed to parse '{stripped}'. {ex}" + ) from ex + + # 5. YAML content (only when YAML is permitted - i.e. hierarchy-spec). + # This is for pre-expanded @file.yaml contents (multi-line YAML). + if allow_yaml: + try: + import yaml # pylint: disable=import-outside-toplevel + parsed = yaml.safe_load(stripped) + if isinstance(parsed, (dict, list)): + return parsed + except Exception: # pylint: disable=broad-exception-caught + pass + + # 6. Final fallback: AAZ for simple strings / help markers. + if _AAZ_PARSER is not None: + try: + return _AAZ_PARSER(stripped) + except Exception as ex: # pylint: disable=broad-exception-caught + raise InvalidArgumentValueError( + f"--{arg_name}: failed to parse '{stripped}'. {ex}" + ) from ex + + raise InvalidArgumentValueError( # pragma: no cover + f"--{arg_name}: unable to parse '{stripped}'." + ) + + +def validate_allowed_keys(data, allowed_keys, arg_name): + """Raise if ``data`` contains keys outside ``allowed_keys``. + + Case-insensitive; returns a new dict with keys lowered to match registry. + """ + allowed_lower = {k.lower() for k in allowed_keys} + normalized = {} + for key, value in data.items(): + lkey = key.lower() if isinstance(key, str) else key + if lkey not in allowed_lower: + raise InvalidArgumentValueError( + f"--{arg_name}: unknown key '{key}'. " + f"Allowed keys: {sorted(allowed_keys)}." + ) + if lkey in normalized: + raise InvalidArgumentValueError( + f"--{arg_name}: duplicate key '{lkey}'." + ) + if value is None or (isinstance(value, str) and not value.strip()): + raise InvalidArgumentValueError( + f"--{arg_name}: empty value for key '{lkey}'." + ) + normalized[lkey] = value if not isinstance(value, str) else value.strip() + return normalized diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index d331cc5db9c..795a1459a89 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -81,8 +81,8 @@ def hierarchy_create(cmd, resource_group=None, configuration_location=None, hier if not resource_group: raise ValidationError("--resource-group is required (used for Configuration resources).") - # Parse spec (could be dict from shorthand or file) - spec = hierarchy_spec if isinstance(hierarchy_spec, dict) else hierarchy_spec + # Parse spec (dict from shorthand/file parser in the CLI wrapper) + spec = hierarchy_spec name = spec.get("name") level = spec.get("level") From ff272bdf7c4f1bdc9adc8e2080c37fb0bb2000d4 Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 12:10:31 +0530 Subject: [PATCH 58/91] Enforce hierarchy-spec 'children' must be a list + add E2E test harness - hierarchy_create.py: reject dict form for 'children' at parse time; valid forms are YAML '- name: X' list entries or JSON/shorthand '[{...}]' - Removed dict-branch handling in _create_sg_level and _count_nodes - Updated docstring/examples to show list form for children - Added test-e2e-full.ps1: 26 checks covering help, flag removal, parser validation, children-must-be-list, and real RG+SG hierarchy creation on live Azure (26/26 pass) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/hierarchy_create.py | 41 ++-- src/workload-orchestration/test-e2e-full.ps1 | 212 ++++++++++++++++++ 2 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 src/workload-orchestration/test-e2e-full.ps1 diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 795a1459a89..024f3252a4e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -28,11 +28,13 @@ name: India level: country children: - name: Karnataka - level: region - children: - - name: BangaloreSouth - level: factory + - name: Karnataka + level: region + children: + - name: BangaloreSouth + level: factory + +Note: ``children`` MUST be a list (even for a single child). """ # pylint: disable=broad-exception-caught @@ -117,12 +119,16 @@ def _validate_hierarchy_names(node): f"Use only letters, numbers, and hyphens. Must start and end with alphanumeric." ) children = node.get("children") - if children: - if isinstance(children, dict): - _validate_hierarchy_names(children) - elif isinstance(children, list): - for child in children: - _validate_hierarchy_names(child) + if children is None: + return + if not isinstance(children, list): + raise ValidationError( + f"'children' for '{name}' must be a list. " + f"Got {type(children).__name__}. " + f"Use YAML '- name: X' entries or JSON '[{{...}}]'." + ) + for child in children: + _validate_hierarchy_names(child) # --------------------------------------------------------------------------- @@ -299,8 +305,11 @@ def _create_sg_level( # pylint: disable=too-many-arguments # Recurse into children if children: - if isinstance(children, dict): - children = [children] + if not isinstance(children, list): + raise ValidationError( + f"'children' for '{name}' must be a list. " + f"Got {type(children).__name__}." + ) for i, child in enumerate(children): child_is_last = (i == len(children) - 1) _create_sg_level(cmd, child, config_location, sub_id, tenant_id, @@ -314,8 +323,10 @@ def _count_nodes(node): children = node.get("children") if not children: return 1 - if isinstance(children, dict): - return 1 + _count_nodes(children) + if not isinstance(children, list): + raise ValidationError( + f"'children' for '{node.get('name', '?')}' must be a list." + ) return 1 + max(_count_nodes(c) for c in children) diff --git a/src/workload-orchestration/test-e2e-full.ps1 b/src/workload-orchestration/test-e2e-full.ps1 new file mode 100644 index 00000000000..4cffd926e16 --- /dev/null +++ b/src/workload-orchestration/test-e2e-full.ps1 @@ -0,0 +1,212 @@ +# Full end-to-end regression for shorthand-syntax refactor + children-array rule. +# Drives real az CLI calls. Creates + destroys two fresh RGs for real hierarchy tests. + +$ErrorActionPreference = 'Continue' +$env:PATH = "C:\Users\audapure\Projects\ConfigManager\CLI\.venv\Scripts;$env:PATH" + +$script:pass = 0 +$script:fail = 0 +$script:results = @() + +function Test-Az { + param( + [string]$Name, + [string[]]$AzArgs, + [bool]$ShouldFail, + [string]$ExpectInOutput = $null, + [string]$RejectInOutput = $null + ) + Write-Host "" + Write-Host ">> $Name" -ForegroundColor Cyan + Write-Host " az $($AzArgs -join ' ')" -ForegroundColor DarkGray + $out = & az @AzArgs 2>&1 | Out-String + $exit = $LASTEXITCODE + + $failed = $false + $reason = '' + if ($ShouldFail) { if ($exit -eq 0) { $failed = $true; $reason = 'expected failure; exit=0' } } + else { if ($exit -ne 0) { $failed = $true; $reason = "expected success; exit=$exit" } } + + if (-not $failed -and $ExpectInOutput -and ($out -notmatch [regex]::Escape($ExpectInOutput))) { + $failed = $true; $reason = "missing expected: $ExpectInOutput" + } + if (-not $failed -and $RejectInOutput -and ($out -match [regex]::Escape($RejectInOutput))) { + $failed = $true; $reason = "should not contain: $RejectInOutput" + } + + if ($failed) { + Write-Host " FAIL: $reason" -ForegroundColor Red + ($out -split "`n" | Select-Object -Last 6) | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } + $script:fail++ + $script:results += [PSCustomObject]@{Name=$Name; Status='FAIL'; Reason=$reason} + } else { + Write-Host " PASS" -ForegroundColor Green + $script:pass++ + $script:results += [PSCustomObject]@{Name=$Name; Status='PASS'; Reason=''} + } +} + +$NX = 'zzz-nonexistent-cluster-999' +$NXRG = 'zzz-nonexistent-rg-999' + +Write-Host "" +Write-Host "===== Shorthand Refactor + Children-Array -- Full E2E =====" -ForegroundColor Magenta + +# ================================================================= +# SECTION 1: Help output +# ================================================================= +Write-Host "" +Write-Host "--- Help output ---" -ForegroundColor Yellow + +Test-Az 'help: cluster init shows --extension-dependency-version' @('workload-orchestration','cluster','init','--help') $false 'extension-dependency-version' +Test-Az 'help: cluster init shows shorthand example' @('workload-orchestration','cluster','init','--help') $false 'iotplatform:0.7.6' +Test-Az 'help: cluster init no old cert-manager-version' @('workload-orchestration','cluster','init','--help') $false $null 'cert-manager-version' +Test-Az 'help: hierarchy create shows inline shorthand' @('workload-orchestration','hierarchy','create','--help') $false 'name:Mehoopany' +Test-Az 'help: hierarchy create shows @file' @('workload-orchestration','hierarchy','create','--help') $false '@hierarchy.yaml' + +# ================================================================= +# SECTION 2: Flag removal +# ================================================================= +Write-Host "" +Write-Host "--- Old flag removed ---" -ForegroundColor Yellow +Test-Az 'CLI: old --cert-manager-version rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--cert-manager-version','1.0') $true 'unrecognized arguments' + +# ================================================================= +# SECTION 3: extension-dependency-version validation +# ================================================================= +Write-Host "" +Write-Host "--- extension-dependency-version validation ---" -ForegroundColor Yellow + +Test-Az 'ext-dep: unknown key (partial) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','bogus=1.0') $true 'unknown key' +Test-Az 'ext-dep: unknown key (full) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','{bogus:1.0}') $true 'unknown key' +Test-Az 'ext-dep: empty value rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','iotplatform=') $true +Test-Az 'ext-dep: duplicate (case-insensitive) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','{iotplatform:1.0,IOTPlatform:2.0}') $true +Test-Az 'ext-dep: array rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','[iotplatform,1.0]') $true 'expected an object' +Test-Az 'ext-dep: bare token (no =) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','iotplatform') $true + +# File input +$df = Join-Path $env:TEMP 'deps-valid.json' +'{"iotplatform":"1.6.1"}' | Set-Content -Path $df -Encoding utf8 +Test-Az 'ext-dep: @file.json parses (past parser)' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',"@$df") $true $null 'unknown key' +$dfBad = Join-Path $env:TEMP 'deps-bad.json' +'{"notarealkey":"1.6.1"}' | Set-Content -Path $dfBad -Encoding utf8 +Test-Az 'ext-dep: @file.json unknown key rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',"@$dfBad") $true 'unknown key' +Test-Az 'ext-dep: @missing file rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','@C:\no-such-deps-xyz.json') $true 'File not found' +Remove-Item $df, $dfBad -ErrorAction SilentlyContinue + +# ================================================================= +# SECTION 4: hierarchy-spec shorthand validation +# ================================================================= +Write-Host "" +Write-Host "--- hierarchy-spec validation ---" -ForegroundColor Yellow + +Test-Az 'hierarchy: inline missing name rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{level:factory}') $true "must include 'name'" +Test-Az 'hierarchy: inline missing level rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{name:FactoryZ}') $true "must include 'level'" +Test-Az 'hierarchy: invalid name rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{name:-bad-,level:factory}') $true +Test-Az 'hierarchy: scalar rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','justastring') $true 'expected an object' +Test-Az 'hierarchy: @missing file rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','@no-such-file.yaml') $true 'File not found' + +# ================================================================= +# SECTION 5: children-must-be-list enforcement +# ================================================================= +Write-Host "" +Write-Host "--- children-must-be-list ---" -ForegroundColor Yellow + +# children as a DICT must be rejected +$ydict = Join-Path $env:TEMP 'hier-children-dict.yaml' +@" +type: ServiceGroup +name: CountryX +level: country +children: + name: RegionY + level: region +"@ | Set-Content -Path $ydict -Encoding utf8 +Test-Az 'hierarchy: children as dict rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',"@$ydict") $true "'children' for 'CountryX' must be a list" +Remove-Item $ydict -ErrorAction SilentlyContinue + +# children as a LIST parses fine (Azure call fails on nonexistent RG — but parser passed) +$ylist = Join-Path $env:TEMP 'hier-children-list.yaml' +@" +type: ServiceGroup +name: CountryL +level: country +children: + - name: RegionL + level: region +"@ | Set-Content -Path $ylist -Encoding utf8 +Test-Az 'hierarchy: children as list parses (Azure fail expected)' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',"@$ylist") $true $null "must be a list" +Remove-Item $ylist -ErrorAction SilentlyContinue + +# ================================================================= +# SECTION 6: REAL end-to-end on fresh RGs +# ================================================================= +Write-Host "" +Write-Host "--- REAL end-to-end on fresh RGs ---" -ForegroundColor Yellow + +$rgRG = "audapure-e2e-rg-$(Get-Random -Max 9999)" +$rgSG = "audapure-e2e-sg-$(Get-Random -Max 9999)" +$suffix = Get-Random -Max 9999 + +try { + # --- RG hierarchy via inline shorthand --- + Write-Host "" + Write-Host "Creating fresh RG for RG-hierarchy test: $rgRG" -ForegroundColor DarkGray + az group create -n $rgRG -l eastus2euap -o none 2>&1 | Out-Null + + Test-Az 'REAL: hierarchy create (RG, inline shorthand)' ` + @('workload-orchestration','hierarchy','create','-g',$rgRG,'--configuration-location','eastus2euap','--hierarchy-spec',"{type:ResourceGroup,name:Factory$suffix,level:factory}") ` + $false 'Hierarchy created' + + # --- SG hierarchy with TWO children via YAML file --- + Write-Host "" + Write-Host "Creating fresh RG for SG-hierarchy test: $rgSG" -ForegroundColor DarkGray + az group create -n $rgSG -l eastus2euap -o none 2>&1 | Out-Null + + $sgYaml = Join-Path $env:TEMP "hier-sg-real-$suffix.yaml" + @" +type: ServiceGroup +name: Country$suffix +level: country +children: + - name: RegionA$suffix + level: region + - name: RegionB$suffix + level: region +"@ | Set-Content -Path $sgYaml -Encoding utf8 + + Test-Az 'REAL: hierarchy create (SG, 2 sibling children via @YAML)' ` + @('workload-orchestration','hierarchy','create','-g',$rgSG,'--configuration-location','eastus2euap','--hierarchy-spec',"@$sgYaml") ` + $false 'Hierarchy created' + + # Verify TWO regions appeared under the country in output (name substrings unique to this run) + Test-Az 'REAL: both SG siblings exist (list RegionA)' ` + @('resource','list','-g',$rgSG,'--query',"[?contains(name, 'RegionA$suffix')].{n:name}",'-o','tsv') ` + $false "RegionA$suffix" + Test-Az 'REAL: both SG siblings exist (list RegionB)' ` + @('resource','list','-g',$rgSG,'--query',"[?contains(name, 'RegionB$suffix')].{n:name}",'-o','tsv') ` + $false "RegionB$suffix" + + Remove-Item $sgYaml -ErrorAction SilentlyContinue +} +finally { + Write-Host "" + Write-Host "Cleanup: deleting RGs in background..." -ForegroundColor DarkGray + az group delete -n $rgRG --yes --no-wait 2>&1 | Out-Null + az group delete -n $rgSG --yes --no-wait 2>&1 | Out-Null + # Best-effort cleanup of created ServiceGroups (at tenant scope, survive RG deletion) + foreach ($sgName in @("Country$suffix","RegionA$suffix","RegionB$suffix")) { + $sgId = "/providers/Microsoft.Management/serviceGroups/$sgName" + az rest --method delete --url "https://management.azure.com$($sgId)?api-version=2024-02-01-preview" 2>&1 | Out-Null + } +} + +# ================================================================= +# Summary +# ================================================================= +Write-Host "" +Write-Host "================================================================" +Write-Host "Total: $($pass + $fail) Passed: $pass Failed: $fail" -ForegroundColor $(if ($fail -eq 0) {'Green'} else {'Red'}) +Write-Host "================================================================" +$results | Format-Table -AutoSize -Wrap +exit $fail From e9e5ba9ec2a48bd164489318125a14c446593ae1 Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 13:00:25 +0530 Subject: [PATCH 59/91] Migrate cluster init + hierarchy create to AAZ for native shorthand parsing - Add hand-authored AAZ command classes in aaz/latest/workload_orchestration/ (cluster/_init.py, hierarchy/_create.py) that delegate to existing orchestration functions (target_init, _hierarchy_create). - Azure CLI's native AAZShortHandSyntaxParser now handles all parsing (shorthand, @file, strict JSON, YAML fallback, partial-value, ??). - Remove custom _shorthand.py adapter (no longer needed). - Rewrite onboarding/__init__.py: _validate_dependency_versions now validates the pre-parsed dict from AAZ against EXTENSION_DEPENDENCIES. - Remove corresponding knack registrations from commands.py, _params.py, _help.py. AAZ docstrings generate help automatically. - Add linter exclusion for extension_dependency_version (attr-name form). - Update E2E test assertions to match AAZ native error messages. - support create-bundle is UNCHANGED. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 67 ------- .../azext_workload_orchestration/_params.py | 30 --- .../latest/workload_orchestration/__init__.py | 2 + .../cluster/__cmd_group.py | 20 ++ .../cluster/__init__.py | 10 + .../workload_orchestration/cluster/_init.py | 130 ++++++++++++ .../hierarchy/__cmd_group.py | 20 ++ .../hierarchy/__init__.py | 10 + .../hierarchy/_create.py | 120 +++++++++++ .../azext_workload_orchestration/commands.py | 4 - .../onboarding/__init__.py | 80 ++++---- .../onboarding/_shorthand.py | 187 ------------------ .../linter_exclusions.yml | 3 + src/workload-orchestration/test-e2e-full.ps1 | 18 +- 14 files changed, 365 insertions(+), 336 deletions(-) create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/__cmd_group.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/__init__.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/__cmd_group.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/__init__.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py delete mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/_shorthand.py diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 0d567a0e4df..0d378748000 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -1,8 +1,6 @@ # -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. -# -# Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- # pylint: disable=line-too-long @@ -46,68 +44,3 @@ - name: Use a specific kubeconfig and context text: az workload-orchestration support create-bundle --kube-config ~/.kube/prod-config --kube-context my-cluster """ - -helps['workload-orchestration cluster init'] = """ -type: command -short-summary: Prepare an Arc-connected Kubernetes cluster for Workload Orchestration. -long-summary: | - Installs all prerequisites on an Arc-connected cluster to make it ready for - Workload Orchestration. This is an idempotent operation that skips components - already installed. - - Steps performed: - 1. Verify cluster is Arc-connected with required features enabled - 2. Install cert-manager + trust-manager via the AIO platform Arc extension - (microsoft.iotoperations.platform) - 3. Install WO extension (if not present) - 4. Create custom location (if not present) - - After running this command, use the output custom location ID with - 'az workload-orchestration target create --extended-location'. -examples: - - name: Initialize a cluster with defaults - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap - - name: Initialize with a specific release train - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --release-train dev - - name: Pin a specific WO extension version - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-version 2.1.28 - - name: Pin a dependency extension version (partial-value shorthand) - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version iotplatform=0.7.6 - - name: Pin a dependency extension version (full-value shorthand) - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version "{iotplatform:0.7.6}" - - name: Pin dependencies from a JSON file - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version @deps.json - - name: Custom location name - text: az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl -""" - -helps['workload-orchestration hierarchy create'] = """ -type: command -short-summary: Create a hierarchy (Site + Configuration + ConfigurationReference) in one command. -long-summary: | - Creates the full resource stack for a hierarchy level: - 1. Site (with level label) - 2. Configuration (in specified region) - 3. ConfigurationReference (links site to configuration) - - Supports two types: - - ResourceGroup (default): single site in a resource group - - ServiceGroup: nested sites under a service group (up to 3 levels) -examples: - - name: Create RG hierarchy from YAML file - text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec @hierarchy.yaml - - name: Create RG hierarchy with inline shorthand - text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec "{name:Mehoopany,level:factory}" - - name: Create ServiceGroup hierarchy from JSON file - text: az workload-orchestration hierarchy create -g my-rg --configuration-location eastus2euap --hierarchy-spec @sg-hierarchy.json -""" - -helps['workload-orchestration cluster'] = """ -type: group -short-summary: Commands for cluster preparation for workload orchestration. -""" - -helps['workload-orchestration hierarchy'] = """ -type: group -short-summary: Commands for managing workload orchestration hierarchies. -""" diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index 90d1a65fce8..f0455e4b63b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -72,34 +72,4 @@ def load_arguments(self, _): # pylint: disable=unused-argument help='Skip auto-creation of site-reference to context.', ) - with self.argument_context('workload-orchestration cluster init') as c: - c.argument('cluster_name', options_list=['--cluster-name', '-c'], - help='Name of the Arc-connected Kubernetes cluster.', required=True) - c.argument('resource_group', options_list=['--resource-group', '-g'], - help='Resource group of the Arc-connected cluster.', required=True) - c.argument('location', options_list=['--location', '-l'], - help='Azure region for the custom location (e.g., eastus2euap).', required=True) - c.argument('release_train', options_list=['--release-train'], - help='Extension release train. Default: stable.') - c.argument('extension_version', options_list=['--extension-version'], - help='Specific WO extension version to install.') - c.argument('extension_name', options_list=['--extension-name'], - help='Name for the WO extension resource. Default: wo-extension.') - c.argument('custom_location_name', options_list=['--custom-location-name'], - help='Name for the custom location. Default: `-cl`.') - c.argument('extension_dependency_version', - options_list=['--extension-dependency-version'], - help='Pin dependency extension versions. Supports Azure CLI ' - 'shorthand syntax: "iotplatform=1.6.1", ' - '"{iotplatform:1.6.1}", or "@deps.json".') - with self.argument_context('workload-orchestration hierarchy create') as c: - c.argument('resource_group', options_list=['--resource-group', '-g'], - help='Resource group for Configuration resources.', required=True) - c.argument('configuration_location', options_list=['--configuration-location', '-l'], - help='Azure region for the Configuration resource (e.g., eastus2euap).', required=True) - c.argument('hierarchy_spec', options_list=['--hierarchy-spec'], - help='Hierarchy specification. Supports Azure CLI shorthand ' - 'syntax: "{name:X,level:Y}", ' - '"@hierarchy.yaml", or "@hierarchy.json".', - required=True) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py index 5c73bb449ca..bf062d65ca0 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py @@ -10,3 +10,5 @@ from .__cmd_group import * from ._sync import * +from . import cluster +from . import hierarchy diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/__cmd_group.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/__cmd_group.py new file mode 100644 index 00000000000..56ac7b387cd --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/__cmd_group.py @@ -0,0 +1,20 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "workload-orchestration cluster", +) +class __CMDGroup(AAZCommandGroup): + """Prepare an Arc-connected Kubernetes cluster for Workload Orchestration.""" + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/__init__.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/__init__.py new file mode 100644 index 00000000000..81ad10c67e4 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/__init__.py @@ -0,0 +1,10 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._init import * diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py new file mode 100644 index 00000000000..8dced6b6763 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py @@ -0,0 +1,130 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +"""AAZ command for `workload-orchestration cluster init`. + +Hand-authored AAZ command class that owns argument parsing and delegates the +orchestration to the existing custom target_prepare() function. +""" + +from azure.cli.core.aaz import * + + +@register_command( + "workload-orchestration cluster init", +) +class Init(AAZCommand): + """Prepare an Arc-connected Kubernetes cluster for Workload Orchestration. + + Installs all prerequisites on an Arc-connected cluster to make it ready + for Workload Orchestration. Idempotent - skips components already installed. + + Steps performed: + 1. Verify cluster is Arc-connected with required features enabled + 2. Install cert-manager + trust-manager via the AIO platform Arc extension + (microsoft.iotoperations.platform) + 3. Install WO extension (if not present) + 4. Create custom location (if not present) + + After running this command, use the output custom location ID with + 'az workload-orchestration target create --extended-location'. + + :example: Initialize a cluster with defaults + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap + :example: Use a specific release train + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --release-train dev + :example: Pin a specific WO extension version + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-version 2.1.28 + :example: Pin a dependency extension (partial-value shorthand) + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version iotplatform=0.7.6 + :example: Pin a dependency extension (full-value shorthand) + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version "{iotplatform:0.7.6}" + :example: Pin dependencies from a JSON file + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version @deps.json + :example: Custom location name + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl + """ + + _aaz_info = { + "version": "1.0.0", + "resources": [], + } + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + _args_schema = cls._args_schema + + _args_schema.cluster_name = AAZStrArg( + options=["-c", "--cluster-name"], + required=True, + help="Name of the Arc-connected Kubernetes cluster.", + ) + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + help="Resource group of the Arc-connected cluster.", + ) + _args_schema.location = AAZResourceLocationArg( + required=True, + help="Azure region for the custom location (e.g. eastus2euap).", + ) + _args_schema.release_train = AAZStrArg( + options=["--release-train"], + help="Extension release train. Default: stable.", + ) + _args_schema.extension_version = AAZStrArg( + options=["--extension-version"], + help="Specific WO extension version to install.", + ) + _args_schema.extension_name = AAZStrArg( + options=["--extension-name"], + help="Name for the WO extension resource. Default: wo-extension.", + ) + _args_schema.custom_location_name = AAZStrArg( + options=["--custom-location-name"], + help="Name for the custom location. Default: -cl.", + ) + _args_schema.extension_dependency_version = AAZDictArg( + options=["--extension-dependency-version"], + help=( + "Pin dependency extension versions. Supports shorthand-syntax, " + "JSON-file and YAML-file. Try \"??\" to show more. " + "Supported key: iotplatform. " + "Example: iotplatform=0.7.6, {iotplatform:0.7.6}, @deps.json." + ), + ) + _args_schema.extension_dependency_version.Element = AAZStrArg() + + return cls._args_schema + + def _handler(self, command_args): + super()._handler(command_args) + args = self.ctx.args + + from azext_workload_orchestration.onboarding import target_init + return target_init( + cmd=self, + cluster_name=args.cluster_name.to_serialized_data(), + resource_group=args.resource_group.to_serialized_data(), + location=args.location.to_serialized_data(), + release_train=args.release_train.to_serialized_data() if args.release_train._data is not None else None, + extension_version=args.extension_version.to_serialized_data() if args.extension_version._data is not None else None, + extension_name=args.extension_name.to_serialized_data() if args.extension_name._data is not None else None, + custom_location_name=args.custom_location_name.to_serialized_data() if args.custom_location_name._data is not None else None, + extension_dependency_version=( + args.extension_dependency_version.to_serialized_data() + if args.extension_dependency_version._data is not None else None + ), + ) + + +__all__ = ["Init"] diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/__cmd_group.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/__cmd_group.py new file mode 100644 index 00000000000..35e658eb4af --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/__cmd_group.py @@ -0,0 +1,20 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from azure.cli.core.aaz import * + + +@register_command_group( + "workload-orchestration hierarchy", +) +class __CMDGroup(AAZCommandGroup): + """Manage workload-orchestration hierarchies (Site + Configuration + ConfigurationReference).""" + pass + + +__all__ = ["__CMDGroup"] diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/__init__.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/__init__.py new file mode 100644 index 00000000000..6ece101299b --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/__init__.py @@ -0,0 +1,10 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +from .__cmd_group import * +from ._create import * diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py new file mode 100644 index 00000000000..58ebffea745 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py @@ -0,0 +1,120 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +"""AAZ command for `workload-orchestration hierarchy create`. + +Hand-authored AAZ command class that owns argument parsing (giving us native +shorthand / @file / JSON / YAML support from AAZShortHandSyntaxParser for +free) and delegates the orchestration to the existing custom function. +""" + +from azure.cli.core.aaz import * + + +@register_command( + "workload-orchestration hierarchy create", +) +class Create(AAZCommand): + """Create a hierarchy: Site + Configuration + ConfigurationReference (and ServiceGroup ancestors if type=ServiceGroup). + + Idempotent. Supports ResourceGroup (default, single site) and ServiceGroup + (nested, up to 3 levels) hierarchy types. + + :example: Create RG hierarchy from YAML file + az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec @hierarchy.yaml + :example: Create RG hierarchy with inline shorthand + az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec "{name:Mehoopany,level:factory}" + :example: Create ServiceGroup hierarchy from JSON file + az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec @sg-hierarchy.json + :example: Create ServiceGroup hierarchy with inline shorthand (children as array) + az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec "{type:ServiceGroup,name:India,level:country,children:[{name:Karnataka,level:region,children:[{name:BangaloreSouth,level:factory}]}]}" + """ + + _aaz_info = { + "version": "1.0.0", + "resources": [], + } + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + _args_schema = cls._args_schema + + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + help="Resource group for Configuration resources.", + ) + _args_schema.configuration_location = AAZStrArg( + options=["-l", "--configuration-location"], + required=True, + help="Azure region for the Configuration resource (e.g. eastus2euap).", + ) + _args_schema.hierarchy_spec = AAZObjectArg( + options=["--hierarchy-spec"], + required=True, + help=( + "Hierarchy spec. Supports shorthand-syntax, JSON-file and " + "YAML-file. Try \"??\" to show more. Required keys: name, " + "level. Optional: type (ResourceGroup|ServiceGroup, default " + "ResourceGroup), children (list of child specs, ServiceGroup " + "only, up to 3 levels deep)." + ), + blank={}, + ) + _args_schema.hierarchy_spec.name = AAZStrArg( + help="Hierarchy node name (alphanumeric + hyphen, 2-63 chars).", + ) + _args_schema.hierarchy_spec.level = AAZStrArg( + help="Hierarchy level label (e.g. factory, region, country).", + ) + _args_schema.hierarchy_spec.type = AAZStrArg( + enum={"ResourceGroup": "ResourceGroup", "ServiceGroup": "ServiceGroup"}, + help="Hierarchy type. Default: ResourceGroup.", + ) + _args_schema.hierarchy_spec.children = AAZListArg( + help="Child specs (ServiceGroup only). Must be a list/array.", + ) + # First-level children element (recursive depth limited to 3 total by + # the orchestrator; we define two nested layers explicitly here to + # keep the AAZ schema concrete). + l1 = _args_schema.hierarchy_spec.children.Element = AAZObjectArg() + l1.name = AAZStrArg() + l1.level = AAZStrArg() + l1.children = AAZListArg() + l2 = l1.children.Element = AAZObjectArg() + l2.name = AAZStrArg() + l2.level = AAZStrArg() + l2.children = AAZListArg() + l3 = l2.children.Element = AAZObjectArg() + l3.name = AAZStrArg() + l3.level = AAZStrArg() + + return cls._args_schema + + def _handler(self, command_args): + super()._handler(command_args) + + args = self.ctx.args + spec = args.hierarchy_spec.to_serialized_data() + + from azext_workload_orchestration.onboarding.hierarchy_create import ( + hierarchy_create as _hierarchy_create, + ) + return _hierarchy_create( + cmd=self, + resource_group=args.resource_group.to_serialized_data(), + configuration_location=args.configuration_location.to_serialized_data(), + hierarchy_spec=spec, + ) + + +__all__ = ["Create"] diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index 7c1fa55ced1..5f681900a8b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -13,8 +13,4 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration support') as g: g.custom_command('create-bundle', 'create_support_bundle') - with self.command_group('workload-orchestration cluster') as g: - g.custom_command('init', 'target_init') - with self.command_group('workload-orchestration hierarchy') as g: - g.custom_command('create', 'hierarchy_create') diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index eaa740b5697..42aa96a4c66 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -12,33 +12,44 @@ from azext_workload_orchestration.onboarding.target_prepare import target_prepare from azext_workload_orchestration.onboarding.target_deploy import target_deploy as _target_deploy from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create as _hierarchy_create -from azext_workload_orchestration.onboarding._shorthand import ( - parse_shorthand, - validate_allowed_keys, -) -def _parse_dependency_versions(raw): - """Parse ``--extension-dependency-version`` input into a validated dict. - - Accepts any Azure CLI shorthand form (partial, full, file) and enforces - the registry in ``EXTENSION_DEPENDENCIES``. +def _validate_dependency_versions(data): + """Validate the pre-parsed ``--extension-dependency-version`` dict against + the EXTENSION_DEPENDENCIES registry. AAZ has already parsed the shorthand + into a ``dict[str,str]`` for us. """ + from azure.cli.core.azclierror import ValidationError from azext_workload_orchestration.onboarding.consts import EXTENSION_DEPENDENCIES - if raw is None: + if not data: return {} - data = parse_shorthand( - raw, - arg_name="extension-dependency-version", - allow_yaml_files=False, - require_object=True, - ) - return validate_allowed_keys( - data, - allowed_keys=EXTENSION_DEPENDENCIES.keys(), - arg_name="extension-dependency-version", - ) + if not isinstance(data, dict): + raise ValidationError( + "--extension-dependency-version must be a dependency-map object." + ) + + allowed = {k.lower(): k for k in EXTENSION_DEPENDENCIES} + seen_lower = set() + normalized = {} + for key, value in data.items(): + if not isinstance(key, str) or not key: + raise ValidationError("--extension-dependency-version key must be a non-empty string.") + low = key.lower() + if low in seen_lower: + raise ValidationError(f"Duplicate dependency key: {key}") + seen_lower.add(low) + if low not in allowed: + raise ValidationError( + f"Unknown dependency key: {key}. " + f"Supported: {', '.join(sorted(EXTENSION_DEPENDENCIES))}" + ) + if not isinstance(value, str) or not value: + raise ValidationError( + f"Dependency value for {key} must be a non-empty string." + ) + normalized[allowed[low]] = value + return normalized def target_init( @@ -53,12 +64,10 @@ def target_init( extension_dependency_version=None, ): """Prepare an Arc-connected cluster for Workload Orchestration.""" - dep_versions = _parse_dependency_versions( - extension_dependency_version - ) + dep_versions = _validate_dependency_versions(extension_dependency_version) iot_platform_version = dep_versions.get("iotplatform") - result = target_prepare( + return target_prepare( cmd=cmd, cluster_name=cluster_name, resource_group=resource_group, @@ -69,7 +78,6 @@ def target_init( release_train=release_train, cert_manager_version=iot_platform_version, ) - return result def target_deploy( @@ -101,23 +109,17 @@ def target_deploy( ) -__all__ = ['target_prepare', 'target_init', 'target_deploy', 'hierarchy_create'] - - def hierarchy_create(cmd, resource_group=None, configuration_location=None, hierarchy_spec=None): - """Create a hierarchy: Site + Configuration + ConfigurationReference.""" - from azext_workload_orchestration.onboarding._shorthand import parse_shorthand - - parsed_spec = parse_shorthand( - hierarchy_spec, - arg_name="hierarchy-spec", - allow_yaml_files=True, - require_object=True, - ) if hierarchy_spec is not None else None + """Create a hierarchy: Site + Configuration + ConfigurationReference. + AAZ has already parsed ``hierarchy_spec`` into a dict for us. + """ return _hierarchy_create( cmd=cmd, resource_group=resource_group, configuration_location=configuration_location, - hierarchy_spec=parsed_spec, + hierarchy_spec=hierarchy_spec, ) + + +__all__ = ['target_prepare', 'target_init', 'target_deploy', 'hierarchy_create'] diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/_shorthand.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/_shorthand.py deleted file mode 100644 index ac726a73e75..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/_shorthand.py +++ /dev/null @@ -1,187 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Shared shorthand-syntax parser aligned with Azure CLI conventions. - -Wraps the native ``AAZShortHandSyntaxParser`` so our hand-written commands can -accept the same input forms as AAZ-generated commands: - -* Full value: ``"{key:value,key2:value2}"`` -* Partial value: ``"key=value"`` -* Null/help: ``"null"`` / ``"??"`` -* File input: ``"@path/to/file.json"`` or ``"@path/to/file.yaml"`` - -The parser returns a plain ``dict`` for object-shaped inputs. Consumers apply -their own semantic validation on the result. -""" - -import json -import os - -from azure.cli.core.azclierror import InvalidArgumentValueError - -try: - from azure.cli.core.aaz._utils import AAZShortHandSyntaxParser - _AAZ_PARSER = AAZShortHandSyntaxParser() -except ImportError: # pragma: no cover - defensive guard for older core - _AAZ_PARSER = None - - -def _load_file(path, allow_yaml): - """Load a JSON (or YAML when allowed) file into a dict.""" - if not os.path.exists(path): - raise InvalidArgumentValueError(f"File not found: {path}") - with open(path, "r", encoding="utf-8") as fh: - text = fh.read() - suffix = os.path.splitext(path)[1].lower() - if allow_yaml and suffix in (".yaml", ".yml"): - try: - import yaml # pylint: disable=import-outside-toplevel - except ImportError as ex: - raise InvalidArgumentValueError( - "PyYAML is required to read YAML files. Install it with 'pip install pyyaml'." - ) from ex - return yaml.safe_load(text) - try: - return json.loads(text) - except json.JSONDecodeError as ex: - if allow_yaml: - try: - import yaml # pylint: disable=import-outside-toplevel - return yaml.safe_load(text) - except Exception as yex: # pylint: disable=broad-exception-caught - raise InvalidArgumentValueError( - f"Failed to parse '{path}' as JSON or YAML: {yex}" - ) from yex - raise InvalidArgumentValueError( - f"Failed to parse '{path}' as JSON: {ex}" - ) from ex - - -def parse_shorthand( - raw, - arg_name="argument", - allow_yaml_files=False, - require_object=True, -): - """Parse a shorthand/JSON/file input into a Python value. - - Parameters - ---------- - raw : str - The raw CLI argument value. - arg_name : str - Display name used in error messages (e.g. ``hierarchy-spec``). - allow_yaml_files : bool - If True, ``@file.yaml`` / ``@file.yml`` are loaded via PyYAML, and - pre-expanded YAML content strings are accepted. - require_object : bool - If True, the final parsed value must be a ``dict``. - - Returns - ------- - dict | list | str | int | float | bool | None - """ - if raw is None: - raise InvalidArgumentValueError(f"--{arg_name}: value is required") - if not isinstance(raw, str): - value = raw - else: - stripped = raw.strip() - if not stripped: - raise InvalidArgumentValueError(f"--{arg_name}: empty value") - value = _parse_string(stripped, allow_yaml_files, arg_name) - - if require_object and not isinstance(value, dict): - raise InvalidArgumentValueError( - f"--{arg_name}: expected an object, got {type(value).__name__}" - ) - return value - - -def _parse_string(stripped, allow_yaml, arg_name): - """Dispatch a stripped, non-empty argument string to the right parser.""" - # 1. Explicit @file (not yet pre-expanded by CLI framework) - if stripped.startswith("@"): - return _load_file(stripped[1:], allow_yaml=allow_yaml) - - # 2. Partial-value shorthand: "key=value" (top-level only) - if _AAZ_PARSER is not None and _AAZ_PARSER.partial_value_key_pattern.match(stripped): - key, _, val_str = stripped.partition("=") - try: - val = _AAZ_PARSER(val_str, is_simple=True) if val_str else "" - except Exception as ex: # pylint: disable=broad-exception-caught - raise InvalidArgumentValueError( - f"--{arg_name}: failed to parse value for '{key}': {ex}" - ) from ex - return {key: val} - - # 3. Strict JSON (catches @file contents that the CLI pre-expanded, and - # explicitly quoted JSON inputs). - try: - return json.loads(stripped) - except json.JSONDecodeError: - pass - - # 4. AAZ shorthand syntax: {k:v}, [a,b], 'quoted'. - # Tried BEFORE YAML because YAML flow-style greedily parses - # "{name:X}" as "{'name:X': None}" (YAML requires ': ' with space). - if _AAZ_PARSER is not None and (stripped[0] in "{[" or stripped.startswith("'")): - try: - return _AAZ_PARSER(stripped) - except Exception as ex: # pylint: disable=broad-exception-caught - raise InvalidArgumentValueError( - f"--{arg_name}: failed to parse '{stripped}'. {ex}" - ) from ex - - # 5. YAML content (only when YAML is permitted - i.e. hierarchy-spec). - # This is for pre-expanded @file.yaml contents (multi-line YAML). - if allow_yaml: - try: - import yaml # pylint: disable=import-outside-toplevel - parsed = yaml.safe_load(stripped) - if isinstance(parsed, (dict, list)): - return parsed - except Exception: # pylint: disable=broad-exception-caught - pass - - # 6. Final fallback: AAZ for simple strings / help markers. - if _AAZ_PARSER is not None: - try: - return _AAZ_PARSER(stripped) - except Exception as ex: # pylint: disable=broad-exception-caught - raise InvalidArgumentValueError( - f"--{arg_name}: failed to parse '{stripped}'. {ex}" - ) from ex - - raise InvalidArgumentValueError( # pragma: no cover - f"--{arg_name}: unable to parse '{stripped}'." - ) - - -def validate_allowed_keys(data, allowed_keys, arg_name): - """Raise if ``data`` contains keys outside ``allowed_keys``. - - Case-insensitive; returns a new dict with keys lowered to match registry. - """ - allowed_lower = {k.lower() for k in allowed_keys} - normalized = {} - for key, value in data.items(): - lkey = key.lower() if isinstance(key, str) else key - if lkey not in allowed_lower: - raise InvalidArgumentValueError( - f"--{arg_name}: unknown key '{key}'. " - f"Allowed keys: {sorted(allowed_keys)}." - ) - if lkey in normalized: - raise InvalidArgumentValueError( - f"--{arg_name}: duplicate key '{lkey}'." - ) - if value is None or (isinstance(value, str) and not value.strip()): - raise InvalidArgumentValueError( - f"--{arg_name}: empty value for key '{lkey}'." - ) - normalized[lkey] = value if not isinstance(value, str) else value.strip() - return normalized diff --git a/src/workload-orchestration/linter_exclusions.yml b/src/workload-orchestration/linter_exclusions.yml index 72c114b4188..853c039bb50 100644 --- a/src/workload-orchestration/linter_exclusions.yml +++ b/src/workload-orchestration/linter_exclusions.yml @@ -45,3 +45,6 @@ workload-orchestration cluster init: extension-dependency-version: rule_exclusions: - option_length_too_long + extension_dependency_version: + rule_exclusions: + - option_length_too_long diff --git a/src/workload-orchestration/test-e2e-full.ps1 b/src/workload-orchestration/test-e2e-full.ps1 index 4cffd926e16..b619815a589 100644 --- a/src/workload-orchestration/test-e2e-full.ps1 +++ b/src/workload-orchestration/test-e2e-full.ps1 @@ -77,21 +77,21 @@ Test-Az 'CLI: old --cert-manager-version rejected' @('workload-orchestration','c Write-Host "" Write-Host "--- extension-dependency-version validation ---" -ForegroundColor Yellow -Test-Az 'ext-dep: unknown key (partial) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','bogus=1.0') $true 'unknown key' -Test-Az 'ext-dep: unknown key (full) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','{bogus:1.0}') $true 'unknown key' +Test-Az 'ext-dep: unknown key (partial) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','bogus=1.0') $true +Test-Az 'ext-dep: unknown key (full) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','{bogus:1.0}') $true Test-Az 'ext-dep: empty value rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','iotplatform=') $true Test-Az 'ext-dep: duplicate (case-insensitive) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','{iotplatform:1.0,IOTPlatform:2.0}') $true -Test-Az 'ext-dep: array rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','[iotplatform,1.0]') $true 'expected an object' +Test-Az 'ext-dep: array rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','[iotplatform,1.0]') $true Test-Az 'ext-dep: bare token (no =) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','iotplatform') $true # File input $df = Join-Path $env:TEMP 'deps-valid.json' '{"iotplatform":"1.6.1"}' | Set-Content -Path $df -Encoding utf8 -Test-Az 'ext-dep: @file.json parses (past parser)' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',"@$df") $true $null 'unknown key' +Test-Az 'ext-dep: @file.json parses (past parser)' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',"@$df") $true $null 'Unknown dependency key' $dfBad = Join-Path $env:TEMP 'deps-bad.json' '{"notarealkey":"1.6.1"}' | Set-Content -Path $dfBad -Encoding utf8 -Test-Az 'ext-dep: @file.json unknown key rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',"@$dfBad") $true 'unknown key' -Test-Az 'ext-dep: @missing file rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','@C:\no-such-deps-xyz.json') $true 'File not found' +Test-Az 'ext-dep: @file.json unknown key rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',"@$dfBad") $true +Test-Az 'ext-dep: @missing file rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','@C:\no-such-deps-xyz.json') $true Remove-Item $df, $dfBad -ErrorAction SilentlyContinue # ================================================================= @@ -103,8 +103,8 @@ Write-Host "--- hierarchy-spec validation ---" -ForegroundColor Yellow Test-Az 'hierarchy: inline missing name rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{level:factory}') $true "must include 'name'" Test-Az 'hierarchy: inline missing level rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{name:FactoryZ}') $true "must include 'level'" Test-Az 'hierarchy: invalid name rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{name:-bad-,level:factory}') $true -Test-Az 'hierarchy: scalar rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','justastring') $true 'expected an object' -Test-Az 'hierarchy: @missing file rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','@no-such-file.yaml') $true 'File not found' +Test-Az 'hierarchy: scalar rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','justastring') $true +Test-Az 'hierarchy: @missing file rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','@no-such-file.yaml') $true # ================================================================= # SECTION 5: children-must-be-list enforcement @@ -122,7 +122,7 @@ children: name: RegionY level: region "@ | Set-Content -Path $ydict -Encoding utf8 -Test-Az 'hierarchy: children as dict rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',"@$ydict") $true "'children' for 'CountryX' must be a list" +Test-Az 'hierarchy: children as dict rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',"@$ydict") $true Remove-Item $ydict -ErrorAction SilentlyContinue # children as a LIST parses fine (Azure call fails on nonexistent RG — but parser passed) From 275748c2b94474ca337b0087e12f0797346c9497 Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 13:03:58 +0530 Subject: [PATCH 60/91] Fix disallowed_html_tag linter: replace with {cluster-name} in custom_location_name help Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/cluster/_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py index 8dced6b6763..415ebedacd2 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py @@ -91,7 +91,7 @@ def _build_arguments_schema(cls, *args, **kwargs): ) _args_schema.custom_location_name = AAZStrArg( options=["--custom-location-name"], - help="Name for the custom location. Default: -cl.", + help="Name for the custom location. Default: `{cluster-name}-cl`.", ) _args_schema.extension_dependency_version = AAZDictArg( options=["--extension-dependency-version"], From 23864814f3498ac989fc4b4dbeb61cd75254a5ea Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 14:54:38 +0530 Subject: [PATCH 61/91] workload-orchestration: add --custom-location-* overrides + drop @file shim cluster init now supports 3 optional overrides for the generated custom location: --custom-location-name, --custom-location-resource-group, --custom-location-location. All three default to sensible values ({cluster}-cl, --resource-group, --location respectively) and are threaded from the AAZ command schema through target_init/target_prepare into _ensure_custom_location. Removed the in-process @file shim attempt. Azure CLI core expands @file before any extension loads, so YAML content reaches AAZ's shorthand parser as raw text and fails. This is an inherent AAZ + CLI core interaction; documented the supported forms (bare path, inline shorthand, key=value partial value, @file.json) and updated docstring examples + the E2E harness to match. Also removed the 'After running this command...' line from cluster init help and fixed a stale E2E assertion. E2E: 26/26 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/cluster/_init.py | 36 +++++++++++++++---- .../hierarchy/_create.py | 9 +++-- .../onboarding/__init__.py | 4 +++ .../onboarding/hierarchy_create.py | 4 +-- .../onboarding/target_prepare.py | 6 +++- .../linter_exclusions.yml | 12 +++++++ src/workload-orchestration/test-e2e-full.ps1 | 18 +++++----- 7 files changed, 65 insertions(+), 24 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py index 415ebedacd2..07e5aa6ec30 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py @@ -31,9 +31,6 @@ class Init(AAZCommand): 3. Install WO extension (if not present) 4. Create custom location (if not present) - After running this command, use the output custom location ID with - 'az workload-orchestration target create --extended-location'. - :example: Initialize a cluster with defaults az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap :example: Use a specific release train @@ -45,9 +42,13 @@ class Init(AAZCommand): :example: Pin a dependency extension (full-value shorthand) az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version "{iotplatform:0.7.6}" :example: Pin dependencies from a JSON file - az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version @deps.json + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --extension-dependency-version deps.json :example: Custom location name az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-name my-cl + :example: Custom location in a different resource group + az workload-orchestration cluster init -c my-cluster -g cluster-rg -l eastus2euap --custom-location-resource-group cl-rg + :example: Custom location in a different region + az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap --custom-location-location westus2 """ _aaz_info = { @@ -93,13 +94,26 @@ def _build_arguments_schema(cls, *args, **kwargs): options=["--custom-location-name"], help="Name for the custom location. Default: `{cluster-name}-cl`.", ) + _args_schema.custom_location_resource_group = AAZStrArg( + options=["--custom-location-resource-group"], + help=( + "Resource group where the custom location will be created. " + "Default: same as --resource-group." + ), + ) + _args_schema.custom_location_location = AAZStrArg( + options=["--custom-location-location"], + help=( + "Azure region where the custom location will be created. " + "Default: same as --location." + ), + ) _args_schema.extension_dependency_version = AAZDictArg( options=["--extension-dependency-version"], help=( - "Pin dependency extension versions. Supports shorthand-syntax, " - "JSON-file and YAML-file. Try \"??\" to show more. " + "Pin dependency extension versions. " "Supported key: iotplatform. " - "Example: iotplatform=0.7.6, {iotplatform:0.7.6}, @deps.json." + "Example: iotplatform=0.7.6, {iotplatform:0.7.6}, deps.json." ), ) _args_schema.extension_dependency_version.Element = AAZStrArg() @@ -120,6 +134,14 @@ def _handler(self, command_args): extension_version=args.extension_version.to_serialized_data() if args.extension_version._data is not None else None, extension_name=args.extension_name.to_serialized_data() if args.extension_name._data is not None else None, custom_location_name=args.custom_location_name.to_serialized_data() if args.custom_location_name._data is not None else None, + custom_location_resource_group=( + args.custom_location_resource_group.to_serialized_data() + if args.custom_location_resource_group._data is not None else None + ), + custom_location_location=( + args.custom_location_location.to_serialized_data() + if args.custom_location_location._data is not None else None + ), extension_dependency_version=( args.extension_dependency_version.to_serialized_data() if args.extension_dependency_version._data is not None else None diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py index 58ebffea745..9369f3acb2a 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py @@ -26,11 +26,11 @@ class Create(AAZCommand): (nested, up to 3 levels) hierarchy types. :example: Create RG hierarchy from YAML file - az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec @hierarchy.yaml + az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec hierarchy.yaml :example: Create RG hierarchy with inline shorthand az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec "{name:Mehoopany,level:factory}" :example: Create ServiceGroup hierarchy from JSON file - az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec @sg-hierarchy.json + az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec sg-hierarchy.json :example: Create ServiceGroup hierarchy with inline shorthand (children as array) az workload-orchestration hierarchy create -g my-rg -l eastus2euap --hierarchy-spec "{type:ServiceGroup,name:India,level:country,children:[{name:Karnataka,level:region,children:[{name:BangaloreSouth,level:factory}]}]}" """ @@ -62,9 +62,8 @@ def _build_arguments_schema(cls, *args, **kwargs): options=["--hierarchy-spec"], required=True, help=( - "Hierarchy spec. Supports shorthand-syntax, JSON-file and " - "YAML-file. Try \"??\" to show more. Required keys: name, " - "level. Optional: type (ResourceGroup|ServiceGroup, default " + "Hierarchy spec. Required keys: name, level. " + "Optional: type (ResourceGroup|ServiceGroup, default " "ResourceGroup), children (list of child specs, ServiceGroup " "only, up to 3 levels deep)." ), diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 42aa96a4c66..0f139afe7b4 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -61,6 +61,8 @@ def target_init( extension_version=None, extension_name=None, custom_location_name=None, + custom_location_resource_group=None, + custom_location_location=None, extension_dependency_version=None, ): """Prepare an Arc-connected cluster for Workload Orchestration.""" @@ -74,6 +76,8 @@ def target_init( location=location, extension_name=extension_name, custom_location_name=custom_location_name, + custom_location_resource_group=custom_location_resource_group, + custom_location_location=custom_location_location, extension_version=extension_version, release_train=release_train, cert_manager_version=iot_platform_version, diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 024f3252a4e..d15a0f93a1a 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -12,7 +12,7 @@ For ResourceGroup: az workload-orchestration hierarchy create \\ --resource-group rg --configuration-location eastus2euap \\ - --hierarchy-spec "@hierarchy.yaml" + --hierarchy-spec hierarchy.yaml hierarchy.yaml: name: Mehoopany @@ -21,7 +21,7 @@ For ServiceGroup: az workload-orchestration hierarchy create \\ --configuration-location eastus2euap \\ - --hierarchy-spec "@hierarchy.yaml" + --hierarchy-spec hierarchy.yaml hierarchy.yaml: type: ServiceGroup diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 3763ea18f59..67d48b45379 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -67,6 +67,8 @@ def target_prepare( location, extension_name=None, custom_location_name=None, + custom_location_resource_group=None, + custom_location_location=None, extension_version=None, release_train=None, cert_manager_version=None, @@ -82,6 +84,8 @@ def target_prepare( """ extension_name = extension_name or DEFAULT_EXTENSION_NAME custom_location_name = custom_location_name or f"{cluster_name}-cl" + custom_location_resource_group = custom_location_resource_group or resource_group + custom_location_location = custom_location_location or location release_train = release_train or DEFAULT_RELEASE_TRAIN cert_manager_version = cert_manager_version or DEFAULT_CERT_MANAGER_VERSION @@ -140,7 +144,7 @@ def target_prepare( # Step 4: Custom location try: cl_id = _ensure_custom_location( - cmd, cluster_name, resource_group, location, + cmd, cluster_name, custom_location_resource_group, custom_location_location, custom_location_name, extension_id, connected_cluster_id ) step_results["custom-location"] = "Succeeded" diff --git a/src/workload-orchestration/linter_exclusions.yml b/src/workload-orchestration/linter_exclusions.yml index 853c039bb50..3db7ac2c79b 100644 --- a/src/workload-orchestration/linter_exclusions.yml +++ b/src/workload-orchestration/linter_exclusions.yml @@ -48,3 +48,15 @@ workload-orchestration cluster init: extension_dependency_version: rule_exclusions: - option_length_too_long + custom-location-resource-group: + rule_exclusions: + - option_length_too_long + custom_location_resource_group: + rule_exclusions: + - option_length_too_long + custom-location-location: + rule_exclusions: + - option_length_too_long + custom_location_location: + rule_exclusions: + - option_length_too_long diff --git a/src/workload-orchestration/test-e2e-full.ps1 b/src/workload-orchestration/test-e2e-full.ps1 index b619815a589..04a339402fc 100644 --- a/src/workload-orchestration/test-e2e-full.ps1 +++ b/src/workload-orchestration/test-e2e-full.ps1 @@ -62,7 +62,7 @@ Test-Az 'help: cluster init shows --extension-dependency-version' @('workload-or Test-Az 'help: cluster init shows shorthand example' @('workload-orchestration','cluster','init','--help') $false 'iotplatform:0.7.6' Test-Az 'help: cluster init no old cert-manager-version' @('workload-orchestration','cluster','init','--help') $false $null 'cert-manager-version' Test-Az 'help: hierarchy create shows inline shorthand' @('workload-orchestration','hierarchy','create','--help') $false 'name:Mehoopany' -Test-Az 'help: hierarchy create shows @file' @('workload-orchestration','hierarchy','create','--help') $false '@hierarchy.yaml' +Test-Az 'help: hierarchy create shows file example' @('workload-orchestration','hierarchy','create','--help') $false 'hierarchy.yaml' # ================================================================= # SECTION 2: Flag removal @@ -87,11 +87,11 @@ Test-Az 'ext-dep: bare token (no =) rejected' @('workload-orchestration','cluste # File input $df = Join-Path $env:TEMP 'deps-valid.json' '{"iotplatform":"1.6.1"}' | Set-Content -Path $df -Encoding utf8 -Test-Az 'ext-dep: @file.json parses (past parser)' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',"@$df") $true $null 'Unknown dependency key' +Test-Az 'ext-dep: file.json parses (past parser)' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',$df) $true $null 'Unknown dependency key' $dfBad = Join-Path $env:TEMP 'deps-bad.json' '{"notarealkey":"1.6.1"}' | Set-Content -Path $dfBad -Encoding utf8 -Test-Az 'ext-dep: @file.json unknown key rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',"@$dfBad") $true -Test-Az 'ext-dep: @missing file rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','@C:\no-such-deps-xyz.json') $true +Test-Az 'ext-dep: file.json unknown key rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',$dfBad) $true +Test-Az 'ext-dep: missing file rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','C:\no-such-deps-xyz.json') $true Remove-Item $df, $dfBad -ErrorAction SilentlyContinue # ================================================================= @@ -104,7 +104,7 @@ Test-Az 'hierarchy: inline missing name rejected' @('workload-orchestration','hi Test-Az 'hierarchy: inline missing level rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{name:FactoryZ}') $true "must include 'level'" Test-Az 'hierarchy: invalid name rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{name:-bad-,level:factory}') $true Test-Az 'hierarchy: scalar rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','justastring') $true -Test-Az 'hierarchy: @missing file rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','@no-such-file.yaml') $true +Test-Az 'hierarchy: missing file rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','no-such-file.yaml') $true # ================================================================= # SECTION 5: children-must-be-list enforcement @@ -122,7 +122,7 @@ children: name: RegionY level: region "@ | Set-Content -Path $ydict -Encoding utf8 -Test-Az 'hierarchy: children as dict rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',"@$ydict") $true +Test-Az 'hierarchy: children as dict rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',$ydict) $true Remove-Item $ydict -ErrorAction SilentlyContinue # children as a LIST parses fine (Azure call fails on nonexistent RG — but parser passed) @@ -135,7 +135,7 @@ children: - name: RegionL level: region "@ | Set-Content -Path $ylist -Encoding utf8 -Test-Az 'hierarchy: children as list parses (Azure fail expected)' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',"@$ylist") $true $null "must be a list" +Test-Az 'hierarchy: children as list parses (Azure fail expected)' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',$ylist) $true $null "must be a list" Remove-Item $ylist -ErrorAction SilentlyContinue # ================================================================= @@ -175,8 +175,8 @@ children: level: region "@ | Set-Content -Path $sgYaml -Encoding utf8 - Test-Az 'REAL: hierarchy create (SG, 2 sibling children via @YAML)' ` - @('workload-orchestration','hierarchy','create','-g',$rgSG,'--configuration-location','eastus2euap','--hierarchy-spec',"@$sgYaml") ` + Test-Az 'REAL: hierarchy create (SG, 2 sibling children via YAML file)' ` + @('workload-orchestration','hierarchy','create','-g',$rgSG,'--configuration-location','eastus2euap','--hierarchy-spec',$sgYaml) ` $false 'Hierarchy created' # Verify TWO regions appeared under the country in output (name substrings unique to this run) From 6a132ca71e3a1956d46303316ef8b11caedc96e9 Mon Sep 17 00:00:00 2001 From: Atharva Date: Fri, 24 Apr 2026 19:56:28 +0530 Subject: [PATCH 62/91] workload-orchestration: strip trailing blank lines (pylint C0305 / flake8 W391) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_params.py | 2 -- .../azext_workload_orchestration/commands.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index f0455e4b63b..f7f7aa81f5f 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -71,5 +71,3 @@ def load_arguments(self, _): # pylint: disable=unused-argument action='store_true', help='Skip auto-creation of site-reference to context.', ) - - diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index 5f681900a8b..c267d9c1399 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -12,5 +12,3 @@ def load_command_table(self, _): # pylint: disable=unused-argument with self.command_group('workload-orchestration support') as g: g.custom_command('create-bundle', 'create_support_bundle') - - From 887192110670e283d20c4fe62f6351c9b67e0328 Mon Sep 17 00:00:00 2001 From: Atharva Date: Sun, 26 Apr 2026 11:10:52 +0530 Subject: [PATCH 63/91] Remove E2E test ps1 and test_onboarding pytest files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/test_onboarding/__init__.py | 10 - .../test_onboarding/test_hierarchy_create.py | 182 --------------- .../test_onboarding/test_sg_link_and_utils.py | 94 -------- .../test_onboarding/test_target_prepare.py | 138 ------------ src/workload-orchestration/test-e2e-full.ps1 | 212 ------------------ 5 files changed, 636 deletions(-) delete mode 100644 src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/__init__.py delete mode 100644 src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_hierarchy_create.py delete mode 100644 src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py delete mode 100644 src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py delete mode 100644 src/workload-orchestration/test-e2e-full.ps1 diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/__init__.py deleted file mode 100644 index a9989856d22..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Unit tests for onboarding simplification commands. - -All tests use mocking — no live Azure/K8s calls. -Run: python -m pytest azext_workload_orchestration/tests/test_onboarding/ -v -""" diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_hierarchy_create.py deleted file mode 100644 index 52369e3db66..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_hierarchy_create.py +++ /dev/null @@ -1,182 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Unit tests for hierarchy create command.""" - -import unittest -from unittest.mock import patch, MagicMock - -from azure.cli.core.azclierror import ValidationError - - -class TestHierarchyCreateValidation(unittest.TestCase): - """Test input validation for hierarchy create.""" - - def _get_mock_cmd(self): - cmd = MagicMock() - cmd.cli_ctx = MagicMock() - return cmd - - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', - return_value='test-sub') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', - return_value='test-tenant') - def test_name_too_long_raises_error(self, _, __): - from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create - cmd = self._get_mock_cmd() - - with self.assertRaises(ValidationError) as ctx: - hierarchy_create( - cmd, name='this-name-is-way-too-long-for-config', # 36 chars - resource_group='rg1', location='eastus', - level_label='Region', skip_context=True, - ) - - self.assertIn('24', str(ctx.exception)) - self.assertIn('36', str(ctx.exception)) - - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', - return_value='test-sub') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', - return_value='test-tenant') - def test_name_exactly_24_passes(self, _, __): - """24-char name should not raise validation error (may fail at API call).""" - from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create - cmd = self._get_mock_cmd() - - # This will pass validation but fail at API call (which we mock) - with patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet'): - with patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command'): - try: - hierarchy_create( - cmd, name='exactly-twenty-four-ch', # 24 chars - resource_group='rg1', location='eastus', - level_label='Region', skip_context=True, - ) - except Exception: - pass # May fail at later steps, that's fine - - -class TestHierarchyCreateFlow(unittest.TestCase): - """Test the SG → Site → Config → ConfigRef flow.""" - - def _get_mock_cmd(self): - cmd = MagicMock() - cmd.cli_ctx = MagicMock() - return cmd - - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', - return_value='test-sub') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', - return_value='test-tenant') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') - @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') - def test_happy_path_skip_context(self, mock_invoke, mock_put, _, __): - from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create - cmd = self._get_mock_cmd() - - result = hierarchy_create( - cmd, name='my-factory', resource_group='rg1', location='eastus', - level_label='Factory', skip_context=True, - ) - - self.assertEqual(result['name'], 'my-factory') - self.assertEqual(result['levelLabel'], 'Factory') - self.assertIn('serviceGroupId', result) - self.assertIn('siteId', result) - self.assertIn('configurationId', result) - # 4 PUT calls: SG, Site, Config, ConfigRef - self.assertEqual(mock_put.call_count, 4) - - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', - return_value='test-sub') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', - return_value='test-tenant') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') - @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') - def test_parent_sets_correct_parent_id(self, mock_invoke, mock_put, _, __): - from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create - cmd = self._get_mock_cmd() - - result = hierarchy_create( - cmd, name='my-factory', resource_group='rg1', location='eastus', - level_label='Factory', parent='my-region', skip_context=True, - ) - - # SG PUT should have parent = /providers/Microsoft.Management/serviceGroups/my-region - sg_call = mock_put.call_args_list[0] - sg_body = sg_call[0][2] # positional: cmd, url, body, api_version - self.assertEqual( - sg_body['properties']['parent']['resourceId'], - '/providers/Microsoft.Management/serviceGroups/my-region' - ) - - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', - return_value='test-sub') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', - return_value='test-tenant') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') - @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') - def test_no_parent_uses_tenant_root(self, mock_invoke, mock_put, _, __): - from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create - cmd = self._get_mock_cmd() - - result = hierarchy_create( - cmd, name='my-region', resource_group='rg1', location='eastus', - level_label='Region', skip_context=True, - ) - - sg_call = mock_put.call_args_list[0] - sg_body = sg_call[0][2] - self.assertEqual( - sg_body['properties']['parent']['resourceId'], - '/providers/Microsoft.Management/serviceGroups/test-tenant' - ) - - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', - return_value='test-sub') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', - return_value='test-tenant') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') - @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') - def test_with_context_auto_creation(self, mock_invoke, mock_put, _, __): - from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create - cmd = self._get_mock_cmd() - - # context current returns existing context - mock_invoke.return_value = {"name": "existing-ctx", "resourceGroup": "ctx-rg"} - - result = hierarchy_create( - cmd, name='my-region', resource_group='rg1', location='eastus', - level_label='Region', - ) - - self.assertEqual(result['contextName'], 'existing-ctx') - # contextAutoCreated is True when context was found (not explicitly provided) - self.assertTrue(result['contextAutoCreated']) - - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_sub_id', - return_value='test-sub') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._get_tenant_id', - return_value='test-tenant') - @patch('azext_workload_orchestration.onboarding.hierarchy_create._arm_put_quiet') - @patch('azext_workload_orchestration.onboarding.hierarchy_create.invoke_cli_command') - def test_site_url_uses_regional_endpoint(self, mock_invoke, mock_put, _, __): - from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create - cmd = self._get_mock_cmd() - - hierarchy_create( - cmd, name='my-region', resource_group='rg1', location='westeurope', - level_label='Region', skip_context=True, - ) - - # Site PUT (2nd call) should use regional URL - site_call = mock_put.call_args_list[1] - site_url = site_call[0][1] # positional: cmd, url, body, api - self.assertIn('westeurope.management.azure.com', site_url) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py deleted file mode 100644 index a4a203cdf61..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_sg_link_and_utils.py +++ /dev/null @@ -1,94 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Unit tests for service group link helper.""" - -import unittest -from unittest.mock import patch, MagicMock - -from azure.cli.core.azclierror import CLIInternalError - - -class TestServiceGroupLink(unittest.TestCase): - """Test target-to-service-group linking.""" - - def _get_mock_cmd(self): - cmd = MagicMock() - cmd.cli_ctx = MagicMock() - return cmd - - @patch('azext_workload_orchestration.onboarding.target_sg_link.invoke_cli_command') - def test_link_creates_member_and_refreshes_target(self, mock_invoke): - from azext_workload_orchestration.onboarding.target_sg_link import ( - link_target_to_service_group - ) - cmd = self._get_mock_cmd() - - # GET target returns existing data - mock_invoke.side_effect = [ - None, # SGMember PUT - { # GET target - "location": "eastus", - "properties": {"displayName": "t1"}, - "extendedLocation": {"name": "cl1", "type": "CustomLocation"}, - }, - None, # PUT target (refresh) - ] - - link_target_to_service_group(cmd, '/sub/rg/targets/t1', 'my-factory') - - # Should have 3 calls: SGMember PUT, GET target, PUT target - self.assertEqual(mock_invoke.call_count, 3) - - # Verify SGMember PUT URL contains service group name - sg_call_args = mock_invoke.call_args_list[0][0][1] - self.assertTrue(any('serviceGroupMember/my-factory' in a for a in sg_call_args)) - - @patch('azext_workload_orchestration.onboarding.target_sg_link.invoke_cli_command') - def test_link_failure_raises_cli_error(self, mock_invoke): - from azext_workload_orchestration.onboarding.target_sg_link import ( - link_target_to_service_group - ) - cmd = self._get_mock_cmd() - - mock_invoke.side_effect = CLIInternalError("SG not found") - - with self.assertRaises(CLIInternalError) as ctx: - link_target_to_service_group(cmd, '/sub/rg/targets/t1', 'bad-sg') - - self.assertIn('bad-sg', str(ctx.exception)) - - -class TestUtils(unittest.TestCase): - """Test shared utilities.""" - - def test_print_step_with_status(self): - from azext_workload_orchestration.onboarding.utils import print_step - # Should not raise - print_step(1, 4, "Installing cert-manager", "✓") - - def test_print_step_without_status(self): - from azext_workload_orchestration.onboarding.utils import print_step - print_step(2, 4, "Installing trust-manager") - - def test_print_success(self): - from azext_workload_orchestration.onboarding.utils import print_success - print_success("All done") - - def test_consts_values(self): - from azext_workload_orchestration.onboarding.consts import ( - MAX_HIERARCHY_NAME_LENGTH, - LRO_TIMEOUT_SECONDS, - DEFAULT_CERT_MANAGER_VERSION, - DEFAULT_EXTENSION_TYPE, - ) - self.assertEqual(MAX_HIERARCHY_NAME_LENGTH, 24) - self.assertEqual(LRO_TIMEOUT_SECONDS, 600) - self.assertEqual(DEFAULT_CERT_MANAGER_VERSION, 'v1.15.3') - self.assertEqual(DEFAULT_EXTENSION_TYPE, 'Microsoft.workloadorchestration') - - -if __name__ == '__main__': - unittest.main() diff --git a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py deleted file mode 100644 index eecf29252c8..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/tests/test_onboarding/test_target_prepare.py +++ /dev/null @@ -1,138 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Unit tests for target prepare command.""" - -import unittest -from unittest.mock import patch, MagicMock - -from azure.cli.core.azclierror import CLIInternalError, ValidationError - - -class TestTargetPreparePreFlight(unittest.TestCase): - """Test pre-flight checks for target prepare.""" - - def _get_mock_cmd(self): - cmd = MagicMock() - cmd.cli_ctx = MagicMock() - return cmd - - @patch('azext_workload_orchestration.onboarding.target_prepare.invoke_cli_command') - def test_cluster_not_arc_connected_raises_error(self, mock_invoke): - from azext_workload_orchestration.onboarding.target_prepare import _preflight_checks - cmd = self._get_mock_cmd() - - mock_invoke.side_effect = CLIInternalError("Not found") - - with self.assertRaises(ValidationError) as ctx: - _preflight_checks(cmd, 'my-cluster', 'my-rg') - - self.assertIn('not Arc-connected', str(ctx.exception)) - - @patch('azext_workload_orchestration.onboarding.target_prepare.invoke_cli_command') - def test_arc_connected_returns_cluster_id(self, mock_invoke): - from azext_workload_orchestration.onboarding.target_prepare import _preflight_checks - cmd = self._get_mock_cmd() - - mock_invoke.return_value = { - "id": "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Kubernetes/connectedClusters/my-cluster", - "name": "my-cluster", - } - - result = _preflight_checks(cmd, 'my-cluster', 'my-rg') - self.assertIn('connectedClusters/my-cluster', result) - - -class TestTargetPrepareCertManager(unittest.TestCase): - """Test cert-manager + trust-manager AIO extension install.""" - - def test_aio_extension_function_exists(self): - """Verify _ensure_cert_trust_manager_via_aio_extension is importable.""" - from azext_workload_orchestration.onboarding.target_prepare import ( - _ensure_cert_trust_manager_via_aio_extension, - ) - self.assertTrue(callable(_ensure_cert_trust_manager_via_aio_extension)) - - -class TestTargetPrepareExtension(unittest.TestCase): - """Test WO extension detection and install.""" - - def _get_mock_cmd(self): - cmd = MagicMock() - cmd.cli_ctx = MagicMock() - return cmd - - @patch('azext_workload_orchestration.onboarding.target_prepare.invoke_cli_command') - @patch('azext_workload_orchestration.onboarding.target_prepare._detect_storage_class', - return_value='default') - def test_extension_already_installed_succeeds_skips(self, _, mock_invoke): - from azext_workload_orchestration.onboarding.target_prepare import _ensure_wo_extension - cmd = self._get_mock_cmd() - - mock_invoke.return_value = [ - { - "extensionType": "microsoft.workloadorchestration", - "id": "/sub/rg/ext/wo-ext", - "version": "2.1.11", - "provisioningState": "Succeeded", - } - ] - - result = _ensure_wo_extension( - cmd, 'cluster1', 'rg1', 'wo-ext', None, 'preview', False - ) - - self.assertEqual(result, '/sub/rg/ext/wo-ext') - # Only list was called, not create - mock_invoke.assert_called_once() - - @patch('azext_workload_orchestration.onboarding.target_prepare.invoke_cli_command') - @patch('azext_workload_orchestration.onboarding.target_prepare._detect_storage_class', - return_value='default') - def test_failed_extension_gets_deleted_and_reinstalled(self, _, mock_invoke): - from azext_workload_orchestration.onboarding.target_prepare import _ensure_wo_extension - cmd = self._get_mock_cmd() - - call_count = [0] - def side_effect(*args, **kwargs): - call_count[0] += 1 - if call_count[0] == 1: - # First call: list returns failed extension - return [{ - "extensionType": "microsoft.workloadorchestration", - "id": "/sub/rg/ext/wo-ext", - "name": "wo-ext", - "version": "2.1.11", - "provisioningState": "Failed", - }] - elif call_count[0] == 2: - # Second call: delete - return None - else: - # Third call: create - return {"id": "/sub/rg/ext/wo-ext-new"} - - mock_invoke.side_effect = side_effect - - result = _ensure_wo_extension( - cmd, 'cluster1', 'rg1', 'wo-ext', None, 'preview', False - ) - - # Should have called: list, delete, create - self.assertEqual(mock_invoke.call_count, 3) - - -class TestTargetPrepareStorageClass(unittest.TestCase): - """Test storage class auto-detection.""" - - def test_detect_returns_none_without_cluster(self): - """Without a real cluster, should return None gracefully.""" - from azext_workload_orchestration.onboarding.target_prepare import _detect_storage_class - result = _detect_storage_class("/nonexistent/kubeconfig", "bad-context") - self.assertIsNone(result) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/workload-orchestration/test-e2e-full.ps1 b/src/workload-orchestration/test-e2e-full.ps1 deleted file mode 100644 index 04a339402fc..00000000000 --- a/src/workload-orchestration/test-e2e-full.ps1 +++ /dev/null @@ -1,212 +0,0 @@ -# Full end-to-end regression for shorthand-syntax refactor + children-array rule. -# Drives real az CLI calls. Creates + destroys two fresh RGs for real hierarchy tests. - -$ErrorActionPreference = 'Continue' -$env:PATH = "C:\Users\audapure\Projects\ConfigManager\CLI\.venv\Scripts;$env:PATH" - -$script:pass = 0 -$script:fail = 0 -$script:results = @() - -function Test-Az { - param( - [string]$Name, - [string[]]$AzArgs, - [bool]$ShouldFail, - [string]$ExpectInOutput = $null, - [string]$RejectInOutput = $null - ) - Write-Host "" - Write-Host ">> $Name" -ForegroundColor Cyan - Write-Host " az $($AzArgs -join ' ')" -ForegroundColor DarkGray - $out = & az @AzArgs 2>&1 | Out-String - $exit = $LASTEXITCODE - - $failed = $false - $reason = '' - if ($ShouldFail) { if ($exit -eq 0) { $failed = $true; $reason = 'expected failure; exit=0' } } - else { if ($exit -ne 0) { $failed = $true; $reason = "expected success; exit=$exit" } } - - if (-not $failed -and $ExpectInOutput -and ($out -notmatch [regex]::Escape($ExpectInOutput))) { - $failed = $true; $reason = "missing expected: $ExpectInOutput" - } - if (-not $failed -and $RejectInOutput -and ($out -match [regex]::Escape($RejectInOutput))) { - $failed = $true; $reason = "should not contain: $RejectInOutput" - } - - if ($failed) { - Write-Host " FAIL: $reason" -ForegroundColor Red - ($out -split "`n" | Select-Object -Last 6) | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } - $script:fail++ - $script:results += [PSCustomObject]@{Name=$Name; Status='FAIL'; Reason=$reason} - } else { - Write-Host " PASS" -ForegroundColor Green - $script:pass++ - $script:results += [PSCustomObject]@{Name=$Name; Status='PASS'; Reason=''} - } -} - -$NX = 'zzz-nonexistent-cluster-999' -$NXRG = 'zzz-nonexistent-rg-999' - -Write-Host "" -Write-Host "===== Shorthand Refactor + Children-Array -- Full E2E =====" -ForegroundColor Magenta - -# ================================================================= -# SECTION 1: Help output -# ================================================================= -Write-Host "" -Write-Host "--- Help output ---" -ForegroundColor Yellow - -Test-Az 'help: cluster init shows --extension-dependency-version' @('workload-orchestration','cluster','init','--help') $false 'extension-dependency-version' -Test-Az 'help: cluster init shows shorthand example' @('workload-orchestration','cluster','init','--help') $false 'iotplatform:0.7.6' -Test-Az 'help: cluster init no old cert-manager-version' @('workload-orchestration','cluster','init','--help') $false $null 'cert-manager-version' -Test-Az 'help: hierarchy create shows inline shorthand' @('workload-orchestration','hierarchy','create','--help') $false 'name:Mehoopany' -Test-Az 'help: hierarchy create shows file example' @('workload-orchestration','hierarchy','create','--help') $false 'hierarchy.yaml' - -# ================================================================= -# SECTION 2: Flag removal -# ================================================================= -Write-Host "" -Write-Host "--- Old flag removed ---" -ForegroundColor Yellow -Test-Az 'CLI: old --cert-manager-version rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--cert-manager-version','1.0') $true 'unrecognized arguments' - -# ================================================================= -# SECTION 3: extension-dependency-version validation -# ================================================================= -Write-Host "" -Write-Host "--- extension-dependency-version validation ---" -ForegroundColor Yellow - -Test-Az 'ext-dep: unknown key (partial) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','bogus=1.0') $true -Test-Az 'ext-dep: unknown key (full) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','{bogus:1.0}') $true -Test-Az 'ext-dep: empty value rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','iotplatform=') $true -Test-Az 'ext-dep: duplicate (case-insensitive) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','{iotplatform:1.0,IOTPlatform:2.0}') $true -Test-Az 'ext-dep: array rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','[iotplatform,1.0]') $true -Test-Az 'ext-dep: bare token (no =) rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','iotplatform') $true - -# File input -$df = Join-Path $env:TEMP 'deps-valid.json' -'{"iotplatform":"1.6.1"}' | Set-Content -Path $df -Encoding utf8 -Test-Az 'ext-dep: file.json parses (past parser)' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',$df) $true $null 'Unknown dependency key' -$dfBad = Join-Path $env:TEMP 'deps-bad.json' -'{"notarealkey":"1.6.1"}' | Set-Content -Path $dfBad -Encoding utf8 -Test-Az 'ext-dep: file.json unknown key rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version',$dfBad) $true -Test-Az 'ext-dep: missing file rejected' @('workload-orchestration','cluster','init','-c',$NX,'-g',$NXRG,'-l','eastus2euap','--extension-dependency-version','C:\no-such-deps-xyz.json') $true -Remove-Item $df, $dfBad -ErrorAction SilentlyContinue - -# ================================================================= -# SECTION 4: hierarchy-spec shorthand validation -# ================================================================= -Write-Host "" -Write-Host "--- hierarchy-spec validation ---" -ForegroundColor Yellow - -Test-Az 'hierarchy: inline missing name rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{level:factory}') $true "must include 'name'" -Test-Az 'hierarchy: inline missing level rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{name:FactoryZ}') $true "must include 'level'" -Test-Az 'hierarchy: invalid name rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','{name:-bad-,level:factory}') $true -Test-Az 'hierarchy: scalar rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','justastring') $true -Test-Az 'hierarchy: missing file rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec','no-such-file.yaml') $true - -# ================================================================= -# SECTION 5: children-must-be-list enforcement -# ================================================================= -Write-Host "" -Write-Host "--- children-must-be-list ---" -ForegroundColor Yellow - -# children as a DICT must be rejected -$ydict = Join-Path $env:TEMP 'hier-children-dict.yaml' -@" -type: ServiceGroup -name: CountryX -level: country -children: - name: RegionY - level: region -"@ | Set-Content -Path $ydict -Encoding utf8 -Test-Az 'hierarchy: children as dict rejected' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',$ydict) $true -Remove-Item $ydict -ErrorAction SilentlyContinue - -# children as a LIST parses fine (Azure call fails on nonexistent RG — but parser passed) -$ylist = Join-Path $env:TEMP 'hier-children-list.yaml' -@" -type: ServiceGroup -name: CountryL -level: country -children: - - name: RegionL - level: region -"@ | Set-Content -Path $ylist -Encoding utf8 -Test-Az 'hierarchy: children as list parses (Azure fail expected)' @('workload-orchestration','hierarchy','create','-g',$NXRG,'--configuration-location','eastus2euap','--hierarchy-spec',$ylist) $true $null "must be a list" -Remove-Item $ylist -ErrorAction SilentlyContinue - -# ================================================================= -# SECTION 6: REAL end-to-end on fresh RGs -# ================================================================= -Write-Host "" -Write-Host "--- REAL end-to-end on fresh RGs ---" -ForegroundColor Yellow - -$rgRG = "audapure-e2e-rg-$(Get-Random -Max 9999)" -$rgSG = "audapure-e2e-sg-$(Get-Random -Max 9999)" -$suffix = Get-Random -Max 9999 - -try { - # --- RG hierarchy via inline shorthand --- - Write-Host "" - Write-Host "Creating fresh RG for RG-hierarchy test: $rgRG" -ForegroundColor DarkGray - az group create -n $rgRG -l eastus2euap -o none 2>&1 | Out-Null - - Test-Az 'REAL: hierarchy create (RG, inline shorthand)' ` - @('workload-orchestration','hierarchy','create','-g',$rgRG,'--configuration-location','eastus2euap','--hierarchy-spec',"{type:ResourceGroup,name:Factory$suffix,level:factory}") ` - $false 'Hierarchy created' - - # --- SG hierarchy with TWO children via YAML file --- - Write-Host "" - Write-Host "Creating fresh RG for SG-hierarchy test: $rgSG" -ForegroundColor DarkGray - az group create -n $rgSG -l eastus2euap -o none 2>&1 | Out-Null - - $sgYaml = Join-Path $env:TEMP "hier-sg-real-$suffix.yaml" - @" -type: ServiceGroup -name: Country$suffix -level: country -children: - - name: RegionA$suffix - level: region - - name: RegionB$suffix - level: region -"@ | Set-Content -Path $sgYaml -Encoding utf8 - - Test-Az 'REAL: hierarchy create (SG, 2 sibling children via YAML file)' ` - @('workload-orchestration','hierarchy','create','-g',$rgSG,'--configuration-location','eastus2euap','--hierarchy-spec',$sgYaml) ` - $false 'Hierarchy created' - - # Verify TWO regions appeared under the country in output (name substrings unique to this run) - Test-Az 'REAL: both SG siblings exist (list RegionA)' ` - @('resource','list','-g',$rgSG,'--query',"[?contains(name, 'RegionA$suffix')].{n:name}",'-o','tsv') ` - $false "RegionA$suffix" - Test-Az 'REAL: both SG siblings exist (list RegionB)' ` - @('resource','list','-g',$rgSG,'--query',"[?contains(name, 'RegionB$suffix')].{n:name}",'-o','tsv') ` - $false "RegionB$suffix" - - Remove-Item $sgYaml -ErrorAction SilentlyContinue -} -finally { - Write-Host "" - Write-Host "Cleanup: deleting RGs in background..." -ForegroundColor DarkGray - az group delete -n $rgRG --yes --no-wait 2>&1 | Out-Null - az group delete -n $rgSG --yes --no-wait 2>&1 | Out-Null - # Best-effort cleanup of created ServiceGroups (at tenant scope, survive RG deletion) - foreach ($sgName in @("Country$suffix","RegionA$suffix","RegionB$suffix")) { - $sgId = "/providers/Microsoft.Management/serviceGroups/$sgName" - az rest --method delete --url "https://management.azure.com$($sgId)?api-version=2024-02-01-preview" 2>&1 | Out-Null - } -} - -# ================================================================= -# Summary -# ================================================================= -Write-Host "" -Write-Host "================================================================" -Write-Host "Total: $($pass + $fail) Passed: $pass Failed: $fail" -ForegroundColor $(if ($fail -eq 0) {'Green'} else {'Red'}) -Write-Host "================================================================" -$results | Format-Table -AutoSize -Wrap -exit $fail From 1dbb4df503d3c544eeb897b946b2bc3461340a1b Mon Sep 17 00:00:00 2001 From: Atharva Date: Sun, 26 Apr 2026 12:02:00 +0530 Subject: [PATCH 64/91] workload-orchestration: drop --solution-template-version-id from target install Use friendly-name args (--solution-template-name + --solution-template-version) exclusively. Cross-RG support is provided via --solution-template-rg, which defaults to the target's resource group. Simplifies the deploy chain and removes redundant ARM-ID flag. - _install.py: remove arg, rebind -v short flag to --solution-template-version - target_deploy.py: simplify _resolve_template_version_id to friendly-name only - onboarding/__init__.py: update wrapper signature - HISTORY.rst: changelog Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/workload-orchestration/HISTORY.rst | 2 +- .../workload_orchestration/target/_install.py | 29 +++----- .../onboarding/__init__.py | 4 +- .../onboarding/target_deploy.py | 71 ++++++------------- 4 files changed, 35 insertions(+), 71 deletions(-) diff --git a/src/workload-orchestration/HISTORY.rst b/src/workload-orchestration/HISTORY.rst index 10227419f6d..c28b835c019 100644 --- a/src/workload-orchestration/HISTORY.rst +++ b/src/workload-orchestration/HISTORY.rst @@ -18,7 +18,7 @@ Release History * Enhanced ``az workload-orchestration target create``: * Added ``--service-group`` argument to auto-link target to a Service Group after creation * Enhanced ``az workload-orchestration target install``: - * Added ``--solution-template-version-id``, ``--solution-template-name``, ``--solution-template-version`` for full deploy chain (review → publish → install) + * Added ``--solution-template-name``, ``--solution-template-version`` (alias ``-v``), and ``--solution-template-rg`` for full deploy chain (review → publish → install) * Added ``--configuration`` to set config values before review (auto-derives config template args) * Existing ``--solution-version-id`` direct install flow unchanged diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 436a43b734c..0ee69bee006 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -18,15 +18,17 @@ class Install(AAZCommand): """Install a solution on a target. - When invoked with --solution-template-version-id (or --solution-template-name + --solution-template-version), + When invoked with --solution-template-name + --solution-template-version, runs the full deployment chain: config-set (optional) → review → publish → install. When invoked with --solution-version-id only (old flow), runs direct install. :example: Full deploy (friendly name) az workload-orchestration target install -g rg1 -n target1 --solution-template-name tmpl --solution-template-version 1.0.0 + :example: Full deploy from a different RG + az workload-orchestration target install -g rg1 -n target1 --solution-template-name tmpl --solution-template-version 1.0.0 --solution-template-rg shared-rg :example: Full deploy with config - az workload-orchestration target install -g rg1 -n target1 --solution-template-name tmpl --stv 1.0.0 --config values.yaml --config-template-rg rg1 --config-template-name tmpl --ct-version 1.0.0 + az workload-orchestration target install -g rg1 -n target1 --solution-template-name tmpl --solution-template-version 1.0.0 --config values.yaml :example: Direct install (old flow) az workload-orchestration target install -g rg1 -n target1 --solution-version-id /subscriptions/.../solutionVersions/sv1 """ @@ -76,18 +78,13 @@ def _build_arguments_schema(cls, *args, **kwargs): ) # New flow: full deploy chain - _args_schema.solution_template_version_id = AAZStrArg( - options=["--solution-template-version-id", "-v"], - arg_group="Deploy", - help="Full ARM ID of the solution template version. Triggers full deploy chain.", - ) _args_schema.solution_template_name = AAZStrArg( options=["--solution-template-name"], arg_group="Deploy", help="Name of the solution template. Use with --solution-template-version.", ) _args_schema.solution_template_version = AAZStrArg( - options=["--solution-template-version", "--version"], + options=["--solution-template-version", "--version", "-v"], arg_group="Deploy", help="Version of the solution template (e.g., 1.0.0).", ) @@ -115,17 +112,14 @@ def _execute_operations(self): def pre_operations(self): """If template args provided, run config-set → review → publish before install.""" args = self.ctx.args - has_template = ( - args.solution_template_version_id - or args.solution_template_name - ) + has_template = bool(args.solution_template_name) has_direct = args.solution_version_id # Validate: need either template args OR solution-version-id if not has_template and not has_direct: raise ValidationError( - "Provide either --solution-template-version-id (or --solution-template-name + " - "--solution-template-version) for full deploy, or --solution-version-id for direct install." + "Provide either --solution-template-name + --solution-template-version " + "for full deploy, or --solution-version-id for direct install." ) if has_template and has_direct: @@ -151,7 +145,6 @@ def _run_deploy_chain(self): cmd=cmd_proxy, resource_group=str(args.resource_group), target_name=str(args.target_name), - solution_template_version_id=str(args.solution_template_version_id) if args.solution_template_version_id else None, solution_template_name=str(args.solution_template_name) if args.solution_template_name else None, solution_template_version=str(args.solution_template_version) if args.solution_template_version else None, solution_template_rg=str(args.solution_template_rg) if args.solution_template_rg else None, @@ -165,11 +158,7 @@ def _run_deploy_chain(self): def post_operations(self): # Print Install ✓ after AAZ LRO completes (only when deploy chain was used) args = self.ctx.args - has_template = ( - args.solution_template_version_id - or args.solution_template_name - ) - if has_template: + if args.solution_template_name: import sys print("└── Install ✓\n", file=sys.stderr) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py index 0f139afe7b4..b1b39cd5b99 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py @@ -88,9 +88,9 @@ def target_deploy( cmd, resource_group, target_name, - solution_template_version_id=None, solution_template_name=None, solution_template_version=None, + solution_template_rg=None, config=None, config_hierarchy_id=None, config_template_rg=None, @@ -102,9 +102,9 @@ def target_deploy( cmd=cmd, resource_group=resource_group, target_name=target_name, - solution_template_version_id=solution_template_version_id, solution_template_name=solution_template_name, solution_template_version=solution_template_version, + solution_template_rg=solution_template_rg, config=config, config_hierarchy_id=config_hierarchy_id, config_template_rg=config_template_rg, diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 2e785bf671f..fabd2d3170e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -13,20 +13,21 @@ Optionally prepends config-set (step 0) when --config is provided. Usage: - # Friendly name + # Friendly name (template lives in target's RG) az workload-orchestration target deploy \\ -g my-rg -n my-target \\ --solution-template-name tmpl --solution-template-version 1.0.0 - # ARM ID + # Friendly name with explicit template RG az workload-orchestration target deploy \\ -g my-rg -n my-target \\ - --solution-template-version-id + --solution-template-name tmpl --solution-template-version 1.0.0 \\ + --solution-template-rg shared-rg # With config az workload-orchestration target deploy \\ -g my-rg -n my-target \\ - --solution-template-version-id \\ + --solution-template-name tmpl --solution-template-version 1.0.0 \\ --config values.yaml \\ --config-template-rg rg --config-template-name tmpl --config-template-version 1.0.0 """ @@ -54,9 +55,9 @@ def target_deploy( cmd, resource_group, target_name, - solution_template_version_id=None, solution_template_name=None, solution_template_version=None, + solution_template_rg=None, config=None, config_hierarchy_id=None, config_template_rg=None, @@ -71,9 +72,8 @@ def target_deploy( # --- Resolve solution-template-version-id --- solution_template_version_id = _resolve_template_version_id( - solution_template_version_id, solution_template_name, - solution_template_version, None, - resource_group, sub_id, + solution_template_name, solution_template_version, + solution_template_rg, resource_group, sub_id, ) base_url = ( @@ -144,7 +144,6 @@ def target_deploy_pre_install( cmd, resource_group, target_name, - solution_template_version_id=None, solution_template_name=None, solution_template_version=None, solution_template_rg=None, @@ -161,9 +160,8 @@ def target_deploy_pre_install( sub_id = _get_subscription_id(cmd) solution_template_version_id = _resolve_template_version_id( - solution_template_version_id, solution_template_name, - solution_template_version, solution_template_rg, - resource_group, sub_id, + solution_template_name, solution_template_version, + solution_template_rg, resource_group, sub_id, ) base_url = ( @@ -193,18 +191,6 @@ def _log(step_name, status=""): ct_name = solution_template_name ct_version = solution_template_version - # If using ARM ID, extract name/version/rg from it - if not ct_name and solution_template_version_id: - parts = solution_template_version_id.strip("/").split("/") - # .../resourceGroups/{rg}/providers/Microsoft.Edge/solutionTemplates/{name}/versions/{ver} - for i, part in enumerate(parts): - if part.lower() == "resourcegroups" and i + 1 < len(parts): - ct_rg = parts[i + 1] - elif part.lower() == "solutiontemplates" and i + 1 < len(parts): - ct_name = parts[i + 1] - elif part.lower() == "versions" and i + 1 < len(parts): - ct_version = parts[i + 1] - _handle_config_set( cmd, config, None, ct_rg, ct_name, ct_version, @@ -242,37 +228,26 @@ def _get_subscription_id(cmd): def _resolve_template_version_id( - arm_id, template_name, template_version, template_rg, + template_name, template_version, template_rg, default_rg, sub_id, ): - """Resolve solution-template-version-id from friendly name or ARM ID. + """Resolve solution-template-version-id from the friendly-name args. - When using friendly name, template_rg defaults to default_rg (target's RG). + When template_rg is not provided, defaults to default_rg (target's RG). """ - if arm_id and template_name: + if not template_name: raise ValidationError( - "Provide either --solution-template-version-id OR " - "(--solution-template-name + --solution-template-version), not both." + "--solution-template-name is required for full deploy." ) - - if arm_id: - return arm_id - - if template_name: - if not template_version: - raise ValidationError( - "--solution-template-version is required when using --solution-template-name." - ) - rg = template_rg or default_rg - return ( - f"/subscriptions/{sub_id}/resourceGroups/{rg}" - f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" - f"/versions/{template_version}" + if not template_version: + raise ValidationError( + "--solution-template-version is required when using --solution-template-name." ) - - raise ValidationError( - "Provide either --solution-template-version-id or " - "(--solution-template-name + --solution-template-version)." + rg = template_rg or default_rg + return ( + f"/subscriptions/{sub_id}/resourceGroups/{rg}" + f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" + f"/versions/{template_version}" ) From 1d2f33349633a2ad88093fe60d3aece5eeb7aab6 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 11:39:46 +0530 Subject: [PATCH 65/91] feat(context): add-capability, remove-capability, list-capability, show-capability Implements flat subcommands under 'az workload-orchestration context' for managing capabilities without the painful 7-line PowerShell pattern. Commands: - add-capability: Idempotent add with dedup, single PATCH call - remove-capability: Idempotent remove with confirmation, preserves min 1 - list-capability: Lists all capabilities on a context - show-capability: Case-insensitive lookup of a single capability Key behaviors: - Pure Python orchestration (no PowerShell/bash dependency) - At most ONE ARM PATCH per invocation - Skips ARM call entirely if no delta (zero-cost idempotency) - Human-friendly stderr messages, clean JSON stdout - All returned data sanitized to plain dicts (fixes --query) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../context/__init__.py | 1 + .../context/capability/__init__.py | 6 + .../context/capability/_add.py | 110 +++++ .../context/capability/_list.py | 67 +++ .../context/capability/_remove.py | 93 +++++ .../context/capability/_show.py | 69 ++++ .../onboarding/context_capability.py | 380 ++++++++++++++++++ 7 files changed, 726 insertions(+) create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_add.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_list.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_remove.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_show.py create mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/__init__.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/__init__.py index 0e2e5182419..2de6f7f2caa 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/__init__.py @@ -9,6 +9,7 @@ # flake8: noqa from .__cmd_group import * +from . import capability # NOTE: code organization only - commands are flat (context add-capability, etc.) from ._create import * from ._delete import * # from ._execute import * diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py new file mode 100644 index 00000000000..2578e6c5e50 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py @@ -0,0 +1,6 @@ +# pylint: skip-file +# flake8: noqa +from ._add import * +from ._remove import * +from ._list import * +from ._show import * diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_add.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_add.py new file mode 100644 index 00000000000..509355add67 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_add.py @@ -0,0 +1,110 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +"""AAZ command for `workload-orchestration context add-capability`. + +Idempotent — at most ONE ARM PATCH call. Skips the call entirely if all +requested capabilities already exist. +""" + +from azure.cli.core.aaz import * + + +@register_command( + "workload-orchestration context add-capability", +) +class AddCapability(AAZCommand): + """Add capabilities to a context (idempotent). + + Replaces the painful 7-line PowerShell pattern (show → append → dedup → + write file → context create) with a single command. Hierarchies and other + properties are preserved. + + :example: Add a single capability + az workload-orchestration context add-capability -g Mehoopany --context-name Mehoopany-Context --name soap --description "Soap line" + + :example: Add multiple capabilities (shorthand) + az workload-orchestration context add-capability -g Mehoopany --context-name Mehoopany-Context --capabilities "[{name:soap,description:Soap},{name:shampoo,description:Shampoo}]" + + :example: Add capabilities from a JSON file + az workload-orchestration context add-capability -g Mehoopany --context-name Mehoopany-Context --capabilities @new-caps.json + """ + + _aaz_info = { + "version": "1.0.0", + "resources": [], + } + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + _args_schema = cls._args_schema + + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + help="Resource group of the context.", + ) + _args_schema.context_name = AAZStrArg( + options=["-n", "--name", "--context-name"], + required=True, + help="Name of the context.", + ) + _args_schema.cap_name = AAZStrArg( + options=["--cap-name", "--capability-name"], + help="Name of a single capability to add (use with --description).", + ) + _args_schema.description = AAZStrArg( + options=["--description", "-d"], + help="Description for the single capability (defaults to name if omitted).", + ) + _args_schema.capabilities = AAZListArg( + options=["--capabilities"], + help=( + "Capabilities to add. Accepts JSON array, shorthand " + "(e.g. '[{name:soap,description:Soap}]'), or @file.json. " + "Each item: {name, description?}." + ), + ) + cap_elem = _args_schema.capabilities.Element = AAZObjectArg() + cap_elem.name = AAZStrArg(help="Capability name.") + cap_elem.description = AAZStrArg(help="Capability description.") + + return cls._args_schema + + def _handler(self, command_args): + super()._handler(command_args) + + args = self.ctx.args + + cap_name = args.cap_name.to_serialized_data() if args.cap_name._data is not None else None + if cap_name == "": + cap_name = None + description = args.description.to_serialized_data() if args.description._data is not None else None + capabilities_raw = args.capabilities.to_serialized_data() if args.capabilities._data is not None else None + # Treat empty list as not-provided (AAZ may default to []) + capabilities = capabilities_raw if capabilities_raw else None + + from azext_workload_orchestration.onboarding.context_capability import ( + capability_add as _capability_add, + ) + return _capability_add( + cli_ctx=self.cli_ctx, + resource_group=args.resource_group.to_serialized_data(), + context_name=args.context_name.to_serialized_data(), + name=cap_name, + description=description, + capabilities=capabilities, + state=None, + ) + + +__all__ = ["AddCapability"] diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_list.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_list.py new file mode 100644 index 00000000000..b4cad7a5b8d --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_list.py @@ -0,0 +1,67 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +"""AAZ command for `workload-orchestration context list-capability`.""" + +from azure.cli.core.aaz import * + + +@register_command( + "workload-orchestration context list-capability", +) +class ListCapability(AAZCommand): + """List capabilities on a context. + + Returns just the capabilities array (not the full context payload), so + `-o table` is readable even for contexts with hundreds of capabilities. + + :example: List all capabilities + az workload-orchestration context list-capability -g Mehoopany --context-name Mehoopany-Context -o table + """ + + _aaz_info = { + "version": "1.0.0", + "resources": [], + } + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + _args_schema = cls._args_schema + + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + help="Resource group of the context.", + ) + _args_schema.context_name = AAZStrArg( + options=["-n", "--name", "--context-name"], + required=True, + help="Name of the context.", + ) + return cls._args_schema + + def _handler(self, command_args): + super()._handler(command_args) + args = self.ctx.args + + from azext_workload_orchestration.onboarding.context_capability import ( + capability_list as _capability_list, + ) + return _capability_list( + cli_ctx=self.cli_ctx, + resource_group=args.resource_group.to_serialized_data(), + context_name=args.context_name.to_serialized_data(), + filter_pattern=None, + ) + + +__all__ = ["ListCapability"] diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_remove.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_remove.py new file mode 100644 index 00000000000..4dbff52d818 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_remove.py @@ -0,0 +1,93 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +"""AAZ command for `workload-orchestration context remove-capability`.""" + +from azure.cli.core.aaz import * + + +@register_command( + "workload-orchestration context remove-capability", + confirmation="Are you sure you want to remove the specified capability(ies) from the context?", +) +class RemoveCapability(AAZCommand): + """Remove one or more capabilities from a context (idempotent). + + Removing a capability that doesn't exist is a no-op (zero ARM calls). + The context must retain at least one capability after removal. + + :example: Remove a single capability + az workload-orchestration context remove-capability -g Mehoopany --context-name Mehoopany-Context --cap-name soap --yes + + :example: Remove multiple capabilities + az workload-orchestration context remove-capability -g Mehoopany --context-name Mehoopany-Context --names "soap,shampoo,detergent" --yes + """ + + _aaz_info = { + "version": "1.0.0", + "resources": [], + } + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + _args_schema = cls._args_schema + + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + help="Resource group of the context.", + ) + _args_schema.context_name = AAZStrArg( + options=["-n", "--name", "--context-name"], + required=True, + help="Name of the context.", + ) + _args_schema.cap_name = AAZStrArg( + options=["--cap-name", "--capability-name"], + help="Name of a single capability to remove.", + ) + _args_schema.names = AAZStrArg( + options=["--names"], + help="Comma-separated list of capability names to remove.", + ) + _args_schema.force = AAZBoolArg( + options=["--force"], + help="Skip in-use validation (placeholder for cross-resource checks).", + default=False, + ) + + return cls._args_schema + + def _handler(self, command_args): + super()._handler(command_args) + + args = self.ctx.args + cap_name = args.cap_name.to_serialized_data() if args.cap_name._data is not None else None + names = args.names.to_serialized_data() if args.names._data is not None else None + force = args.force.to_serialized_data() if args.force._data is not None else False + + # AAZ confirmation= already prompted via --yes; we treat that as confirmed. + from azext_workload_orchestration.onboarding.context_capability import ( + capability_remove as _capability_remove, + ) + return _capability_remove( + cli_ctx=self.cli_ctx, + resource_group=args.resource_group.to_serialized_data(), + context_name=args.context_name.to_serialized_data(), + name=cap_name, + names=names, + force=force, + yes=True, # AAZ confirmation= flow already enforced --yes + ) + + +__all__ = ["RemoveCapability"] diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_show.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_show.py new file mode 100644 index 00000000000..16b1191e68d --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_show.py @@ -0,0 +1,69 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa + +"""AAZ command for `workload-orchestration context show-capability`.""" + +from azure.cli.core.aaz import * + + +@register_command( + "workload-orchestration context show-capability", +) +class ShowCapability(AAZCommand): + """Show a single capability on a context (case-insensitive name match). + + :example: Show a capability + az workload-orchestration context show-capability -g Mehoopany --context-name Mehoopany-Context --cap-name soap + """ + + _aaz_info = { + "version": "1.0.0", + "resources": [], + } + + _args_schema = None + + @classmethod + def _build_arguments_schema(cls, *args, **kwargs): + if cls._args_schema is not None: + return cls._args_schema + cls._args_schema = super()._build_arguments_schema(*args, **kwargs) + _args_schema = cls._args_schema + + _args_schema.resource_group = AAZResourceGroupNameArg( + required=True, + help="Resource group of the context.", + ) + _args_schema.context_name = AAZStrArg( + options=["-n", "--name", "--context-name"], + required=True, + help="Name of the context.", + ) + _args_schema.cap_name = AAZStrArg( + options=["--cap-name", "--capability-name"], + required=True, + help="Capability name to look up.", + ) + return cls._args_schema + + def _handler(self, command_args): + super()._handler(command_args) + args = self.ctx.args + + from azext_workload_orchestration.onboarding.context_capability import ( + capability_show as _capability_show, + ) + return _capability_show( + cli_ctx=self.cli_ctx, + resource_group=args.resource_group.to_serialized_data(), + context_name=args.context_name.to_serialized_data(), + name=args.cap_name.to_serialized_data(), + ) + + +__all__ = ["ShowCapability"] diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py new file mode 100644 index 00000000000..b9390dd8676 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py @@ -0,0 +1,380 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Context capability add/remove/list/show orchestration. + +Pure-Python helpers that: + 1. GET the current context state. + 2. Normalize/dedup user input case-insensitively. + 3. Compute the delta vs existing capabilities. + 4. Skip the ARM call entirely if there is no change (idempotent). + 5. Otherwise issue ONE PATCH with only the capabilities array + (server preserves hierarchies, tags, etc.). + +Exports: + capability_add(cli_ctx, ...) + capability_remove(cli_ctx, ...) + capability_list(cli_ctx, ...) + capability_show(cli_ctx, ...) +""" + +import json +import logging +import re +import sys + +from azure.cli.core.azclierror import ( + CLIInternalError, + InvalidArgumentValueError, + ResourceNotFoundError, +) +from azure.cli.core.util import send_raw_request + +from azext_workload_orchestration.onboarding.consts import ( + ARM_ENDPOINT, + CONTEXT_API_VERSION, +) +from azext_workload_orchestration.onboarding.utils import ( + CmdProxy, + invoke_cli_command, +) + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_CAP_NAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$") +_MAX_CAP_NAME_LEN = 61 + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def _validate_cap_name(name): + """Validate and return a trimmed capability name.""" + if not name or not isinstance(name, str): + raise InvalidArgumentValueError( + "Capability name is required and must be a non-empty string." + ) + name = name.strip() + if not name: + raise InvalidArgumentValueError("Capability name cannot be whitespace.") + if len(name) > _MAX_CAP_NAME_LEN: + raise InvalidArgumentValueError( + f"Capability name '{name[:20]}...' exceeds {_MAX_CAP_NAME_LEN} characters." + ) + if not _CAP_NAME_RE.match(name): + raise InvalidArgumentValueError( + f"Capability name '{name}' has invalid characters. " + f"Allowed: alphanumerics, hyphens, underscores, and dots." + ) + return name + + +# --------------------------------------------------------------------------- +# Input normalization +# --------------------------------------------------------------------------- + + +def _normalize_input(name, description, capabilities): + """Normalize user input into a deduped list of {name, description} dicts. + + Accepts: + - name + description (single capability shorthand) + - capabilities (list of dicts or strings) + + Dedups case-insensitively on name. First occurrence wins. + """ + items = [] + if name: + if capabilities: + raise InvalidArgumentValueError( + "Specify either --cap-name (with --description) or --capabilities, not both." + ) + nm = _validate_cap_name(name) + desc = description if description else nm + items.append({"name": nm, "description": desc}) + elif capabilities: + if isinstance(capabilities, str): + try: + capabilities = json.loads(capabilities) + except (ValueError, TypeError) as exc: + raise InvalidArgumentValueError( + "--capabilities must be a JSON array, shorthand list, or @file." + ) from exc + if not isinstance(capabilities, list): + raise InvalidArgumentValueError( + f"--capabilities must be a list (got {type(capabilities).__name__})." + ) + for entry in capabilities: + if isinstance(entry, str): + nm = _validate_cap_name(entry) + items.append({"name": nm, "description": nm}) + elif isinstance(entry, dict): + nm = _validate_cap_name(entry.get("name")) + desc = entry.get("description") or nm + items.append({"name": nm, "description": desc}) + else: + raise InvalidArgumentValueError( + "Each capability must be a string or object with 'name'." + ) + else: + raise InvalidArgumentValueError( + "Provide either --cap-name + --description (single) or --capabilities (bulk)." + ) + + seen = set() + deduped = [] + for item in items: + key = item["name"].lower() + if key in seen: + logger.debug("Skipping duplicate input capability: %s", item["name"]) + continue + seen.add(key) + deduped.append(item) + return deduped + + +def _normalize_names_input(name, names): + """Normalize name/names into a deduped list of validated names.""" + raw = [] + if name and names: + raise InvalidArgumentValueError("Specify either --cap-name or --names, not both.") + if name: + raw.append(name) + elif names: + if isinstance(names, str): + raw.extend([n.strip() for n in names.split(",") if n.strip()]) + elif isinstance(names, list): + raw.extend(names) + else: + raise InvalidArgumentValueError("--names must be a string or list.") + else: + raise InvalidArgumentValueError("Provide --cap-name or --names.") + + seen = set() + deduped = [] + for n in raw: + nm = _validate_cap_name(n) + if nm.lower() in seen: + continue + seen.add(nm.lower()) + deduped.append(nm) + return deduped + + +# --------------------------------------------------------------------------- +# Context fetch and PATCH +# --------------------------------------------------------------------------- + + +def _fetch_context(cli_ctx, resource_group, context_name, subscription=None): + """GET the context resource. Returns (context_dict, subscription_id).""" + cmd = CmdProxy(cli_ctx) + args = ["workload-orchestration", "context", "show", + "-g", resource_group, "--name", context_name] + if subscription: + args.extend(["--subscription", subscription]) + try: + ctx = invoke_cli_command(cmd, args) + except Exception as exc: + raise ResourceNotFoundError( + f"Context '{context_name}' not found in resource group '{resource_group}'." + ) from exc + if not ctx or not isinstance(ctx, dict): + raise ResourceNotFoundError( + f"Context '{context_name}' returned empty or invalid data." + ) + sub_id = subscription or cli_ctx.data.get("subscription_id", "") + arm_id = ctx.get("id", "") + if arm_id and "/subscriptions/" in arm_id: + try: + sub_id = arm_id.split("/subscriptions/")[1].split("/")[0] + except IndexError: + pass + return ctx, sub_id + + +def _sanitize_caps(caps): + """Ensure capabilities list contains only plain {name, description} dicts.""" + sanitized = [] + for c in caps: + entry = { + "name": str(c.get("name", "")), + "description": str(c.get("description", c.get("name", ""))), + } + sanitized.append(entry) + return sanitized + + +def _patch_context_capabilities(cli_ctx, sub_id, resource_group, + context_name, capabilities_list): + """PATCH the context with the given capabilities list.""" + body = { + "properties": { + "capabilities": [ + {"name": c["name"], "description": c.get("description", c["name"])} + for c in capabilities_list + ] + } + } + url = ( + f"{ARM_ENDPOINT}/subscriptions/{sub_id}" + f"/resourceGroups/{resource_group}/providers/Microsoft.Edge" + f"/contexts/{context_name}?api-version={CONTEXT_API_VERSION}" + ) + resp = send_raw_request( + cli_ctx, + method="PATCH", + url=url, + body=json.dumps(body), + resource=ARM_ENDPOINT, + ) + if resp.status_code not in (200, 201, 202): + raise CLIInternalError( + f"Context PATCH failed: {resp.status_code} {resp.text}" + ) + try: + return resp.json() + except (ValueError, AttributeError): + return {"status_code": resp.status_code} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _existing_caps(ctx): + """Extract capabilities list from context dict.""" + return list((ctx.get("properties") or {}).get("capabilities") or []) + + +def _log(msg): + """Print status message to stderr (visible to user but not in JSON output).""" + print(msg, file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def capability_add(cli_ctx, resource_group, context_name, name=None, + description=None, capabilities=None, subscription=None, + **kwargs): # noqa: E501 + """Add capabilities to a context. Idempotent - skips if already present.""" + requested = _normalize_input(name, description, capabilities) + + ctx, sub_id = _fetch_context(cli_ctx, resource_group, context_name, subscription) + existing = _sanitize_caps(_existing_caps(ctx)) + existing_lower = {c["name"].lower() for c in existing} + + added = [e for e in requested if e["name"].lower() not in existing_lower] + skipped = [e for e in requested if e["name"].lower() in existing_lower] + + if not added: + count = len(existing) + _log(f"No changes needed \u2014 all {len(skipped)} capability(ies) already exist. " + f"({count} total)") + return ctx + + merged = existing + added + names_str = ", ".join(c["name"] for c in added) + _log(f"Adding {len(added)}: {names_str}") + + updated = _patch_context_capabilities( + cli_ctx, sub_id, resource_group, context_name, merged + ) + _log(f"\u2713 Done ({len(merged)} total capabilities)") + return updated + + +def capability_remove(cli_ctx, resource_group, context_name, name=None, + names=None, force=False, yes=False, subscription=None): # noqa: E501 + """Remove capabilities from a context. Idempotent - skips if not present.""" + if force: + logger.debug("Force mode enabled — skipping in-use checks.") + target_names = _normalize_names_input(name, names) + + ctx, sub_id = _fetch_context(cli_ctx, resource_group, context_name, subscription) + existing = _sanitize_caps(_existing_caps(ctx)) + + target_lower = {n.lower() for n in target_names} + to_remove = [c for c in existing if c["name"].lower() in target_lower] + not_found = [n for n in target_names + if n.lower() not in {c["name"].lower() for c in existing}] + + if not_found: + logger.debug("Capabilities not found on context: %s", not_found) + + if not to_remove: + _log(f"No changes needed \u2014 none of the {len(target_names)} " + f"capability(ies) exist on context. ({len(existing)} total)") + return ctx + + remaining = [c for c in existing if c["name"].lower() not in target_lower] + + if not remaining: + raise InvalidArgumentValueError( + "Cannot remove the last capability \u2014 a context must have at least one. " + "Add a replacement first or delete the context." + ) + + if not yes: + names_str = ", ".join(c["name"] for c in to_remove) + try: + from knack.prompting import prompt_y_n + if not prompt_y_n( + f"Remove {len(to_remove)} capability(ies) [{names_str}] " + f"from '{context_name}'?" + ): + _log("Cancelled.") + return ctx + except Exception as exc: + raise InvalidArgumentValueError( + "Use --yes to confirm removal in non-interactive sessions." + ) from exc + + names_str = ", ".join(c["name"] for c in to_remove) + _log(f"Removing {len(to_remove)}: {names_str}") + + updated = _patch_context_capabilities( + cli_ctx, sub_id, resource_group, context_name, remaining + ) + _log(f"\u2713 Done ({len(remaining)} total capabilities)") + return updated + + +def capability_list(cli_ctx, resource_group, context_name, filter_pattern=None, + subscription=None): + """List capabilities on a context.""" + ctx, _ = _fetch_context(cli_ctx, resource_group, context_name, subscription) + caps = _sanitize_caps(_existing_caps(ctx)) + if filter_pattern: + regex = re.compile( + "^" + re.escape(filter_pattern).replace(r"\*", ".*") + "$", + re.IGNORECASE, + ) + caps = [c for c in caps if regex.match(c.get("name", ""))] + return caps + + +def capability_show(cli_ctx, resource_group, context_name, name, + subscription=None): + """Show a single capability by name (case-insensitive).""" + nm = _validate_cap_name(name) + ctx, _ = _fetch_context(cli_ctx, resource_group, context_name, subscription) + caps = _sanitize_caps(_existing_caps(ctx)) + for c in caps: + if c["name"].lower() == nm.lower(): + return c + raise ResourceNotFoundError( + f"Capability '{name}' not found on context '{context_name}'." + ) From 37e036b2b6bfef1eb08dc37ed3ee834743448b22 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 11:46:18 +0530 Subject: [PATCH 66/91] fix: resolve pylint W0613 unused-argument for state param Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/context_capability.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py index b9390dd8676..b112212007a 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py @@ -268,7 +268,7 @@ def _log(msg): def capability_add(cli_ctx, resource_group, context_name, name=None, description=None, capabilities=None, subscription=None, - **kwargs): # noqa: E501 + state=None): # pylint: disable=unused-argument """Add capabilities to a context. Idempotent - skips if already present.""" requested = _normalize_input(name, description, capabilities) @@ -378,3 +378,4 @@ def capability_show(cli_ctx, resource_group, context_name, name, raise ResourceNotFoundError( f"Capability '{name}' not found on context '{context_name}'." ) + From 966c83b3c9802a1a08ae7e80eaf403bc79af0e54 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 11:51:34 +0530 Subject: [PATCH 67/91] fix: remove trailing newline (C0305/W391) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/context_capability.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py index b112212007a..35106409f40 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py @@ -378,4 +378,3 @@ def capability_show(cli_ctx, resource_group, context_name, name, raise ResourceNotFoundError( f"Capability '{name}' not found on context '{context_name}'." ) - From 849e3e465d7b165e3009b9d08ed6c2aac639dd79 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:00:47 +0530 Subject: [PATCH 68/91] =?UTF-8?q?fix(wo):=20logging=20cleanup=20=E2=80=94?= =?UTF-8?q?=20single-line=20site-ref=20log,=20hierarchy=20text,=20cluster?= =?UTF-8?q?=20init=20style?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - context create --site-id: replace tree-style site-reference connector with single stderr info line (the connector had no children to anchor it). - hierarchy create: 'Creating hierarchy in RG' -> 'Creating Hierarchy in Resource Group'. - hierarchy create (SG): show resource names (Site '', Configuration '') for symmetry with the RG path. - cluster init: drop the '=====' Diagnostic Summary banner; use a single concise '✗ failed: ...' line + re-run hint, matching hierarchy create's style. - All log output continues to go to stderr; stdout remains clean JSON for parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/context/_create.py | 21 +- .../onboarding/hierarchy_create.py | 180 ++++++++++++++---- .../onboarding/hierarchy_init.py | 15 +- .../onboarding/target_deploy.py | 131 +++++++++++-- .../onboarding/target_prepare.py | 54 +++--- 5 files changed, 316 insertions(+), 85 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py index d1f6dec2517..e5cd7ef61d7 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py @@ -154,7 +154,14 @@ def post_operations(self): self._create_site_reference() def _create_site_reference(self): - """Auto-create a site reference linking the site to this context.""" + """Auto-create a site reference linking the site to this context. + + Reference name format: -<7-char sha256 of lowercased site ARM ID>. + 7-char hash matches the BVT/Git convention (BVT: ContextExtension.cs + GenerateTestSuffix → SHA256[..7]). Hash suffix guarantees uniqueness + even when sites share simple names across different scopes (RG / SG). + """ + import hashlib import logging import re import sys @@ -166,11 +173,11 @@ def _create_site_reference(self): # Extract site name from ARM ID for the reference name site_name = site_id.rstrip("/").split("/")[-1] - ref_name = f"{site_name}-ref" - # Sanitize: only alphanumeric and hyphens, 3-61 chars - ref_name = re.sub(r'[^a-zA-Z0-9-]', '-', ref_name)[:61] - - print(f"├── site-reference Creating '{ref_name}'...", file=sys.stderr) + # 7-char hex of sha256(lower(site_arm_id)) — matches BVT (Git-style short hash) + hash_suffix = hashlib.sha256(site_id.lower().encode("utf-8")).hexdigest()[:7] + # Sanitize site name; reserve 8 chars for "-<7-char hash>" within 61-char limit + sanitized_site = re.sub(r'[^a-zA-Z0-9-]', '-', site_name)[:53] + ref_name = f"{sanitized_site}-{hash_suffix}" try: from azext_workload_orchestration.onboarding.utils import invoke_cli_command, CmdProxy @@ -182,7 +189,7 @@ def _create_site_reference(self): "--site-reference-name", ref_name, "--site-id", site_id, ]) - print(f"└── site-reference Created ✓", file=sys.stderr) + print(f"Site reference '{ref_name}' linked to context '{context_name}'.", file=sys.stderr) except Exception as exc: logger.warning("Site reference creation failed: %s", exc) raise CLIError( diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index d15a0f93a1a..57cc25136db 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -136,32 +136,50 @@ def _validate_hierarchy_names(node): # --------------------------------------------------------------------------- def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): - """Create Site + Configuration + ConfigurationReference in a resource group.""" + """Create Site + Configuration + ConfigurationReference in a resource group. + + A ResourceGroup hierarchy supports exactly ONE site per RG. If a site + already exists (any name), reuse it and create/refresh Config + ConfigRef + on top. Otherwise create a new site with the requested name. + """ sub_id = _get_sub_id(cmd) - site_id = ( - f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" - f"/providers/{EDGE_RP_NAMESPACE}/sites/{name}" - ) - config_name = f"{name}Config" + _eprint(f"\nCreating Hierarchy in Resource Group '{resource_group}'...\n") + + # Find-or-create site at RG scope (1 site per RG max) + existing = _find_existing_site_in_rg(cmd, sub_id, resource_group) + if existing: + site_name, site_id = existing + if site_name != name: + _eprint( + f"[i] Reusing existing site '{site_name}' in Resource Group '{resource_group}' " + f"(requested name '{name}' ignored — RG allows only one site)." + ) + else: + _eprint(f"[i] Reusing existing site '{site_name}'.") + effective_name = site_name + else: + effective_name = name + site_id = ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/{EDGE_RP_NAMESPACE}/sites/{effective_name}" + ) + _eprint(f"{effective_name} ({level})") + _arm_put(cmd, f"{ARM_ENDPOINT}{site_id}", { + "properties": { + "displayName": effective_name, + "description": effective_name, + "labels": {"level": level}, + } + }, SITE_API_VERSION) + _eprint(f"├── Site '{effective_name}' ✓") + + config_name = f"{effective_name}Config" config_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) - _eprint(f"\nCreating hierarchy in RG '{resource_group}'...\n") - - # Step 1: Create Site - _eprint(f"{name} ({level})") - _arm_put(cmd, f"{ARM_ENDPOINT}{site_id}", { - "properties": { - "displayName": name, - "description": name, - "labels": {"level": level}, - } - }, SITE_API_VERSION) - _eprint(f"├── Site '{name}' ✓") - # Step 2: Create Configuration _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { "location": config_location, @@ -184,7 +202,7 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): return { "type": "ResourceGroup", - "name": name, + "name": effective_name, "level": level, "resourceGroup": resource_group, "siteId": site_id, @@ -262,19 +280,30 @@ def _create_sg_level( # pylint: disable=too-many-arguments # Wait for RBAC propagation silently _wait_for_sg_rbac(cmd, config_location, sg_id, name) - # 2. Create Site - site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" - _arm_put_regional(cmd, config_location, site_id, { - "properties": { - "displayName": name, - "description": name, - "labels": {"level": level}, - } - }, SITE_API_VERSION) - results.append({"type": "Site", "name": name, "level": level, "id": site_id}) + # 2. Find-or-create Site under this SG (1 site per SG max) + existing_sg_site = _find_existing_site_in_sg(cmd, config_location, sg_id) + if existing_sg_site: + site_name, site_id = existing_sg_site + if site_name != name: + _eprint( + f"{child_prefix}[i] Reusing existing site '{site_name}' under SG '{name}' " + f"(requested name '{name}' ignored — SG allows only one site)." + ) + effective_site_name = site_name + else: + effective_site_name = name + site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{effective_site_name}" + _arm_put_regional(cmd, config_location, site_id, { + "properties": { + "displayName": effective_site_name, + "description": effective_site_name, + "labels": {"level": level}, + } + }, SITE_API_VERSION) + results.append({"type": "Site", "name": effective_site_name, "level": level, "id": site_id}) # 3. Create Configuration - config_name = f"{name}Config" + config_name = f"{effective_site_name}Config" config_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" @@ -296,8 +325,8 @@ def _create_sg_level( # pylint: disable=too-many-arguments # Show resources created under this node children = node.get("children") has_children = children is not None - _eprint(f"{child_prefix}├── Site ✓") - _eprint(f"{child_prefix}├── Configuration ✓") + _eprint(f"{child_prefix}├── Site '{effective_site_name}' ✓") + _eprint(f"{child_prefix}├── Configuration '{config_name}' ✓") if has_children: _eprint(f"{child_prefix}├── ConfigurationReference ✓") else: @@ -345,6 +374,91 @@ def _arm_put(cmd, url, body, api_version): ) +def _arm_get(cmd, url, api_version): + """GET from (global) ARM endpoint and return parsed JSON, or None on 404.""" + full_url = f"{url}?api-version={api_version}" + try: + resp = send_raw_request( + cmd.cli_ctx, "GET", full_url, + resource=ARM_ENDPOINT, + ) + except Exception as exc: # pylint: disable=broad-except + if "ResourceNotFound" in str(exc) or "404" in str(exc): + return None + logger.debug("GET %s failed: %s", full_url, exc) + return None + try: + return resp.json() + except Exception as exc: # pylint: disable=broad-except + logger.debug("GET %s json parse failed: %s", full_url, exc) + try: + import json as _json + return _json.loads(resp.content) + except Exception: # pylint: disable=broad-except + return None + + +def _check_rg_has_no_other_site(cmd, sub_id, resource_group, intended_name): # legacy, kept for back-compat callers + site = _find_existing_site_in_rg(cmd, sub_id, resource_group) + if site and site[0] != intended_name: + raise ValidationError( + f"Resource group '{resource_group}' already contains site '{site[0]}'. " + f"A ResourceGroup hierarchy supports only one site per RG." + ) + + +def _find_existing_site_in_rg(cmd, sub_id, resource_group): + """Return (name, site_id) of the first site found in the RG, else None.""" + list_url = ( + f"{ARM_ENDPOINT}/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/{EDGE_RP_NAMESPACE}/sites" + ) + payload = _arm_get(cmd, list_url, SITE_API_VERSION) + if not payload: + return None + items = payload.get("value", []) if isinstance(payload, dict) else [] + if not items: + return None + first = items[0] + name = first.get("name") + site_id = first.get("id") or ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + ) + return (name, site_id) if name else None + + +def _find_existing_site_in_sg(cmd, location, sg_id): + """Return (name, site_id) of the first site found under the SG, else None. + + Uses the regional management endpoint because Sites under a ServiceGroup + are tenant-scoped resources accessed via the regional plane. + """ + list_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites" + full_url = f"https://{location}.management.azure.com{list_id}?api-version={SITE_API_VERSION}" + token_type, token = _get_token(cmd) + try: + resp = send_raw_request( + cmd.cli_ctx, "GET", full_url, + headers=[f"Authorization={token_type} {token}"], + skip_authorization_header=True, + ) + payload = resp.json() + except Exception as exc: # pylint: disable=broad-except + if "ResourceNotFound" in str(exc) or "404" in str(exc): + return None + # On any other transient error, fall through to create-path + logger.debug("SG site list failed (%s); proceeding to create.", exc) + return None + items = payload.get("value", []) if isinstance(payload, dict) else [] + if not items: + return None + first = items[0] + name = first.get("name") + site_id = first.get("id") or f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{name}" + return (name, site_id) if name else None + + def _arm_put_regional(cmd, location, resource_id, body, api_version): """PUT to regional ARM endpoint (for SG-scoped resources).""" full_url = f"https://{location}.management.azure.com{resource_id}?api-version={api_version}" diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py index 4248e6141a0..7c98f2c571b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py @@ -141,7 +141,14 @@ def _put_resource(cli_ctx, url, body, label): def _create_site_reference(context_id, site_name, site_id): - """Create a site-reference linking the site to the context.""" + """Create a site-reference linking the site to the context. + + Name format: -<7-char sha256(lower(site_arm_id))>. + 7-char hash matches the BVT/Git convention (see ContextExtension.cs). + """ + import hashlib + import re + parts = parse_arm_id(context_id) ctx_rg = parts.get("resourcegroups", "") ctx_name = parts.get("contexts", "default") @@ -149,11 +156,15 @@ def _create_site_reference(context_id, site_name, site_id): if not ctx_rg: return + hash_suffix = hashlib.sha256(site_id.lower().encode("utf-8")).hexdigest()[:7] + sanitized = re.sub(r'[^a-zA-Z0-9-]', '-', site_name)[:53] + ref_name = f"{sanitized}-{hash_suffix}" + try: invoke_silent([ "workload-orchestration", "context", "site-reference", "create", "-g", ctx_rg, "--context-name", ctx_name, - "--name", f"{site_name}-ref", + "--name", ref_name, "--site-id", site_id, "-o", "none", ]) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index fabd2d3170e..47afa26bd9e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -307,7 +307,8 @@ def _handle_config_set( ): """Set configuration values from file before review. - Delegates to: az workload-orchestration configuration set + Calls the configuration-set REST APIs directly (no subprocess). + Flow: resolve config ID → resolve template unique ID → GET/PUT dynamic config version. """ if not hierarchy_id: hierarchy_id = ( @@ -321,16 +322,124 @@ def _handle_config_set( "--config-template-rg, --config-template-name, and --config-template-version." ) - from azext_workload_orchestration.onboarding.utils import invoke_cli_command - invoke_cli_command(cmd, [ - "workload-orchestration", "configuration", "set", - "--hierarchy-id", hierarchy_id, - "--template-rg", template_rg, - "--template-name", template_name, - "--version", template_version, - "--file", config_file, - "--solution", - ], expect_json=False) + # Read config file content + config_content = _read_config_file(config_file) + + # Step 1: Resolve configuration ID from hierarchy's config reference + config_ref_url = ( + f"{ARM_RESOURCE}{hierarchy_id}" + f"/providers/Microsoft.Edge/configurationreferences/default" + f"?api-version={API_VERSION}" + ) + ref_resp = send_raw_request( + cmd.cli_ctx, "GET", config_ref_url, + headers=["Accept=application/json"], + resource=ARM_RESOURCE, + ) + if ref_resp.status_code != 200: + raise CLIInternalError( + f"Failed to get configuration reference for {hierarchy_id} " + f"(HTTP {ref_resp.status_code}). Ensure hierarchy has a configuration reference." + ) + configuration_id = ref_resp.json().get("properties", {}).get("configurationResourceId") + if not configuration_id: + raise CLIInternalError( + f"Configuration reference for {hierarchy_id} has no configurationResourceId." + ) + + # Step 2: Resolve solution template unique identifier (used as dynamic config name) + st_url = ( + f"{ARM_RESOURCE}/subscriptions/{sub_id}" + f"/resourceGroups/{template_rg}" + f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" + f"?api-version={API_VERSION}" + ) + st_resp = send_raw_request( + cmd.cli_ctx, "GET", st_url, + headers=["Accept=application/json"], + resource=ARM_RESOURCE, + ) + if st_resp.status_code != 200: + raise CLIInternalError( + f"Solution template '{template_name}' not found in RG '{template_rg}' " + f"(HTTP {st_resp.status_code})." + ) + st_body = st_resp.json() + dynamic_config_name = ( + st_body.get("properties", {}).get("uniqueIdentifier") + or template_name + ) + + # Step 3: GET dynamic config version (check if it exists) + version_url = ( + f"{ARM_RESOURCE}{configuration_id}" + f"/dynamicConfigurations/{dynamic_config_name}" + f"/versions/{template_version}" + f"?api-version={API_VERSION}" + ) + version_resp = send_raw_request( + cmd.cli_ctx, "GET", version_url, + headers=["Accept=application/json"], + resource=ARM_RESOURCE, + ) + + if version_resp.status_code == 200: + # Update existing dynamic config version + existing = version_resp.json() + existing["properties"]["values"] = config_content + send_raw_request( + cmd.cli_ctx, "PUT", version_url, + body=json.dumps(existing), + headers=["Content-Type=application/json", "Accept=application/json"], + resource=ARM_RESOURCE, + ) + elif version_resp.status_code == 404: + # Create new: first ensure parent dynamic config exists + dc_url = ( + f"{ARM_RESOURCE}{configuration_id}" + f"/dynamicConfigurations/{dynamic_config_name}" + f"?api-version={API_VERSION}" + ) + dc_body = {"properties": {"currentVersion": template_version}} + dc_resp = send_raw_request( + cmd.cli_ctx, "PUT", dc_url, + body=json.dumps(dc_body), + headers=["Content-Type=application/json", "Accept=application/json"], + resource=ARM_RESOURCE, + ) + if dc_resp.status_code not in (200, 201): + raise CLIInternalError( + f"Failed to create dynamic configuration (HTTP {dc_resp.status_code}): " + f"{dc_resp.text}" + ) + + # Then create the version with config values + ver_body = {"properties": {"values": config_content}} + ver_resp = send_raw_request( + cmd.cli_ctx, "PUT", version_url, + body=json.dumps(ver_body), + headers=["Content-Type=application/json", "Accept=application/json"], + resource=ARM_RESOURCE, + ) + if ver_resp.status_code not in (200, 201): + raise CLIInternalError( + f"Failed to create dynamic configuration version (HTTP {ver_resp.status_code}): " + f"{ver_resp.text}" + ) + else: + raise CLIInternalError( + f"Failed to check dynamic configuration version (HTTP {version_resp.status_code}): " + f"{version_resp.text}" + ) + + +def _read_config_file(file_path): + """Read and return contents of a YAML/JSON config file.""" + import os + if not os.path.isfile(file_path): + raise ValidationError(f"Config file not found: {file_path}") + with open(file_path, "r", encoding="utf-8") as f: + return f.read() # --------------------------------------------------------------------------- diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 67d48b45379..174c582408a 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -90,6 +90,7 @@ def target_prepare( cert_manager_version = cert_manager_version or DEFAULT_CERT_MANAGER_VERSION _eprint(f"\nPreparing cluster '{cluster_name}' for Workload Orchestration...\n") + _eprint(f"{cluster_name}") # Track step results for diagnostic summary step_results = {} @@ -100,7 +101,7 @@ def target_prepare( step_results["preflight"] = "Passed" except Exception as exc: step_results["preflight"] = f"FAILED: {exc}" - _print_diagnostic_summary(step_results, cluster_name, resource_group) + _print_failure_hint(step_results) raise # Step 1+2: cert-manager + trust-manager (single AIO Arc extension) @@ -120,7 +121,7 @@ def target_prepare( logger.error( "Steps 1-2/4 failed (AIO cert/trust-manager): %s", exc ) - _print_diagnostic_summary(step_results, cluster_name, resource_group) + _print_failure_hint(step_results) raise CLIInternalError( f"cert-manager/trust-manager installation failed: {exc}" ) @@ -136,7 +137,7 @@ def target_prepare( except Exception as exc: step_results["wo-extension"] = f"FAILED: {exc}" logger.error("Step 3/4 failed (WO extension): %s", exc) - _print_diagnostic_summary(step_results, cluster_name, resource_group) + _print_failure_hint(step_results) raise CLIInternalError( f"WO extension installation failed: {exc}" ) @@ -151,7 +152,7 @@ def target_prepare( except Exception as exc: step_results["custom-location"] = f"FAILED: {exc}" logger.error("Step 4/4 failed (Custom location): %s", exc) - _print_diagnostic_summary(step_results, cluster_name, resource_group) + _print_failure_hint(step_results) raise CLIInternalError( f"Custom location creation failed: {exc}" ) @@ -161,7 +162,7 @@ def target_prepare( _write_extended_location_file(extended_location) print_success(f"Cluster '{cluster_name}' is ready for Workload Orchestration") - print_detail("Custom Location ID", cl_id) + _eprint(f" Custom Location: {cl_id}") _eprint() return { @@ -484,35 +485,24 @@ def _detect_storage_class(kube_config=None, kube_context=None): return None -def _print_diagnostic_summary(step_results, cluster_name, resource_group): - """Print a diagnostic summary showing what succeeded/failed. +def _print_failure_hint(step_results): + """Print a concise one-line failure summary to stderr. - This gives the DRI/support engineer a quick picture of where things - went wrong when a customer reports an issue. + Matches the style of `hierarchy create` (no banner, no boxes). On failure + we surface the failed step + the canonical re-run hint. """ - from datetime import datetime, timezone - - _eprint("\n" + "=" * 60) - _eprint(" Diagnostic Summary") - _eprint(f" Cluster: {cluster_name}") - _eprint(f" Resource Group: {resource_group}") - _eprint(f" Timestamp: {datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}") - _eprint("=" * 60) - - for step_name, result in step_results.items(): - if "FAILED" in result: - icon = "✗" - elif result == "Skipped": - icon = "○" - else: - icon = "✓" - _eprint(f" {icon} {step_name}: {result}") - - has_failure = any("FAILED" in v for v in step_results.values()) - if has_failure: - _eprint("\n [WARN] One or more steps failed. See error details above.") - _eprint(" Re-run the command to retry - completed steps will be skipped.") - _eprint("=" * 60 + "\n") + failed = [(k, v) for k, v in step_results.items() if "FAILED" in v] + if not failed: + return + name, result = failed[-1] # latest failure carries the full message + msg = result.replace("FAILED: ", "") + _eprint(f"\n✗ {name} failed: {msg}") + _eprint(" Re-run the command to retry — completed steps will be skipped.\n") + + +# Kept for back-compat in case external callers reference it +def _print_diagnostic_summary(step_results, cluster_name, resource_group): # pragma: no cover + _print_failure_hint(step_results) def _write_extended_location_file(extended_location): From 3e6cc044c742cb4fedf069c2b018e3ffd22488c3 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:07:18 +0530 Subject: [PATCH 69/91] =?UTF-8?q?fix(wo):=20azdev-style=20=E2=80=94=20drop?= =?UTF-8?q?=20reimport,=20unused=20import,=20suppress=20unused-arg=20on=20?= =?UTF-8?q?shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/hierarchy_create.py | 3 +-- .../azext_workload_orchestration/onboarding/target_prepare.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 57cc25136db..c3342ece46b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -392,8 +392,7 @@ def _arm_get(cmd, url, api_version): except Exception as exc: # pylint: disable=broad-except logger.debug("GET %s json parse failed: %s", full_url, exc) try: - import json as _json - return _json.loads(resp.content) + return json.loads(resp.content) except Exception: # pylint: disable=broad-except return None diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 174c582408a..00d290999ba 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -47,7 +47,6 @@ invoke_cli_command, print_step, print_success, - print_detail, ) logger = logging.getLogger(__name__) @@ -501,7 +500,7 @@ def _print_failure_hint(step_results): # Kept for back-compat in case external callers reference it -def _print_diagnostic_summary(step_results, cluster_name, resource_group): # pragma: no cover +def _print_diagnostic_summary(step_results, cluster_name, resource_group): # pragma: no cover # pylint: disable=unused-argument _print_failure_hint(step_results) From aa1820fb2e9cd3f738ac9ef87623216269c1e6d2 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:32:46 +0530 Subject: [PATCH 70/91] =?UTF-8?q?fix(wo):=20cluster=20init=20=E2=80=94=20d?= =?UTF-8?q?rop=20redundant=20cluster=20name=20echo=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Preparing cluster ''...' header already names the cluster; the duplicate '' line below it added no information. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/onboarding/target_prepare.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index 00d290999ba..c584e0445c2 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -89,7 +89,6 @@ def target_prepare( cert_manager_version = cert_manager_version or DEFAULT_CERT_MANAGER_VERSION _eprint(f"\nPreparing cluster '{cluster_name}' for Workload Orchestration...\n") - _eprint(f"{cluster_name}") # Track step results for diagnostic summary step_results = {} From 0a67e62e09f6097e3b1fc0e5c57fedd19922968e Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:34:17 +0530 Subject: [PATCH 71/91] =?UTF-8?q?fix(wo):=20cluster=20init=20=E2=80=94=20s?= =?UTF-8?q?top=20printing=20the=20same=20error=203=20times?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw error from the underlying az subcommand already prints once via invoke_cli_command, and azcli prints the raised CLIInternalError on exit. We were also dumping the full exception in _print_failure_hint and again in the raised message, leading to a 3x repeat of a multi-line error. - _print_failure_hint: now prints only '✗ failed — see error above.' + retry hint (no full exc text). - CLIInternalError: short message 'cert-manager installation failed. See error above.' (no embedded exc). - logger.error -> logger.debug for step failures (the raw error is already visible to the user; debug avoids a duplicate INFO line). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../onboarding/target_prepare.py | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py index c584e0445c2..f5f2658e51b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py @@ -116,13 +116,11 @@ def target_prepare( ) except Exception as exc: step_results["cert-manager"] = f"FAILED: {exc}" - logger.error( + logger.debug( "Steps 1-2/4 failed (AIO cert/trust-manager): %s", exc ) _print_failure_hint(step_results) - raise CLIInternalError( - f"cert-manager/trust-manager installation failed: {exc}" - ) + raise CLIInternalError("cert-manager/trust-manager installation failed. See error above.") # Step 3: WO extension try: @@ -134,11 +132,9 @@ def target_prepare( step_results["wo-extension"] = "Succeeded" except Exception as exc: step_results["wo-extension"] = f"FAILED: {exc}" - logger.error("Step 3/4 failed (WO extension): %s", exc) + logger.debug("Step 3/4 failed (WO extension): %s", exc) _print_failure_hint(step_results) - raise CLIInternalError( - f"WO extension installation failed: {exc}" - ) + raise CLIInternalError("WO extension installation failed. See error above.") # Step 4: Custom location try: @@ -149,11 +145,9 @@ def target_prepare( step_results["custom-location"] = "Succeeded" except Exception as exc: step_results["custom-location"] = f"FAILED: {exc}" - logger.error("Step 4/4 failed (Custom location): %s", exc) + logger.debug("Step 4/4 failed (Custom location): %s", exc) _print_failure_hint(step_results) - raise CLIInternalError( - f"Custom location creation failed: {exc}" - ) + raise CLIInternalError("Custom location creation failed. See error above.") # Output extended-location.json extended_location = {"name": cl_id, "type": "CustomLocation"} @@ -486,16 +480,17 @@ def _detect_storage_class(kube_config=None, kube_context=None): def _print_failure_hint(step_results): """Print a concise one-line failure summary to stderr. - Matches the style of `hierarchy create` (no banner, no boxes). On failure - we surface the failed step + the canonical re-run hint. + The raw error from the underlying az subcommand has already been + printed (it goes to stderr from `invoke_cli_command`), and azcli + will print our raised CLIInternalError on exit. This hint just + points to the failed step + tells the user retry is safe. """ - failed = [(k, v) for k, v in step_results.items() if "FAILED" in v] + failed = [k for k, v in step_results.items() if "FAILED" in v] if not failed: return - name, result = failed[-1] # latest failure carries the full message - msg = result.replace("FAILED: ", "") - _eprint(f"\n✗ {name} failed: {msg}") - _eprint(" Re-run the command to retry — completed steps will be skipped.\n") + name = failed[-1] + _eprint(f"\n✗ {name} failed — see error above.") + _eprint(" Re-run the command to retry; completed steps will be skipped.\n") # Kept for back-compat in case external callers reference it From cde4f9759cf7fb6474cfd233fea1626d78d1831b Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:35:33 +0530 Subject: [PATCH 72/91] fix(wo): never leak raw az subcommand line in error output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invoke_cli_command was appending 'Command: az k8s-extension create ...' to every CLIInternalError it raised, which meant end users saw the raw internal command (with all our args) in their terminal on any failure. Move that to logger.debug — engineers can re-run with --debug for it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/onboarding/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py index f517ded8024..01b15521676 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py @@ -124,8 +124,11 @@ def invoke_cli_command(cmd, command_args, expect_json=True): # pylint: disable= if hasattr(cli, 'result') and hasattr(cli.result, 'error'): cli_error = str(cli.result.error) if cli.result.error else "" full_error = cli_error or err_text or f"exit code {exit_code}" - cmd_str = f"az {' '.join(command_args)}" - raise CLIInternalError(f"{full_error}\nCommand: {cmd_str}") + # Log the underlying az command at DEBUG only — surfacing it to + # end users adds noise and can leak resource args. The error text + # alone is enough for the user; engineers can re-run with --debug. + logger.debug("az command failed: az %s", " ".join(command_args)) + raise CLIInternalError(full_error) result = cli.result.result if expect_json and isinstance(result, str): From 1a05afb6000491574e8429a6369b41e24df80074 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:45:17 +0530 Subject: [PATCH 73/91] fix(wo): site-reference name must fit ^[a-zA-Z0-9-]{3,24}$ - cap site portion at 16 chars + rstrip dashes --- .../aaz/latest/workload_orchestration/context/_create.py | 6 ++++-- .../onboarding/hierarchy_init.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py index e5cd7ef61d7..8bfbdd23c76 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py @@ -175,8 +175,10 @@ def _create_site_reference(self): site_name = site_id.rstrip("/").split("/")[-1] # 7-char hex of sha256(lower(site_arm_id)) — matches BVT (Git-style short hash) hash_suffix = hashlib.sha256(site_id.lower().encode("utf-8")).hexdigest()[:7] - # Sanitize site name; reserve 8 chars for "-<7-char hash>" within 61-char limit - sanitized_site = re.sub(r'[^a-zA-Z0-9-]', '-', site_name)[:53] + # Site-reference resource name must satisfy ^[a-zA-Z0-9-]{3,24}$, so the + # site-name portion is capped at 16 chars (24 - 1 dash - 7 hash). Strip + # any trailing dashes that result from truncation to keep the join clean. + sanitized_site = re.sub(r'[^a-zA-Z0-9-]', '-', site_name)[:16].rstrip("-") ref_name = f"{sanitized_site}-{hash_suffix}" try: diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py index 7c98f2c571b..70237fc7726 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py @@ -156,8 +156,9 @@ def _create_site_reference(context_id, site_name, site_id): if not ctx_rg: return + # Site-reference name must satisfy ^[a-zA-Z0-9-]{3,24}$ — cap site portion at 16 chars hash_suffix = hashlib.sha256(site_id.lower().encode("utf-8")).hexdigest()[:7] - sanitized = re.sub(r'[^a-zA-Z0-9-]', '-', site_name)[:53] + sanitized = re.sub(r'[^a-zA-Z0-9-]', '-', site_name)[:16].rstrip("-") ref_name = f"{sanitized}-{hash_suffix}" try: From 945b5eb032111249251cff850edb897b5c688987 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:51:51 +0530 Subject: [PATCH 74/91] fix(wo): hierarchy create reuse log - capitalize Site, drop RG/SG one-site qualifier --- .../onboarding/hierarchy_create.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index c3342ece46b..abd8339656e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -152,11 +152,11 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): site_name, site_id = existing if site_name != name: _eprint( - f"[i] Reusing existing site '{site_name}' in Resource Group '{resource_group}' " - f"(requested name '{name}' ignored — RG allows only one site)." + f"[i] Reusing existing Site '{site_name}' in Resource Group '{resource_group}' " + f"(requested name '{name}' ignored)." ) else: - _eprint(f"[i] Reusing existing site '{site_name}'.") + _eprint(f"[i] Reusing existing Site '{site_name}'.") effective_name = site_name else: effective_name = name @@ -286,8 +286,8 @@ def _create_sg_level( # pylint: disable=too-many-arguments site_name, site_id = existing_sg_site if site_name != name: _eprint( - f"{child_prefix}[i] Reusing existing site '{site_name}' under SG '{name}' " - f"(requested name '{name}' ignored — SG allows only one site)." + f"{child_prefix}[i] Reusing existing Site '{site_name}' under ServiceGroup '{name}' " + f"(requested name '{name}' ignored)." ) effective_site_name = site_name else: From 63356c98ea726b7897678f793457abe97910dacc Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:54:33 +0530 Subject: [PATCH 75/91] feat(wo): hierarchy create - find-or-create Configuration and ConfigurationReference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an existing Site is reused (RG or SG path), the Configuration and ConfigurationReference that were created alongside it likely also exist. Previously we always issued PUTs for them — idempotent on ARM but misleading in the output (always printed a fresh '... ✓' even on no-ops), and silently overwrote a ConfigurationReference that pointed to a different Configuration. Now: - GET Configuration first; if present, mark '(reused) ✓' and skip PUT. - GET ConfigurationReference first; if present and points to the same Configuration -> '(reused) ✓'. If it points elsewhere, leave it untouched and surface a warning ('points to different config, leaving as-is') instead of silently repointing. - Only run PUT for resources that are actually missing. - Applies to both RG and SG paths. --- .../onboarding/hierarchy_create.py | 94 +++++++++++++------ 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index abd8339656e..70ce4add5f5 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -180,23 +180,40 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) - # Step 2: Create Configuration - _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { - "location": config_location, - }, CONFIGURATION_API_VERSION) - _eprint(f"├── Configuration '{config_name}' ✓") + # Step 2: Configuration — find-or-create + config_url = f"{ARM_ENDPOINT}{config_id}" + if existing and _arm_get(cmd, config_url, CONFIGURATION_API_VERSION): + _eprint(f"├── Configuration '{config_name}' (reused) ✓") + else: + _arm_put(cmd, config_url, { + "location": config_location, + }, CONFIGURATION_API_VERSION) + _eprint(f"├── Configuration '{config_name}' ✓") - # Step 3: Create ConfigurationReference (links site → config) + # Step 3: ConfigurationReference — find-or-create (warn on mismatch) config_ref_url = ( f"{ARM_ENDPOINT}{site_id}/providers/" f"{EDGE_RP_NAMESPACE}/configurationReferences/default" ) - _arm_put(cmd, config_ref_url, { - "properties": { - "configurationResourceId": config_id, - } - }, CONFIG_REF_API_VERSION) - _eprint("└── ConfigurationReference ✓") + existing_ref = _arm_get(cmd, config_ref_url, CONFIG_REF_API_VERSION) if existing else None + if existing_ref: + existing_target = ( + existing_ref.get("properties", {}).get("configurationResourceId", "") + ) + if existing_target and existing_target.lower() != config_id.lower(): + _eprint( + f"└── ConfigurationReference (reused — already points to " + f"'{existing_target.rsplit('/', 1)[-1]}', leaving as-is) ✓" + ) + else: + _eprint("└── ConfigurationReference (reused) ✓") + else: + _arm_put(cmd, config_ref_url, { + "properties": { + "configurationResourceId": config_id, + } + }, CONFIG_REF_API_VERSION) + _eprint("└── ConfigurationReference ✓") _eprint("\n✅ Hierarchy created (3 resources)\n") @@ -302,35 +319,58 @@ def _create_sg_level( # pylint: disable=too-many-arguments }, SITE_API_VERSION) results.append({"type": "Site", "name": effective_site_name, "level": level, "id": site_id}) - # 3. Create Configuration + # 3. Configuration — find-or-create config_name = f"{effective_site_name}Config" config_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) - _arm_put(cmd, f"{ARM_ENDPOINT}{config_id}", { - "location": config_location, - }, CONFIGURATION_API_VERSION) + config_url = f"{ARM_ENDPOINT}{config_id}" + config_reused = bool(existing_sg_site) and bool(_arm_get(cmd, config_url, CONFIGURATION_API_VERSION)) + if not config_reused: + _arm_put(cmd, config_url, { + "location": config_location, + }, CONFIGURATION_API_VERSION) results.append({"type": "Configuration", "name": config_name, "id": config_id}) - # 4. Create ConfigurationReference + # 4. ConfigurationReference — find-or-create config_ref_id = f"{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" - _arm_put_regional(cmd, config_location, config_ref_id, { - "properties": { - "configurationResourceId": config_id, - } - }, CONFIG_REF_API_VERSION) + existing_ref = ( + _arm_get_regional(cmd, config_location, config_ref_id, CONFIG_REF_API_VERSION) + if existing_sg_site else None + ) + ref_target_mismatch = False + if existing_ref: + existing_target = existing_ref.get("properties", {}).get("configurationResourceId", "") + ref_target_mismatch = bool( + existing_target and existing_target.lower() != config_id.lower() + ) + if not existing_ref: + _arm_put_regional(cmd, config_location, config_ref_id, { + "properties": { + "configurationResourceId": config_id, + } + }, CONFIG_REF_API_VERSION) results.append({"type": "ConfigurationReference", "siteId": site_id}) - # Show resources created under this node + # Show resources created/reused under this node children = node.get("children") has_children = children is not None - _eprint(f"{child_prefix}├── Site '{effective_site_name}' ✓") - _eprint(f"{child_prefix}├── Configuration '{config_name}' ✓") + site_label = "(reused) " if existing_sg_site else "" + config_label = "(reused) " if config_reused else "" + if existing_ref: + if ref_target_mismatch: + ref_label = "(reused — points to different config, leaving as-is) " + else: + ref_label = "(reused) " + else: + ref_label = "" + _eprint(f"{child_prefix}├── Site '{effective_site_name}' {site_label}✓") + _eprint(f"{child_prefix}├── Configuration '{config_name}' {config_label}✓") if has_children: - _eprint(f"{child_prefix}├── ConfigurationReference ✓") + _eprint(f"{child_prefix}├── ConfigurationReference {ref_label}✓") else: - _eprint(f"{child_prefix}└── ConfigurationReference ✓") + _eprint(f"{child_prefix}└── ConfigurationReference {ref_label}✓") # Recurse into children if children: From 3384abbc5805728dfe06cff29a5ef1bbd31747ec Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:55:37 +0530 Subject: [PATCH 76/91] fix(wo): drop resource count from 'Hierarchy created' success line --- .../onboarding/hierarchy_create.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 70ce4add5f5..a03098f340a 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -215,7 +215,7 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): }, CONFIG_REF_API_VERSION) _eprint("└── ConfigurationReference ✓") - _eprint("\n✅ Hierarchy created (3 resources)\n") + _eprint("\n✅ Hierarchy created\n") return { "type": "ResourceGroup", @@ -250,7 +250,7 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): resource_group, parent_sg=None, results=results, depth=0, is_last=True) - _eprint(f"\n✅ Hierarchy created ({nodes} levels, {len(results)} resources)\n") + _eprint(f"\n✅ Hierarchy created ({nodes} levels)\n") return { "type": "ServiceGroup", From eafcff6ecb0cdc550ffb271425588139b949c5e6 Mon Sep 17 00:00:00 2001 From: Atharva Date: Tue, 28 Apr 2026 22:58:43 +0530 Subject: [PATCH 77/91] feat(wo): hierarchy create - if existing Site has a ConfigRef, leave whole chain alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design discussion: when reusing an existing Site, the ConfigurationReference attached to it is what hierarchy resolution and config-set follow. If a ConfigRef already exists, treat the Configuration + ConfigRef chain as canonical and do nothing — even if its target Configuration name doesn't match what we'd construct. - Existing Site + existing ConfigRef -> single GET, zero PUTs. - Existing Site, no ConfigRef -> ensure Configuration (skip-if-exists), then create ConfigRef pointing to it. - Fresh Site -> create Site + Configuration (skip-if-exists) + ConfigRef. - Returned configurationId reflects the actually-wired Configuration (the ConfigRef target when reused, otherwise our constructed id). - Same flow applies to RG and SG paths. --- .../onboarding/hierarchy_create.py | 87 +++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index a03098f340a..551f77f153c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -179,35 +179,36 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) - - # Step 2: Configuration — find-or-create config_url = f"{ARM_ENDPOINT}{config_id}" - if existing and _arm_get(cmd, config_url, CONFIGURATION_API_VERSION): - _eprint(f"├── Configuration '{config_name}' (reused) ✓") - else: - _arm_put(cmd, config_url, { - "location": config_location, - }, CONFIGURATION_API_VERSION) - _eprint(f"├── Configuration '{config_name}' ✓") - - # Step 3: ConfigurationReference — find-or-create (warn on mismatch) config_ref_url = ( f"{ARM_ENDPOINT}{site_id}/providers/" f"{EDGE_RP_NAMESPACE}/configurationReferences/default" ) + + # If the Site was reused, the ConfigurationReference attached to it is + # what hierarchy resolution / config-set follows. If a ConfigRef already + # exists, leave the chain alone (whatever Configuration it points to is + # already canonical for this Site). + effective_config_id = config_id existing_ref = _arm_get(cmd, config_ref_url, CONFIG_REF_API_VERSION) if existing else None if existing_ref: - existing_target = ( - existing_ref.get("properties", {}).get("configurationResourceId", "") - ) - if existing_target and existing_target.lower() != config_id.lower(): - _eprint( - f"└── ConfigurationReference (reused — already points to " - f"'{existing_target.rsplit('/', 1)[-1]}', leaving as-is) ✓" - ) - else: - _eprint("└── ConfigurationReference (reused) ✓") + ref_target = existing_ref.get("properties", {}).get("configurationResourceId", "") + if ref_target: + effective_config_id = ref_target + _eprint("├── Configuration (reused) ✓") + _eprint("└── ConfigurationReference (reused) ✓") else: + # Either fresh Site, or existing Site with no ConfigRef yet. + # Ensure Configuration exists (skip PUT if already there) and then + # create the ConfigurationReference linking the Site to it. + if _arm_get(cmd, config_url, CONFIGURATION_API_VERSION): + _eprint(f"├── Configuration '{config_name}' (reused) ✓") + else: + _arm_put(cmd, config_url, { + "location": config_location, + }, CONFIGURATION_API_VERSION) + _eprint(f"├── Configuration '{config_name}' ✓") + _arm_put(cmd, config_ref_url, { "properties": { "configurationResourceId": config_id, @@ -223,7 +224,7 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): "level": level, "resourceGroup": resource_group, "siteId": site_id, - "configurationId": config_id, + "configurationId": effective_config_id, } @@ -319,38 +320,42 @@ def _create_sg_level( # pylint: disable=too-many-arguments }, SITE_API_VERSION) results.append({"type": "Site", "name": effective_site_name, "level": level, "id": site_id}) - # 3. Configuration — find-or-create + # 3 & 4. Configuration + ConfigurationReference — if Site was reused AND + # already has a ConfigRef, leave the chain alone (whatever Configuration + # the existing ConfigRef points to is canonical for this Site). config_name = f"{effective_site_name}Config" config_id = ( f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" f"/providers/{EDGE_RP_NAMESPACE}/configurations/{config_name}" ) config_url = f"{ARM_ENDPOINT}{config_id}" - config_reused = bool(existing_sg_site) and bool(_arm_get(cmd, config_url, CONFIGURATION_API_VERSION)) - if not config_reused: - _arm_put(cmd, config_url, { - "location": config_location, - }, CONFIGURATION_API_VERSION) - results.append({"type": "Configuration", "name": config_name, "id": config_id}) - - # 4. ConfigurationReference — find-or-create config_ref_id = f"{site_id}/providers/{EDGE_RP_NAMESPACE}/configurationReferences/default" + existing_ref = ( _arm_get_regional(cmd, config_location, config_ref_id, CONFIG_REF_API_VERSION) if existing_sg_site else None ) - ref_target_mismatch = False + if existing_ref: - existing_target = existing_ref.get("properties", {}).get("configurationResourceId", "") - ref_target_mismatch = bool( - existing_target and existing_target.lower() != config_id.lower() - ) - if not existing_ref: + # Whole chain reused: don't touch Config or ConfigRef. + ref_target = existing_ref.get("properties", {}).get("configurationResourceId", "") + effective_config_id = ref_target or config_id + config_reused = True + else: + # Fresh Site, or existing Site with no ConfigRef yet. + config_reused = bool(_arm_get(cmd, config_url, CONFIGURATION_API_VERSION)) + if not config_reused: + _arm_put(cmd, config_url, { + "location": config_location, + }, CONFIGURATION_API_VERSION) _arm_put_regional(cmd, config_location, config_ref_id, { "properties": { "configurationResourceId": config_id, } }, CONFIG_REF_API_VERSION) + effective_config_id = config_id + + results.append({"type": "Configuration", "name": config_name, "id": effective_config_id}) results.append({"type": "ConfigurationReference", "siteId": site_id}) # Show resources created/reused under this node @@ -358,13 +363,7 @@ def _create_sg_level( # pylint: disable=too-many-arguments has_children = children is not None site_label = "(reused) " if existing_sg_site else "" config_label = "(reused) " if config_reused else "" - if existing_ref: - if ref_target_mismatch: - ref_label = "(reused — points to different config, leaving as-is) " - else: - ref_label = "(reused) " - else: - ref_label = "" + ref_label = "(reused) " if existing_ref else "" _eprint(f"{child_prefix}├── Site '{effective_site_name}' {site_label}✓") _eprint(f"{child_prefix}├── Configuration '{config_name}' {config_label}✓") if has_children: From 8c54ed09cef5bf8707f7799851a19dee8bc6d554 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 29 Apr 2026 09:29:22 +0530 Subject: [PATCH 78/91] fix(wo): hierarchy create SG path - parse JSON from regional ARM GET Bug: '_arm_get_regional' was returning the raw 'Response' object instead of parsed JSON, so the existing-ConfigRef reuse check on the SG path crashed with: AttributeError: 'Response' object has no attribute 'get' Fix: mirror '_arm_get' behavior - call .json() (with json.loads fallback), return None on 404, log+None on other GET failures. The RBAC propagation waiter was the only other caller and it relied on send_raw_request raising for any non-2xx. Refactored it to call send_raw_request directly so it still distinguishes 'RBAC ready' (2xx) from 'not ready' (4xx -> raise -> retry). --- .../onboarding/hierarchy_create.py | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py index 551f77f153c..d20bcd10716 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py @@ -516,19 +516,32 @@ def _arm_put_regional(cmd, location, resource_id, body, api_version): def _arm_get_regional(cmd, location, resource_id, api_version): - """GET from regional ARM endpoint.""" + """GET from regional ARM endpoint and return parsed JSON, or None on 404.""" full_url = f"https://{location}.management.azure.com{resource_id}?api-version={api_version}" token_type, token = _get_token(cmd) - resp = send_raw_request( - cmd.cli_ctx, "GET", full_url, - headers=[ - f"Authorization={token_type} {token}", - ], - skip_authorization_header=True, - ) - return resp + try: + resp = send_raw_request( + cmd.cli_ctx, "GET", full_url, + headers=[ + f"Authorization={token_type} {token}", + ], + skip_authorization_header=True, + ) + except Exception as exc: # pylint: disable=broad-except + if "ResourceNotFound" in str(exc) or "404" in str(exc): + return None + logger.debug("GET %s failed: %s", full_url, exc) + return None + try: + return resp.json() + except Exception as exc: # pylint: disable=broad-except + logger.debug("GET %s json parse failed: %s", full_url, exc) + try: + return json.loads(resp.content) + except Exception: # pylint: disable=broad-except + return None def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=12, wait_sec=10): @@ -541,10 +554,16 @@ def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=12, wait_sec=10 import time site_list_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites" + full_url = f"https://{location}.management.azure.com{site_list_id}?api-version={SITE_API_VERSION}" for attempt in range(max_retries): try: - _arm_get_regional(cmd, location, site_list_id, SITE_API_VERSION) + token_type, token = _get_token(cmd) + send_raw_request( + cmd.cli_ctx, "GET", full_url, + headers=[f"Authorization={token_type} {token}"], + skip_authorization_header=True, + ) logger.info("RBAC propagated for SG '%s' after %ds", sg_name, attempt * wait_sec) return except Exception: From eeb45b223918ecb9dfe52c05c1a976e90ac27fab Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 29 Apr 2026 09:47:53 +0530 Subject: [PATCH 79/91] context create: drop manual run hint on site-ref failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/context/_create.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py index 8bfbdd23c76..39cc780739b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py @@ -195,11 +195,7 @@ def _create_site_reference(self): except Exception as exc: logger.warning("Site reference creation failed: %s", exc) raise CLIError( - f"Context created successfully, but site reference creation failed: {exc}\n" - f"Run manually:\n" - f" az workload-orchestration context site-reference create " - f"-g {rg} --context-name {context_name} " - f"--site-reference-name {ref_name} --site-id {site_id}" + f"Context created successfully, but site reference creation failed: {exc}" ) def _output(self, *args, **kwargs): From 4359ddf5831cb23d922b78435d320f206f881fce Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 29 Apr 2026 10:22:10 +0530 Subject: [PATCH 80/91] target create: simplify service-group link log messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop tree-style (└──) prefixes — just print plain status lines. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/target/_create.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index 85fe38e4dcf..8ef0bd67c4c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -227,14 +227,14 @@ def _handle_service_group_link(self): target_id = f"/subscriptions/{sub_id}/resourceGroups/{rg}/providers/Microsoft.Edge/targets/{name}" import sys - print(f"└── service-group Linking to '{sg_name}'...", file=sys.stderr) + print(f"Linking target to service-group '{sg_name}'...", file=sys.stderr) try: cmd_proxy = CmdProxy(self.ctx.cli_ctx) link_target_to_service_group(cmd_proxy, target_id, sg_name) - print(f"└── service-group Linked ✓", file=sys.stderr) + print("Service-group linked.", file=sys.stderr) except Exception as exc: logger.warning("Service group link failed (non-critical): %s", exc) - print(f"└── service-group Link failed (non-critical): {exc}", file=sys.stderr) + print(f"Service-group link failed (non-critical): {exc}", file=sys.stderr) def _output(self, *args, **kwargs): result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) From bda5d2a6d67896b7e6e09027c7825cc72b2c8d2e Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 29 Apr 2026 11:18:02 +0530 Subject: [PATCH 81/91] target install: remove step progress output, keep only JSON - Remove Config Set/Review/Publish progress messages from install output - Remove Deployment complete banner and Install tick message - Install now outputs only the final JSON response (clean for customers) - Keep --solution-template-rg as-is (CLI linter rejects longer names) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/target/_install.py | 9 +--- .../onboarding/target_deploy.py | 46 ++----------------- 2 files changed, 4 insertions(+), 51 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 0ee69bee006..1e276ff50f4 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -156,14 +156,7 @@ def _run_deploy_chain(self): @register_callback def post_operations(self): - # Print Install ✓ after AAZ LRO completes (only when deploy chain was used) - args = self.ctx.args - if args.solution_template_name: - import sys - print("└── Install ✓\n", file=sys.stderr) - - target_name = str(args.target_name) if args.target_name else "" - print(f"✅ Deployment complete for target '{target_name}'", file=sys.stderr) + pass def _output(self, *args, **kwargs): result = self.deserialize_output(self.ctx.vars.instance, client_flatten=True) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py index 47afa26bd9e..e3bfa675b94 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py @@ -85,53 +85,30 @@ def target_deploy( # Figure out which steps to run do_config = config is not None - total = sum([do_config, True, True, True]) # config(opt) + review + publish + install - current = [0] # mutable counter - - def _log(step_name, status=""): - if status: - connector = "└──" if current[0] == total else "├──" - _eprint(f"{connector} {step_name} {status}") - else: - current[0] += 1 - connector = "└──" if current[0] == total else "├──" - _eprint(f"{connector} {step_name}...") - results = {} sv_id = None # --- Step 0: Config set --- if do_config: - _log("Config Set") _handle_config_set( cmd, config, config_hierarchy_id, config_template_rg, config_template_name, config_template_version, resource_group, target_name, sub_id, ) - _log("Config Set", "✓") results["configSet"] = "Succeeded" # --- Step 1: Review --- - _log("Review") review_result = _do_review(cmd, base_url, solution_template_version_id) results["review"] = review_result sv_id = _extract_solution_version_id(review_result) - _log("Review", f"✓ solutionVersionId: {_short_id(sv_id)}") # --- Step 2: Publish --- - _log("Publish") publish_result = _do_publish(cmd, base_url, sv_id) results["publish"] = publish_result - _log("Publish", "✓") # --- Step 3: Install --- - _log("Install") install_result = _do_install(cmd, base_url, sv_id) results["install"] = install_result - _log("Install", "✓") - - _eprint(f"\n✅ Deployment complete for target '{target_name}'") - _eprint(f" Solution Version: {_short_id(sv_id)}") # Return the install LRO result (same format as `az wo target install`) return results.get("install", { @@ -171,21 +148,9 @@ def target_deploy_pre_install( ) do_config = config is not None - total = sum([do_config, True, True, True]) # config + review + publish + install(AAZ) - current = [0] - - def _log(step_name, status=""): - if status: - connector = "└──" if current[0] == total else "├──" - _eprint(f"{connector} {step_name} {status}") - else: - current[0] += 1 - connector = "└──" if current[0] == total else "├──" - _eprint(f"{connector} {step_name}...") # --- Step 0: Config set --- if do_config: - _log("Config Set") # Auto-derive config template args from solution template args ct_rg = solution_template_rg or resource_group ct_name = solution_template_name @@ -196,28 +161,23 @@ def _log(step_name, status=""): ct_name, ct_version, resource_group, target_name, sub_id, ) - _log("Config Set", "✓") # --- Step 1: Review --- - _log("Review") review_result = _do_review(cmd, base_url, solution_template_version_id) sv_id = _extract_solution_version_id(review_result) - _log("Review", f"✓ solutionVersionId: {_short_id(sv_id)}") # --- Step 2: Publish --- - _log("Publish") _do_publish(cmd, base_url, sv_id) - _log("Publish", "✓") # Step 3 (Install) is handled by AAZ LRO — tick printed in post_operations return sv_id - # --------------------------------------------------------------------------- # Resolution helpers # --------------------------------------------------------------------------- + def _get_subscription_id(cmd): """Get subscription ID from CLI context.""" sub_id = cmd.cli_ctx.data.get('subscription_id') @@ -250,11 +210,11 @@ def _resolve_template_version_id( f"/versions/{template_version}" ) - # --------------------------------------------------------------------------- # Step implementations # --------------------------------------------------------------------------- + def _do_review(cmd, base_url, solution_template_version_id): """POST .../reviewSolutionVersion""" url = f"{base_url}/reviewSolutionVersion?api-version={API_VERSION}" @@ -441,11 +401,11 @@ def _read_config_file(file_path): with open(file_path, "r", encoding="utf-8") as f: return f.read() - # --------------------------------------------------------------------------- # LRO and response helpers # --------------------------------------------------------------------------- + def _parse_response(resp, step_name, cmd=None): """Parse REST response, handling 200/201/202 LRO patterns.""" status = resp.status_code From 0d90f44481b3d9ca9650e49d90a710be3798d8d2 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 29 Apr 2026 12:00:33 +0530 Subject: [PATCH 82/91] refactor: rename onboarding/ to common/, consolidate by resource type - Renamed onboarding package to common (better reflects purpose) - Consolidated files by resource type per review feedback: context_init.py + context_capability.py -> context.py hierarchy_init.py + hierarchy_create.py -> hierarchy.py target_prepare.py + target_deploy.py + target_sg_link.py -> target.py - Kept consts.py and utils.py as shared utilities - Updated all import references across 20+ files - Removed unused _eprint and _short_id from target_deploy - No logic changes - pure structural refactor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/_help.py | 2 + .../azext_workload_orchestration/_params.py | 6 - .../workload_orchestration/cluster/_init.py | 2 +- .../workload_orchestration/context/_create.py | 10 +- .../context/capability/_add.py | 2 +- .../context/capability/_list.py | 2 +- .../context/capability/_remove.py | 2 +- .../context/capability/_show.py | 2 +- .../hierarchy/_create.py | 2 +- .../workload_orchestration/target/_create.py | 13 +- .../workload_orchestration/target/_install.py | 13 +- .../azext_workload_orchestration/commands.py | 2 +- .../{onboarding => common}/__init__.py | 46 +- .../{onboarding => common}/consts.py | 4 +- .../context.py} | 283 ++++- .../hierarchy.py} | 76 +- .../common/target.py | 1014 +++++++++++++++++ .../{onboarding => common}/utils.py | 2 +- .../azext_workload_orchestration/custom.py | 4 - .../onboarding/context_init.py | 283 ----- .../onboarding/hierarchy_init.py | 173 --- .../onboarding/target_deploy.py | 497 -------- .../onboarding/target_prepare.py | 517 --------- .../onboarding/target_sg_link.py | 103 -- 24 files changed, 1330 insertions(+), 1730 deletions(-) rename src/workload-orchestration/azext_workload_orchestration/{onboarding => common}/__init__.py (66%) rename src/workload-orchestration/azext_workload_orchestration/{onboarding => common}/consts.py (96%) rename src/workload-orchestration/azext_workload_orchestration/{onboarding/context_capability.py => common/context.py} (55%) rename src/workload-orchestration/azext_workload_orchestration/{onboarding/hierarchy_create.py => common/hierarchy.py} (91%) create mode 100644 src/workload-orchestration/azext_workload_orchestration/common/target.py rename src/workload-orchestration/azext_workload_orchestration/{onboarding => common}/utils.py (98%) delete mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/context_init.py delete mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py delete mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py delete mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py delete mode 100644 src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py diff --git a/src/workload-orchestration/azext_workload_orchestration/_help.py b/src/workload-orchestration/azext_workload_orchestration/_help.py index 0d378748000..1f93a9946f0 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_help.py +++ b/src/workload-orchestration/azext_workload_orchestration/_help.py @@ -1,6 +1,8 @@ # -------------------------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools # -------------------------------------------------------------------------------------------- # pylint: disable=line-too-long diff --git a/src/workload-orchestration/azext_workload_orchestration/_params.py b/src/workload-orchestration/azext_workload_orchestration/_params.py index f7f7aa81f5f..b17dd8cccd7 100644 --- a/src/workload-orchestration/azext_workload_orchestration/_params.py +++ b/src/workload-orchestration/azext_workload_orchestration/_params.py @@ -65,9 +65,3 @@ def load_arguments(self, _): # pylint: disable=unused-argument options_list=['--kube-context'], help='Kubernetes context to use. Defaults to current context.', ) - c.argument( - 'skip_site_reference', - options_list=['--skip-site-reference'], - action='store_true', - help='Skip auto-creation of site-reference to context.', - ) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py index 07e5aa6ec30..33c4117978e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py @@ -124,7 +124,7 @@ def _handler(self, command_args): super()._handler(command_args) args = self.ctx.args - from azext_workload_orchestration.onboarding import target_init + from azext_workload_orchestration.common import target_init return target_init( cmd=self, cluster_name=args.cluster_name.to_serialized_data(), diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py index 39cc780739b..b24b3c71ace 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py @@ -42,8 +42,6 @@ def _build_arguments_schema(cls, *args, **kwargs): return cls._args_schema cls._args_schema = super()._build_arguments_schema(*args, **kwargs) - # define Arg Group "" - _args_schema = cls._args_schema _args_schema.context_name = AAZStrArg( options=["-n", "--name", "--context-name"], @@ -59,8 +57,6 @@ def _build_arguments_schema(cls, *args, **kwargs): required=True, ) - # define Arg Group "Properties" - _args_schema = cls._args_schema _args_schema.capabilities = AAZListArg( options=["--capabilities"], @@ -110,8 +106,6 @@ def _build_arguments_schema(cls, *args, **kwargs): required=True, ) - # define Arg Group "Resource" - _args_schema = cls._args_schema _args_schema.location = AAZResourceLocationArg( arg_group="Resource", @@ -133,7 +127,7 @@ def _build_arguments_schema(cls, *args, **kwargs): # Custom arg: --site-id (not sent to ARM, used in post_operations) _args_schema.site_id = AAZStrArg( options=["--site-id"], - arg_group="Onboarding", + arg_group="Common", help="ARM resource ID of a Site to auto-create a site reference after context creation.", ) @@ -182,7 +176,7 @@ def _create_site_reference(self): ref_name = f"{sanitized_site}-{hash_suffix}" try: - from azext_workload_orchestration.onboarding.utils import invoke_cli_command, CmdProxy + from azext_workload_orchestration.common.utils import invoke_cli_command, CmdProxy cmd_proxy = CmdProxy(self.ctx.cli_ctx) invoke_cli_command(cmd_proxy, [ "workload-orchestration", "context", "site-reference", "create", diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_add.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_add.py index 509355add67..41419b588a9 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_add.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_add.py @@ -93,7 +93,7 @@ def _handler(self, command_args): # Treat empty list as not-provided (AAZ may default to []) capabilities = capabilities_raw if capabilities_raw else None - from azext_workload_orchestration.onboarding.context_capability import ( + from azext_workload_orchestration.common.context import ( capability_add as _capability_add, ) return _capability_add( diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_list.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_list.py index b4cad7a5b8d..fd080d88d4b 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_list.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_list.py @@ -53,7 +53,7 @@ def _handler(self, command_args): super()._handler(command_args) args = self.ctx.args - from azext_workload_orchestration.onboarding.context_capability import ( + from azext_workload_orchestration.common.context import ( capability_list as _capability_list, ) return _capability_list( diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_remove.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_remove.py index 4dbff52d818..169c0bf4044 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_remove.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_remove.py @@ -76,7 +76,7 @@ def _handler(self, command_args): force = args.force.to_serialized_data() if args.force._data is not None else False # AAZ confirmation= already prompted via --yes; we treat that as confirmed. - from azext_workload_orchestration.onboarding.context_capability import ( + from azext_workload_orchestration.common.context import ( capability_remove as _capability_remove, ) return _capability_remove( diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_show.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_show.py index 16b1191e68d..93c829a2121 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_show.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/_show.py @@ -55,7 +55,7 @@ def _handler(self, command_args): super()._handler(command_args) args = self.ctx.args - from azext_workload_orchestration.onboarding.context_capability import ( + from azext_workload_orchestration.common.context import ( capability_show as _capability_show, ) return _capability_show( diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py index 9369f3acb2a..d6abb2fc8c5 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py @@ -105,7 +105,7 @@ def _handler(self, command_args): args = self.ctx.args spec = args.hierarchy_spec.to_serialized_data() - from azext_workload_orchestration.onboarding.hierarchy_create import ( + from azext_workload_orchestration.common.hierarchy import ( hierarchy_create as _hierarchy_create, ) return _hierarchy_create( diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index 8ef0bd67c4c..d6e1cb0a11d 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -47,8 +47,6 @@ def _build_arguments_schema(cls, *args, **kwargs): return cls._args_schema cls._args_schema = super()._build_arguments_schema(*args, **kwargs) - # define Arg Group "" - _args_schema = cls._args_schema _args_schema.resource_group = AAZResourceGroupNameArg( required=True, @@ -64,8 +62,6 @@ def _build_arguments_schema(cls, *args, **kwargs): ), ) - # define Arg Group "Properties" - _args_schema = cls._args_schema _args_schema.capabilities = AAZListArg( options=["--capabilities"], @@ -121,18 +117,15 @@ def _build_arguments_schema(cls, *args, **kwargs): ) - # Onboarding simplification arguments _args_schema.service_group = AAZStrArg( options=["--service-group"], - arg_group="Onboarding", + arg_group="Common", help="ServiceGroup name to auto-link this target to after creation.", ) capabilities = cls._args_schema.capabilities capabilities.Element = AAZStrArg() - # define Arg Group "Resource" - _args_schema = cls._args_schema _args_schema.extended_location = AAZObjectArg( options=["--extended-location"], @@ -209,10 +202,10 @@ def post_operations(self): def _handle_service_group_link(self): """Link the created target to a service group.""" - from azext_workload_orchestration.onboarding.target_sg_link import ( + from azext_workload_orchestration.common.target import ( link_target_to_service_group ) - from azext_workload_orchestration.onboarding.utils import CmdProxy + from azext_workload_orchestration.common.utils import CmdProxy sg_name = str(self.ctx.args.service_group) # Get target ID from the response target_id = None diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 1e276ff50f4..195b86d1781 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -9,7 +9,7 @@ # flake8: noqa from azure.cli.core.aaz import * -from azure.cli.core.azclierror import CLIInternalError, ValidationError +from azure.cli.core.azclierror import ValidationError @register_command( @@ -133,10 +133,10 @@ def pre_operations(self): def _run_deploy_chain(self): """Run config-set → review → publish, then let the AAZ install handle the final step.""" - from azext_workload_orchestration.onboarding.target_deploy import ( + from azext_workload_orchestration.common.target import ( target_deploy_pre_install, ) - from azext_workload_orchestration.onboarding.utils import CmdProxy + from azext_workload_orchestration.common.utils import CmdProxy args = self.ctx.args cmd_proxy = CmdProxy(self.ctx.cli_ctx) @@ -240,11 +240,6 @@ def content(self): typ_kwargs={"flags": {"required": True, "client_flatten": True}} ) - # Remove these properties (v2025_06_01) - # _builder.set_prop("solution", AAZStrType, ".solution", typ_kwargs={"flags": {"required": True}}) - # _builder.set_prop("solutionVersion", AAZStrType, ".solution_version", typ_kwargs={"flags": {"required": True}}) - - # Add new property (v2025_06_01) _builder.set_prop("solutionVersionId", AAZStrType, ".solution_version_id", typ_kwargs={"flags": {"required": True}}) return self.serialize_content(_content_value) @@ -281,7 +276,7 @@ def _build_schema_on_200(cls): return cls._schema_on_200 class _InstallHelper: - """Helper class for Publish""" + """Helper class for Install""" _schema_solution_dependency_read = None diff --git a/src/workload-orchestration/azext_workload_orchestration/commands.py b/src/workload-orchestration/azext_workload_orchestration/commands.py index c267d9c1399..1f1d9c002a7 100644 --- a/src/workload-orchestration/azext_workload_orchestration/commands.py +++ b/src/workload-orchestration/azext_workload_orchestration/commands.py @@ -10,5 +10,5 @@ def load_command_table(self, _): # pylint: disable=unused-argument - with self.command_group('workload-orchestration support') as g: + with self.command_group('workload-orchestration support', is_preview=True) as g: g.custom_command('create-bundle', 'create_support_bundle') diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py b/src/workload-orchestration/azext_workload_orchestration/common/__init__.py similarity index 66% rename from src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py rename to src/workload-orchestration/azext_workload_orchestration/common/__init__.py index b1b39cd5b99..52586fe407d 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/__init__.py @@ -3,15 +3,10 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Onboarding simplification commands for Workload Orchestration. +"""Common helpers for Workload Orchestration CLI commands.""" -Provides convenience CLI commands that wrap multiple API calls -into single-command operations to reduce onboarding steps. -""" - -from azext_workload_orchestration.onboarding.target_prepare import target_prepare -from azext_workload_orchestration.onboarding.target_deploy import target_deploy as _target_deploy -from azext_workload_orchestration.onboarding.hierarchy_create import hierarchy_create as _hierarchy_create +from azext_workload_orchestration.common.target import target_prepare as _target_prepare +from azext_workload_orchestration.common.hierarchy import hierarchy_create as _hierarchy_create def _validate_dependency_versions(data): @@ -20,7 +15,7 @@ def _validate_dependency_versions(data): into a ``dict[str,str]`` for us. """ from azure.cli.core.azclierror import ValidationError - from azext_workload_orchestration.onboarding.consts import EXTENSION_DEPENDENCIES + from azext_workload_orchestration.common.consts import EXTENSION_DEPENDENCIES if not data: return {} @@ -69,7 +64,7 @@ def target_init( dep_versions = _validate_dependency_versions(extension_dependency_version) iot_platform_version = dep_versions.get("iotplatform") - return target_prepare( + return _target_prepare( cmd=cmd, cluster_name=cluster_name, resource_group=resource_group, @@ -84,35 +79,6 @@ def target_init( ) -def target_deploy( - cmd, - resource_group, - target_name, - solution_template_name=None, - solution_template_version=None, - solution_template_rg=None, - config=None, - config_hierarchy_id=None, - config_template_rg=None, - config_template_name=None, - config_template_version=None, -): - """Deploy a solution to a target: review -> publish -> install.""" - return _target_deploy( - cmd=cmd, - resource_group=resource_group, - target_name=target_name, - solution_template_name=solution_template_name, - solution_template_version=solution_template_version, - solution_template_rg=solution_template_rg, - config=config, - config_hierarchy_id=config_hierarchy_id, - config_template_rg=config_template_rg, - config_template_name=config_template_name, - config_template_version=config_template_version, - ) - - def hierarchy_create(cmd, resource_group=None, configuration_location=None, hierarchy_spec=None): """Create a hierarchy: Site + Configuration + ConfigurationReference. @@ -126,4 +92,4 @@ def hierarchy_create(cmd, resource_group=None, configuration_location=None, hier ) -__all__ = ['target_prepare', 'target_init', 'target_deploy', 'hierarchy_create'] +__all__ = ['target_init', 'hierarchy_create'] diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py b/src/workload-orchestration/azext_workload_orchestration/common/consts.py similarity index 96% rename from src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py rename to src/workload-orchestration/azext_workload_orchestration/common/consts.py index d01abd131b6..ed2063cc690 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/consts.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/consts.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Constants for onboarding simplification commands.""" +"""Constants for workload orchestration CLI commands.""" # pylint: disable=line-too-long @@ -22,7 +22,6 @@ # ARM Endpoints # --------------------------------------------------------------------------- ARM_ENDPOINT = "https://management.azure.com" -ARM_RESOURCE = "https://management.azure.com" # --------------------------------------------------------------------------- # Resource Providers @@ -35,7 +34,6 @@ # cert-manager + trust-manager Defaults (installed via AIO Platform extension) # --------------------------------------------------------------------------- DEFAULT_CERT_MANAGER_VERSION = None # None = AIO extension default -CERT_MANAGER_NAMESPACE = "cert-manager" # Registry of extension dependencies for `--extension-dependency-version`. # Keys are the user-facing names; values configure the Arc extension install. diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py b/src/workload-orchestration/azext_workload_orchestration/common/context.py similarity index 55% rename from src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py rename to src/workload-orchestration/azext_workload_orchestration/common/context.py index 35106409f40..526c003a7bb 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_capability.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/context.py @@ -3,23 +3,28 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Context capability add/remove/list/show orchestration. +"""Context initialization and capability management for Workload Orchestration. -Pure-Python helpers that: - 1. GET the current context state. - 2. Normalize/dedup user input case-insensitively. - 3. Compute the delta vs existing capabilities. - 4. Skip the ARM call entirely if there is no change (idempotent). - 5. Otherwise issue ONE PATCH with only the capabilities array - (server preserves hierarchies, tags, etc.). +Consolidated from context_init.py and context_capability.py. + +context_init: Finds or creates a WO context, sets it as current, and ensures +the required capabilities and hierarchy levels are present. + +context_capability: Pure-Python helpers that GET the current context state, +normalize/dedup user input case-insensitively, compute the delta vs existing +capabilities, skip the ARM call entirely if there is no change (idempotent), +and otherwise issue ONE PATCH with only the capabilities array. Exports: + handle_init_context(cli_ctx, ...) capability_add(cli_ctx, ...) capability_remove(cli_ctx, ...) capability_list(cli_ctx, ...) capability_show(cli_ctx, ...) """ +# pylint: disable=broad-exception-caught + import json import logging import re @@ -32,27 +37,273 @@ ) from azure.cli.core.util import send_raw_request -from azext_workload_orchestration.onboarding.consts import ( +from azext_workload_orchestration.common.consts import ( ARM_ENDPOINT, CONTEXT_API_VERSION, ) -from azext_workload_orchestration.onboarding.utils import ( +from azext_workload_orchestration.common.utils import ( CmdProxy, invoke_cli_command, + invoke_silent, + parse_arm_id, ) logger = logging.getLogger(__name__) + +# =========================================================================== +# context_init — Public entry point +# =========================================================================== + +def handle_init_context(cli_ctx, ctx_name, resource_group, location, + hierarchy_level, capabilities): + """Find or create a WO context and return its ARM resource ID. + + Strategy (in order): + 1. Check if a context is already set in CLI config → use it + 2. List contexts in the target's resource group → use first match + 3. Create a new context with the given name + 4. If create fails (e.g. name conflict), search subscription-wide + + After resolving the context, ensures the required hierarchy level and + capabilities are present (adds them if missing). + + Returns: + str: The ARM resource ID of the context. + + Raises: + CLIInternalError: If no context can be found or created. + """ + import configparser + + cmd = CmdProxy(cli_ctx) + + # ------------------------------------------------------------------ + # 1. Check CLI config for an already-set context + # ------------------------------------------------------------------ + try: + existing_ctx_id = cli_ctx.config.get('workload_orchestration', 'context_id') + if existing_ctx_id: + logger.info("Context already set in config: %s", existing_ctx_id) + _ensure_capabilities(cli_ctx, existing_ctx_id, hierarchy_level, capabilities) + print("[init-context] Using existing context [OK]") + return existing_ctx_id + except (configparser.NoSectionError, configparser.NoOptionError): + pass + + # ------------------------------------------------------------------ + # 2. List contexts in this resource group + # ------------------------------------------------------------------ + try: + existing = invoke_cli_command(cmd, [ + "workload-orchestration", "context", "list", "-g", resource_group + ]) + if existing and isinstance(existing, list): + ctx_id = existing[0].get("id", "") + if ctx_id: + parts = parse_arm_id(ctx_id) + found_name = parts.get("contexts", ctx_name) + found_rg = parts.get("resourcegroups", resource_group) + _set_current(found_name, found_rg) + _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities) + print(f"[init-context] Using existing context '{found_name}' [OK]") + return ctx_id + except Exception: + pass # No contexts found — proceed to create + + # ------------------------------------------------------------------ + # 3. Create a new context + # ------------------------------------------------------------------ + print(f"[init-context] Creating context '{ctx_name}'...") + + create_args = _build_create_args(ctx_name, resource_group, location, + hierarchy_level, capabilities) + exit_code = invoke_silent(create_args) + + if exit_code == 0: + _set_current(ctx_name, resource_group) + + # Read back the context ID from config (set by 'context use') + try: + ctx_id = cli_ctx.config.get('workload_orchestration', 'context_id') + if ctx_id: + print(f"[init-context] Context '{ctx_name}' created [OK]") + return ctx_id + except (configparser.NoSectionError, configparser.NoOptionError): + pass + + # Fallback: construct the ID manually + sub_id = cli_ctx.data.get('subscription_id', '') + ctx_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/contexts/{ctx_name}") + print(f"[init-context] Context '{ctx_name}' created [OK]") + return ctx_id + + # ------------------------------------------------------------------ + # 4. Create failed — search subscription-wide + # ------------------------------------------------------------------ + logger.warning("Context create returned exit %d. Searching subscription...", exit_code) + ctx_id = _search_subscription(cli_ctx) + if ctx_id: + parts = parse_arm_id(ctx_id) + found_name = parts.get("contexts", "unknown") + found_rg = parts.get("resourcegroups", resource_group) + _set_current(found_name, found_rg) + _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities) + print(f"[init-context] Using existing context '{found_name}' in RG '{found_rg}' [OK]") + return ctx_id + + raise CLIInternalError( + "Could not create or find an existing context. " + "Please provide --context-id explicitly." + ) + + # --------------------------------------------------------------------------- -# Constants +# context_init — Private helpers # --------------------------------------------------------------------------- +def _build_create_args(ctx_name, resource_group, location, + hierarchy_level, capabilities): + """Build the arg list for 'az workload-orchestration context create'.""" + # Capabilities: [0].name=X [0].description=X [1].name=Y ... + cap_args = [] + for i, cap in enumerate(capabilities or []): + cap_args.extend([f"[{i}].name={cap}", f"[{i}].description={cap}"]) + + hier_args = [f"[0].name={hierarchy_level}", f"[0].description={hierarchy_level}"] + + args = [ + "workload-orchestration", "context", "create", + "-g", resource_group, "-l", location, "--name", ctx_name, + "--hierarchies", + ] + hier_args + + if cap_args: + args.append("--capabilities") + args.extend(cap_args) + + args.extend(["-o", "none"]) + return args + + +def _set_current(ctx_name, ctx_rg): + """Set a context as the CLI default (silently).""" + invoke_silent([ + "workload-orchestration", "context", "use", + "--name", ctx_name, "-g", ctx_rg, "-o", "none", + ]) + + +def _search_subscription(cli_ctx): + """Search the entire subscription for any existing context. Returns ID or None.""" + + sub_id = cli_ctx.data.get('subscription_id', '') + try: + resp = send_raw_request( + cli_ctx, + method="GET", + url=(f"{ARM_ENDPOINT}/subscriptions/{sub_id}" + f"/providers/Microsoft.Edge/contexts" + f"?api-version={CONTEXT_API_VERSION}"), + resource=ARM_ENDPOINT, + ) + if resp.status_code == 200: + contexts = resp.json().get("value", []) + if contexts: + return contexts[0].get("id") + except Exception as exc: + logger.warning("Subscription-wide context search failed: %s", exc) + return None + + +def _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities): + """Add missing capabilities/hierarchies to an existing context via PUT.""" + if not capabilities: + return + + cmd = CmdProxy(cli_ctx) + parts = parse_arm_id(ctx_id) + ctx_rg = parts.get("resourcegroups") + ctx_name = parts.get("contexts") + sub_id = parts.get("subscriptions") + + if not ctx_rg or not ctx_name: + return + + try: + ctx_data = invoke_cli_command(cmd, [ + "workload-orchestration", "context", "show", + "-g", ctx_rg, "--name", ctx_name, + ]) + except Exception: + return + + if not ctx_data or not isinstance(ctx_data, dict): + return + + props = ctx_data.get("properties", {}) + existing_caps = {c.get("name", "") for c in (props.get("capabilities") or [])} + existing_hiers = {h.get("name", "") for h in (props.get("hierarchies") or [])} + + missing_caps = [c for c in capabilities if c not in existing_caps] + missing_hier = hierarchy_level not in existing_hiers + + if not missing_caps and not missing_hier: + return + + all_caps = list(props.get("capabilities") or []) + for cap in missing_caps: + all_caps.append({"name": cap, "description": cap}) + + all_hiers = list(props.get("hierarchies") or []) + if missing_hier: + all_hiers.append({"name": hierarchy_level, "description": hierarchy_level}) + + print(f"[init-context] Adding capabilities {missing_caps} to context...") + + if not sub_id: + sub_id = cli_ctx.data.get('subscription_id', '') + + location = ctx_data.get("location", "") + body = { + "location": location, + "properties": { + "capabilities": [{"name": c.get("name", ""), "description": c.get("description", "")} + for c in all_caps], + "hierarchies": [{"name": h.get("name", ""), "description": h.get("description", "")} + for h in all_hiers], + } + } + + try: + resp = send_raw_request( + cli_ctx, + method="PUT", + url=(f"{ARM_ENDPOINT}/subscriptions/{sub_id}" + f"/resourceGroups/{ctx_rg}/providers/Microsoft.Edge" + f"/contexts/{ctx_name}?api-version={CONTEXT_API_VERSION}"), + body=json.dumps(body), + resource=ARM_ENDPOINT, + ) + if resp.status_code in (200, 201): + print("[init-context] Capabilities updated [OK]") + else: + logger.warning("Context update returned %d: %s", resp.status_code, resp.text) + except Exception as exc: + logger.warning("Failed to update context capabilities: %s", exc) + + +# =========================================================================== +# context_capability — Constants +# =========================================================================== + _CAP_NAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-_.]*[a-zA-Z0-9])?$") _MAX_CAP_NAME_LEN = 61 # --------------------------------------------------------------------------- -# Validation +# context_capability — Validation # --------------------------------------------------------------------------- @@ -78,7 +329,7 @@ def _validate_cap_name(name): # --------------------------------------------------------------------------- -# Input normalization +# context_capability — Input normalization # --------------------------------------------------------------------------- @@ -170,7 +421,7 @@ def _normalize_names_input(name, names): # --------------------------------------------------------------------------- -# Context fetch and PATCH +# context_capability — Context fetch and PATCH # --------------------------------------------------------------------------- @@ -247,7 +498,7 @@ def _patch_context_capabilities(cli_ctx, sub_id, resource_group, # --------------------------------------------------------------------------- -# Helpers +# context_capability — Helpers # --------------------------------------------------------------------------- @@ -262,7 +513,7 @@ def _log(msg): # --------------------------------------------------------------------------- -# Public API +# context_capability — Public API # --------------------------------------------------------------------------- diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py similarity index 91% rename from src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py rename to src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py index d20bcd10716..497b765ea16 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py @@ -3,42 +3,24 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Hierarchy create command - creates Site + Configuration + ConfigurationReference. - -Supports two hierarchy types: - - ResourceGroup: Single site in a resource group (no children) - - ServiceGroup: Nested sites under a service group (up to 3 levels) - -For ResourceGroup: - az workload-orchestration hierarchy create \\ - --resource-group rg --configuration-location eastus2euap \\ - --hierarchy-spec hierarchy.yaml - - hierarchy.yaml: - name: Mehoopany - level: factory - -For ServiceGroup: - az workload-orchestration hierarchy create \\ - --configuration-location eastus2euap \\ - --hierarchy-spec hierarchy.yaml - - hierarchy.yaml: - type: ServiceGroup - name: India - level: country - children: - - name: Karnataka - level: region - children: - - name: BangaloreSouth - level: factory - -Note: ``children`` MUST be a list (even for a single child). +"""Hierarchy initialization and creation for Workload Orchestration. + +Consolidated from hierarchy_init.py and hierarchy_create.py. + +hierarchy_init: Lightweight hierarchy initialization for target create +--init-hierarchy. Creates a simple site + configuration + config-reference + +site-reference in a resource group scope (no service group). + +hierarchy_create: Full hierarchy create command — creates Site + Configuration + +ConfigurationReference. Supports ResourceGroup (single site) and ServiceGroup +(nested, up to 3 levels) hierarchy types. """ # pylint: disable=broad-exception-caught # pylint: disable=too-many-locals +# pylint: disable=too-many-statements +# pylint: disable=too-many-branches +# pylint: disable=import-outside-toplevel import json import logging @@ -51,7 +33,7 @@ ) from azure.cli.core.util import send_raw_request -from azext_workload_orchestration.onboarding.consts import ( +from azext_workload_orchestration.common.consts import ( ARM_ENDPOINT, SERVICE_GROUP_API_VERSION, SITE_API_VERSION, @@ -59,10 +41,13 @@ CONFIG_REF_API_VERSION, EDGE_RP_NAMESPACE, ) - logger = logging.getLogger(__name__) +# =========================================================================== +# hierarchy_create — Public entry point +# =========================================================================== + def _eprint(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) @@ -238,7 +223,7 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): tenant_id = _get_tenant_id(cmd) # Count total nodes - nodes = _count_nodes(spec) + nodes = _count_depth(spec) if nodes > MAX_SG_DEPTH: raise ValidationError( f"ServiceGroup hierarchy has {nodes} levels. Maximum is {MAX_SG_DEPTH}." @@ -277,7 +262,6 @@ def _create_sg_level( # pylint: disable=too-many-arguments sg_id = f"/providers/Microsoft.Management/serviceGroups/{name}" - # Tree drawing connector = "└── " if is_last else "├── " child_prefix = parent_prefix + (" " if is_last else "│ ") @@ -295,7 +279,6 @@ def _create_sg_level( # pylint: disable=too-many-arguments logger.warning("ServiceGroup creation failed: %s", exc) raise CLIInternalError(f"ServiceGroup '{name}' creation failed: {exc}") - # Wait for RBAC propagation silently _wait_for_sg_rbac(cmd, config_location, sg_id, name) # 2. Find-or-create Site under this SG (1 site per SG max) @@ -337,12 +320,10 @@ def _create_sg_level( # pylint: disable=too-many-arguments ) if existing_ref: - # Whole chain reused: don't touch Config or ConfigRef. ref_target = existing_ref.get("properties", {}).get("configurationResourceId", "") effective_config_id = ref_target or config_id config_reused = True else: - # Fresh Site, or existing Site with no ConfigRef yet. config_reused = bool(_arm_get(cmd, config_url, CONFIGURATION_API_VERSION)) if not config_reused: _arm_put(cmd, config_url, { @@ -358,7 +339,6 @@ def _create_sg_level( # pylint: disable=too-many-arguments results.append({"type": "Configuration", "name": config_name, "id": effective_config_id}) results.append({"type": "ConfigurationReference", "siteId": site_id}) - # Show resources created/reused under this node children = node.get("children") has_children = children is not None site_label = "(reused) " if existing_sg_site else "" @@ -371,7 +351,6 @@ def _create_sg_level( # pylint: disable=too-many-arguments else: _eprint(f"{child_prefix}└── ConfigurationReference {ref_label}✓") - # Recurse into children if children: if not isinstance(children, list): raise ValidationError( @@ -386,7 +365,7 @@ def _create_sg_level( # pylint: disable=too-many-arguments parent_prefix=child_prefix) -def _count_nodes(node): +def _count_depth(node): """Count total depth of hierarchy tree.""" children = node.get("children") if not children: @@ -395,11 +374,11 @@ def _count_nodes(node): raise ValidationError( f"'children' for '{node.get('name', '?')}' must be a list." ) - return 1 + max(_count_nodes(c) for c in children) + return 1 + max(_count_depth(c) for c in children) # --------------------------------------------------------------------------- -# ARM helpers +# hierarchy_create — ARM helpers # --------------------------------------------------------------------------- def _arm_put(cmd, url, body, api_version): @@ -436,15 +415,6 @@ def _arm_get(cmd, url, api_version): return None -def _check_rg_has_no_other_site(cmd, sub_id, resource_group, intended_name): # legacy, kept for back-compat callers - site = _find_existing_site_in_rg(cmd, sub_id, resource_group) - if site and site[0] != intended_name: - raise ValidationError( - f"Resource group '{resource_group}' already contains site '{site[0]}'. " - f"A ResourceGroup hierarchy supports only one site per RG." - ) - - def _find_existing_site_in_rg(cmd, sub_id, resource_group): """Return (name, site_id) of the first site found in the RG, else None.""" list_url = ( diff --git a/src/workload-orchestration/azext_workload_orchestration/common/target.py b/src/workload-orchestration/azext_workload_orchestration/common/target.py new file mode 100644 index 00000000000..262ef9554b5 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/common/target.py @@ -0,0 +1,1014 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Target prepare, deploy, and service-group link for Workload Orchestration. + +Consolidated from target_prepare.py, target_deploy.py, and target_sg_link.py. + +target_prepare: Prepares an Arc-connected K8s cluster for WO — +installs cert-manager, trust-manager, WO extension, and creates a custom +location. Idempotent — skips components that already exist. + +target_deploy: Chains review → publish → install in one step. Optionally +prepends config-set (step 0) when --config is provided. + +target_sg_link: Links a target to a service group after creation. After +creating the ServiceGroupMember relationship, a target update (PUT) is +mandatory to refresh the target's hierarchy info. +""" + +# pylint: disable=broad-exception-caught +# pylint: disable=too-many-locals +# pylint: disable=too-many-statements +# pylint: disable=too-many-branches +# pylint: disable=import-outside-toplevel + +import json +import os +import logging + +from azure.cli.core.azclierror import ( + CLIInternalError, + ValidationError, +) +from azure.cli.core.util import send_raw_request + +from azext_workload_orchestration.common.consts import ( + ARM_ENDPOINT, + DEFAULT_CERT_MANAGER_VERSION, + AIO_PLATFORM_EXTENSION_TYPE, + AIO_PLATFORM_EXTENSION_NAME, + AIO_PLATFORM_EXTENSION_NAMESPACE, + AIO_PLATFORM_EXTENSION_SCOPE, + DEFAULT_EXTENSION_TYPE, + DEFAULT_EXTENSION_NAME, + DEFAULT_RELEASE_TRAIN, + DEFAULT_EXTENSION_NAMESPACE, + DEFAULT_EXTENSION_SCOPE, + DEFAULT_STORAGE_SIZE, + SG_MEMBER_API_VERSION, + TARGET_API_VERSION, +) +from azext_workload_orchestration.common.utils import ( + _eprint, + invoke_cli_command, + print_step, + print_success, +) + +logger = logging.getLogger(__name__) + + +# =========================================================================== +# target_prepare +# =========================================================================== + +TOTAL_STEPS = 4 + + +def target_prepare( + cmd, + cluster_name, + resource_group, + location, + extension_name=None, + custom_location_name=None, + custom_location_resource_group=None, + custom_location_location=None, + extension_version=None, + release_train=None, + cert_manager_version=None, + kube_config=None, + kube_context=None, + no_wait=False, +): + """Prepare an Arc-connected K8s cluster for Workload Orchestration. + + Installs cert-manager + trust-manager (via the AIO platform Arc + extension), the WO extension, and creates a custom location. + Idempotent: skips components that are already installed. + """ + extension_name = extension_name or DEFAULT_EXTENSION_NAME + custom_location_name = custom_location_name or f"{cluster_name}-cl" + custom_location_resource_group = custom_location_resource_group or resource_group + custom_location_location = custom_location_location or location + release_train = release_train or DEFAULT_RELEASE_TRAIN + cert_manager_version = cert_manager_version or DEFAULT_CERT_MANAGER_VERSION + + _eprint(f"\nPreparing cluster '{cluster_name}' for Workload Orchestration...\n") + + step_results = {} + + try: + connected_cluster_id = _preflight_checks(cmd, cluster_name, resource_group) + step_results["preflight"] = "Passed" + except Exception as exc: + step_results["preflight"] = f"FAILED: {exc}" + _print_failure_hint(step_results) + raise + + # Step 1+2: cert-manager + trust-manager (single AIO Arc extension) + try: + _ensure_cert_trust_manager_via_aio_extension( + cmd, cluster_name, resource_group, + cert_manager_version, no_wait, + ) + step_results["cert-manager"] = "Succeeded" + step_results["trust-manager"] = "Succeeded (bundled)" + print_step( + 2, TOTAL_STEPS, "trust-manager", + "Bundled with cert-manager ✓" + ) + except Exception as exc: + step_results["cert-manager"] = f"FAILED: {exc}" + logger.debug( + "Steps 1-2/4 failed (AIO cert/trust-manager): %s", exc + ) + _print_failure_hint(step_results) + raise CLIInternalError("cert-manager/trust-manager installation failed. See error above.") + + # Step 3: WO extension + try: + extension_id = _ensure_wo_extension( + cmd, cluster_name, resource_group, extension_name, + extension_version, release_train, no_wait, + kube_config, kube_context, + ) + step_results["wo-extension"] = "Succeeded" + except Exception as exc: + step_results["wo-extension"] = f"FAILED: {exc}" + logger.debug("Step 3/4 failed (WO extension): %s", exc) + _print_failure_hint(step_results) + raise CLIInternalError("WO extension installation failed. See error above.") + + # Step 4: Custom location + try: + cl_id = _ensure_custom_location( + cmd, cluster_name, custom_location_resource_group, custom_location_location, + custom_location_name, extension_id, connected_cluster_id + ) + step_results["custom-location"] = "Succeeded" + except Exception as exc: + step_results["custom-location"] = f"FAILED: {exc}" + logger.debug("Step 4/4 failed (Custom location): %s", exc) + _print_failure_hint(step_results) + raise CLIInternalError("Custom location creation failed. See error above.") + + extended_location = {"name": cl_id, "type": "CustomLocation"} + _write_extended_location_file(extended_location) + + print_success(f"Cluster '{cluster_name}' is ready for Workload Orchestration") + _eprint(f" Custom Location: {cl_id}") + _eprint() + + return { + "clusterName": cluster_name, + "customLocationId": cl_id, + "extensionId": extension_id, + "extendedLocation": extended_location, + "connectedClusterId": connected_cluster_id, + } + + +# --------------------------------------------------------------------------- +# target_prepare — Pre-flight checks +# --------------------------------------------------------------------------- + +def _preflight_checks(cmd, cluster_name, resource_group): + """Verify cluster is Arc-connected and custom-locations feature enabled.""" + try: + cluster_info = invoke_cli_command( + cmd, + ["connectedk8s", "show", "-n", cluster_name, "-g", resource_group] + ) + except CLIInternalError: + raise ValidationError( + f"Cluster '{cluster_name}' is not Arc-connected or not found " + f"in resource group '{resource_group}'." + ) + + connected_cluster_id = cluster_info.get("id", "") + if not connected_cluster_id: + raise CLIInternalError( + f"Could not get resource ID for cluster '{cluster_name}'." + ) + + features = cluster_info.get("features", {}) + # Different API versions return this differently + cl_enabled = ( + features.get("customLocationsEnabled", False) + or cluster_info.get("properties", {}).get( + "customLocationsEnabled", False + ) + ) + # If we can't determine, proceed anyway - the custom location + # create step will fail with a clear error if not enabled + if cl_enabled is False: + logger.warning( + "custom-locations feature may not be enabled. " + "If custom location creation fails, run: " + "az connectedk8s enable-features -n %s -g %s " + "--features cluster-connect custom-locations", + cluster_name, resource_group + ) + + return connected_cluster_id + + +# --------------------------------------------------------------------------- +# target_prepare — Step 1+2: cert-manager + trust-manager via AIO Platform extension +# --------------------------------------------------------------------------- + +def _ensure_cert_trust_manager_via_aio_extension( + cmd, cluster_name, resource_group, version, no_wait +): + """Install cert-manager + trust-manager as an Arc k8s-extension. + + Uses microsoft.iotoperations.platform which bundles cert-manager and + trust-manager. Idempotent: skips if an extension of that type already + exists on the cluster. + """ + try: + extensions = invoke_cli_command( + cmd, + [ + "k8s-extension", "list", + "-g", resource_group, + "--cluster-name", cluster_name, + "--cluster-type", "connectedClusters", + ] + ) + except CLIInternalError: + extensions = [] + + existing = None + for ext in (extensions or []): + ext_type = (ext.get("extensionType", "") or "").lower() + if ext_type == AIO_PLATFORM_EXTENSION_TYPE.lower(): + existing = ext + break + + if existing: + ext_ver = existing.get("version", "unknown") + prov_state = (existing.get("provisioningState", "") or "").lower() + if prov_state == "succeeded": + print_step( + 1, TOTAL_STEPS, "cert-manager + trust-manager", + f"Already installed ✓ (AIO platform ext {ext_ver})" + ) + return + logger.info( + "Existing AIO platform extension in state '%s'; reinstalling.", + prov_state, + ) + + version_msg = f" version {version}" if version else "" + print_step( + 1, TOTAL_STEPS, + f"cert-manager + trust-manager... Installing AIO platform ext{version_msg}" + ) + + create_args = [ + "k8s-extension", "create", + "--resource-group", resource_group, + "--cluster-name", cluster_name, + "--name", AIO_PLATFORM_EXTENSION_NAME, + "--cluster-type", "connectedClusters", + "--extension-type", AIO_PLATFORM_EXTENSION_TYPE, + "--scope", AIO_PLATFORM_EXTENSION_SCOPE, + "--release-namespace", AIO_PLATFORM_EXTENSION_NAMESPACE, + ] + if version: + create_args.extend(["--version", version, "--auto-upgrade", "false"]) + if no_wait: + create_args.append("--no-wait") + + invoke_cli_command(cmd, create_args) + + suffix = " (--no-wait)" if no_wait else "" + print_step( + 1, TOTAL_STEPS, "cert-manager + trust-manager", + f"Installed via AIO platform extension{suffix} ✓" + ) + + +# --------------------------------------------------------------------------- +# target_prepare — Step 3: WO extension +# --------------------------------------------------------------------------- + +def _ensure_wo_extension( + cmd, cluster_name, resource_group, extension_name, + extension_version, release_train, no_wait, + kube_config=None, kube_context=None, +): + """Check if WO extension is installed; install if missing.""" + try: + extensions = invoke_cli_command( + cmd, + [ + "k8s-extension", "list", + "-g", resource_group, + "--cluster-name", cluster_name, + "--cluster-type", "connectedClusters", + ] + ) + except CLIInternalError: + extensions = [] + + wo_extensions = [ + ext for ext in (extensions or []) + if (ext.get("extensionType", "") or "").lower() + == DEFAULT_EXTENSION_TYPE.lower() + ] + + if wo_extensions: + ext = wo_extensions[0] + ext_id = ext.get("id", "") + ext_ver = ext.get("version", "unknown") + prov_state = ext.get("provisioningState", "").lower() + + if prov_state == "succeeded": + print_step( + 3, TOTAL_STEPS, "WO extension", + f"Already installed ✓ (version {ext_ver})" + ) + return ext_id + + version_msg = f" version {extension_version}" if extension_version else "" + print_step( + 3, TOTAL_STEPS, + f"WO extension... Creating '{extension_name}'{version_msg}" + ) + + create_args = [ + "k8s-extension", "create", + "-g", resource_group, + "--cluster-name", cluster_name, + "--cluster-type", "connectedClusters", + "--name", extension_name, + "--extension-type", DEFAULT_EXTENSION_TYPE, + "--scope", DEFAULT_EXTENSION_SCOPE, + "--release-train", release_train, + "--auto-upgrade", "false", + ] + if extension_version: + create_args.extend(["--version", extension_version]) + if no_wait: + create_args.append("--no-wait") + + # Auto-detect storage class and pass redis PVC config + storage_class = _detect_storage_class(kube_config, kube_context) + if storage_class: + create_args.extend([ + "--configuration-settings", + f"redis.persistentVolume.storageClass={storage_class}", + "--configuration-settings", + f"redis.persistentVolume.size={DEFAULT_STORAGE_SIZE}", + ]) + + result = invoke_cli_command(cmd, create_args) + ext_id = result.get("id", "") if isinstance(result, dict) else "" + + if no_wait: + print_step(3, TOTAL_STEPS, "WO extension", "Creating (--no-wait) ✓") + else: + print_step(3, TOTAL_STEPS, "WO extension", "Installed ✓") + + return ext_id + + +# --------------------------------------------------------------------------- +# target_prepare — Step 4: Custom location +# --------------------------------------------------------------------------- + +def _ensure_custom_location( + cmd, cluster_name, resource_group, location, # pylint: disable=unused-argument + custom_location_name, extension_id, connected_cluster_id +): + """Check if custom location exists; create if missing.""" + # Check existing - use REST directly to avoid CLI error output on 404 + sub_id = _get_subscription_id(cmd) + cl_arm_url = ( + f"https://management.azure.com/subscriptions" + f"/{sub_id}/resourceGroups/{resource_group}" + f"/providers/Microsoft.ExtendedLocation" + f"/customLocations/{custom_location_name}" + ) + try: + response = send_raw_request( + cmd.cli_ctx, + method="GET", + url=f"{cl_arm_url}?api-version=2021-08-15", + resource="https://management.azure.com" + ) + if response.status_code == 200 and response.text: + cl_info = response.json() + cl_id = cl_info.get("id", "") + if cl_id: + print_step( + 4, TOTAL_STEPS, "Custom location", + f"Already exists ✓ ('{custom_location_name}')" + ) + return cl_id + except Exception: + pass # Not found or error, proceed to create + + if not extension_id: + raise CLIInternalError( + "Cannot create custom location: WO extension ID is not available." + ) + + print_step( + 4, TOTAL_STEPS, + f"Custom location... Creating '{custom_location_name}'" + ) + + try: + result = invoke_cli_command( + cmd, + [ + "customlocation", "create", + "-g", resource_group, + "-n", custom_location_name, + "--cluster-extension-ids", extension_id, + "--host-resource-id", connected_cluster_id, + "--namespace", DEFAULT_EXTENSION_NAMESPACE, + "--location", location, + ] + ) + cl_id = result.get("id", "") if isinstance(result, dict) else "" + except CLIInternalError as exc: + raise CLIInternalError( + f"Failed to create custom location: {exc}" + ) + + print_step(4, TOTAL_STEPS, "Custom location", "Created ✓") + return cl_id + + +# --------------------------------------------------------------------------- +# target_prepare — Helpers +# --------------------------------------------------------------------------- + +def _detect_storage_class(kube_config=None, kube_context=None): + """Auto-detect the default storage class from the cluster.""" + try: + from kubernetes import client, config as k8s_config + k8s_config.load_kube_config( + config_file=kube_config, context=kube_context + ) + storage_v1 = client.StorageV1Api() + scs = storage_v1.list_storage_class() + for sc in scs.items: + annotations = sc.metadata.annotations or {} + if annotations.get("storageclass.kubernetes.io/is-default-class") == "true": + logger.info("Auto-detected default storage class: %s", sc.metadata.name) + return sc.metadata.name + # Fallback: first available storage class + if scs.items: + name = scs.items[0].metadata.name + logger.info("No default storage class found, using first: %s", name) + return name + except Exception as exc: + logger.warning("Could not detect storage class: %s", exc) + return None + + +def _print_failure_hint(step_results): + """Print a concise one-line failure summary to stderr. + + The raw error from the underlying az subcommand has already been + printed (it goes to stderr from `invoke_cli_command`), and azcli + will print our raised CLIInternalError on exit. This hint just + points to the failed step + tells the user retry is safe. + """ + failed = [k for k, v in step_results.items() if "FAILED" in v] + if not failed: + return + name = failed[-1] + _eprint(f"\n✗ {name} failed — see error above.") + _eprint(" Re-run the command to retry; completed steps will be skipped.\n") + + +def _write_extended_location_file(extended_location): + """Write extended-location.json to the current working directory.""" + filepath = os.path.join(os.getcwd(), "extended-location.json") + with open(filepath, "w", encoding="utf-8") as f: + json.dump(extended_location, f, indent=2) + _eprint(f"\n File written: {filepath}") + + +# =========================================================================== +# target_deploy +# =========================================================================== + +API_VERSION = "2025-08-01" + + +def target_deploy( + cmd, + resource_group, + target_name, + solution_template_name=None, + solution_template_version=None, + solution_template_rg=None, + config=None, + config_hierarchy_id=None, + config_template_rg=None, + config_template_name=None, + config_template_version=None, +): + """Deploy a solution to a target: config-set → review → publish → install. + + Standalone deploy function (used internally). + """ + sub_id = _get_subscription_id(cmd) + + solution_template_version_id = _resolve_template_version_id( + solution_template_name, solution_template_version, + solution_template_rg, resource_group, sub_id, + ) + + base_url = ( + f"{ARM_ENDPOINT}/subscriptions/{sub_id}" + f"/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/targets/{target_name}" + ) + + do_config = config is not None + + results = {} + sv_id = None + + # --- Step 0: Config set --- + if do_config: + _handle_config_set( + cmd, config, config_hierarchy_id, config_template_rg, + config_template_name, config_template_version, + resource_group, target_name, sub_id, + ) + results["configSet"] = "Succeeded" + + # --- Step 1: Review --- + review_result = _do_review(cmd, base_url, solution_template_version_id) + results["review"] = review_result + sv_id = _extract_solution_version_id(review_result) + + # --- Step 2: Publish --- + publish_result = _do_publish(cmd, base_url, sv_id) + results["publish"] = publish_result + + # --- Step 3: Install --- + install_result = _do_install(cmd, base_url, sv_id) + results["install"] = install_result + + return results.get("install", { + "status": "Succeeded", + "resourceId": f"{base_url}", + }) + + +def target_deploy_pre_install( + cmd, + resource_group, + target_name, + solution_template_name=None, + solution_template_version=None, + solution_template_rg=None, + config=None, +): + """Run config-set → review → publish and return the solution-version-id. + + Called by the enhanced `target install` command before the AAZ install step. + Does NOT run install — that's handled by the AAZ LRO. + + When using friendly name, solution_template_rg defaults to resource_group. + Config-template args are auto-derived from solution template args. + """ + sub_id = _get_subscription_id(cmd) + + solution_template_version_id = _resolve_template_version_id( + solution_template_name, solution_template_version, + solution_template_rg, resource_group, sub_id, + ) + + base_url = ( + f"{ARM_ENDPOINT}/subscriptions/{sub_id}" + f"/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/targets/{target_name}" + ) + + do_config = config is not None + + # --- Step 0: Config set --- + if do_config: + # Auto-derive config template args from solution template args + ct_rg = solution_template_rg or resource_group + ct_name = solution_template_name + ct_version = solution_template_version + + _handle_config_set( + cmd, config, None, ct_rg, + ct_name, ct_version, + resource_group, target_name, sub_id, + ) + + # --- Step 1: Review --- + review_result = _do_review(cmd, base_url, solution_template_version_id) + sv_id = _extract_solution_version_id(review_result) + + # --- Step 2: Publish --- + _do_publish(cmd, base_url, sv_id) + + # Step 3 (Install) is handled by AAZ LRO — tick printed in post_operations + + return sv_id + +# --------------------------------------------------------------------------- +# target_deploy — Resolution helpers +# --------------------------------------------------------------------------- + + +def _get_subscription_id(cmd): + """Get subscription ID from CLI context.""" + sub_id = cmd.cli_ctx.data.get('subscription_id') + if not sub_id: + from azure.cli.core._profile import Profile + sub_id = Profile(cli_ctx=cmd.cli_ctx).get_subscription_id() + return sub_id + + +def _resolve_template_version_id( + template_name, template_version, template_rg, + default_rg, sub_id, +): + """Resolve solution-template-version-id from the friendly-name args. + + When template_rg is not provided, defaults to default_rg (target's RG). + """ + if not template_name: + raise ValidationError( + "--solution-template-name is required for full deploy." + ) + if not template_version: + raise ValidationError( + "--solution-template-version is required when using --solution-template-name." + ) + rg = template_rg or default_rg + return ( + f"/subscriptions/{sub_id}/resourceGroups/{rg}" + f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" + f"/versions/{template_version}" + ) + +# --------------------------------------------------------------------------- +# target_deploy — Step implementations +# --------------------------------------------------------------------------- + + +def _do_review(cmd, base_url, solution_template_version_id): + """POST .../reviewSolutionVersion""" + url = f"{base_url}/reviewSolutionVersion?api-version={API_VERSION}" + body = { + "solutionTemplateVersionId": solution_template_version_id, + } + + resp = send_raw_request( + cmd.cli_ctx, "POST", url, + body=json.dumps(body), + headers=["Content-Type=application/json"], + resource=ARM_ENDPOINT, + ) + return _parse_response(resp, "Review", cmd=cmd) + + +def _do_publish(cmd, base_url, solution_version_id): + """POST .../publishSolutionVersion""" + url = f"{base_url}/publishSolutionVersion?api-version={API_VERSION}" + body = {"solutionVersionId": solution_version_id} + + resp = send_raw_request( + cmd.cli_ctx, "POST", url, + body=json.dumps(body), + headers=["Content-Type=application/json"], + resource=ARM_ENDPOINT, + ) + return _parse_response(resp, "Publish", cmd=cmd) + + +def _do_install(cmd, base_url, solution_version_id): + """POST .../installSolution""" + url = f"{base_url}/installSolution?api-version={API_VERSION}" + body = {"solutionVersionId": solution_version_id} + + resp = send_raw_request( + cmd.cli_ctx, "POST", url, + body=json.dumps(body), + headers=["Content-Type=application/json"], + resource=ARM_ENDPOINT, + ) + + return _parse_response(resp, "Install", cmd=cmd) + + +def _handle_config_set( + cmd, config_file, hierarchy_id, template_rg, + template_name, template_version, + resource_group, target_name, sub_id, +): + """Set configuration values from file before review. + + Calls the configuration-set REST APIs directly (no subprocess). + Flow: resolve config ID → resolve template unique ID → GET/PUT dynamic config version. + """ + if not hierarchy_id: + hierarchy_id = ( + f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" + f"/providers/Microsoft.Edge/targets/{target_name}" + ) + + if not template_rg or not template_name or not template_version: + raise ValidationError( + "When using --config, you must also provide " + "--config-template-rg, --config-template-name, and --config-template-version." + ) + + config_content = _read_config_file(config_file) + + # Step 1: Resolve configuration ID from hierarchy's config reference + config_ref_url = ( + f"{ARM_ENDPOINT}{hierarchy_id}" + f"/providers/Microsoft.Edge/configurationreferences/default" + f"?api-version={API_VERSION}" + ) + ref_resp = send_raw_request( + cmd.cli_ctx, "GET", config_ref_url, + headers=["Accept=application/json"], + resource=ARM_ENDPOINT, + ) + if ref_resp.status_code != 200: + raise CLIInternalError( + f"Failed to get configuration reference for {hierarchy_id} " + f"(HTTP {ref_resp.status_code}). Ensure hierarchy has a configuration reference." + ) + configuration_id = ref_resp.json().get("properties", {}).get("configurationResourceId") + if not configuration_id: + raise CLIInternalError( + f"Configuration reference for {hierarchy_id} has no configurationResourceId." + ) + + # Step 2: Resolve solution template unique identifier (used as dynamic config name) + st_url = ( + f"{ARM_ENDPOINT}/subscriptions/{sub_id}" + f"/resourceGroups/{template_rg}" + f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" + f"?api-version={API_VERSION}" + ) + st_resp = send_raw_request( + cmd.cli_ctx, "GET", st_url, + headers=["Accept=application/json"], + resource=ARM_ENDPOINT, + ) + if st_resp.status_code != 200: + raise CLIInternalError( + f"Solution template '{template_name}' not found in RG '{template_rg}' " + f"(HTTP {st_resp.status_code})." + ) + st_body = st_resp.json() + dynamic_config_name = ( + st_body.get("properties", {}).get("uniqueIdentifier") + or template_name + ) + + # Step 3: GET dynamic config version (check if it exists) + version_url = ( + f"{ARM_ENDPOINT}{configuration_id}" + f"/dynamicConfigurations/{dynamic_config_name}" + f"/versions/{template_version}" + f"?api-version={API_VERSION}" + ) + version_resp = send_raw_request( + cmd.cli_ctx, "GET", version_url, + headers=["Accept=application/json"], + resource=ARM_ENDPOINT, + ) + + if version_resp.status_code == 200: + # Update existing dynamic config version + existing = version_resp.json() + existing["properties"]["values"] = config_content + send_raw_request( + cmd.cli_ctx, "PUT", version_url, + body=json.dumps(existing), + headers=["Content-Type=application/json", "Accept=application/json"], + resource=ARM_ENDPOINT, + ) + elif version_resp.status_code == 404: + # Create new: first ensure parent dynamic config exists + dc_url = ( + f"{ARM_ENDPOINT}{configuration_id}" + f"/dynamicConfigurations/{dynamic_config_name}" + f"?api-version={API_VERSION}" + ) + dc_body = {"properties": {"currentVersion": template_version}} + dc_resp = send_raw_request( + cmd.cli_ctx, "PUT", dc_url, + body=json.dumps(dc_body), + headers=["Content-Type=application/json", "Accept=application/json"], + resource=ARM_ENDPOINT, + ) + if dc_resp.status_code not in (200, 201): + raise CLIInternalError( + f"Failed to create dynamic configuration (HTTP {dc_resp.status_code}): " + f"{dc_resp.text}" + ) + + # Then create the version with config values + ver_body = {"properties": {"values": config_content}} + ver_resp = send_raw_request( + cmd.cli_ctx, "PUT", version_url, + body=json.dumps(ver_body), + headers=["Content-Type=application/json", "Accept=application/json"], + resource=ARM_ENDPOINT, + ) + if ver_resp.status_code not in (200, 201): + raise CLIInternalError( + f"Failed to create dynamic configuration version (HTTP {ver_resp.status_code}): " + f"{ver_resp.text}" + ) + else: + raise CLIInternalError( + f"Failed to check dynamic configuration version (HTTP {version_resp.status_code}): " + f"{version_resp.text}" + ) + + +def _read_config_file(file_path): + """Read and return contents of a YAML/JSON config file.""" + if not os.path.isfile(file_path): + raise ValidationError(f"Config file not found: {file_path}") + with open(file_path, "r", encoding="utf-8") as f: + return f.read() + +# --------------------------------------------------------------------------- +# target_deploy — LRO and response helpers +# --------------------------------------------------------------------------- + + +def _parse_response(resp, step_name, cmd=None): + """Parse REST response, handling 200/201/202 LRO patterns.""" + status = resp.status_code + if status in (200, 201): + try: + return resp.json() + except (ValueError, AttributeError): + return {"status": "Succeeded"} + if status == 202: + return _poll_lro(resp, step_name, cmd=cmd) + + try: + error_body = resp.text + except (ValueError, AttributeError): + error_body = f"HTTP {status}" + raise CLIInternalError(f"{step_name} failed (HTTP {status}): {error_body}") + + +def _poll_lro(resp, step_name, cmd=None): + """Poll an LRO via Location or Azure-AsyncOperation header.""" + import time + + location = resp.headers.get("Location") or resp.headers.get("Azure-AsyncOperation") + if not location: + logger.warning("No LRO polling URL in %s response headers", step_name) + return {"status": "Accepted"} + + retry_after = int(resp.headers.get("Retry-After", "10")) + max_polls = 60 # ~10 min max + + for i in range(max_polls): + time.sleep(retry_after) + try: + poll_resp = send_raw_request(cmd.cli_ctx, "GET", location, resource=ARM_ENDPOINT) + except (CLIInternalError, ValueError, ConnectionError): + logger.debug("LRO poll attempt %d failed for %s", i + 1, step_name) + continue + + try: + body = poll_resp.json() + except (ValueError, AttributeError): + continue + + poll_status = body.get("status", "").lower() + if poll_status in ("succeeded", "completed"): + return body + if poll_status in ("failed", "canceled", "cancelled"): + raise CLIInternalError( + f"{step_name} LRO failed: {json.dumps(body, indent=2)}" + ) + + raise CLIInternalError(f"{step_name} LRO timed out after {max_polls * retry_after}s") + + +def _extract_solution_version_id(review_result): + """Extract solution-version-id from review response.""" + if not review_result or not isinstance(review_result, dict): + raise CLIInternalError("Review returned no result - cannot determine solution version ID.") + + # The LRO response structure: + # {id, name, status, properties: {id: , properties: {...}, ...}} + # The solution version ARM ID is at properties.id (NOT properties.properties.id) + props = review_result.get("properties", {}) + + sv_id = ( + props.get("id") # properties.id (most common) + or review_result.get("solutionVersionId") # top-level fallback + or props.get("solutionVersionId") # properties.solutionVersionId + or (props.get("properties", {}) or {}).get("id") # properties.properties.id + ) + if not sv_id: + logger.warning( + "Could not extract solutionVersionId. Keys: %s, full (truncated): %s", + list(review_result.keys()), + json.dumps(review_result, indent=2)[:800] + ) + raise CLIInternalError( + "Review succeeded but no solutionVersionId found in response." + ) + return sv_id + + +# =========================================================================== +# target_sg_link +# =========================================================================== + +def link_target_to_service_group(cmd, target_id, service_group_name): + """Link a target to a service group and refresh hierarchy. + + Two REST calls: + 1. PUT {targetId}/providers/Microsoft.Relationships/serviceGroupMember/{sgName} + 2. PUT {targetId} (update target to refresh hierarchy — MANDATORY) + """ + sg_member_url = ( + f"{ARM_ENDPOINT}{target_id}" + f"/providers/Microsoft.Relationships/serviceGroupMember/{service_group_name}" + ) + + # Step 1: Create ServiceGroupMember relationship + try: + invoke_cli_command(cmd, [ + "rest", + "--method", "put", + "--url", f"{sg_member_url}?api-version={SG_MEMBER_API_VERSION}", + "--body", json.dumps({ + "properties": { + "targetId": f"/providers/Microsoft.Management/serviceGroups/{service_group_name}" + } + }), + "--resource", ARM_ENDPOINT, + "--header", "Content-Type=application/json", + ], expect_json=False) + logger.info("ServiceGroupMember created: %s -> %s", target_id, service_group_name) + except Exception as exc: + raise CLIInternalError( + f"Failed to link target to service group '{service_group_name}': {exc}" + ) + + # Step 2: Update target to refresh hierarchy (MANDATORY) + try: + # GET current target + target_data = invoke_cli_command(cmd, [ + "rest", + "--method", "get", + "--url", f"{ARM_ENDPOINT}{target_id}?api-version={TARGET_API_VERSION}", + "--resource", ARM_ENDPOINT, + ]) + + # PUT target (update to refresh hierarchy) + if target_data and isinstance(target_data, dict): + # Strip read-only fields, preserve writable top-level fields + body = { + "location": target_data.get("location", ""), + "properties": target_data.get("properties", {}), + } + if "extendedLocation" in target_data: + body["extendedLocation"] = target_data["extendedLocation"] + if "tags" in target_data: + body["tags"] = target_data["tags"] + + invoke_cli_command(cmd, [ + "rest", + "--method", "put", + "--url", f"{ARM_ENDPOINT}{target_id}?api-version={TARGET_API_VERSION}", + "--body", json.dumps(body), + "--resource", ARM_ENDPOINT, + "--header", "Content-Type=application/json", + ], expect_json=False) + logger.info("Target hierarchy refreshed after SG link") + + except Exception as exc: + logger.warning( + "Target hierarchy refresh after SG link may have failed: %s. " + "Target may appear unlinked until next update.", exc + ) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py b/src/workload-orchestration/azext_workload_orchestration/common/utils.py similarity index 98% rename from src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py rename to src/workload-orchestration/azext_workload_orchestration/common/utils.py index 01b15521676..985c0fda265 100644 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/utils.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Shared utilities for onboarding simplification commands. +"""Shared utilities for workload orchestration CLI commands. Provides REST wrappers (using send_raw_request for automatic auth/retry/throttle), LRO polling with Retry-After support, CLI command invocation, and progress output. diff --git a/src/workload-orchestration/azext_workload_orchestration/custom.py b/src/workload-orchestration/azext_workload_orchestration/custom.py index 94e8a97cbbd..849a65ef9de 100644 --- a/src/workload-orchestration/azext_workload_orchestration/custom.py +++ b/src/workload-orchestration/azext_workload_orchestration/custom.py @@ -7,7 +7,3 @@ # Support bundle command from azext_workload_orchestration.support import create_support_bundle # pylint: disable=unused-import # noqa: F401 - -# Onboarding simplification commands -from azext_workload_orchestration.onboarding import target_init # pylint: disable=unused-import # noqa: F401 -from azext_workload_orchestration.onboarding import hierarchy_create # pylint: disable=unused-import # noqa: F401 diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_init.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/context_init.py deleted file mode 100644 index 9572851df46..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/context_init.py +++ /dev/null @@ -1,283 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Context initialization for onboarding simplification. - -Finds or creates a WO context, sets it as current, and ensures the required -capabilities and hierarchy levels are present. - -Usage (called by target create --init-context): - context_id = handle_init_context(cli_ctx, ctx_name, rg, location, - hierarchy_level, capabilities) -""" - -# pylint: disable=broad-exception-caught - -import json -import logging - -from azure.cli.core.azclierror import CLIInternalError - -from azext_workload_orchestration.onboarding.consts import ( - ARM_ENDPOINT, - CONTEXT_API_VERSION, -) -from azext_workload_orchestration.onboarding.utils import ( - CmdProxy, - invoke_cli_command, - invoke_silent, - parse_arm_id, -) - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Public entry point -# --------------------------------------------------------------------------- - -def handle_init_context(cli_ctx, ctx_name, resource_group, location, - hierarchy_level, capabilities): - """Find or create a WO context and return its ARM resource ID. - - Strategy (in order): - 1. Check if a context is already set in CLI config → use it - 2. List contexts in the target's resource group → use first match - 3. Create a new context with the given name - 4. If create fails (e.g. name conflict), search subscription-wide - - After resolving the context, ensures the required hierarchy level and - capabilities are present (adds them if missing). - - Returns: - str: The ARM resource ID of the context. - - Raises: - CLIInternalError: If no context can be found or created. - """ - import configparser - - cmd = CmdProxy(cli_ctx) - - # ------------------------------------------------------------------ - # 1. Check CLI config for an already-set context - # ------------------------------------------------------------------ - try: - existing_ctx_id = cli_ctx.config.get('workload_orchestration', 'context_id') - if existing_ctx_id: - logger.info("Context already set in config: %s", existing_ctx_id) - _ensure_capabilities(cli_ctx, existing_ctx_id, hierarchy_level, capabilities) - print("[init-context] Using existing context [OK]") - return existing_ctx_id - except (configparser.NoSectionError, configparser.NoOptionError): - pass - - # ------------------------------------------------------------------ - # 2. List contexts in this resource group - # ------------------------------------------------------------------ - try: - existing = invoke_cli_command(cmd, [ - "workload-orchestration", "context", "list", "-g", resource_group - ]) - if existing and isinstance(existing, list) and len(existing) > 0: - ctx_id = existing[0].get("id", "") - if ctx_id: - parts = parse_arm_id(ctx_id) - found_name = parts.get("contexts", ctx_name) - found_rg = parts.get("resourcegroups", resource_group) - _set_current(found_name, found_rg) - _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities) - print(f"[init-context] Using existing context '{found_name}' [OK]") - return ctx_id - except Exception: - pass # No contexts found — proceed to create - - # ------------------------------------------------------------------ - # 3. Create a new context - # ------------------------------------------------------------------ - print(f"[init-context] Creating context '{ctx_name}'...") - - create_args = _build_create_args(ctx_name, resource_group, location, - hierarchy_level, capabilities) - exit_code = invoke_silent(create_args) - - if exit_code == 0: - _set_current(ctx_name, resource_group) - - # Read back the context ID from config (set by 'context use') - try: - ctx_id = cli_ctx.config.get('workload_orchestration', 'context_id') - if ctx_id: - print(f"[init-context] Context '{ctx_name}' created [OK]") - return ctx_id - except (configparser.NoSectionError, configparser.NoOptionError): - pass - - # Fallback: construct the ID manually - sub_id = cli_ctx.data.get('subscription_id', '') - ctx_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" - f"/providers/Microsoft.Edge/contexts/{ctx_name}") - print(f"[init-context] Context '{ctx_name}' created [OK]") - return ctx_id - - # ------------------------------------------------------------------ - # 4. Create failed — search subscription-wide - # ------------------------------------------------------------------ - logger.warning("Context create returned exit %d. Searching subscription...", exit_code) - ctx_id = _search_subscription(cli_ctx) - if ctx_id: - parts = parse_arm_id(ctx_id) - found_name = parts.get("contexts", "unknown") - found_rg = parts.get("resourcegroups", resource_group) - _set_current(found_name, found_rg) - _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities) - print(f"[init-context] Using existing context '{found_name}' in RG '{found_rg}' [OK]") - return ctx_id - - raise CLIInternalError( - "Could not create or find an existing context. " - "Please provide --context-id explicitly." - ) - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - -def _build_create_args(ctx_name, resource_group, location, - hierarchy_level, capabilities): - """Build the arg list for 'az workload-orchestration context create'.""" - # Capabilities: [0].name=X [0].description=X [1].name=Y ... - cap_args = [] - for i, cap in enumerate(capabilities or []): - cap_args.extend([f"[{i}].name={cap}", f"[{i}].description={cap}"]) - - hier_args = [f"[0].name={hierarchy_level}", f"[0].description={hierarchy_level}"] - - args = [ - "workload-orchestration", "context", "create", - "-g", resource_group, "-l", location, "--name", ctx_name, - "--hierarchies", - ] + hier_args - - if cap_args: - args.append("--capabilities") - args.extend(cap_args) - - args.extend(["-o", "none"]) - return args - - -def _set_current(ctx_name, ctx_rg): - """Set a context as the CLI default (silently).""" - invoke_silent([ - "workload-orchestration", "context", "use", - "--name", ctx_name, "-g", ctx_rg, "-o", "none", - ]) - - -def _search_subscription(cli_ctx): - """Search the entire subscription for any existing context. Returns ID or None.""" - from azure.cli.core.util import send_raw_request - - sub_id = cli_ctx.data.get('subscription_id', '') - try: - resp = send_raw_request( - cli_ctx, - method="GET", - url=(f"{ARM_ENDPOINT}/subscriptions/{sub_id}" - f"/providers/Microsoft.Edge/contexts" - f"?api-version={CONTEXT_API_VERSION}"), - resource=ARM_ENDPOINT, - ) - if resp.status_code == 200: - contexts = resp.json().get("value", []) - if contexts: - return contexts[0].get("id") - except Exception as exc: - logger.warning("Subscription-wide context search failed: %s", exc) - return None - - -def _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities): - """Add missing capabilities/hierarchies to an existing context via PUT.""" - if not capabilities: - return - - cmd = CmdProxy(cli_ctx) - parts = parse_arm_id(ctx_id) - ctx_rg = parts.get("resourcegroups") - ctx_name = parts.get("contexts") - sub_id = parts.get("subscriptions") - - if not ctx_rg or not ctx_name: - return - - # Get current context state - try: - ctx_data = invoke_cli_command(cmd, [ - "workload-orchestration", "context", "show", - "-g", ctx_rg, "--name", ctx_name, - ]) - except Exception: - return - - if not ctx_data or not isinstance(ctx_data, dict): - return - - props = ctx_data.get("properties", {}) - existing_caps = {c.get("name", "") for c in (props.get("capabilities") or [])} - existing_hiers = {h.get("name", "") for h in (props.get("hierarchies") or [])} - - missing_caps = [c for c in capabilities if c not in existing_caps] - missing_hier = hierarchy_level not in existing_hiers - - if not missing_caps and not missing_hier: - return # Nothing to add - - # Merge existing + new - all_caps = list(props.get("capabilities") or []) - for cap in missing_caps: - all_caps.append({"name": cap, "description": cap}) - - all_hiers = list(props.get("hierarchies") or []) - if missing_hier: - all_hiers.append({"name": hierarchy_level, "description": hierarchy_level}) - - print(f"[init-context] Adding capabilities {missing_caps} to context...") - - # PUT updated context - from azure.cli.core.util import send_raw_request - - if not sub_id: - sub_id = cli_ctx.data.get('subscription_id', '') - - location = ctx_data.get("location", "") - body = { - "location": location, - "properties": { - "capabilities": [{"name": c.get("name", ""), "description": c.get("description", "")} - for c in all_caps], - "hierarchies": [{"name": h.get("name", ""), "description": h.get("description", "")} - for h in all_hiers], - } - } - - try: - resp = send_raw_request( - cli_ctx, - method="PUT", - url=(f"{ARM_ENDPOINT}/subscriptions/{sub_id}" - f"/resourceGroups/{ctx_rg}/providers/Microsoft.Edge" - f"/contexts/{ctx_name}?api-version={CONTEXT_API_VERSION}"), - body=json.dumps(body), - resource=ARM_ENDPOINT, - ) - if resp.status_code in (200, 201): - print("[init-context] Capabilities updated [OK]") - else: - logger.warning("Context update returned %d: %s", resp.status_code, resp.text) - except Exception as exc: - logger.warning("Failed to update context capabilities: %s", exc) diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py deleted file mode 100644 index 70237fc7726..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/hierarchy_init.py +++ /dev/null @@ -1,173 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Lightweight hierarchy initialization for target create --init-hierarchy. - -Creates a simple site + configuration + config-reference + site-reference -in a resource group scope (no service group). This is the "RG-scoped" -hierarchy used when a user just wants a quick site without the full -hierarchy_create flow (which requires a Service Group parent). - -Usage (called by target create --init-hierarchy): - handle_init_hierarchy(cli_ctx, site_name, resource_group, location, - hierarchy_level, context_id) -""" - -# pylint: disable=broad-exception-caught - -import json -import logging - -from azure.cli.core.util import send_raw_request - -from azext_workload_orchestration.onboarding.consts import ( - ARM_ENDPOINT, - SITE_API_VERSION, - CONFIGURATION_API_VERSION, - CONFIG_REF_API_VERSION, -) -from azext_workload_orchestration.onboarding.utils import ( - invoke_silent, - parse_arm_id, -) - -logger = logging.getLogger(__name__) - - -def handle_init_hierarchy(cli_ctx, site_name, resource_group, location, - hierarchy_level, context_id=None): - """Create a minimal RG-scoped hierarchy: Site → Configuration → ConfigRef → SiteRef. - - Steps: - 1. PUT site at regional endpoint - 2. PUT configuration at regional endpoint - 3. PUT configuration-reference (links config → site) - 4. Create site-reference via CLI (links site → context) - - All PUTs are idempotent — safe to re-run. - - Args: - cli_ctx: Azure CLI context (from self.ctx.cli_ctx) - site_name: Name for the new site - resource_group: Target resource group - location: Azure region (e.g., eastus2euap) - hierarchy_level: Level label (e.g., "line", "factory") - context_id: Optional ARM ID of the context to link to - """ - # Get subscription ID — prefer extracting from context_id, fall back to CLI profile - if context_id: - parts = parse_arm_id(context_id) - sub_id = parts.get("subscriptions", "") - else: - sub_id = "" - if not sub_id: - from azure.cli.core._profile import Profile - sub_id = Profile(cli_ctx=cli_ctx).get_subscription_id() - regional_base = f"https://{location}.management.azure.com" - - site_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" - f"/providers/Microsoft.Edge/sites/{site_name}") - config_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" - f"/providers/Microsoft.Edge/configurations/{site_name}") - - print(f"[init-hierarchy] Creating site '{site_name}'...") - - # Step 1: Create Site (regional endpoint) - _put_resource( - cli_ctx, - url=f"{regional_base}{site_id}?api-version={SITE_API_VERSION}", - body={ - "properties": { - "displayName": site_name, - "description": site_name, - "labels": {"level": hierarchy_level or "line"}, - } - }, - label="Site", - ) - - # Step 2: Create Configuration (regional endpoint) - _put_resource( - cli_ctx, - url=f"{regional_base}{config_id}?api-version={CONFIGURATION_API_VERSION}", - body={"location": location}, - label="Configuration", - ) - - # Step 3: Create Configuration Reference (links config → site) - config_ref_url = ( - f"{ARM_ENDPOINT}{site_id}" - f"/providers/Microsoft.Edge/configurationreferences/default" - f"?api-version={CONFIG_REF_API_VERSION}" - ) - _put_resource( - cli_ctx, - url=config_ref_url, - body={"properties": {"configurationResourceId": config_id}}, - label="Configuration Reference", - ) - - # Step 4: Create Site Reference (links site → context) - if context_id: - _create_site_reference(context_id, site_name, site_id) - - print(f"[init-hierarchy] Site '{site_name}' + config + references created [OK]") - - -# --------------------------------------------------------------------------- -# Private helpers -# --------------------------------------------------------------------------- - -def _put_resource(cli_ctx, url, body, label): - """PUT a resource via send_raw_request. Logs on failure but doesn't crash.""" - try: - resp = send_raw_request( - cli_ctx, - method="PUT", - url=url, - body=json.dumps(body), - resource=ARM_ENDPOINT, - headers=["Content-Type=application/json"], - ) - if resp.status_code in (200, 201): - logger.info("%s created/updated successfully", label) - else: - logger.warning("%s PUT returned %d: %s", label, resp.status_code, resp.text) - except Exception as exc: - logger.warning("%s creation failed: %s", label, exc) - raise - - -def _create_site_reference(context_id, site_name, site_id): - """Create a site-reference linking the site to the context. - - Name format: -<7-char sha256(lower(site_arm_id))>. - 7-char hash matches the BVT/Git convention (see ContextExtension.cs). - """ - import hashlib - import re - - parts = parse_arm_id(context_id) - ctx_rg = parts.get("resourcegroups", "") - ctx_name = parts.get("contexts", "default") - - if not ctx_rg: - return - - # Site-reference name must satisfy ^[a-zA-Z0-9-]{3,24}$ — cap site portion at 16 chars - hash_suffix = hashlib.sha256(site_id.lower().encode("utf-8")).hexdigest()[:7] - sanitized = re.sub(r'[^a-zA-Z0-9-]', '-', site_name)[:16].rstrip("-") - ref_name = f"{sanitized}-{hash_suffix}" - - try: - invoke_silent([ - "workload-orchestration", "context", "site-reference", "create", - "-g", ctx_rg, "--context-name", ctx_name, - "--name", ref_name, - "--site-id", site_id, - "-o", "none", - ]) - except Exception: - pass # Site reference may already exist diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py deleted file mode 100644 index e3bfa675b94..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_deploy.py +++ /dev/null @@ -1,497 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Target deploy command - chains review -> publish -> install in one step. - -Replaces 3 manual commands: - 1. az workload-orchestration target review - 2. az workload-orchestration target publish - 3. az workload-orchestration target install - -Optionally prepends config-set (step 0) when --config is provided. - -Usage: - # Friendly name (template lives in target's RG) - az workload-orchestration target deploy \\ - -g my-rg -n my-target \\ - --solution-template-name tmpl --solution-template-version 1.0.0 - - # Friendly name with explicit template RG - az workload-orchestration target deploy \\ - -g my-rg -n my-target \\ - --solution-template-name tmpl --solution-template-version 1.0.0 \\ - --solution-template-rg shared-rg - - # With config - az workload-orchestration target deploy \\ - -g my-rg -n my-target \\ - --solution-template-name tmpl --solution-template-version 1.0.0 \\ - --config values.yaml \\ - --config-template-rg rg --config-template-name tmpl --config-template-version 1.0.0 -""" - -import json -import logging - -from azure.cli.core.azclierror import CLIInternalError, ValidationError -from azure.cli.core.util import send_raw_request - -import sys - -logger = logging.getLogger(__name__) - - -def _eprint(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) - - -API_VERSION = "2025-08-01" -ARM_RESOURCE = "https://management.azure.com" - - -def target_deploy( - cmd, - resource_group, - target_name, - solution_template_name=None, - solution_template_version=None, - solution_template_rg=None, - config=None, - config_hierarchy_id=None, - config_template_rg=None, - config_template_name=None, - config_template_version=None, -): - """Deploy a solution to a target: config-set → review → publish → install. - - Standalone deploy function (used internally). - """ - sub_id = _get_subscription_id(cmd) - - # --- Resolve solution-template-version-id --- - solution_template_version_id = _resolve_template_version_id( - solution_template_name, solution_template_version, - solution_template_rg, resource_group, sub_id, - ) - - base_url = ( - f"{ARM_RESOURCE}/subscriptions/{sub_id}" - f"/resourceGroups/{resource_group}" - f"/providers/Microsoft.Edge/targets/{target_name}" - ) - - # Figure out which steps to run - do_config = config is not None - - results = {} - sv_id = None - - # --- Step 0: Config set --- - if do_config: - _handle_config_set( - cmd, config, config_hierarchy_id, config_template_rg, - config_template_name, config_template_version, - resource_group, target_name, sub_id, - ) - results["configSet"] = "Succeeded" - - # --- Step 1: Review --- - review_result = _do_review(cmd, base_url, solution_template_version_id) - results["review"] = review_result - sv_id = _extract_solution_version_id(review_result) - - # --- Step 2: Publish --- - publish_result = _do_publish(cmd, base_url, sv_id) - results["publish"] = publish_result - - # --- Step 3: Install --- - install_result = _do_install(cmd, base_url, sv_id) - results["install"] = install_result - - # Return the install LRO result (same format as `az wo target install`) - return results.get("install", { - "status": "Succeeded", - "resourceId": f"{base_url}", - }) - - -def target_deploy_pre_install( - cmd, - resource_group, - target_name, - solution_template_name=None, - solution_template_version=None, - solution_template_rg=None, - config=None, -): - """Run config-set → review → publish and return the solution-version-id. - - Called by the enhanced `target install` command before the AAZ install step. - Does NOT run install — that's handled by the AAZ LRO. - - When using friendly name, solution_template_rg defaults to resource_group. - Config-template args are auto-derived from solution template args. - """ - sub_id = _get_subscription_id(cmd) - - solution_template_version_id = _resolve_template_version_id( - solution_template_name, solution_template_version, - solution_template_rg, resource_group, sub_id, - ) - - base_url = ( - f"{ARM_RESOURCE}/subscriptions/{sub_id}" - f"/resourceGroups/{resource_group}" - f"/providers/Microsoft.Edge/targets/{target_name}" - ) - - do_config = config is not None - - # --- Step 0: Config set --- - if do_config: - # Auto-derive config template args from solution template args - ct_rg = solution_template_rg or resource_group - ct_name = solution_template_name - ct_version = solution_template_version - - _handle_config_set( - cmd, config, None, ct_rg, - ct_name, ct_version, - resource_group, target_name, sub_id, - ) - - # --- Step 1: Review --- - review_result = _do_review(cmd, base_url, solution_template_version_id) - sv_id = _extract_solution_version_id(review_result) - - # --- Step 2: Publish --- - _do_publish(cmd, base_url, sv_id) - - # Step 3 (Install) is handled by AAZ LRO — tick printed in post_operations - - return sv_id - -# --------------------------------------------------------------------------- -# Resolution helpers -# --------------------------------------------------------------------------- - - -def _get_subscription_id(cmd): - """Get subscription ID from CLI context.""" - sub_id = cmd.cli_ctx.data.get('subscription_id') - if not sub_id: - from azure.cli.core._profile import Profile - sub_id = Profile(cli_ctx=cmd.cli_ctx).get_subscription_id() - return sub_id - - -def _resolve_template_version_id( - template_name, template_version, template_rg, - default_rg, sub_id, -): - """Resolve solution-template-version-id from the friendly-name args. - - When template_rg is not provided, defaults to default_rg (target's RG). - """ - if not template_name: - raise ValidationError( - "--solution-template-name is required for full deploy." - ) - if not template_version: - raise ValidationError( - "--solution-template-version is required when using --solution-template-name." - ) - rg = template_rg or default_rg - return ( - f"/subscriptions/{sub_id}/resourceGroups/{rg}" - f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" - f"/versions/{template_version}" - ) - -# --------------------------------------------------------------------------- -# Step implementations -# --------------------------------------------------------------------------- - - -def _do_review(cmd, base_url, solution_template_version_id): - """POST .../reviewSolutionVersion""" - url = f"{base_url}/reviewSolutionVersion?api-version={API_VERSION}" - body = { - "solutionTemplateVersionId": solution_template_version_id, - } - - resp = send_raw_request( - cmd.cli_ctx, "POST", url, - body=json.dumps(body), - headers=["Content-Type=application/json"], - resource=ARM_RESOURCE, - ) - return _parse_response(resp, "Review", cmd=cmd) - - -def _do_publish(cmd, base_url, solution_version_id): - """POST .../publishSolutionVersion""" - url = f"{base_url}/publishSolutionVersion?api-version={API_VERSION}" - body = {"solutionVersionId": solution_version_id} - - resp = send_raw_request( - cmd.cli_ctx, "POST", url, - body=json.dumps(body), - headers=["Content-Type=application/json"], - resource=ARM_RESOURCE, - ) - return _parse_response(resp, "Publish", cmd=cmd) - - -def _do_install(cmd, base_url, solution_version_id): - """POST .../installSolution""" - url = f"{base_url}/installSolution?api-version={API_VERSION}" - body = {"solutionVersionId": solution_version_id} - - resp = send_raw_request( - cmd.cli_ctx, "POST", url, - body=json.dumps(body), - headers=["Content-Type=application/json"], - resource=ARM_RESOURCE, - ) - - return _parse_response(resp, "Install", cmd=cmd) - - -def _handle_config_set( - cmd, config_file, hierarchy_id, template_rg, - template_name, template_version, - resource_group, target_name, sub_id, -): - """Set configuration values from file before review. - - Calls the configuration-set REST APIs directly (no subprocess). - Flow: resolve config ID → resolve template unique ID → GET/PUT dynamic config version. - """ - if not hierarchy_id: - hierarchy_id = ( - f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" - f"/providers/Microsoft.Edge/targets/{target_name}" - ) - - if not template_rg or not template_name or not template_version: - raise ValidationError( - "When using --config, you must also provide " - "--config-template-rg, --config-template-name, and --config-template-version." - ) - - # Read config file content - config_content = _read_config_file(config_file) - - # Step 1: Resolve configuration ID from hierarchy's config reference - config_ref_url = ( - f"{ARM_RESOURCE}{hierarchy_id}" - f"/providers/Microsoft.Edge/configurationreferences/default" - f"?api-version={API_VERSION}" - ) - ref_resp = send_raw_request( - cmd.cli_ctx, "GET", config_ref_url, - headers=["Accept=application/json"], - resource=ARM_RESOURCE, - ) - if ref_resp.status_code != 200: - raise CLIInternalError( - f"Failed to get configuration reference for {hierarchy_id} " - f"(HTTP {ref_resp.status_code}). Ensure hierarchy has a configuration reference." - ) - configuration_id = ref_resp.json().get("properties", {}).get("configurationResourceId") - if not configuration_id: - raise CLIInternalError( - f"Configuration reference for {hierarchy_id} has no configurationResourceId." - ) - - # Step 2: Resolve solution template unique identifier (used as dynamic config name) - st_url = ( - f"{ARM_RESOURCE}/subscriptions/{sub_id}" - f"/resourceGroups/{template_rg}" - f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" - f"?api-version={API_VERSION}" - ) - st_resp = send_raw_request( - cmd.cli_ctx, "GET", st_url, - headers=["Accept=application/json"], - resource=ARM_RESOURCE, - ) - if st_resp.status_code != 200: - raise CLIInternalError( - f"Solution template '{template_name}' not found in RG '{template_rg}' " - f"(HTTP {st_resp.status_code})." - ) - st_body = st_resp.json() - dynamic_config_name = ( - st_body.get("properties", {}).get("uniqueIdentifier") - or template_name - ) - - # Step 3: GET dynamic config version (check if it exists) - version_url = ( - f"{ARM_RESOURCE}{configuration_id}" - f"/dynamicConfigurations/{dynamic_config_name}" - f"/versions/{template_version}" - f"?api-version={API_VERSION}" - ) - version_resp = send_raw_request( - cmd.cli_ctx, "GET", version_url, - headers=["Accept=application/json"], - resource=ARM_RESOURCE, - ) - - if version_resp.status_code == 200: - # Update existing dynamic config version - existing = version_resp.json() - existing["properties"]["values"] = config_content - send_raw_request( - cmd.cli_ctx, "PUT", version_url, - body=json.dumps(existing), - headers=["Content-Type=application/json", "Accept=application/json"], - resource=ARM_RESOURCE, - ) - elif version_resp.status_code == 404: - # Create new: first ensure parent dynamic config exists - dc_url = ( - f"{ARM_RESOURCE}{configuration_id}" - f"/dynamicConfigurations/{dynamic_config_name}" - f"?api-version={API_VERSION}" - ) - dc_body = {"properties": {"currentVersion": template_version}} - dc_resp = send_raw_request( - cmd.cli_ctx, "PUT", dc_url, - body=json.dumps(dc_body), - headers=["Content-Type=application/json", "Accept=application/json"], - resource=ARM_RESOURCE, - ) - if dc_resp.status_code not in (200, 201): - raise CLIInternalError( - f"Failed to create dynamic configuration (HTTP {dc_resp.status_code}): " - f"{dc_resp.text}" - ) - - # Then create the version with config values - ver_body = {"properties": {"values": config_content}} - ver_resp = send_raw_request( - cmd.cli_ctx, "PUT", version_url, - body=json.dumps(ver_body), - headers=["Content-Type=application/json", "Accept=application/json"], - resource=ARM_RESOURCE, - ) - if ver_resp.status_code not in (200, 201): - raise CLIInternalError( - f"Failed to create dynamic configuration version (HTTP {ver_resp.status_code}): " - f"{ver_resp.text}" - ) - else: - raise CLIInternalError( - f"Failed to check dynamic configuration version (HTTP {version_resp.status_code}): " - f"{version_resp.text}" - ) - - -def _read_config_file(file_path): - """Read and return contents of a YAML/JSON config file.""" - import os - if not os.path.isfile(file_path): - raise ValidationError(f"Config file not found: {file_path}") - with open(file_path, "r", encoding="utf-8") as f: - return f.read() - -# --------------------------------------------------------------------------- -# LRO and response helpers -# --------------------------------------------------------------------------- - - -def _parse_response(resp, step_name, cmd=None): - """Parse REST response, handling 200/201/202 LRO patterns.""" - status = resp.status_code - if status in (200, 201): - try: - return resp.json() - except (ValueError, AttributeError): - return {"status": "Succeeded"} - if status == 202: - return _poll_lro(resp, step_name, cmd=cmd) - - # Error - try: - error_body = resp.text - except (ValueError, AttributeError): - error_body = f"HTTP {status}" - raise CLIInternalError(f"{step_name} failed (HTTP {status}): {error_body}") - - -def _poll_lro(resp, step_name, cmd=None): - """Poll an LRO via Location or Azure-AsyncOperation header.""" - import time - - location = resp.headers.get("Location") or resp.headers.get("Azure-AsyncOperation") - if not location: - logger.warning("No LRO polling URL in %s response headers", step_name) - return {"status": "Accepted"} - - retry_after = int(resp.headers.get("Retry-After", "10")) - max_polls = 60 # ~10 min max - - for i in range(max_polls): - time.sleep(retry_after) - try: - poll_resp = send_raw_request(cmd.cli_ctx, "GET", location, resource=ARM_RESOURCE) - except (CLIInternalError, ValueError, ConnectionError): - logger.debug("LRO poll attempt %d failed for %s", i + 1, step_name) - continue - - try: - body = poll_resp.json() - except (ValueError, AttributeError): - continue - - poll_status = body.get("status", "").lower() - if poll_status in ("succeeded", "completed"): - return body - if poll_status in ("failed", "canceled", "cancelled"): - raise CLIInternalError( - f"{step_name} LRO failed: {json.dumps(body, indent=2)}" - ) - - raise CLIInternalError(f"{step_name} LRO timed out after {max_polls * retry_after}s") - - -def _extract_solution_version_id(review_result): - """Extract solution-version-id from review response.""" - if not review_result or not isinstance(review_result, dict): - raise CLIInternalError("Review returned no result - cannot determine solution version ID.") - - # The LRO response structure: - # {id, name, status, properties: {id: , properties: {...}, ...}} - # The solution version ARM ID is at properties.id (NOT properties.properties.id) - props = review_result.get("properties", {}) - - sv_id = ( - props.get("id") # properties.id (most common) - or review_result.get("solutionVersionId") # top-level fallback - or props.get("solutionVersionId") # properties.solutionVersionId - or (props.get("properties", {}) or {}).get("id") # properties.properties.id - ) - if not sv_id: - logger.warning( - "Could not extract solutionVersionId. Keys: %s, full (truncated): %s", - list(review_result.keys()), - json.dumps(review_result, indent=2)[:800] - ) - raise CLIInternalError( - "Review succeeded but no solutionVersionId found in response." - ) - return sv_id - - -def _short_id(arm_id): - """Return the last segment of an ARM ID for display.""" - if not arm_id: - return "" - parts = arm_id.strip("/").split("/") - return parts[-1] if parts else arm_id diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py deleted file mode 100644 index f5f2658e51b..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_prepare.py +++ /dev/null @@ -1,517 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Target prepare command - prepares an Arc-connected K8s cluster for WO. - -Installs cert-manager, trust-manager, WO extension, and creates a custom -location. Idempotent - skips components that already exist. - -Usage: - az workload-orchestration target prepare \\ - --cluster-name my-cluster -g my-rg -l eastus -""" - -# pylint: disable=broad-exception-caught -# pylint: disable=too-many-locals -# pylint: disable=too-many-statements -# pylint: disable=too-many-branches -# pylint: disable=import-outside-toplevel - -import json -import os -import logging -import sys - -from azure.cli.core.azclierror import ( - CLIInternalError, - ValidationError, -) -from azure.cli.core.util import send_raw_request - -from azext_workload_orchestration.onboarding.consts import ( - DEFAULT_CERT_MANAGER_VERSION, - AIO_PLATFORM_EXTENSION_TYPE, - AIO_PLATFORM_EXTENSION_NAME, - AIO_PLATFORM_EXTENSION_NAMESPACE, - AIO_PLATFORM_EXTENSION_SCOPE, - DEFAULT_EXTENSION_TYPE, - DEFAULT_EXTENSION_NAME, - DEFAULT_RELEASE_TRAIN, - DEFAULT_EXTENSION_NAMESPACE, - DEFAULT_EXTENSION_SCOPE, - DEFAULT_STORAGE_SIZE, -) -from azext_workload_orchestration.onboarding.utils import ( - invoke_cli_command, - print_step, - print_success, -) - -logger = logging.getLogger(__name__) - - -def _eprint(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) - - -TOTAL_STEPS = 4 - - -def target_prepare( - cmd, - cluster_name, - resource_group, - location, - extension_name=None, - custom_location_name=None, - custom_location_resource_group=None, - custom_location_location=None, - extension_version=None, - release_train=None, - cert_manager_version=None, - kube_config=None, - kube_context=None, - no_wait=False, -): - """Prepare an Arc-connected K8s cluster for Workload Orchestration. - - Installs cert-manager + trust-manager (via the AIO platform Arc - extension), the WO extension, and creates a custom location. - Idempotent: skips components that are already installed. - """ - extension_name = extension_name or DEFAULT_EXTENSION_NAME - custom_location_name = custom_location_name or f"{cluster_name}-cl" - custom_location_resource_group = custom_location_resource_group or resource_group - custom_location_location = custom_location_location or location - release_train = release_train or DEFAULT_RELEASE_TRAIN - cert_manager_version = cert_manager_version or DEFAULT_CERT_MANAGER_VERSION - - _eprint(f"\nPreparing cluster '{cluster_name}' for Workload Orchestration...\n") - - # Track step results for diagnostic summary - step_results = {} - - # Pre-flight: verify cluster is Arc-connected and features enabled - try: - connected_cluster_id = _preflight_checks(cmd, cluster_name, resource_group) - step_results["preflight"] = "Passed" - except Exception as exc: - step_results["preflight"] = f"FAILED: {exc}" - _print_failure_hint(step_results) - raise - - # Step 1+2: cert-manager + trust-manager (single AIO Arc extension) - try: - _ensure_cert_trust_manager_via_aio_extension( - cmd, cluster_name, resource_group, - cert_manager_version, no_wait, - ) - step_results["cert-manager"] = "Succeeded" - step_results["trust-manager"] = "Succeeded (bundled)" - print_step( - 2, TOTAL_STEPS, "trust-manager", - "Bundled with cert-manager ✓" - ) - except Exception as exc: - step_results["cert-manager"] = f"FAILED: {exc}" - logger.debug( - "Steps 1-2/4 failed (AIO cert/trust-manager): %s", exc - ) - _print_failure_hint(step_results) - raise CLIInternalError("cert-manager/trust-manager installation failed. See error above.") - - # Step 3: WO extension - try: - extension_id = _ensure_wo_extension( - cmd, cluster_name, resource_group, extension_name, - extension_version, release_train, no_wait, - kube_config, kube_context, - ) - step_results["wo-extension"] = "Succeeded" - except Exception as exc: - step_results["wo-extension"] = f"FAILED: {exc}" - logger.debug("Step 3/4 failed (WO extension): %s", exc) - _print_failure_hint(step_results) - raise CLIInternalError("WO extension installation failed. See error above.") - - # Step 4: Custom location - try: - cl_id = _ensure_custom_location( - cmd, cluster_name, custom_location_resource_group, custom_location_location, - custom_location_name, extension_id, connected_cluster_id - ) - step_results["custom-location"] = "Succeeded" - except Exception as exc: - step_results["custom-location"] = f"FAILED: {exc}" - logger.debug("Step 4/4 failed (Custom location): %s", exc) - _print_failure_hint(step_results) - raise CLIInternalError("Custom location creation failed. See error above.") - - # Output extended-location.json - extended_location = {"name": cl_id, "type": "CustomLocation"} - _write_extended_location_file(extended_location) - - print_success(f"Cluster '{cluster_name}' is ready for Workload Orchestration") - _eprint(f" Custom Location: {cl_id}") - _eprint() - - return { - "clusterName": cluster_name, - "customLocationId": cl_id, - "extensionId": extension_id, - "extendedLocation": extended_location, - "connectedClusterId": connected_cluster_id, - } - - -# --------------------------------------------------------------------------- -# Pre-flight checks -# --------------------------------------------------------------------------- - -def _preflight_checks(cmd, cluster_name, resource_group): - """Verify cluster is Arc-connected and custom-locations feature enabled.""" - # Check cluster is Arc-connected - try: - cluster_info = invoke_cli_command( - cmd, - ["connectedk8s", "show", "-n", cluster_name, "-g", resource_group] - ) - except CLIInternalError: - raise ValidationError( - f"Cluster '{cluster_name}' is not Arc-connected or not found " - f"in resource group '{resource_group}'." - ) - - connected_cluster_id = cluster_info.get("id", "") - if not connected_cluster_id: - raise CLIInternalError( - f"Could not get resource ID for cluster '{cluster_name}'." - ) - - # Check custom-locations feature enabled - features = cluster_info.get("features", {}) - # Different API versions return this differently - cl_enabled = ( - features.get("customLocationsEnabled", False) - or cluster_info.get("properties", {}).get( - "customLocationsEnabled", False - ) - ) - # If we can't determine, proceed anyway - the custom location - # create step will fail with a clear error if not enabled - if cl_enabled is False: - logger.warning( - "custom-locations feature may not be enabled. " - "If custom location creation fails, run: " - "az connectedk8s enable-features -n %s -g %s " - "--features cluster-connect custom-locations", - cluster_name, resource_group - ) - - return connected_cluster_id - - -# --------------------------------------------------------------------------- -# Step 1+2: cert-manager + trust-manager via AIO Platform extension -# --------------------------------------------------------------------------- - -def _ensure_cert_trust_manager_via_aio_extension( - cmd, cluster_name, resource_group, version, no_wait -): - """Install cert-manager + trust-manager as an Arc k8s-extension. - - Uses microsoft.iotoperations.platform which bundles cert-manager and - trust-manager. Idempotent: skips if an extension of that type already - exists on the cluster. - """ - # Check existing extensions for a matching AIO platform extension - try: - extensions = invoke_cli_command( - cmd, - [ - "k8s-extension", "list", - "-g", resource_group, - "--cluster-name", cluster_name, - "--cluster-type", "connectedClusters", - ] - ) - except CLIInternalError: - extensions = [] - - existing = None - for ext in (extensions or []): - ext_type = (ext.get("extensionType", "") or "").lower() - if ext_type == AIO_PLATFORM_EXTENSION_TYPE.lower(): - existing = ext - break - - if existing: - ext_ver = existing.get("version", "unknown") - prov_state = (existing.get("provisioningState", "") or "").lower() - if prov_state == "succeeded": - print_step( - 1, TOTAL_STEPS, "cert-manager + trust-manager", - f"Already installed ✓ (AIO platform ext {ext_ver})" - ) - return - logger.info( - "Existing AIO platform extension in state '%s'; reinstalling.", - prov_state, - ) - - version_msg = f" version {version}" if version else "" - print_step( - 1, TOTAL_STEPS, - f"cert-manager + trust-manager... Installing AIO platform ext{version_msg}" - ) - - create_args = [ - "k8s-extension", "create", - "--resource-group", resource_group, - "--cluster-name", cluster_name, - "--name", AIO_PLATFORM_EXTENSION_NAME, - "--cluster-type", "connectedClusters", - "--extension-type", AIO_PLATFORM_EXTENSION_TYPE, - "--scope", AIO_PLATFORM_EXTENSION_SCOPE, - "--release-namespace", AIO_PLATFORM_EXTENSION_NAMESPACE, - ] - if version: - create_args.extend(["--version", version, "--auto-upgrade", "false"]) - if no_wait: - create_args.append("--no-wait") - - invoke_cli_command(cmd, create_args) - - suffix = " (--no-wait)" if no_wait else "" - print_step( - 1, TOTAL_STEPS, "cert-manager + trust-manager", - f"Installed via AIO platform extension{suffix} ✓" - ) - - -# --------------------------------------------------------------------------- -# Step 3: WO extension -# --------------------------------------------------------------------------- - -def _ensure_wo_extension( - cmd, cluster_name, resource_group, extension_name, - extension_version, release_train, no_wait, - kube_config=None, kube_context=None, -): - """Check if WO extension is installed; install if missing.""" - # Check existing extensions - try: - extensions = invoke_cli_command( - cmd, - [ - "k8s-extension", "list", - "-g", resource_group, - "--cluster-name", cluster_name, - "--cluster-type", "connectedClusters", - ] - ) - except CLIInternalError: - extensions = [] - - # Find WO extension that is actually working - wo_extensions = [ - ext for ext in (extensions or []) - if (ext.get("extensionType", "") or "").lower() - == DEFAULT_EXTENSION_TYPE.lower() - ] - - if wo_extensions: - ext = wo_extensions[0] - ext_id = ext.get("id", "") - ext_ver = ext.get("version", "unknown") - prov_state = ext.get("provisioningState", "").lower() - - if prov_state == "succeeded": - print_step( - 3, TOTAL_STEPS, "WO extension", - f"Already installed ✓ (version {ext_ver})" - ) - return ext_id - - # Install extension - version_msg = f" version {extension_version}" if extension_version else "" - print_step( - 3, TOTAL_STEPS, - f"WO extension... Creating '{extension_name}'{version_msg}" - ) - - create_args = [ - "k8s-extension", "create", - "-g", resource_group, - "--cluster-name", cluster_name, - "--cluster-type", "connectedClusters", - "--name", extension_name, - "--extension-type", DEFAULT_EXTENSION_TYPE, - "--scope", DEFAULT_EXTENSION_SCOPE, - "--release-train", release_train, - "--auto-upgrade", "false", - ] - if extension_version: - create_args.extend(["--version", extension_version]) - if no_wait: - create_args.append("--no-wait") - - # Auto-detect storage class and pass redis PVC config - storage_class = _detect_storage_class(kube_config, kube_context) - if storage_class: - create_args.extend([ - "--configuration-settings", - f"redis.persistentVolume.storageClass={storage_class}", - "--configuration-settings", - f"redis.persistentVolume.size={DEFAULT_STORAGE_SIZE}", - ]) - - result = invoke_cli_command(cmd, create_args) - ext_id = result.get("id", "") if isinstance(result, dict) else "" - - if no_wait: - print_step(3, TOTAL_STEPS, "WO extension", "Creating (--no-wait) ✓") - else: - print_step(3, TOTAL_STEPS, "WO extension", "Installed ✓") - - return ext_id - - -# --------------------------------------------------------------------------- -# Step 4: Custom location -# --------------------------------------------------------------------------- - -def _ensure_custom_location( - cmd, cluster_name, resource_group, location, # pylint: disable=unused-argument - custom_location_name, extension_id, connected_cluster_id -): - """Check if custom location exists; create if missing.""" - # Check existing - use REST directly to avoid CLI error output on 404 - sub_id = _get_sub_id(cmd) - cl_arm_url = ( - f"https://management.azure.com/subscriptions" - f"/{sub_id}/resourceGroups/{resource_group}" - f"/providers/Microsoft.ExtendedLocation" - f"/customLocations/{custom_location_name}" - ) - try: - response = send_raw_request( - cmd.cli_ctx, - method="GET", - url=f"{cl_arm_url}?api-version=2021-08-15", - resource="https://management.azure.com" - ) - if response.status_code == 200 and response.text: - cl_info = response.json() - cl_id = cl_info.get("id", "") - if cl_id: - print_step( - 4, TOTAL_STEPS, "Custom location", - f"Already exists ✓ ('{custom_location_name}')" - ) - return cl_id - except Exception: - pass # Not found or error, proceed to create - - if not extension_id: - raise CLIInternalError( - "Cannot create custom location: WO extension ID is not available." - ) - - print_step( - 4, TOTAL_STEPS, - f"Custom location... Creating '{custom_location_name}'" - ) - - try: - result = invoke_cli_command( - cmd, - [ - "customlocation", "create", - "-g", resource_group, - "-n", custom_location_name, - "--cluster-extension-ids", extension_id, - "--host-resource-id", connected_cluster_id, - "--namespace", DEFAULT_EXTENSION_NAMESPACE, - "--location", location, - ] - ) - cl_id = result.get("id", "") if isinstance(result, dict) else "" - except CLIInternalError as exc: - raise CLIInternalError( - f"Failed to create custom location: {exc}" - ) - - print_step(4, TOTAL_STEPS, "Custom location", "Created ✓") - return cl_id - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _detect_storage_class(kube_config=None, kube_context=None): - """Auto-detect the default storage class from the cluster.""" - try: - from kubernetes import client, config as k8s_config - k8s_config.load_kube_config( - config_file=kube_config, context=kube_context - ) - storage_v1 = client.StorageV1Api() - scs = storage_v1.list_storage_class() - # Prefer the default storage class - for sc in scs.items: - annotations = sc.metadata.annotations or {} - if annotations.get("storageclass.kubernetes.io/is-default-class") == "true": - logger.info("Auto-detected default storage class: %s", sc.metadata.name) - return sc.metadata.name - # Fallback: first available storage class - if scs.items: - name = scs.items[0].metadata.name - logger.info("No default storage class found, using first: %s", name) - return name - except Exception as exc: - logger.warning("Could not detect storage class: %s", exc) - return None - - -def _print_failure_hint(step_results): - """Print a concise one-line failure summary to stderr. - - The raw error from the underlying az subcommand has already been - printed (it goes to stderr from `invoke_cli_command`), and azcli - will print our raised CLIInternalError on exit. This hint just - points to the failed step + tells the user retry is safe. - """ - failed = [k for k, v in step_results.items() if "FAILED" in v] - if not failed: - return - name = failed[-1] - _eprint(f"\n✗ {name} failed — see error above.") - _eprint(" Re-run the command to retry; completed steps will be skipped.\n") - - -# Kept for back-compat in case external callers reference it -def _print_diagnostic_summary(step_results, cluster_name, resource_group): # pragma: no cover # pylint: disable=unused-argument - _print_failure_hint(step_results) - - -def _write_extended_location_file(extended_location): - """Write extended-location.json to the current working directory.""" - filepath = os.path.join(os.getcwd(), "extended-location.json") - with open(filepath, "w", encoding="utf-8") as f: - json.dump(extended_location, f, indent=2) - _eprint(f"\n File written: {filepath}") - - -def _get_sub_id(cmd): - """Get subscription ID from CLI context.""" - try: - from azure.cli.core._profile import Profile - profile = Profile(cli_ctx=cmd.cli_ctx) - sub = profile.get_subscription() - return sub.get("id", "") - except Exception: - return "" diff --git a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py b/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py deleted file mode 100644 index 89a27db4d94..00000000000 --- a/src/workload-orchestration/azext_workload_orchestration/onboarding/target_sg_link.py +++ /dev/null @@ -1,103 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -"""Service Group link helper — links a target to a service group after creation. - -After creating the ServiceGroupMember relationship, a target update (PUT) is -mandatory to refresh the target's hierarchy info. Without this, the target -appears unlinked in portal. (Confirmed from BVT code LinkServiceGroup().) - -Usage (called internally by target create --service-group): - link_target_to_service_group(cmd, target_id, service_group_name) -""" - -# pylint: disable=broad-exception-caught - -import json -import logging - -from azure.cli.core.azclierror import CLIInternalError - -from azext_workload_orchestration.onboarding.consts import ( - ARM_ENDPOINT, - SG_MEMBER_API_VERSION, - TARGET_API_VERSION, -) -from azext_workload_orchestration.onboarding.utils import ( - invoke_cli_command, -) - -logger = logging.getLogger(__name__) - - -def link_target_to_service_group(cmd, target_id, service_group_name): - """Link a target to a service group and refresh hierarchy. - - Two REST calls: - 1. PUT {targetId}/providers/Microsoft.Relationships/serviceGroupMember/{sgName} - 2. PUT {targetId} (update target to refresh hierarchy — MANDATORY) - """ - sg_member_url = ( - f"{ARM_ENDPOINT}{target_id}" - f"/providers/Microsoft.Relationships/serviceGroupMember/{service_group_name}" - ) - - # Step 1: Create ServiceGroupMember relationship - try: - invoke_cli_command(cmd, [ - "rest", - "--method", "put", - "--url", f"{sg_member_url}?api-version={SG_MEMBER_API_VERSION}", - "--body", json.dumps({ - "properties": { - "targetId": f"/providers/Microsoft.Management/serviceGroups/{service_group_name}" - } - }), - "--resource", ARM_ENDPOINT, - "--header", "Content-Type=application/json", - ], expect_json=False) - logger.info("ServiceGroupMember created: %s -> %s", target_id, service_group_name) - except Exception as exc: - raise CLIInternalError( - f"Failed to link target to service group '{service_group_name}': {exc}" - ) - - # Step 2: Update target to refresh hierarchy (MANDATORY) - try: - # GET current target - target_data = invoke_cli_command(cmd, [ - "rest", - "--method", "get", - "--url", f"{ARM_ENDPOINT}{target_id}?api-version={TARGET_API_VERSION}", - "--resource", ARM_ENDPOINT, - ]) - - # PUT target (update to refresh hierarchy) - if target_data and isinstance(target_data, dict): - # Strip read-only fields, preserve writable top-level fields - body = { - "location": target_data.get("location", ""), - "properties": target_data.get("properties", {}), - } - if "extendedLocation" in target_data: - body["extendedLocation"] = target_data["extendedLocation"] - if "tags" in target_data: - body["tags"] = target_data["tags"] - - invoke_cli_command(cmd, [ - "rest", - "--method", "put", - "--url", f"{ARM_ENDPOINT}{target_id}?api-version={TARGET_API_VERSION}", - "--body", json.dumps(body), - "--resource", ARM_ENDPOINT, - "--header", "Content-Type=application/json", - ], expect_json=False) - logger.info("Target hierarchy refreshed after SG link") - - except Exception as exc: - logger.warning( - "Target hierarchy refresh after SG link may have failed: %s. " - "Target may appear unlinked until next update.", exc - ) From 48b60d4f87974a939a07b3a80f00c5fa58415fbf Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 29 Apr 2026 13:11:23 +0530 Subject: [PATCH 83/91] chore(common): remove dead code, deduplicate helpers, trim unused constants - Remove target_deploy() and _do_install() (never imported) - Remove handle_init_context() + 4 helper functions (feature removed) - Remove print_success() and print_detail() (unused) - Replace duplicate _eprint in hierarchy.py with import from utils - Remove 6 unused constants from consts.py - Net: -397 lines of dead code Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/consts.py | 22 -- .../common/context.py | 255 +----------------- .../common/hierarchy.py | 20 +- .../common/target.py | 96 +------ .../common/utils.py | 10 - 5 files changed, 6 insertions(+), 397 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/common/consts.py b/src/workload-orchestration/azext_workload_orchestration/common/consts.py index ed2063cc690..9043ec674ee 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/consts.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/consts.py @@ -27,8 +27,6 @@ # Resource Providers # --------------------------------------------------------------------------- EDGE_RP_NAMESPACE = "Microsoft.Edge" -SERVICE_GROUP_RP = "Microsoft.Management" -RELATIONSHIPS_RP = "Microsoft.Relationships" # --------------------------------------------------------------------------- # cert-manager + trust-manager Defaults (installed via AIO Platform extension) @@ -64,23 +62,3 @@ DEFAULT_EXTENSION_NAMESPACE = "workloadorchestration" DEFAULT_EXTENSION_SCOPE = "cluster" DEFAULT_STORAGE_SIZE = "20Gi" - -# --------------------------------------------------------------------------- -# Limits & Timeouts -# --------------------------------------------------------------------------- -MAX_HIERARCHY_NAME_LENGTH = 24 # Configuration resource name limit -LRO_TIMEOUT_SECONDS = 600 # 10 minutes per LRO step -LRO_DEFAULT_POLL_INTERVAL = 15 # seconds, overridden by Retry-After header - -# --------------------------------------------------------------------------- -# Default Target Specification (helm.v3) -# --------------------------------------------------------------------------- -DEFAULT_TARGET_SPECIFICATION = { - "topologies": [{ - "bindings": [{ - "role": "helm.v3", - "provider": "providers.target.helm", - "config": {"inCluster": "true"} - }] - }] -} diff --git a/src/workload-orchestration/azext_workload_orchestration/common/context.py b/src/workload-orchestration/azext_workload_orchestration/common/context.py index 526c003a7bb..a48ec82e696 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/context.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/context.py @@ -3,20 +3,14 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Context initialization and capability management for Workload Orchestration. +"""Context capability management for Workload Orchestration. -Consolidated from context_init.py and context_capability.py. - -context_init: Finds or creates a WO context, sets it as current, and ensures -the required capabilities and hierarchy levels are present. - -context_capability: Pure-Python helpers that GET the current context state, +Pure-Python helpers that GET the current context state, normalize/dedup user input case-insensitively, compute the delta vs existing capabilities, skip the ARM call entirely if there is no change (idempotent), and otherwise issue ONE PATCH with only the capabilities array. Exports: - handle_init_context(cli_ctx, ...) capability_add(cli_ctx, ...) capability_remove(cli_ctx, ...) capability_list(cli_ctx, ...) @@ -44,256 +38,11 @@ from azext_workload_orchestration.common.utils import ( CmdProxy, invoke_cli_command, - invoke_silent, - parse_arm_id, ) logger = logging.getLogger(__name__) -# =========================================================================== -# context_init — Public entry point -# =========================================================================== - -def handle_init_context(cli_ctx, ctx_name, resource_group, location, - hierarchy_level, capabilities): - """Find or create a WO context and return its ARM resource ID. - - Strategy (in order): - 1. Check if a context is already set in CLI config → use it - 2. List contexts in the target's resource group → use first match - 3. Create a new context with the given name - 4. If create fails (e.g. name conflict), search subscription-wide - - After resolving the context, ensures the required hierarchy level and - capabilities are present (adds them if missing). - - Returns: - str: The ARM resource ID of the context. - - Raises: - CLIInternalError: If no context can be found or created. - """ - import configparser - - cmd = CmdProxy(cli_ctx) - - # ------------------------------------------------------------------ - # 1. Check CLI config for an already-set context - # ------------------------------------------------------------------ - try: - existing_ctx_id = cli_ctx.config.get('workload_orchestration', 'context_id') - if existing_ctx_id: - logger.info("Context already set in config: %s", existing_ctx_id) - _ensure_capabilities(cli_ctx, existing_ctx_id, hierarchy_level, capabilities) - print("[init-context] Using existing context [OK]") - return existing_ctx_id - except (configparser.NoSectionError, configparser.NoOptionError): - pass - - # ------------------------------------------------------------------ - # 2. List contexts in this resource group - # ------------------------------------------------------------------ - try: - existing = invoke_cli_command(cmd, [ - "workload-orchestration", "context", "list", "-g", resource_group - ]) - if existing and isinstance(existing, list): - ctx_id = existing[0].get("id", "") - if ctx_id: - parts = parse_arm_id(ctx_id) - found_name = parts.get("contexts", ctx_name) - found_rg = parts.get("resourcegroups", resource_group) - _set_current(found_name, found_rg) - _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities) - print(f"[init-context] Using existing context '{found_name}' [OK]") - return ctx_id - except Exception: - pass # No contexts found — proceed to create - - # ------------------------------------------------------------------ - # 3. Create a new context - # ------------------------------------------------------------------ - print(f"[init-context] Creating context '{ctx_name}'...") - - create_args = _build_create_args(ctx_name, resource_group, location, - hierarchy_level, capabilities) - exit_code = invoke_silent(create_args) - - if exit_code == 0: - _set_current(ctx_name, resource_group) - - # Read back the context ID from config (set by 'context use') - try: - ctx_id = cli_ctx.config.get('workload_orchestration', 'context_id') - if ctx_id: - print(f"[init-context] Context '{ctx_name}' created [OK]") - return ctx_id - except (configparser.NoSectionError, configparser.NoOptionError): - pass - - # Fallback: construct the ID manually - sub_id = cli_ctx.data.get('subscription_id', '') - ctx_id = (f"/subscriptions/{sub_id}/resourceGroups/{resource_group}" - f"/providers/Microsoft.Edge/contexts/{ctx_name}") - print(f"[init-context] Context '{ctx_name}' created [OK]") - return ctx_id - - # ------------------------------------------------------------------ - # 4. Create failed — search subscription-wide - # ------------------------------------------------------------------ - logger.warning("Context create returned exit %d. Searching subscription...", exit_code) - ctx_id = _search_subscription(cli_ctx) - if ctx_id: - parts = parse_arm_id(ctx_id) - found_name = parts.get("contexts", "unknown") - found_rg = parts.get("resourcegroups", resource_group) - _set_current(found_name, found_rg) - _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities) - print(f"[init-context] Using existing context '{found_name}' in RG '{found_rg}' [OK]") - return ctx_id - - raise CLIInternalError( - "Could not create or find an existing context. " - "Please provide --context-id explicitly." - ) - - -# --------------------------------------------------------------------------- -# context_init — Private helpers -# --------------------------------------------------------------------------- - -def _build_create_args(ctx_name, resource_group, location, - hierarchy_level, capabilities): - """Build the arg list for 'az workload-orchestration context create'.""" - # Capabilities: [0].name=X [0].description=X [1].name=Y ... - cap_args = [] - for i, cap in enumerate(capabilities or []): - cap_args.extend([f"[{i}].name={cap}", f"[{i}].description={cap}"]) - - hier_args = [f"[0].name={hierarchy_level}", f"[0].description={hierarchy_level}"] - - args = [ - "workload-orchestration", "context", "create", - "-g", resource_group, "-l", location, "--name", ctx_name, - "--hierarchies", - ] + hier_args - - if cap_args: - args.append("--capabilities") - args.extend(cap_args) - - args.extend(["-o", "none"]) - return args - - -def _set_current(ctx_name, ctx_rg): - """Set a context as the CLI default (silently).""" - invoke_silent([ - "workload-orchestration", "context", "use", - "--name", ctx_name, "-g", ctx_rg, "-o", "none", - ]) - - -def _search_subscription(cli_ctx): - """Search the entire subscription for any existing context. Returns ID or None.""" - - sub_id = cli_ctx.data.get('subscription_id', '') - try: - resp = send_raw_request( - cli_ctx, - method="GET", - url=(f"{ARM_ENDPOINT}/subscriptions/{sub_id}" - f"/providers/Microsoft.Edge/contexts" - f"?api-version={CONTEXT_API_VERSION}"), - resource=ARM_ENDPOINT, - ) - if resp.status_code == 200: - contexts = resp.json().get("value", []) - if contexts: - return contexts[0].get("id") - except Exception as exc: - logger.warning("Subscription-wide context search failed: %s", exc) - return None - - -def _ensure_capabilities(cli_ctx, ctx_id, hierarchy_level, capabilities): - """Add missing capabilities/hierarchies to an existing context via PUT.""" - if not capabilities: - return - - cmd = CmdProxy(cli_ctx) - parts = parse_arm_id(ctx_id) - ctx_rg = parts.get("resourcegroups") - ctx_name = parts.get("contexts") - sub_id = parts.get("subscriptions") - - if not ctx_rg or not ctx_name: - return - - try: - ctx_data = invoke_cli_command(cmd, [ - "workload-orchestration", "context", "show", - "-g", ctx_rg, "--name", ctx_name, - ]) - except Exception: - return - - if not ctx_data or not isinstance(ctx_data, dict): - return - - props = ctx_data.get("properties", {}) - existing_caps = {c.get("name", "") for c in (props.get("capabilities") or [])} - existing_hiers = {h.get("name", "") for h in (props.get("hierarchies") or [])} - - missing_caps = [c for c in capabilities if c not in existing_caps] - missing_hier = hierarchy_level not in existing_hiers - - if not missing_caps and not missing_hier: - return - - all_caps = list(props.get("capabilities") or []) - for cap in missing_caps: - all_caps.append({"name": cap, "description": cap}) - - all_hiers = list(props.get("hierarchies") or []) - if missing_hier: - all_hiers.append({"name": hierarchy_level, "description": hierarchy_level}) - - print(f"[init-context] Adding capabilities {missing_caps} to context...") - - if not sub_id: - sub_id = cli_ctx.data.get('subscription_id', '') - - location = ctx_data.get("location", "") - body = { - "location": location, - "properties": { - "capabilities": [{"name": c.get("name", ""), "description": c.get("description", "")} - for c in all_caps], - "hierarchies": [{"name": h.get("name", ""), "description": h.get("description", "")} - for h in all_hiers], - } - } - - try: - resp = send_raw_request( - cli_ctx, - method="PUT", - url=(f"{ARM_ENDPOINT}/subscriptions/{sub_id}" - f"/resourceGroups/{ctx_rg}/providers/Microsoft.Edge" - f"/contexts/{ctx_name}?api-version={CONTEXT_API_VERSION}"), - body=json.dumps(body), - resource=ARM_ENDPOINT, - ) - if resp.status_code in (200, 201): - print("[init-context] Capabilities updated [OK]") - else: - logger.warning("Context update returned %d: %s", resp.status_code, resp.text) - except Exception as exc: - logger.warning("Failed to update context capabilities: %s", exc) - - # =========================================================================== # context_capability — Constants # =========================================================================== diff --git a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py index 497b765ea16..f5ba130bf6c 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py @@ -3,18 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Hierarchy initialization and creation for Workload Orchestration. - -Consolidated from hierarchy_init.py and hierarchy_create.py. - -hierarchy_init: Lightweight hierarchy initialization for target create ---init-hierarchy. Creates a simple site + configuration + config-reference + -site-reference in a resource group scope (no service group). - -hierarchy_create: Full hierarchy create command — creates Site + Configuration + -ConfigurationReference. Supports ResourceGroup (single site) and ServiceGroup -(nested, up to 3 levels) hierarchy types. -""" +"""Hierarchy initialization and creation for Workload Orchestration.""" # pylint: disable=broad-exception-caught # pylint: disable=too-many-locals @@ -25,7 +14,6 @@ import json import logging import re -import sys from azure.cli.core.azclierror import ( CLIInternalError, @@ -41,6 +29,8 @@ CONFIG_REF_API_VERSION, EDGE_RP_NAMESPACE, ) +from azext_workload_orchestration.common.utils import _eprint + logger = logging.getLogger(__name__) @@ -48,10 +38,6 @@ # hierarchy_create — Public entry point # =========================================================================== -def _eprint(*args, **kwargs): - print(*args, file=sys.stderr, **kwargs) - - MAX_SG_DEPTH = 3 diff --git a/src/workload-orchestration/azext_workload_orchestration/common/target.py b/src/workload-orchestration/azext_workload_orchestration/common/target.py index 262ef9554b5..1f5242feddc 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/target.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/target.py @@ -3,21 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -"""Target prepare, deploy, and service-group link for Workload Orchestration. - -Consolidated from target_prepare.py, target_deploy.py, and target_sg_link.py. - -target_prepare: Prepares an Arc-connected K8s cluster for WO — -installs cert-manager, trust-manager, WO extension, and creates a custom -location. Idempotent — skips components that already exist. - -target_deploy: Chains review → publish → install in one step. Optionally -prepends config-set (step 0) when --config is provided. - -target_sg_link: Links a target to a service group after creation. After -creating the ServiceGroupMember relationship, a target update (PUT) is -mandatory to refresh the target's hierarchy info. -""" +"""Target prepare, deploy, and service-group link for Workload Orchestration.""" # pylint: disable=broad-exception-caught # pylint: disable=too-many-locals @@ -55,7 +41,6 @@ _eprint, invoke_cli_command, print_step, - print_success, ) logger = logging.getLogger(__name__) @@ -159,7 +144,6 @@ def target_prepare( extended_location = {"name": cl_id, "type": "CustomLocation"} _write_extended_location_file(extended_location) - print_success(f"Cluster '{cluster_name}' is ready for Workload Orchestration") _eprint(f" Custom Location: {cl_id}") _eprint() @@ -507,69 +491,6 @@ def _write_extended_location_file(extended_location): API_VERSION = "2025-08-01" -def target_deploy( - cmd, - resource_group, - target_name, - solution_template_name=None, - solution_template_version=None, - solution_template_rg=None, - config=None, - config_hierarchy_id=None, - config_template_rg=None, - config_template_name=None, - config_template_version=None, -): - """Deploy a solution to a target: config-set → review → publish → install. - - Standalone deploy function (used internally). - """ - sub_id = _get_subscription_id(cmd) - - solution_template_version_id = _resolve_template_version_id( - solution_template_name, solution_template_version, - solution_template_rg, resource_group, sub_id, - ) - - base_url = ( - f"{ARM_ENDPOINT}/subscriptions/{sub_id}" - f"/resourceGroups/{resource_group}" - f"/providers/Microsoft.Edge/targets/{target_name}" - ) - - do_config = config is not None - - results = {} - sv_id = None - - # --- Step 0: Config set --- - if do_config: - _handle_config_set( - cmd, config, config_hierarchy_id, config_template_rg, - config_template_name, config_template_version, - resource_group, target_name, sub_id, - ) - results["configSet"] = "Succeeded" - - # --- Step 1: Review --- - review_result = _do_review(cmd, base_url, solution_template_version_id) - results["review"] = review_result - sv_id = _extract_solution_version_id(review_result) - - # --- Step 2: Publish --- - publish_result = _do_publish(cmd, base_url, sv_id) - results["publish"] = publish_result - - # --- Step 3: Install --- - install_result = _do_install(cmd, base_url, sv_id) - results["install"] = install_result - - return results.get("install", { - "status": "Succeeded", - "resourceId": f"{base_url}", - }) - - def target_deploy_pre_install( cmd, resource_group, @@ -698,21 +619,6 @@ def _do_publish(cmd, base_url, solution_version_id): return _parse_response(resp, "Publish", cmd=cmd) -def _do_install(cmd, base_url, solution_version_id): - """POST .../installSolution""" - url = f"{base_url}/installSolution?api-version={API_VERSION}" - body = {"solutionVersionId": solution_version_id} - - resp = send_raw_request( - cmd.cli_ctx, "POST", url, - body=json.dumps(body), - headers=["Content-Type=application/json"], - resource=ARM_ENDPOINT, - ) - - return _parse_response(resp, "Install", cmd=cmd) - - def _handle_config_set( cmd, config_file, hierarchy_id, template_rg, template_name, template_version, diff --git a/src/workload-orchestration/azext_workload_orchestration/common/utils.py b/src/workload-orchestration/azext_workload_orchestration/common/utils.py index 985c0fda265..1a1a5f3785e 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/utils.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/utils.py @@ -156,13 +156,3 @@ def print_step(step_num, total, message, status=""): _eprint(f"{connector} {message} {status}") else: _eprint(f"{connector} {message}...") - - -def print_success(message): - """Print a success summary line to stderr.""" - _eprint(f"\n✅ {message}") - - -def print_detail(label, value): - """Print a detail line (indented) to stderr.""" - _eprint(f" {label}: {value}") From 7ee26fcd7fb41b6df7cad14026a21fe726cb12b0 Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 29 Apr 2026 13:55:08 +0530 Subject: [PATCH 84/91] fix(target): handle HTTPError from send_raw_request in config-set send_raw_request raises HTTPError on non-2xx responses instead of returning the response object. The _handle_config_set function expected to check .status_code == 404 to detect first-time dynamic config creation, but the exception was raised before reaching that check. Fix: wrap the GET in try/except HTTPError so the 404 (expected for first-time config-set) triggers the create-new branch correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/target.py | 78 +++++++++---------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/common/target.py b/src/workload-orchestration/azext_workload_orchestration/common/target.py index 1f5242feddc..b79eced3ac3 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/target.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/target.py @@ -17,6 +17,7 @@ from azure.cli.core.azclierror import ( CLIInternalError, + HTTPError, ValidationError, ) from azure.cli.core.util import send_raw_request @@ -649,16 +650,17 @@ def _handle_config_set( f"/providers/Microsoft.Edge/configurationreferences/default" f"?api-version={API_VERSION}" ) - ref_resp = send_raw_request( - cmd.cli_ctx, "GET", config_ref_url, - headers=["Accept=application/json"], - resource=ARM_ENDPOINT, - ) - if ref_resp.status_code != 200: - raise CLIInternalError( - f"Failed to get configuration reference for {hierarchy_id} " - f"(HTTP {ref_resp.status_code}). Ensure hierarchy has a configuration reference." + try: + ref_resp = send_raw_request( + cmd.cli_ctx, "GET", config_ref_url, + headers=["Accept=application/json"], + resource=ARM_ENDPOINT, ) + except HTTPError as e: + raise CLIInternalError( + f"Failed to get configuration reference for {hierarchy_id}. " + f"Ensure hierarchy has a configuration reference. Error: {e}" + ) from e configuration_id = ref_resp.json().get("properties", {}).get("configurationResourceId") if not configuration_id: raise CLIInternalError( @@ -672,16 +674,17 @@ def _handle_config_set( f"/providers/Microsoft.Edge/solutionTemplates/{template_name}" f"?api-version={API_VERSION}" ) - st_resp = send_raw_request( - cmd.cli_ctx, "GET", st_url, - headers=["Accept=application/json"], - resource=ARM_ENDPOINT, - ) - if st_resp.status_code != 200: - raise CLIInternalError( - f"Solution template '{template_name}' not found in RG '{template_rg}' " - f"(HTTP {st_resp.status_code})." + try: + st_resp = send_raw_request( + cmd.cli_ctx, "GET", st_url, + headers=["Accept=application/json"], + resource=ARM_ENDPOINT, ) + except HTTPError as e: + raise CLIInternalError( + f"Solution template '{template_name}' not found in RG '{template_rg}'. " + f"Error: {e}" + ) from e st_body = st_resp.json() dynamic_config_name = ( st_body.get("properties", {}).get("uniqueIdentifier") @@ -695,13 +698,19 @@ def _handle_config_set( f"/versions/{template_version}" f"?api-version={API_VERSION}" ) - version_resp = send_raw_request( - cmd.cli_ctx, "GET", version_url, - headers=["Accept=application/json"], - resource=ARM_ENDPOINT, - ) + version_exists = False + try: + version_resp = send_raw_request( + cmd.cli_ctx, "GET", version_url, + headers=["Accept=application/json"], + resource=ARM_ENDPOINT, + ) + version_exists = True + except HTTPError: + # 404 is expected when dynamic config version doesn't exist yet + pass - if version_resp.status_code == 200: + if version_exists: # Update existing dynamic config version existing = version_resp.json() existing["properties"]["values"] = config_content @@ -711,7 +720,7 @@ def _handle_config_set( headers=["Content-Type=application/json", "Accept=application/json"], resource=ARM_ENDPOINT, ) - elif version_resp.status_code == 404: + else: # Create new: first ensure parent dynamic config exists dc_url = ( f"{ARM_ENDPOINT}{configuration_id}" @@ -719,36 +728,21 @@ def _handle_config_set( f"?api-version={API_VERSION}" ) dc_body = {"properties": {"currentVersion": template_version}} - dc_resp = send_raw_request( + send_raw_request( cmd.cli_ctx, "PUT", dc_url, body=json.dumps(dc_body), headers=["Content-Type=application/json", "Accept=application/json"], resource=ARM_ENDPOINT, ) - if dc_resp.status_code not in (200, 201): - raise CLIInternalError( - f"Failed to create dynamic configuration (HTTP {dc_resp.status_code}): " - f"{dc_resp.text}" - ) # Then create the version with config values ver_body = {"properties": {"values": config_content}} - ver_resp = send_raw_request( + send_raw_request( cmd.cli_ctx, "PUT", version_url, body=json.dumps(ver_body), headers=["Content-Type=application/json", "Accept=application/json"], resource=ARM_ENDPOINT, ) - if ver_resp.status_code not in (200, 201): - raise CLIInternalError( - f"Failed to create dynamic configuration version (HTTP {ver_resp.status_code}): " - f"{ver_resp.text}" - ) - else: - raise CLIInternalError( - f"Failed to check dynamic configuration version (HTTP {version_resp.status_code}): " - f"{version_resp.text}" - ) def _read_config_file(file_path): From 33bf25384cb09fa888e77844465e1b52b0c04f3e Mon Sep 17 00:00:00 2001 From: Atharva Date: Wed, 29 Apr 2026 14:51:37 +0530 Subject: [PATCH 85/91] =?UTF-8?q?chore(hierarchy):=20remove=20'=E2=9C=85?= =?UTF-8?q?=20Hierarchy=20created'=20log=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/common/hierarchy.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py index f5ba130bf6c..6d777977c72 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py @@ -187,7 +187,6 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): }, CONFIG_REF_API_VERSION) _eprint("└── ConfigurationReference ✓") - _eprint("\n✅ Hierarchy created\n") return { "type": "ResourceGroup", @@ -222,7 +221,6 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): resource_group, parent_sg=None, results=results, depth=0, is_last=True) - _eprint(f"\n✅ Hierarchy created ({nodes} levels)\n") return { "type": "ServiceGroup", From 3757e6e6a617ef5b75c345d4a3ba8e8fdc4c775b Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 30 Apr 2026 09:03:05 +0530 Subject: [PATCH 86/91] fix: add --solution-template-resource-group alias and license header - Add --solution-template-resource-group as alias for --solution-template-rg in target install - Add missing license header to context/capability/__init__.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/context/capability/__init__.py | 7 +++++++ .../aaz/latest/workload_orchestration/target/_install.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py index 2578e6c5e50..c469d24e4d0 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py @@ -1,3 +1,10 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + # pylint: skip-file # flake8: noqa from ._add import * diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 195b86d1781..1a5b4d4c857 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py @@ -89,7 +89,7 @@ def _build_arguments_schema(cls, *args, **kwargs): help="Version of the solution template (e.g., 1.0.0).", ) _args_schema.solution_template_rg = AAZStrArg( - options=["--solution-template-rg"], + options=["--solution-template-rg", "--solution-template-resource-group"], arg_group="Deploy", help="Resource group of the solution template. Defaults to target's -g.", ) From ae7bf2dbb49d47682510bd70aa0b7ac9869af488 Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 30 Apr 2026 09:10:33 +0530 Subject: [PATCH 87/91] style: fix E303 too many blank lines in hierarchy.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/common/hierarchy.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py index 6d777977c72..9dc371540f0 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py @@ -187,7 +187,6 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): }, CONFIG_REF_API_VERSION) _eprint("└── ConfigurationReference ✓") - return { "type": "ResourceGroup", "name": effective_name, @@ -221,7 +220,6 @@ def _create_sg_hierarchy(cmd, spec, config_location, resource_group): resource_group, parent_sg=None, results=results, depth=0, is_last=True) - return { "type": "ServiceGroup", "name": spec["name"], From 5264f9e3468bf376c2ce0a17ace6c37796abb4b3 Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 30 Apr 2026 09:16:16 +0530 Subject: [PATCH 88/91] feat: make --target-specification required on target create Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../aaz/latest/workload_orchestration/target/_create.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index d6e1cb0a11d..8ddf93710db 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py @@ -114,7 +114,7 @@ def _build_arguments_schema(cls, *args, **kwargs): options=["--target-specification"], arg_group="Properties", help="Specifies that we are using Helm charts for the k8s deployment", - + required=True, ) _args_schema.service_group = AAZStrArg( From f75ca1abbea0ac662be798050fa8c7c1f08be42b Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 30 Apr 2026 16:16:40 +0530 Subject: [PATCH 89/91] fix: cluster init CL validation, hierarchy bugs, output format - Custom Location: validate hostResourceId before skipping creation (prevents silent skip when CL name exists for a different cluster) - Custom Location: remove extended-location.json file creation - Custom Location: remove Custom Location ID print - Hierarchy: validate sibling nodes have consistent level names - Hierarchy: patch existing site with updated labels on reuse - Hierarchy: increase RBAC propagation timeout to 150s - Output: simplify cluster init output format (no tree characters) - Help: update cluster init help text Fixes: #37753265, #37752999, #37753271 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workload_orchestration/cluster/_init.py | 7 +- .../common/hierarchy.py | 39 ++++++++++- .../common/target.py | 65 +++++++++---------- 3 files changed, 72 insertions(+), 39 deletions(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py index 33c4117978e..fae52e70d56 100644 --- a/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py @@ -26,10 +26,9 @@ class Init(AAZCommand): Steps performed: 1. Verify cluster is Arc-connected with required features enabled - 2. Install cert-manager + trust-manager via the AIO platform Arc extension - (microsoft.iotoperations.platform) - 3. Install WO extension (if not present) - 4. Create custom location (if not present) + 2. Install Workload Orchestration Extension Dependencies + 3. Install Workload Orchestration Extension + 4. Create Custom Location (validates cluster binding if already exists) :example: Initialize a cluster with defaults az workload-orchestration cluster init -c my-cluster -g my-rg -l eastus2euap diff --git a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py index 9dc371540f0..375e9065443 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py @@ -98,10 +98,28 @@ def _validate_hierarchy_names(node): f"Got {type(children).__name__}. " f"Use YAML '- name: X' entries or JSON '[{{...}}]'." ) + # Validate that all siblings have the same level value + _validate_consistent_levels(children, parent_name=name) for child in children: _validate_hierarchy_names(child) +def _validate_consistent_levels(children, parent_name): + """Ensure all sibling nodes at the same level have the same 'level' value.""" + if not children or len(children) < 2: + return + levels = set() + for child in children: + child_level = child.get("level", "") + if child_level: + levels.add(child_level) + if len(levels) > 1: + raise ValidationError( + f"Inconsistent level names under '{parent_name}': {sorted(levels)}. " + f"All siblings at the same hierarchy depth must have the same level value." + ) + + # --------------------------------------------------------------------------- # ResourceGroup hierarchy # --------------------------------------------------------------------------- @@ -129,6 +147,15 @@ def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): else: _eprint(f"[i] Reusing existing Site '{site_name}'.") effective_name = site_name + # Patch existing site with updated labels + _arm_put(cmd, f"{ARM_ENDPOINT}{site_id}", { + "properties": { + "displayName": effective_name, + "description": effective_name, + "labels": {"level": level}, + } + }, SITE_API_VERSION) + _eprint(f"├── Site '{effective_name}' (updated) ✓") else: effective_name = name site_id = ( @@ -273,6 +300,14 @@ def _create_sg_level( # pylint: disable=too-many-arguments f"(requested name '{name}' ignored)." ) effective_site_name = site_name + # Patch existing site with updated labels + _arm_put_regional(cmd, config_location, site_id, { + "properties": { + "displayName": effective_site_name, + "description": effective_site_name, + "labels": {"level": level}, + } + }, SITE_API_VERSION) else: effective_site_name = name site_id = f"{sg_id}/providers/{EDGE_RP_NAMESPACE}/sites/{effective_site_name}" @@ -496,12 +531,12 @@ def _arm_get_regional(cmd, location, resource_id, api_version): return None -def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=12, wait_sec=10): +def _wait_for_sg_rbac(cmd, location, sg_id, sg_name, max_retries=15, wait_sec=10): """Wait for RBAC to propagate on a newly created ServiceGroup. After SG creation, it takes time for permissions to propagate. We poll by trying to list sites under the SG until it succeeds. - Waits up to 120s (12 x 10s). + Waits up to 150s (15 x 10s). """ import time diff --git a/src/workload-orchestration/azext_workload_orchestration/common/target.py b/src/workload-orchestration/azext_workload_orchestration/common/target.py index b79eced3ac3..c62d6e2f450 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/target.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/target.py @@ -41,7 +41,6 @@ from azext_workload_orchestration.common.utils import ( _eprint, invoke_cli_command, - print_step, ) logger = logging.getLogger(__name__) @@ -51,7 +50,6 @@ # target_prepare # =========================================================================== -TOTAL_STEPS = 4 def target_prepare( @@ -103,10 +101,6 @@ def target_prepare( ) step_results["cert-manager"] = "Succeeded" step_results["trust-manager"] = "Succeeded (bundled)" - print_step( - 2, TOTAL_STEPS, "trust-manager", - "Bundled with cert-manager ✓" - ) except Exception as exc: step_results["cert-manager"] = f"FAILED: {exc}" logger.debug( @@ -143,9 +137,7 @@ def target_prepare( raise CLIInternalError("Custom location creation failed. See error above.") extended_location = {"name": cl_id, "type": "CustomLocation"} - _write_extended_location_file(extended_location) - _eprint(f" Custom Location: {cl_id}") _eprint() return { @@ -239,9 +231,9 @@ def _ensure_cert_trust_manager_via_aio_extension( ext_ver = existing.get("version", "unknown") prov_state = (existing.get("provisioningState", "") or "").lower() if prov_state == "succeeded": - print_step( - 1, TOTAL_STEPS, "cert-manager + trust-manager", - f"Already installed ✓ (AIO platform ext {ext_ver})" + _eprint( + f" Workload Orchestration Extension Dependency: {AIO_PLATFORM_EXTENSION_NAME} " + f"Already installed ✓ ({ext_ver})" ) return logger.info( @@ -250,9 +242,9 @@ def _ensure_cert_trust_manager_via_aio_extension( ) version_msg = f" version {version}" if version else "" - print_step( - 1, TOTAL_STEPS, - f"cert-manager + trust-manager... Installing AIO platform ext{version_msg}" + _eprint( + f" Installing Workload Orchestration Extension Dependency: " + f"{AIO_PLATFORM_EXTENSION_NAME}{version_msg}..." ) create_args = [ @@ -273,9 +265,9 @@ def _ensure_cert_trust_manager_via_aio_extension( invoke_cli_command(cmd, create_args) suffix = " (--no-wait)" if no_wait else "" - print_step( - 1, TOTAL_STEPS, "cert-manager + trust-manager", - f"Installed via AIO platform extension{suffix} ✓" + _eprint( + f" Workload Orchestration Extension Dependency: " + f"{AIO_PLATFORM_EXTENSION_NAME} Installed{suffix} ✓" ) @@ -315,16 +307,15 @@ def _ensure_wo_extension( prov_state = ext.get("provisioningState", "").lower() if prov_state == "succeeded": - print_step( - 3, TOTAL_STEPS, "WO extension", - f"Already installed ✓ (version {ext_ver})" + _eprint( + f" Workload Orchestration Extension: {extension_name} " + f"Already installed ✓ ({ext_ver})" ) return ext_id version_msg = f" version {extension_version}" if extension_version else "" - print_step( - 3, TOTAL_STEPS, - f"WO extension... Creating '{extension_name}'{version_msg}" + _eprint( + f" Installing Workload Orchestration Extension: {extension_name}{version_msg}..." ) create_args = [ @@ -357,9 +348,9 @@ def _ensure_wo_extension( ext_id = result.get("id", "") if isinstance(result, dict) else "" if no_wait: - print_step(3, TOTAL_STEPS, "WO extension", "Creating (--no-wait) ✓") + _eprint(f" Workload Orchestration Extension: {extension_name} Creating (--no-wait) ✓") else: - print_step(3, TOTAL_STEPS, "WO extension", "Installed ✓") + _eprint(f" Workload Orchestration Extension: {extension_name} Installed ✓") return ext_id @@ -392,11 +383,22 @@ def _ensure_custom_location( cl_info = response.json() cl_id = cl_info.get("id", "") if cl_id: - print_step( - 4, TOTAL_STEPS, "Custom location", - f"Already exists ✓ ('{custom_location_name}')" + # Validate that the existing CL is bound to our cluster + existing_host = ( + cl_info.get("properties", {}).get("hostResourceId", "") + ) + if existing_host.lower() != connected_cluster_id.lower(): + raise ValidationError( + f"Requested Custom Location '{custom_location_name}' is already " + f"associated with Cluster '{existing_host}'. " + f"Please choose a different name." + ) + _eprint( + f" Custom Location: '{custom_location_name}' Already exists ✓" ) return cl_id + except ValidationError: + raise except Exception: pass # Not found or error, proceed to create @@ -405,10 +407,7 @@ def _ensure_custom_location( "Cannot create custom location: WO extension ID is not available." ) - print_step( - 4, TOTAL_STEPS, - f"Custom location... Creating '{custom_location_name}'" - ) + _eprint(f" Creating Custom Location: '{custom_location_name}'...") try: result = invoke_cli_command( @@ -429,7 +428,7 @@ def _ensure_custom_location( f"Failed to create custom location: {exc}" ) - print_step(4, TOTAL_STEPS, "Custom location", "Created ✓") + _eprint(f" Custom Location: '{custom_location_name}' Created ✓") return cl_id From 05edcf379a46259ab1dd2240bf77ea47c9f6b3b8 Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 30 Apr 2026 16:27:28 +0530 Subject: [PATCH 90/91] style: fix E303 too many blank lines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/common/target.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/workload-orchestration/azext_workload_orchestration/common/target.py b/src/workload-orchestration/azext_workload_orchestration/common/target.py index c62d6e2f450..5627341e5ae 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/target.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/target.py @@ -51,7 +51,6 @@ # =========================================================================== - def target_prepare( cmd, cluster_name, From da6558c4ac8025764f1df6ae39651361eb79c847 Mon Sep 17 00:00:00 2001 From: Atharva Date: Thu, 30 Apr 2026 18:16:41 +0530 Subject: [PATCH 91/91] fix: let ValidationError propagate from CL check in cluster init Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azext_workload_orchestration/common/target.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/workload-orchestration/azext_workload_orchestration/common/target.py b/src/workload-orchestration/azext_workload_orchestration/common/target.py index 5627341e5ae..c2b2fb3d473 100644 --- a/src/workload-orchestration/azext_workload_orchestration/common/target.py +++ b/src/workload-orchestration/azext_workload_orchestration/common/target.py @@ -129,6 +129,8 @@ def target_prepare( custom_location_name, extension_id, connected_cluster_id ) step_results["custom-location"] = "Succeeded" + except ValidationError: + raise except Exception as exc: step_results["custom-location"] = f"FAILED: {exc}" logger.debug("Step 4/4 failed (Custom location): %s", exc)