diff --git a/src/workload-orchestration/HISTORY.rst b/src/workload-orchestration/HISTORY.rst index 49671d6fc4a..c28b835c019 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-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 + 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/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/__init__.py index 5a9d61963d6..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 @@ -9,3 +9,6 @@ # flake8: noqa 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/_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"] 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..fae52e70d56 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/cluster/_init.py @@ -0,0 +1,151 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# 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 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 + :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 + :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 = { + "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: `{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. " + "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.common 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, + 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 + ), + ) + + +__all__ = ["Init"] 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/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/_create.py index 6292ad711b4..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 @@ -9,6 +9,7 @@ # flake8: noqa from azure.cli.core.aaz import * +from azure.cli.core.azclierror import CLIInternalError as CLIError @register_command( @@ -41,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"], @@ -58,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"], @@ -109,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", @@ -128,6 +123,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="Common", + 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 +144,53 @@ 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. + + 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 + 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] + # 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] + # 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: + 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", + "-g", rg, + "--context-name", context_name, + "--site-reference-name", ref_name, + "--site-id", site_id, + ]) + 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( + f"Context created successfully, but site reference creation failed: {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/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..c469d24e4d0 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/context/capability/__init__.py @@ -0,0 +1,13 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# +# Code generated by aaz-dev-tools +# -------------------------------------------------------------------------------------------- + +# pylint: skip-file +# flake8: noqa +from ._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..41419b588a9 --- /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.common.context 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..fd080d88d4b --- /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.common.context 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..169c0bf4044 --- /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.common.context 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..93c829a2121 --- /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.common.context 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/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..d6abb2fc8c5 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/hierarchy/_create.py @@ -0,0 +1,119 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# 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. 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.common.hierarchy 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/aaz/latest/workload_orchestration/target/_create.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_create.py index 7308557c30a..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 @@ -15,6 +15,7 @@ logger = logging.getLogger(__name__) + @register_command( "workload-orchestration target create", ) @@ -46,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, @@ -63,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"], @@ -118,14 +115,17 @@ def _build_arguments_schema(cls, *args, **kwargs): arg_group="Properties", help="Specifies that we are using Helm charts for the k8s deployment", required=True, + ) + _args_schema.service_group = AAZStrArg( + options=["--service-group"], + 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"], @@ -170,30 +170,64 @@ def _execute_operations(self): @register_callback def pre_operations(self): - # If context_id is not provided, try to get it from config + # Resolve context_id from CLI config if not provided 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'." - ) - 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 + self._resolve_context_id_from_config() + + 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 or set a default context using 'az workload-orchestration context use'." + "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 " + "or set a default context using 'az workload-orchestration context use'." + ) @register_callback def post_operations(self): - pass + # --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.common.target import ( + link_target_to_service_group + ) + 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 + 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}" + + import sys + 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("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) 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/aaz/latest/workload_orchestration/target/_install.py b/src/workload-orchestration/azext_workload_orchestration/aaz/latest/workload_orchestration/target/_install.py index 6a1a0e35238..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 @@ -9,15 +9,28 @@ # flake8: noqa from azure.cli.core.aaz import * +from azure.cli.core.azclierror import 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-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 --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 """ _aaz_info = { @@ -41,8 +54,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 +70,37 @@ 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_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", "-v"], + arg_group="Deploy", + help="Version of the solution template (e.g., 1.0.0).", + ) + _args_schema.solution_template_rg = AAZStrArg( + options=["--solution-template-rg", "--solution-template-resource-group"], + arg_group="Deploy", + help="Resource group of the solution template. Defaults to target's -g.", + ) + + # Config set args + _args_schema.config = AAZStrArg( + options=["--config", "--configuration"], + arg_group="Config", + help="Path to YAML/JSON config file to set before review.", ) - + return cls._args_schema def _execute_operations(self): @@ -93,7 +110,49 @@ 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 = 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-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.common.target import ( + target_deploy_pre_install, + ) + from azext_workload_orchestration.common.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_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, + ) + + # Set the solution_version_id for the AAZ install step + args.solution_version_id = sv_id @register_callback def post_operations(self): @@ -181,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) @@ -222,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/common/__init__.py b/src/workload-orchestration/azext_workload_orchestration/common/__init__.py new file mode 100644 index 00000000000..52586fe407d --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/common/__init__.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. +# -------------------------------------------------------------------------------------------- + +"""Common helpers for Workload Orchestration CLI commands.""" + +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): + """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.common.consts import EXTENSION_DEPENDENCIES + + if not data: + return {} + 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( + cmd, + cluster_name, + resource_group, + location, + release_train=None, + 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.""" + dep_versions = _validate_dependency_versions(extension_dependency_version) + iot_platform_version = dep_versions.get("iotplatform") + + return _target_prepare( + cmd=cmd, + cluster_name=cluster_name, + resource_group=resource_group, + 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, + ) + + +def hierarchy_create(cmd, resource_group=None, configuration_location=None, hierarchy_spec=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=hierarchy_spec, + ) + + +__all__ = ['target_init', 'hierarchy_create'] diff --git a/src/workload-orchestration/azext_workload_orchestration/common/consts.py b/src/workload-orchestration/azext_workload_orchestration/common/consts.py new file mode 100644 index 00000000000..9043ec674ee --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/common/consts.py @@ -0,0 +1,64 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Constants for workload orchestration CLI 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" + +# --------------------------------------------------------------------------- +# Resource Providers +# --------------------------------------------------------------------------- +EDGE_RP_NAMESPACE = "Microsoft.Edge" + +# --------------------------------------------------------------------------- +# cert-manager + trust-manager Defaults (installed via AIO Platform extension) +# --------------------------------------------------------------------------- +DEFAULT_CERT_MANAGER_VERSION = None # None = AIO extension default + +# 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) +# --------------------------------------------------------------------------- +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 +# --------------------------------------------------------------------------- +DEFAULT_EXTENSION_TYPE = "Microsoft.workloadorchestration" +DEFAULT_EXTENSION_NAME = "wo-extension" +DEFAULT_RELEASE_TRAIN = "stable" +DEFAULT_EXTENSION_NAMESPACE = "workloadorchestration" +DEFAULT_EXTENSION_SCOPE = "cluster" +DEFAULT_STORAGE_SIZE = "20Gi" diff --git a/src/workload-orchestration/azext_workload_orchestration/common/context.py b/src/workload-orchestration/azext_workload_orchestration/common/context.py new file mode 100644 index 00000000000..a48ec82e696 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/common/context.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 management for Workload Orchestration. + +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: + 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 +import sys + +from azure.cli.core.azclierror import ( + CLIInternalError, + InvalidArgumentValueError, + ResourceNotFoundError, +) +from azure.cli.core.util import send_raw_request + +from azext_workload_orchestration.common.consts import ( + ARM_ENDPOINT, + CONTEXT_API_VERSION, +) +from azext_workload_orchestration.common.utils import ( + CmdProxy, + invoke_cli_command, +) + +logger = logging.getLogger(__name__) + + +# =========================================================================== +# 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 + + +# --------------------------------------------------------------------------- +# context_capability — 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 + + +# --------------------------------------------------------------------------- +# context_capability — 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_capability — 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} + + +# --------------------------------------------------------------------------- +# context_capability — 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) + + +# --------------------------------------------------------------------------- +# context_capability — Public API +# --------------------------------------------------------------------------- + + +def capability_add(cli_ctx, resource_group, context_name, name=None, + description=None, capabilities=None, subscription=None, + state=None): # pylint: disable=unused-argument + """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}'." + ) diff --git a/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py new file mode 100644 index 00000000000..375e9065443 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/common/hierarchy.py @@ -0,0 +1,592 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Hierarchy initialization and creation for Workload Orchestration.""" + +# 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 +import re + +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, + SERVICE_GROUP_API_VERSION, + SITE_API_VERSION, + CONFIGURATION_API_VERSION, + CONFIG_REF_API_VERSION, + EDGE_RP_NAMESPACE, +) +from azext_workload_orchestration.common.utils import _eprint + +logger = logging.getLogger(__name__) + + +# =========================================================================== +# hierarchy_create — Public entry point +# =========================================================================== + +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.") + if not resource_group: + raise ValidationError("--resource-group is required (used for Configuration resources).") + + # Parse spec (dict from shorthand/file parser in the CLI wrapper) + spec = 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'.") + + # 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 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 '[{{...}}]'." + ) + # 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 +# --------------------------------------------------------------------------- + +def _create_rg_hierarchy(cmd, resource_group, config_location, name, level): + """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) + + _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)." + ) + 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 = ( + 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}" + ) + config_url = f"{ARM_ENDPOINT}{config_id}" + 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: + 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, + } + }, CONFIG_REF_API_VERSION) + _eprint("└── ConfigurationReference ✓") + + return { + "type": "ResourceGroup", + "name": effective_name, + "level": level, + "resourceGroup": resource_group, + "siteId": site_id, + "configurationId": effective_config_id, + } + + +# --------------------------------------------------------------------------- +# ServiceGroup hierarchy (recursive, max 3 levels) +# --------------------------------------------------------------------------- + +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) + + # Count total nodes + nodes = _count_depth(spec) + if nodes > MAX_SG_DEPTH: + raise ValidationError( + f"ServiceGroup hierarchy has {nodes} levels. Maximum is {MAX_SG_DEPTH}." + ) + + _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, is_last=True) + + return { + "type": "ServiceGroup", + "name": spec["name"], + "levels": nodes, + "resources": results, + } + + +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"] + + 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}" + + connector = "└── " if is_last else "├── " + child_prefix = parent_prefix + (" " if is_last else "│ ") + + # 1. Create ServiceGroup + _eprint(f"{parent_prefix}{connector}{name} ({level})") + try: + _arm_put(cmd, f"{ARM_ENDPOINT}{sg_id}", { + "properties": { + "displayName": name, + "parent": {"resourceId": parent_id}, + } + }, SERVICE_GROUP_API_VERSION) + 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_sg_rbac(cmd, config_location, sg_id, name) + + # 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 ServiceGroup '{name}' " + 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}" + _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 & 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_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 + ) + + if existing_ref: + ref_target = existing_ref.get("properties", {}).get("configurationResourceId", "") + effective_config_id = ref_target or config_id + config_reused = True + else: + 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}) + + children = node.get("children") + has_children = children is not None + site_label = "(reused) " if existing_sg_site else "" + config_label = "(reused) " if config_reused else "" + 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: + _eprint(f"{child_prefix}├── ConfigurationReference {ref_label}✓") + else: + _eprint(f"{child_prefix}└── ConfigurationReference {ref_label}✓") + + if 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, + resource_group, parent_sg=name, results=results, + depth=depth + 1, is_last=child_is_last, + parent_prefix=child_prefix) + + +def _count_depth(node): + """Count total depth of hierarchy tree.""" + children = node.get("children") + if not children: + return 1 + if not isinstance(children, list): + raise ValidationError( + f"'children' for '{node.get('name', '?')}' must be a list." + ) + return 1 + max(_count_depth(c) for c in children) + + +# --------------------------------------------------------------------------- +# hierarchy_create — 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_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: + return json.loads(resp.content) + except Exception: # pylint: disable=broad-except + return None + + +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}" + body_str = json.dumps(body) + + token_type, token = _get_token(cmd) + + 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 _arm_get_regional(cmd, location, resource_id, api_version): + """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) + + 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=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 150s (15 x 10s). + """ + 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: + 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: + 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: + 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." + ) + + +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.""" + 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 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..c2b2fb3d473 --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/common/target.py @@ -0,0 +1,914 @@ +# -------------------------------------------------------------------------------------------- +# 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.""" + +# 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, + HTTPError, + 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, +) + +logger = logging.getLogger(__name__) + + +# =========================================================================== +# target_prepare +# =========================================================================== + + +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)" + 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 ValidationError: + raise + 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"} + + _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": + _eprint( + f" Workload Orchestration Extension Dependency: {AIO_PLATFORM_EXTENSION_NAME} " + f"Already installed ✓ ({ext_ver})" + ) + return + logger.info( + "Existing AIO platform extension in state '%s'; reinstalling.", + prov_state, + ) + + version_msg = f" version {version}" if version else "" + _eprint( + f" Installing Workload Orchestration Extension Dependency: " + f"{AIO_PLATFORM_EXTENSION_NAME}{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 "" + _eprint( + f" Workload Orchestration Extension Dependency: " + f"{AIO_PLATFORM_EXTENSION_NAME} Installed{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": + _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 "" + _eprint( + f" Installing Workload Orchestration Extension: {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: + _eprint(f" Workload Orchestration Extension: {extension_name} Creating (--no-wait) ✓") + else: + _eprint(f" Workload Orchestration Extension: {extension_name} 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: + # 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 + + if not extension_id: + raise CLIInternalError( + "Cannot create custom location: WO extension ID is not available." + ) + + _eprint(f" Creating Custom Location: '{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}" + ) + + _eprint(f" Custom Location: '{custom_location_name}' 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_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 _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}" + ) + 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( + 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}" + ) + 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") + 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_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_exists: + # 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, + ) + else: + # 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}} + 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, + ) + + # Then create the version with config values + ver_body = {"properties": {"values": config_content}} + 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, + ) + + +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/common/utils.py b/src/workload-orchestration/azext_workload_orchestration/common/utils.py new file mode 100644 index 00000000000..1a1a5f3785e --- /dev/null +++ b/src/workload-orchestration/azext_workload_orchestration/common/utils.py @@ -0,0 +1,158 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""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. +""" + +# pylint: disable=broad-exception-caught + +import json +import logging +import sys + +from azure.cli.core.azclierror import CLIInternalError + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# CmdProxy - bridge between AAZ hooks and helpers expecting cmd.cli_ctx +# --------------------------------------------------------------------------- + +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 + + +# --------------------------------------------------------------------------- +# 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 + + 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 +# --------------------------------------------------------------------------- + +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 + 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 + + 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}" + # 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): + try: + return json.loads(result) + except (json.JSONDecodeError, TypeError): + pass + return result + + +# --------------------------------------------------------------------------- +# Progress output (uses stderr so -o json/table/tsv is clean) +# --------------------------------------------------------------------------- + + +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 using tree characters.""" + connector = "└──" if step_num == total else "├──" + if status: + _eprint(f"{connector} {message} {status}") + else: + _eprint(f"{connector} {message}...") diff --git a/src/workload-orchestration/linter_exclusions.yml b/src/workload-orchestration/linter_exclusions.yml index d2e221c3189..3db7ac2c79b 100644 --- a/src/workload-orchestration/linter_exclusions.yml +++ b/src/workload-orchestration/linter_exclusions.yml @@ -32,4 +32,31 @@ workload-orchestration target review: - option_length_too_long solution_template_version_id: rule_exclusions: - - option_length_too_long \ No newline at end of file + - option_length_too_long + +workload-orchestration sync: + parameters: + local_connected_registry_ip: + rule_exclusions: + - option_length_too_long + +workload-orchestration cluster init: + parameters: + extension-dependency-version: + rule_exclusions: + - option_length_too_long + 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/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