diff --git a/.coveragerc b/.coveragerc index 81f7690a9..0c7ffb902 100644 --- a/.coveragerc +++ b/.coveragerc @@ -11,4 +11,9 @@ omit = azext_edge/edge/params.py azext_edge/edge/command_map.py # skip params and command map - azext_edge/edge/providers/rpsaas/adr/params.py \ No newline at end of file + azext_edge/edge/providers/rpsaas/adr/params.py + +[paths] +edge = + azext_edge/edge + */azure-cli-extensions/azure-iot-ops/azext_edge/edge \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index 970cddac7..e77e93ab5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,7 +16,8 @@ docs/ *.zip # tools -tools/ +tools/* +!tools/integration_runner.py # non-code files *.md @@ -27,6 +28,7 @@ tools/ # venv env*/ +.venv/ # pycache **/__pycache__/ @@ -36,7 +38,14 @@ env*/ .pylintrc # coverage -.coveragerc +.coverage* +!.coveragerc +htmlcov/ + +# runtime inputs/results are mounted explicitly, never baked into the image +.artifacts/ +junit/ +pytest.ini # devcontainer .devcontainer/ diff --git a/.github/actions/build-int-test-matrix/action.yml b/.github/actions/build-int-test-matrix/action.yml index afb7f0b04..7f960bae0 100644 --- a/.github/actions/build-int-test-matrix/action.yml +++ b/.github/actions/build-int-test-matrix/action.yml @@ -13,6 +13,18 @@ inputs: description: Comma-separated list of scenarios to run required: false default: '' + runtime-channels: + description: Comma-separated runtime channels (stable,preview) + default: stable,preview + upgrade-baselines: + description: JSON object of pinned baseline wheels and runtime identities, keyed by channel + default: '{}' + runtime-init-args: + description: Additional shared foundation arguments + default: '' + runtime-create-args: + description: Additional runtime arguments, excluding channel/version/train selection + default: '' outputs: matrix: @@ -32,5 +44,9 @@ runs: env: TEST_SCENARIO_FILE: ${{ inputs.config-file }} TEST_SCENARIOS: ${{ inputs.test-scenarios }} + RUNTIME_CHANNELS: ${{ inputs.runtime-channels }} + UPGRADE_BASELINES: ${{ inputs.upgrade-baselines }} + RUNTIME_INIT_ARGS: ${{ inputs.runtime-init-args }} + RUNTIME_CREATE_ARGS: ${{ inputs.runtime-create-args }} run: | python "${{ github.action_path }}/build_matrix.py" \ No newline at end of file diff --git a/.github/actions/build-int-test-matrix/build_matrix.py b/.github/actions/build-int-test-matrix/build_matrix.py index ef2cac587..dfc8368d0 100644 --- a/.github/actions/build-int-test-matrix/build_matrix.py +++ b/.github/actions/build-int-test-matrix/build_matrix.py @@ -5,8 +5,12 @@ # ---------------------------------------------------------------------------------------------- import os import sys -from contextlib import nullcontext +import json +import re +import shlex +from copy import deepcopy from json import dumps +from urllib.parse import urlsplit from yaml import safe_load @@ -21,6 +25,7 @@ "test_redeploy", # Whether to test redeployment in the scenario "env", # Custom environment variable dict for the scenario "parallel", # Controls parallel execution in pytest-xdist + "requires_baseline", # Opt-in real upgrade, rather than same-version reconciliation } @@ -55,6 +60,8 @@ def process_scenarios(scenarios: list[dict], user_selected: str) -> list[dict]: # If user provided test selection, only include matching scenarios if selected_scenarios and name not in selected_scenarios: continue + if not selected_scenarios and scenario.get("requires_baseline"): + continue # Warn if unknown fields present unknown = set(scenario.keys()) - KNOWN_FIELDS @@ -83,6 +90,7 @@ def process_scenarios(scenarios: list[dict], user_selected: str) -> list[dict]: "test_redeploy": bool(scenario.get("test_redeploy", False)), "env": formatted_env, "parallel": bool(scenario.get("parallel", True)), + "requires_baseline": bool(scenario.get("requires_baseline", False)), } processed_scenarios.append(normalized) @@ -90,6 +98,55 @@ def process_scenarios(scenarios: list[dict], user_selected: str) -> list[dict]: return processed_scenarios +def validate_baseline(baseline: dict) -> None: + required = {"wheel_url", "sha256", "version", "train", "create_args"} + if not isinstance(baseline, dict) or set(baseline) != required: + raise ValueError(f"Upgrade baseline must contain exactly: {', '.join(sorted(required))}.") + if any(not isinstance(value, str) for value in baseline.values()): + raise ValueError("Upgrade baseline values must be strings.") + url = urlsplit(baseline["wheel_url"]) + if url.scheme != "https" or not url.hostname or url.username or url.password or url.query or url.fragment: + raise ValueError("Baseline wheel URL must be credential-free HTTPS (no query string or fragment).") + if not url.path.endswith(".whl") or not re.fullmatch(r"[a-fA-F0-9]{64}", baseline["sha256"]): + raise ValueError("Baseline must identify a wheel and its SHA256 digest.") + if not baseline["version"] or baseline["train"] not in {"stable", "preview", "integration"}: + raise ValueError("Baseline requires an explicit version and deployment train.") + + +def expand_channels(scenarios, channels="stable,preview", baselines=None, init_args="", create_args=""): + """Each row is an independent cluster; never convert an existing runtime's channel.""" + selected = list(dict.fromkeys(channel.strip() for channel in channels.split(",") if channel.strip())) + if not selected or set(selected) - {"stable", "preview"}: + raise ValueError("runtime-channels must contain stable and/or preview.") + baselines = baselines or {} + if not isinstance(baselines, dict) or set(baselines) - {"stable", "preview"}: + raise ValueError("upgrade-baselines must be an object keyed by stable and/or preview.") + rows = [] + for scenario in scenarios: + # The channel matrix owns these flags. Arbitrary overrides would make a + # labelled GA job silently deploy preview, or test a different target. + for arguments in (scenario["init_args"], scenario["create_args"], init_args, create_args): + options = {arg.split("=", 1)[0] for arg in shlex.split(arguments)} + if options & {"--use-preview", "--ops-version", "--ops-train"}: + raise ValueError("Runtime selection belongs to runtime-channels / upgrade-baselines, not extra args.") + for channel in selected: + row = deepcopy(scenario) + row["name"] = f"{scenario['name']}-{channel}" + row["description"] = f"{scenario['description']} [{channel}]" + row["channel"] = channel + row["init_args"] = f"{scenario['init_args']} {init_args}".strip() + selector = "--use-preview" if channel == "preview" else "" + row["create_args"] = f"{scenario['create_args']} {create_args} {selector}".strip() + row["baseline"] = None + if row["requires_baseline"]: + if channel not in baselines: + raise ValueError(f"Scenario {scenario['name']} requires an explicit {channel} upgrade baseline.") + validate_baseline(baselines[channel]) + row["baseline"] = baselines[channel] + rows.append(row) + return rows + + def main() -> None: """ Helper utility to build test scenario matrix for GitHub Actions. @@ -107,7 +164,13 @@ def main() -> None: config_scenarios = config.get("scenarios", []) # Filter based on user input - test_scenarios = process_scenarios(config_scenarios, custom_scenarios) + test_scenarios = expand_channels( + process_scenarios(config_scenarios, custom_scenarios), + channels=os.getenv("RUNTIME_CHANNELS", "stable,preview"), + baselines=json.loads(os.getenv("UPGRADE_BASELINES", "{}")), + init_args=os.getenv("RUNTIME_INIT_ARGS", ""), + create_args=os.getenv("RUNTIME_CREATE_ARGS", ""), + ) # Exit if no valid scenarios if not test_scenarios: @@ -123,9 +186,11 @@ def main() -> None: # Write to github action output or stdout output_path = os.environ.get("GITHUB_OUTPUT") - ctx = open(output_path, "a", encoding="utf-8") if output_path else nullcontext(sys.stdout) - with ctx as out: - out.write(f"scenarios={matrix_json}\n") + if output_path: + with open(output_path, "a", encoding="utf-8") as out: + out.write(f"scenarios={matrix_json}\n") + else: + sys.stdout.write(f"scenarios={matrix_json}\n") if __name__ == "__main__": diff --git a/.github/actions/setup-cluster-resources/action.yml b/.github/actions/setup-cluster-resources/action.yml index 2742469ee..518d5c021 100644 --- a/.github/actions/setup-cluster-resources/action.yml +++ b/.github/actions/setup-cluster-resources/action.yml @@ -59,8 +59,8 @@ runs: echo "SCHEMA_REGISTRY_ID=$registry_id" >> $GITHUB_OUTPUT echo "Schema registry created: $registry_id" - # ADR namespace - limit name to 30 chars - adr_namespace_name=$(echo "init-adr-ns-${{ inputs.suffix }}" | cut -c 1-30) + # ADR namespace - limit name to 30 chars without leaving an invalid trailing hyphen + adr_namespace_name=$(echo "init-adr-ns-${{ inputs.suffix }}" | cut -c 1-30 | sed 's/-*$//') echo "Creating ADR namespace: $adr_namespace_name" adr_namespace_id=$(az iot ops ns create \ diff --git a/.github/skills/sync-aio-bicep-templates/SKILL.md b/.github/skills/sync-aio-bicep-templates/SKILL.md index 248cd51c0..0e57e57ce 100644 --- a/.github/skills/sync-aio-bicep-templates/SKILL.md +++ b/.github/skills/sync-aio-bicep-templates/SKILL.md @@ -1,11 +1,27 @@ --- name: sync-aio-bicep-templates -description: Generate and safely integrate updated template.py blueprints from the azure-iot-operations-tests deployment repository and an explicit release ref. +description: Generate and safely integrate GA or preview AIO runtime templates from azure-iot-operations-tests at explicit source refs, excluding GDS Manager from both profiles. Use for template sync, adding a separate preview blueprint, or refreshing release inputs without changing the other runtime profile or shared init dependencies. --- # Synchronize AIO Bicep templates -Use this workflow when a teammate asks to generate `template.py` for a new AIO release. +Use this workflow when a teammate asks to generate deployment blueprints for an AIO release. + +## Choose the synchronization scope first + +For a **preview template**, an explicit runtime profile, or a package supporting **both GA and preview**, first read +[Runtime-profile synchronization](./references/runtime-profiles.md). It defines the target files, independent source +refs, review gates, and validation mapping for that scope. Its scope-specific rules replace the single-template +write targets, global constants/version policy, and publication hand-off below; the source safety, provenance, +redaction, and compile checks still apply. Never run the legacy two-assignment replacement against a preview ref. + +The existing single-release workflow below remains for a request to refresh the legacy GA/shared templates. It is +not a reason to replace the GA instance blueprint, shared enablement blueprint, or package version when adding a +preview profile. With runtime profiles present, use the profile workflow even for a GA-only instance refresh. + +An unspecified scope must be resolved before generation; do not infer it from the source train. A branch explicitly +supplied as the preview source selects preview scope, but does not establish its actual deployment train, final +release approval, or permission to publish it. ## Required inputs @@ -54,8 +70,10 @@ Do not require the user to know repository implementation details after these in version, stop and ask, because recompiling with it rewrites `_generator.version` backwards. Never change the machine's Bicep installation as a side effect of this workflow — it is shared with everything else on the system. 10. Parse `Deployment/release.json` from the selected ref and confirm its `release` value equals the supplied release - moniker. Fail on a mismatch rather than combining metadata from different releases. The value is stored as an - integer and the moniker is a string, so coerce before comparing. Compare it against the **supplied moniker**, + moniker. Fail on a mismatch rather than combining metadata from different releases. The value may be an integer + or a prefixed string, so coerce to a string without stripping a prefix. If the user supplied only a source ref, + report the discovered moniker; do not invent an expected value from the branch name. Compare an explicitly + supplied moniker against that source value, never against `AIO_RELEASE`: that constant still holds the *previous* release until section 4 updates it, so comparing against it here fails every genuinely new release and lets only a re-sync through. 11. Note whether `AIO_RELEASE` in `azext_edge/constants.py` already equals the supplied moniker. If it does, this is @@ -213,6 +231,28 @@ not. That combination is only possible if someone edited generated output by han ### Source redactions +#### Required GDS Manager exclusion for GA and preview + +Exclude GDS Manager from every selected GA and preview instance template, including same-release refreshes. +This approved policy supersedes earlier approval to retain GDS in internal preview qualification templates. +Do not ask for that settled exclusion again or treat the upstream default as permission to restore it. +A single-profile sync must still preserve the nonselected profile and shared enablement; when both profiles are +selected, apply and validate the exclusion independently in both exports. + +Treat this as an approved behavioral release-policy override, not a behavior-preserving substitution: + +- Inspect the selected source's compiled dependency closure for GDS-only parameters, variables, resources, + configuration settings, conditions, and their readers. Include `enableGdsManager` and + `connectors.values.gdsManager.enabled`, plus any additional GDS symbols introduced by that ref. +- Remove those inputs and all GDS-only readers from the exported Bicep source before recompiling. Remove orphaned + decorators too. Preserve unrelated expressions and components when an expression contains both GDS and non-GDS + content. Do not merely set the default to `false`, hand-edit generated Python/JSON, or change the source checkout. +- Report the removed source paths and symbols per profile, record the override in provenance, and update stale + generated-module notes and release-specific test expectations that previously claimed GDS was retained. +- Validate each final compiled blueprint independently: no GDS Manager parameter, configuration entry, resource, + or reference may remain. Scan both keys and string values case-insensitively, accounting for the GDS names found + in the selected source. Stop integration if any GDS input or reader survives. + `template.py` is normally byte-identical to the compiled Bicep, and that is the invariant this skill maintains. Redactions are a deliberate, bounded exception to it: the goal is a template that deploys identically, not one that matches the compiler byte for byte. Apply a redaction only when it is provably behavior-preserving and there is a @@ -298,6 +338,13 @@ depends on the file's structure rather than on one scalar within it. ### Constants and test expectations +For profile-aware syncs, complete the [release-test maintenance steps](./references/runtime-profiles.md#maintain-release-tracking-tests) +in the same change as the selected blueprint and catalog. The GA-only list below is not sufficient for preview. +Do not leave release-specific test updates as a manual task for the user. +Also complete [management API maintenance](./references/runtime-profiles.md#maintain-release-specific-management-apis) +from the approved release inputs. Keep runtime-profile API selection separate from generated deployment API values; +an intentionally pinned blueprint must not be regenerated merely to align those values. + Also update, from the same generated output: - `EXTENSION_CONFIGS` in `azext_edge/tests/edge/orchestration/test_template_unit.py` from generated diff --git a/.github/skills/sync-aio-bicep-templates/references/runtime-profiles.md b/.github/skills/sync-aio-bicep-templates/references/runtime-profiles.md new file mode 100644 index 000000000..3203a5c89 --- /dev/null +++ b/.github/skills/sync-aio-bicep-templates/references/runtime-profiles.md @@ -0,0 +1,261 @@ +# Runtime-profile synchronization + +Use with the parent skill for a CLI package that supports GA and preview runtimes independently. +This is a scope-specific extension of the existing generation workflow, not a second generator. + +## 1. Inputs and write boundaries + +Resolve these from the request and existing release handoff; ask only for missing decisions: + +- Local deployment repository and an explicit source ref **for each selected profile**. +- Scope: `stable`, `preview`, or both. Shared enablement changes require their own explicit scope and source ref. +- Source release moniker and profile release identifier. Keep them separate: source metadata can contain a numeric + `YYMM` release or a prefixed string while the handoff identifies preview separately. Preserve the exact source + moniker; do not strip its prefix or silently convert it to `YYMM-preview`. Read the metadata rather than parsing + the branch name into a release or guessing a runtime version. If no moniker was supplied, report the discovered + value instead of treating an inferred branch suffix as a conflicting input. +- Approved public-content exclusions, deliberate train overrides, and connector/backfill compatibility inputs. +- GDS Manager exclusion is mandatory for both selected GA and preview instance templates, as defined in the + parent skill. Earlier internal-preview retention approval is superseded; no new approval is needed to exclude it. +- Reviewed IoT Operations management API version for each selected profile, from the release handoff or confirmed + API contract. Do not infer it from the release moniker, runtime version, or the newest available API constant. +- Approved preview notice/agreement URL in `--use-preview` help and the create long summary. Reuse the existing + approved help during a refresh; never invent legal text or a URL. + +Use the following layout for this repository: + +| Scope | Generated assignment | Destination | +| --- | --- | --- | +| Shared foundation | `TEMPLATE_BLUEPRINT_ENABLEMENT` | `azext_edge/edge/providers/orchestration/template.py` | +| GA instance | `TEMPLATE_BLUEPRINT_INSTANCE` | `azext_edge/edge/providers/orchestration/template.py` | +| Preview instance | `TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW` | `azext_edge/edge/providers/orchestration/template_preview.py` | + +The preview module imports `TemplateBlueprint` from `.template`; do not duplicate the class, helpers, or shared +foundation. Do not synthesize preview by copying the GA dictionary and changing its version/train. + +An instance-only sync must preserve the other profile and shared enablement. A preview-only sync must also preserve +`AIO_RELEASE`, `VERSION`, package maturity metadata, and GA-specific constants such as `OPCUA_CONNECTOR_VERSION`. +Record their starting contents or parsed assignments and assert they have not changed at the end. Changes already +present in the worktree belong to the user: compare with the **pre-run worktree**, not just `HEAD`. + +Inspect status for every planned destination, catalog, and test. Do not overwrite or stage existing edits. Dry-run +generation in temporary storage can continue while these files are dirty; integration must be narrowly reviewed +against their current contents. Never reset, stash, switch branches, commit, or publish as part of this skill. + +## 2. Pin and generate each source independently + +Apply parent preflight sections 1.1-1.10 per selected ref. In particular: + +1. Compare the local ref with the remote; do not silently fetch, use a stale ref, or substitute the current checkout. +2. Resolve one commit and use it for the entire export, including imported Bicep/types, release metadata, and loaded + YAML/JSON/text. A different GA ref is not a substitute for a missing preview dependency file. +3. Require `str(release.json['release'])` to match the source moniker. Validate the profile identifier separately; + never compare a preview-only source release against the unchanged global `AIO_RELEASE`. +4. Export into separate `stable`, `preview`, and/or `shared` temporary subdirectories. Keep each optimizer run in + its own directory because JSON mode always writes `optimized.json`. +5. Record the Bicep compiler version and enforce the no-toolchain-downgrade rule against the selected existing + blueprint. For a first preview addition, compare against the shared/GA baseline. Do not install tooling silently. +6. Compile the selected instance using parent section 2: optimizer JSON mode, then `repr()` and Black. Render its + dictionary into the corresponding `TemplateBlueprint` assignment only after review. + +For a preview-only run, compile its enablement template **for comparison**, not replacement. Compare versions, +trains, resources, conditions, trust/configuration defaults, API contracts, and auto-upgrade settings with the +bundled shared foundation. Report differences; never introduce preview-specific CM/Secret Store deployments or +change `az iot ops init`. Any incompatibility with the shared foundation blocks activation until reviewed. +Compiled deployment defaults are not proof of a supported installed-dependency range. + +Resolve transitive provenance with parent section 3. Record independently: + +- selected `source_ref` and full resolved `source_commit` for the profile; +- justified blueprint `commit_id`, including an explicit override if transitive files require it; +- unredacted and final compiler hashes, tool version, and approved source edits. + +Neither a branch name nor a `preview.N` suffix proves the actual train. Preserve the compiled train. An +`integration` build remains `integration` and needs an exact version/train/intended-channel association; do not +silently relabel it `preview` or `stable`. Shipped-train overrides require approval, an edit to exported Bicep, +and a fresh compile. Preserve source `autoUpgradeMinorVersion` inputs and flag anything other than the reviewed +manual-upgrade policy for AIO and shared dependencies. + +## 3. Review before integration + +Use the selected profile's previous blueprint as the baseline. For a first preview addition, use the GA instance +blueprint only as a **comparison baseline**, label the artifact as new, and report the cross-profile differences. +Do not treat the GA comparison as authorization to copy GA defaults or migrations. + +Split routine changes from behavioral changes exactly as in parent section 3. Show every changed object path with +old/new values, including added/removed resources, new feature defaults, API versions, and conditions. Audit the +shared enablement comparison separately. Keep internal loaded-file blobs in temporary storage; report their paths, +readers, and intended substitutions rather than copying them into a public blueprint or publication payload. + +Inspect scalar substitutions for public suitability too: replacing a loaded-file reader with its current literal +can still ship a development registry or an unapproved setting. Do not substitute a public registry or an older +connector tag on your own. Report the exact source value and request a scoped release-owner decision when needed. +A generated preview API version also requires a compatibility review of existing clients/update payloads; it does +not by itself authorize client regeneration, API fallback, or silently dropping unsupported fields. + +Apply public-content exclusions and behavior-preserving loaded-file substitutions to the exported source only, +then recompile. Use the exclusions already approved through the release handoff and ask about newly unclassified +settings; do not ask for the same settled decision repeatedly. Apply the mandatory GDS Manager exclusion to each +selected profile's exported source, removing its inputs and readers rather than merely disabling its default. +Record this approved behavioral override separately from behavior-preserving substitutions. Preserve the pre-redaction output as +`opt_instance_unredacted.json` **inside that profile's temporary directory**. Resolve each literal from that same +profile/ref, not the other profile's YAML or the previous release's constant. + +Present the behavioral report and obtain confirmation before writing a new generated preview module or replacing +an existing blueprint. A request to add a template is not blanket approval of all upstream behavioral changes. +When no behavioral review has happened, report `generated for review`, not `integrated` or `release-ready`. + +## 4. Catalog and release metadata + +After the generated content is approved: + +1. Integrate only the selected complete assignment/module, preserving unrelated code and current user edits. +2. Construct that channel's `RuntimeProfile` using the generated blueprint, profile release identifier, exact ref, + and resolved commit. Its connector version must come from that profile's reviewed source/literal substitutions; + never update the global GA connector constant to accommodate preview. +3. In `runtime_catalog.py`, register the reviewed preview as `PREVIEW_PROFILE` only when its shared-foundation and + runtime/API assumptions are reviewed. If still pending, leave it `None` and state that the generated blueprint + is staged but not selectable. Do not create a dummy profile to bypass validation. +4. Treat `--use-preview` as the explicit opt-in and terms acceptance. Keep the approved notice and terms URL in + that option's help and the create long summary, not in deployment output or runtime-profile metadata. Do not + restore a consent prompt, TTY check, create-only `--yes`, or separate `--accept-terms` parameter. Preserve + confirmation options on other commands. Registration also exposes update/upgrade inputs, so help text is not + a substitute for runtime/API review. +5. Maintain exact historical `QUALIFICATION_IDENTITIES` and `SHARED_DEPENDENCY_REQUIREMENTS` only from reviewed + handoff data. A generated target does not prove historical upgrades or every newer dependency compatible. +6. Review whether connector backfills and new resources are compatible with existing instances. Create defaults + are not update/upgrade migrations, and a target connector tag is not automatically valid for all older runtimes + in the channel. Report missing policies rather than stamping the latest tag indiscriminately. + +### Maintain release-specific management APIs + +Maintain the management API selection on every new release and same-release refresh: + +1. Compare the selected profile's `iotops_api_version` in `runtime_catalog.py` with the approved release API input. + Reuse or add the corresponding `IoTOpsMgmtApiVersion` constant in `azext_edge/edge/util/az_client.py`, and select + it in that profile. Preserve existing constants, the nonselected profile, and the GA default during preview-only + work. Providers must consume profile metadata, not duplicate release-specific API strings. +2. Report the management API separately from the `apiVersion` values in the compiled deployment resources. + A user may intentionally keep an older, pinned qualification blueprint while selecting a newer management API. + Record the approved difference and its validation status; do not silently change the source ref, refresh the + blueprint, or hand-edit generated output to make the dates match. An API-only change does not authorize a + template regeneration. Missing compatibility evidence remains visible in the handoff. +3. Check the selected API's operation paths, request/response shapes, and generated-client support. The existing + model-less client can pass an explicit API version and preserve JSON fields, but that does not prove service + compatibility or add missing preview-only operation groups. Report unsupported operations and seek scoped + client-generation approval; never fall back silently to GA for preview mutations. +4. Update independent API expectations in `test_runtime_profiles_unit.py`, `resources/test_instances_unit.py`, + and `test_upgrade2_unit.py`. Cover GA and preview request API versions, re-reading through the selected API + before read-modify-write, preview field preservation, and registry/connector backfill requests. Preserve fixed + synthetic upgrade versions and GA expectations. Test any newly supported child operations separately. +5. Run those modules and `azext_edge/tests/utility/test_az_client_unit.py` after the changes, then the section 5 + suites. Report offline request/serialization tests separately from live runtime/API qualification; do not claim + that mocked field preservation proves server-side child-resource preservation. + +`--use-preview` is a normal runtime-selection parameter, not a Preview-tagged CLI interface. Leave that distinction +and the `init` interface intact. Component `--feature ...mode=Preview` and package `--allow-preview` are unrelated. + +The parent **CLI version/train policy does not apply to a multi-profile sync**. Package maturity is independent of +the selected runtime's maturity/train. Preserve `VERSION` for template preparation unless an explicit package +release change is requested. For a packaging request, use one agreed CLI version for both runtime profiles and +check reuse under the existing release process. Do not promote/demote the wheel because preview is bundled. + +### Maintain release-tracking tests + +Test maintenance is part of every selected-profile refresh, including a new release cycle and a same-release +re-sync. Once the source changes are reviewed, identify and update affected expectations in the same change as +the blueprint/catalog; do not ask the user to locate stale assertions or hand-edit them later. + +1. For preview, inspect `azext_edge/tests/edge/orchestration/test_template_preview_unit.py` on every refresh. + Compare its pinned AIO version, actual train, API versions, connector tags/registries, resource keys/counts, + phase membership, feature expressions, parameter defaults and catalog/backfill assertions with the approved + source and final compile. Update only expectations whose corresponding inputs intentionally changed. +2. Keep that file's `qualification_profile` release/ref labels aligned with the selected release/ref for clarity. + These labels do not select the artifact or prove provenance; `source_commit="test-commit"` may remain synthetic. + Production catalog provenance must still use the exact resolved source SHA. +3. Follow references to the selected blueprint/profile and changed release values in the orchestration tests, + especially `test_runtime_activation_unit.py`, `test_runtime_profiles_unit.py`, `test_runtime_commands_unit.py` + and `test_get_versions_unit.py`. Distinguish expectations for the actual bundled artifact from deliberately + synthetic versions, historical upgrade baselines and boundary cases. Never bulk-replace old version strings + or advance a historical fixture merely because a new release has the same-looking value. +4. Keep artifact expectations independent: take new expected literals from the reviewed source/final compile, + not from the object under test at test runtime. Do not replace pinned assertions with self-comparisons, weaken + them, or skip failing cases to make a refresh green. Reuse existing fixtures and expectation sets where suitable; + preserve GA/shared expectations during preview-only work. +5. Reconcile added/removed resources and changed defaults with phase, custom-name, feature-preservation and + catalog-wiring tests. An unexpected failure is a reason to investigate the source or implementation, not + permission to change the expected result. Obtain the existing behavioral approval for newly discovered changes. +6. Run the focused checks below immediately after the related edits, then the broader section 5 suites. Include + the reviewed old/new expectations and validation results in the handoff. Do not report a completed sync while + these tests fail or have not run; report a blocked/incomplete sync instead. + +For a preview refresh, run from the CLI repository root with the configured development interpreter: + +```shell +python -m pytest -q \ + azext_edge/tests/edge/orchestration/test_template_preview_unit.py \ + azext_edge/tests/edge/orchestration/test_runtime_activation_unit.py \ + azext_edge/tests/edge/orchestration/test_runtime_profiles_unit.py \ + azext_edge/tests/edge/orchestration/test_runtime_commands_unit.py \ + azext_edge/tests/edge/orchestration/test_get_versions_unit.py +``` + +## 5. Validate selected outputs and isolation + +Adapt parent section 5's AST checks to an explicit mapping of **selected outputs**; its legacy loop assumes exactly +two assignments in one file and must not be used unchanged for preview. For preview-only validation the mapping is: + +- `template_preview.py:TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW` -> `/opt_instance.json`. + +For GA/shared runs add only their selected assignments and corresponding per-ref JSON paths. Require exactly one +assignment for each expected symbol, extract `content` with AST/literal evaluation (never import generated modules +for equality checks), and assert equality with its own final compile. Missing/duplicate assignments are failures, +not skipped checks. Compare protected assignments with their pre-run snapshots instead of unrelated fresh outputs. + +For every selected output independently: + +- Check exact generated dictionary equality and no surviving `$fxv` strings/references. +- Check both keys and string values in every selected final blueprint for surviving GDS Manager inputs, resources, + configuration, and readers. Use the source-specific symbol inventory and a case-insensitive scan; any survivor + blocks integration. Update assertions and module provenance that previously expected GDS retention. +- Check all substituted literals against its unredacted compile and loaded source files; enumerate every reader. + Missing unredacted evidence is blocking when redactions occurred, not merely an informational warning. +- Check profile identity/release/provenance, connector tag, schema and API inputs, and manual-upgrade settings. +- For preview, check the existing runtime version policy (patch zero and dotted numeric `preview.N`). Stop on a + mismatch; never rewrite a source version to force it to pass. +- Verify the nonselected instance and shared enablement are unchanged. For preview-only sync also check global + release/package metadata and GA runtime constants are unchanged. +- Test selected-profile create preparation, feature/default preservation, custom instance names (including newly + added children), and all phase resource sets. Use profile-specific resource expectations; do not append preview + resource keys to the GA canonical set just to make the tests pass. +- Ensure profile copies do not mutate shared data. Check both profiles in one process in either selection order. +- Keep offline help/version reporting and the normal create-only selector tests green. + +Run the parent template/targets/work/upgrade unit suites plus runtime profile/discovery/command/dependency and +version-reporting tests. Add tests for the actual new artifact, including catalog wiring if activated. Lint all +changed Python files and run `git diff --check`. No live tests or workflow dispatch without explicit authorization. + +Report mechanical generation/test success separately from runtime qualification and public-release readiness. +A missing reviewed preview profile must remain visible rather than being replaced with synthetic test fixtures +in production. Verify flag-only preview creation is noninteractive and does not print a separate consent notice. + +## 6. Handoff without publication + +For template-only work, report each profile's release, actual version/train, source SHA, blueprint provenance, +compiler hash, connector version, management API and deployment API versions, shared-foundation differences, +behavioral decisions, and activation status. Include any intentional API mismatch and unverified client/service +compatibility rather than describing the profile as release-ready. +Keep review output in the conversation; do not create a separate implementation-plan document. + +If a versions-wiki payload is requested, adapt parent section 6 per profile: components come from that profile's +source, dependencies from the unchanged shared blueprint, and both rows reference the same CLI package. Include +the intended runtime channel separately from the actual train. Use visible placeholders for dates and supported +upgrade baselines; do not infer them from matching versions or a successful compile. Retain integration builds as +qualification/draft inputs, not public-preview release claims. + +The public index handoff in parent section 7 applies only to an explicitly requested **package release**, once per +package, not once per runtime template. Adding a preview template does not create a second wheel or index entry. +Do not publish, update the wiki/index, push, create a PR, or dispatch any workflow during synchronization. + +Retain temporary artifacts through review/recompile/validation, then clean them up when the run is completed or +abandoned. Report any retained review directory while paused; never remove unrelated temporary or source files. \ No newline at end of file diff --git a/.github/test-container-scenarios.yml b/.github/test-container-scenarios.yml new file mode 100644 index 000000000..834f7ba40 --- /dev/null +++ b/.github/test-container-scenarios.yml @@ -0,0 +1,5 @@ +# Each channel uses an independent cluster but the same candidate wheel. +scenarios: + - name: container-e2e + description: Containerized end-to-end tests + tox_env: python-e2e-int \ No newline at end of file diff --git a/.github/test-scenarios.yml b/.github/test-scenarios.yml index e21e9a4e6..d4db21828 100644 --- a/.github/test-scenarios.yml +++ b/.github/test-scenarios.yml @@ -10,6 +10,8 @@ # - test_redeploy: Optional boolean to test redeployment in the scenario (default: false) # - env: Optional list of environment variable dictionaries with key-value pairs # - parallel: Optional boolean controlling parallel execution in pytest-xdist (default: true) +# Every selected scenario is expanded across runtime-channels (stable,preview by default). +# - requires_baseline: Explicit opt-in scenario; requires a pinned baseline per selected channel. scenarios: - name: edge @@ -27,9 +29,20 @@ scenarios: description: "Cloud tests" tox_env: python-rpsaas-int + - name: runtime-channel + description: "Shared commands and runtime-channel boundaries" + tox_env: python-runtime-int + parallel: false + - name: upgrade - description: "Upgrade tests" + description: "Upgrade reconciliation tests (same runtime version)" + tox_env: python-upgrade-int + parallel: false + + - name: upgrade-path + description: "Older runtime to bundled target upgrade tests" tox_env: python-upgrade-int + requires_baseline: true parallel: false - name: mgmtactions @@ -42,6 +55,10 @@ scenarios: test_redeploy: true create_args: "--feature opcua.mode=Disabled" + - name: essentials-sku + description: "Essentials SKU tests" + create_args: "--sku Essentials --feature opcua.mode=Stable" + - name: trustbundle description: "Workload identity tests" tox_env: python-wlif-int diff --git a/.github/workflows/cluster_cleanup.yml b/.github/workflows/cluster_cleanup.yml index 463170d42..587969929 100644 --- a/.github/workflows/cluster_cleanup.yml +++ b/.github/workflows/cluster_cleanup.yml @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Az CLI login - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -72,7 +72,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Az CLI login - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -104,7 +104,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Az CLI login - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6b53c4bda..c7161fbca 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -21,9 +21,9 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 diff --git a/.github/workflows/container_int_test.yml b/.github/workflows/container_int_test.yml index 6721999e3..fca8ba59c 100644 --- a/.github/workflows/container_int_test.yml +++ b/.github/workflows/container_int_test.yml @@ -3,6 +3,10 @@ name: E2E Containerized Integration tests on: workflow_call: inputs: + runtime-channels: + description: "Runtime channels to test (stable,preview); each gets an independent cluster" + type: string + default: "stable,preview" resource-group: description: Resource group for cluster creation type: string @@ -23,6 +27,10 @@ on: required: true workflow_dispatch: inputs: + runtime-channels: + description: "Runtime channels to test (stable,preview)" + type: string + default: "stable,preview" resource-group: description: Resource group for cluster creation type: string @@ -41,37 +49,91 @@ permissions: env: RESOURCE_GROUP: "${{ inputs.resource-group }}" - CLUSTER_NAME: "opt${{ github.run_number }}" - INSTANCE_NAME: "inst${{ github.run_number }}" - STORAGE_NAME: "initstore${{ github.run_number }}" - SCHEMAREG_NAME: "init-registry${{ github.run_number }}" - ADR_NAMESPACE_NAME: "init-adr-ns${{ github.run_number }}" CUSTOM_LOCATIONS_OID: ${{ inputs.custom-locations-oid }} K3S_VERSION: "v1.31.1+k3s1" jobs: + build-candidate: + name: "Build one container-test candidate for both runtime channels" + runs-on: ubuntu-22.04 + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + sha256: ${{ steps.wheel.outputs.sha256 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + - name: "Build container channel matrix" + id: matrix + uses: ./.github/actions/build-int-test-matrix + with: + config-file: .github/test-container-scenarios.yml + runtime-channels: ${{ inputs.runtime-channels }} + - name: "Build and fingerprint candidate" + id: wheel + run: | + python -m pip install build + python -m build --wheel + cd dist + wheels=(*.whl) + test "${#wheels[@]}" -eq 1 + sha256sum "${wheels[0]}" > SHA256SUMS + echo "sha256=$(cut -d ' ' -f1 SHA256SUMS)" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: container-integration-candidate + path: dist/ + if-no-files-found: error test: - name: "Run cluster tests" + name: "${{ matrix.scenario.description }}" + needs: build-candidate runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + scenario: ${{ fromJson(needs.build-candidate.outputs.matrix) }} + env: + CLUSTER_NAME: "opt${{ github.run_number }}-${{ github.run_attempt }}-${{ matrix.scenario.name }}" + INSTANCE_NAME: "inst${{ github.run_number }}-${{ github.run_attempt }}-${{ matrix.scenario.name }}" + CREATE_ARGS: ${{ matrix.scenario.create_args }} + azext_edge_runtime_channel: ${{ matrix.scenario.channel }} + azext_edge_wheel_sha256: ${{ needs.build-candidate.outputs.sha256 }} + AZURE_EXTENSION_USE_DYNAMIC_INSTALL: "no" steps: - name: "Setup python" uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" - - name: "Checkout extension source for build" + - name: "Checkout test sources and orchestration tools" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: "Build and install local IoT Ops extension from source" + - name: "Download the shared candidate" + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: container-integration-candidate + path: .artifacts/candidate + - name: "Verify and install the candidate for workflow CLI commands" + run: | + cd .artifacts/candidate + sha256sum --check SHA256SUMS + wheels=(*.whl) + test "${#wheels[@]}" -eq 1 + export azext_edge_wheel="$PWD/${wheels[0]}" + test "$(sha256sum "$azext_edge_wheel" | cut -d ' ' -f1)" = "$azext_edge_wheel_sha256" + echo "azext_edge_wheel=$azext_edge_wheel" >> "$GITHUB_ENV" + az extension add --source "$azext_edge_wheel" -y + extension_path=$(az extension show --name azure-iot-ops --query path -o tsv) + python "$GITHUB_WORKSPACE/tools/integration_runner.py" --verify-installed --extension-dir "$extension_path" + - name: "Choose collision-resistant resource suffix" + id: resources run: | - pip install -r dev_requirements.txt - python -m build - wheel=$(find ./dist/*.whl) - az extension add --source $wheel -y + echo "suffix=$(printf '%s' "$CLUSTER_NAME" | sha256sum | cut -c1-11)" >> "$GITHUB_OUTPUT" - name: "Create k3s cluster" uses: ./.github/actions/create-k3s-cluster with: version: ${{ env.K3S_VERSION }} - name: "Az CLI login" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -83,80 +145,73 @@ jobs: resource-group: ${{ env.RESOURCE_GROUP }} custom-locations-oid: ${{ env.CUSTOM_LOCATIONS_OID }} - name: "Az CLI login refresh" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - name: "Run az iot ops init" run: | - # init - az iot ops init --cluster ${{ env.CLUSTER_NAME }} -g ${{ env.RESOURCE_GROUP }} - - name: "Create storage" - id: "create_storage" - run: | - # create storage account - storage_id=$(az storage account create -g ${{ env.RESOURCE_GROUP }} -n ${{ env.STORAGE_NAME }} --enable-hierarchical-namespace --public-network-access Disabled --allow-shared-key-access false --allow-blob-public-access false --default-action Deny --query "id" -o tsv) - echo "created storage account with id $storage_id" - echo "STORAGE_ID=$storage_id" >> $GITHUB_OUTPUT - - name: "Create schema registry" - id: "create_schema_registry" - run: | - # create schema registry - schema_registry=$(az iot ops schema registry create -g ${{ env.RESOURCE_GROUP }} -n ${{ env.SCHEMAREG_NAME }} --rn ${{ env.SCHEMAREG_NAME }}-ns --sa-resource-id ${{ steps.create_storage.outputs.STORAGE_ID}} --query "id" -o tsv) - echo "created schema registry with id $schema_registry" - echo "SCHEMAREGISTRY_ID=$schema_registry" >> $GITHUB_OUTPUT - - name: "Create adr namespace" - id: "create_adr_namespace" - run: | - # create adr namespace - adr_namespace_id=$(az resource create -g ${{ env.RESOURCE_GROUP }} -n ${{ env.ADR_NAMESPACE_NAME }} --api-version 2025-07-01-preview --resource-type Microsoft.DeviceRegistry/namespaces --properties '{}' --query "id" -o tsv) - echo "created adr namespace with id $adr_namespace_id" - echo "ADR_NAMESPACE_ID=$adr_namespace_id" >> $GITHUB_OUTPUT + az iot ops init --cluster "$CLUSTER_NAME" -g "$RESOURCE_GROUP" + - name: "Setup Cluster Resources" + id: init_reqs + uses: ./.github/actions/setup-cluster-resources + with: + suffix: ${{ steps.resources.outputs.suffix }} + resource-group: ${{ env.RESOURCE_GROUP }} - name: "Run az iot ops create" + env: + SCHEMA_REGISTRY_ID: ${{ steps.init_reqs.outputs.SCHEMA_REGISTRY_ID }} + ADR_NAMESPACE_ID: ${{ steps.init_reqs.outputs.ADR_NAMESPACE_ID }} run: | - az iot ops create --cluster ${{ env.CLUSTER_NAME }} -g ${{ env.RESOURCE_GROUP }} -n ${{ env.INSTANCE_NAME }} --sr-resource-id ${{ steps.create_schema_registry.outputs.SCHEMAREGISTRY_ID }} --ns-resource-id ${{ steps.create_adr_namespace.outputs.ADR_NAMESPACE_ID }} - - name: "Output variables for future steps" - id: "env_out" - run: | - echo "RESOURCE_GROUP=${{ env.RESOURCE_GROUP }}" >> $GITHUB_OUTPUT - echo "CLUSTER_NAME=${{ env.CLUSTER_NAME }}" >> $GITHUB_OUTPUT + # The matrix supplies only the channel selector/consent flags for this scenario. + read -r -a create_args <<< "$CREATE_ARGS" + az iot ops create --cluster "$CLUSTER_NAME" -g "$RESOURCE_GROUP" -n "$INSTANCE_NAME" \ + --sr-resource-id "$SCHEMA_REGISTRY_ID" --ns-resource-id "$ADR_NAMESPACE_ID" "${create_args[@]}" - name: "Containerized tests" env: - azext_edge_skip_init: true # skip init tests in container - azext_edge_init_redeployment: false # ensure no redeployment in container - azext_edge_rg: ${{ steps.env_out.outputs.RESOURCE_GROUP }} - azext_edge_cluster: ${{ steps.env_out.outputs.CLUSTER_NAME }} - azext_edge_instance: ${{ steps.env_out.outputs.INSTANCE_NAME }} + azext_edge_skip_init: "true" # skip init tests in container + azext_edge_init_redeployment: "false" # ensure no redeployment in container + azext_edge_rg: ${{ env.RESOURCE_GROUP }} + azext_edge_cluster: ${{ env.CLUSTER_NAME }} + azext_edge_instance: ${{ env.INSTANCE_NAME }} run: | + set -euo pipefail # volume mounts azure_dir=$(realpath ~/.azure) kubeconfig=$(realpath ~/.kube/config) kubeconfig_mount=/root/.kube/config - tempLog=$(mktemp -d) + candidate_dir=$(dirname "$azext_edge_wheel") + mkdir -p .artifacts/container-results + results_dir=$(realpath .artifacts/container-results) # env vars envVars=() envVars+=("-e" "azext_edge_skip_init=$azext_edge_skip_init") envVars+=("-e" "azext_edge_init_redeployment=$azext_edge_init_redeployment") - envVars+=("-e" "AIO_CLI_INIT_PREFLIGHT_DISABLED=$AIO_CLI_INIT_PREFLIGHT_DISABLED") envVars+=("-e" "azext_edge_rg=$azext_edge_rg") envVars+=("-e" "azext_edge_cluster=$azext_edge_cluster") envVars+=("-e" "azext_edge_instance=$azext_edge_instance") + envVars+=("-e" "azext_edge_runtime_channel=$azext_edge_runtime_channel") + envVars+=("-e" "azext_edge_wheel=/opt/aio-candidate/$(basename "$azext_edge_wheel")") + envVars+=("-e" "azext_edge_wheel_sha256=$azext_edge_wheel_sha256") + envVars+=("-e" "azext_edge_junit_path=/integration-results/e2e.xml") + envVars+=("-e" "azext_edge_coverage_file=/integration-results/.coverage") envVars+=("-e" "KUBECONFIG=$kubeconfig_mount") - # Run tests - set +e + # Build only the test harness; the candidate is mounted, never rebuilt in the image. + image=$(docker build . -q) docker run \ --rm \ "${envVars[@]}" \ -v "$kubeconfig:$kubeconfig_mount:ro" \ -v "${azure_dir}:/root/.azure" \ - -v "${tempLog}:/usr/src/azure-iot-ops/junit" \ + -v "${candidate_dir}:/opt/aio-candidate:ro" \ + -v "${results_dir}:/integration-results" \ --network host \ - $(docker build . -q) + "$image" - name: "Az CLI login refresh" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -169,9 +224,29 @@ jobs: if: ${{ always() }} run: | az resource delete -v --name ${{ env.CLUSTER_NAME }} -g ${{ env.RESOURCE_GROUP }} --resource-type Microsoft.Kubernetes/connectedClusters --verbose - - name: "Delete schemaregistry and storage" + - name: "Delete schema registry, ADR namespace and storage" if: ${{ always() }} + env: + SCHEMA_REGISTRY_ID: ${{ steps.init_reqs.outputs.SCHEMA_REGISTRY_ID }} + ADR_NAMESPACE_ID: ${{ steps.init_reqs.outputs.ADR_NAMESPACE_ID }} + STORAGE_ID: ${{ steps.init_reqs.outputs.STORAGE_ID }} run: | - az resource delete -v --id ${{ steps.create_storage.outputs.STORAGE_ID }} --verbose --no-wait - # Match the GA API used by schema registry create. - az resource delete -v --id ${{ steps.create_schema_registry.outputs.SCHEMAREGISTRY_ID }} --api-version 2026-04-01 --verbose --no-wait \ No newline at end of file + status=0 + for id in "$SCHEMA_REGISTRY_ID" "$ADR_NAMESPACE_ID"; do + if [[ -n "$id" ]]; then + az resource delete --id "$id" --api-version 2026-04-01 --verbose --no-wait || status=1 + fi + done + if [[ -n "$STORAGE_ID" ]]; then + az resource delete --id "$STORAGE_ID" --verbose --no-wait || status=1 + fi + exit "$status" + - name: "Upload container results and candidate fingerprint" + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: integration-results-${{ matrix.scenario.name }} + path: | + .artifacts/container-results/ + .artifacts/candidate/SHA256SUMS + include-hidden-files: true diff --git a/.github/workflows/int_test.yml b/.github/workflows/int_test.yml index 3c1ad8298..2f1a13f33 100644 --- a/.github/workflows/int_test.yml +++ b/.github/workflows/int_test.yml @@ -5,6 +5,14 @@ on: - cron: "0 9 * * *" # Run daily at 1:00 AM Pacific Time (9:00 AM UTC) workflow_call: inputs: + runtime-channels: + description: "Runtime channels to test (stable,preview); each gets independent clusters" + type: string + default: "stable,preview" + upgrade-baselines: + description: "JSON pinned baseline definitions by channel, required for explicit upgrade-path scenarios" + type: string + default: "{}" resource-group: description: Resource group for cluster creation type: string @@ -49,6 +57,14 @@ on: required: true workflow_dispatch: inputs: + runtime-channels: + description: "Runtime channels to test (stable,preview)" + type: string + default: "stable,preview" + upgrade-baselines: + description: "JSON pinned baseline definitions by channel, required for upgrade-path" + type: string + default: "{}" resource-group: description: Resource group for cluster creation type: string @@ -105,9 +121,6 @@ jobs: runs-on: ubuntu-latest # Run on dispatch, or ensure scheduled runs only on dev branch if: github.event_name != 'schedule' || (github.repository == 'azure/azure-iot-ops-cli-extension' && github.ref == 'refs/heads/dev') - outputs: - runtime_create_args: ${{ steps.runtime_args.outputs.CREATE_ARGS }} - runtime_init_args: ${{ steps.runtime_args.outputs.INIT_ARGS }} steps: - name: "Check Keep On Failure arg" run: | @@ -115,12 +128,6 @@ jobs: echo "The keep alive input is not a valid number" exit 1 fi - - name: "Output runtime args for int-test job" - id: runtime_args - run: | - echo "CREATE_ARGS=${{ env.RUNTIME_CREATE_ARGS }}" >> $GITHUB_OUTPUT - echo "INIT_ARGS=${{ env.RUNTIME_INIT_ARGS }}" >> $GITHUB_OUTPUT - build-matrix: name: "Build Test Matrix" needs: setup @@ -139,6 +146,10 @@ jobs: uses: ./.github/actions/build-int-test-matrix with: test-scenarios: ${{ env.TEST_SCENARIOS }} + runtime-channels: ${{ inputs.runtime-channels || 'stable,preview' }} + upgrade-baselines: ${{ inputs.upgrade-baselines || '{}' }} + runtime-init-args: ${{ env.RUNTIME_INIT_ARGS }} + runtime-create-args: ${{ env.RUNTIME_CREATE_ARGS }} unit-test: name: "Run linter and unit tests" @@ -165,15 +176,54 @@ jobs: name: coverage-unit path: ./.coverage include-hidden-files: true + build-candidate: + name: "Build one candidate wheel for every runtime channel" + needs: build-matrix + runs-on: ubuntu-24.04 + outputs: + sha256: ${{ steps.wheel.outputs.sha256 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: "Build and fingerprint candidate" + id: wheel + run: | + python -m pip install build + python -m build --wheel + cd dist + wheels=(*.whl) + test "${#wheels[@]}" -eq 1 + sha256sum "${wheels[0]}" > SHA256SUMS + echo "sha256=$(cut -d ' ' -f1 SHA256SUMS)" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: integration-candidate + path: dist/ + if-no-files-found: error + int-test: - needs: [setup, build-matrix] + # Lint/unit checks run independently; integration still requires the shared candidate. + needs: [setup, build-matrix, build-candidate] if: ${{ success() && needs.build-matrix.outputs.matrix != '[]' }} + concurrency: + group: >- + ${{ matrix.scenario.tox_env == 'python-wlif-int' && + format('iot-ops-wlif-{0}', inputs.resource-group || 'ops-cli-int-test-rg') || + format('iot-ops-int-{0}-{1}-{2}', github.run_id, github.run_attempt, matrix.scenario.name) }} + cancel-in-progress: false + queue: max env: CLUSTER_NAME: "opt${{ github.run_number }}${{ matrix.scenario.name }}" INSTANCE_NAME: "inst${{ github.run_number }}${{ matrix.scenario.name }}" KEYVAULT_NAME: "kv${{ github.run_number }}" INIT_ARG: "" CREATE_ARG: "" + azext_edge_runtime_channel: ${{ matrix.scenario.channel }} + azext_edge_upgrade_baseline: ${{ toJson(matrix.scenario.baseline) }} + azext_edge_wheel_sha256: ${{ needs.build-candidate.outputs.sha256 }} + AZURE_EXTENSION_USE_DYNAMIC_INSTALL: "no" strategy: fail-fast: false matrix: @@ -182,13 +232,15 @@ jobs: runs-on: ubuntu-24.04 steps: - name: "Export init args and environment variables" + env: + SCENARIO_INIT_ARGS: ${{ matrix.scenario.init_args }} + SCENARIO_CREATE_ARGS: ${{ matrix.scenario.create_args }} + SCENARIO_ENV: ${{ toJson(matrix.scenario.env) }} run: | - echo "INIT_ARG=${{ matrix.scenario.init_args }} ${{ env.RUNTIME_INIT_ARGS }}" >> $GITHUB_ENV - echo "CREATE_ARG=${{ matrix.scenario.create_args }} ${{ env.RUNTIME_CREATE_ARGS }}" >> $GITHUB_ENV - - env_json='${{ toJson(matrix.scenario.env) }}' - if [[ "$env_json" != "null" && "$env_json" != "[]" ]]; then - echo "$env_json" | jq -r '.[] | "\(.name)=\(.value)"' >> $GITHUB_ENV + echo "INIT_ARG=$SCENARIO_INIT_ARGS" >> "$GITHUB_ENV" + echo "CREATE_ARG=$SCENARIO_CREATE_ARGS" >> "$GITHUB_ENV" + if [[ "$SCENARIO_ENV" != "null" && "$SCENARIO_ENV" != "[]" ]]; then + echo "$SCENARIO_ENV" | jq -r '.[] | "\(.name)=\(.value)"' >> "$GITHUB_ENV" fi - name: "Output variables for future steps" id: "env_out" @@ -196,18 +248,32 @@ jobs: echo "RESOURCE_GROUP=${{ env.RESOURCE_GROUP }}" >> $GITHUB_OUTPUT echo "CLUSTER_NAME=${{ env.CLUSTER_NAME }}" >> $GITHUB_OUTPUT echo "INSTANCE_NAME=${{ env.INSTANCE_NAME }}" >> $GITHUB_OUTPUT + # The resource action truncates names: use a digest so the channel suffix + # cannot disappear and make parallel GA/preview jobs share storage/ADR. + echo "RESOURCE_SUFFIX=$(printf '%s' "$CLUSTER_NAME" | sha256sum | cut -c1-11)" >> "$GITHUB_OUTPUT" - name: "Setup python" uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - - name: "Checkout extension source for build" + - name: "Checkout test sources and orchestration tools" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: "Build and install local IoT Ops extension from source" + - name: "Download the shared candidate" + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: integration-candidate + path: .artifacts/candidate + - name: "Verify and install the candidate for workflow CLI commands" run: | - pip install -r dev_requirements.txt - python -m build - wheel=$(find ./dist/*.whl) - az extension add --source $wheel -y + cd .artifacts/candidate + sha256sum --check SHA256SUMS + wheels=(*.whl) + test "${#wheels[@]}" -eq 1 + export azext_edge_wheel="$PWD/${wheels[0]}" + test "$(sha256sum "$azext_edge_wheel" | cut -d ' ' -f1)" = "$azext_edge_wheel_sha256" + echo "azext_edge_wheel=$azext_edge_wheel" >> "$GITHUB_ENV" + az extension add --source "$azext_edge_wheel" -y + extension_path=$(az extension show --name azure-iot-ops --query path -o tsv) + python "$GITHUB_WORKSPACE/tools/integration_runner.py" --verify-installed --extension-dir "$extension_path" - name: "Create k3s cluster" uses: ./.github/actions/create-k3s-cluster - name: "Apply custom cluster issuer" @@ -221,7 +287,7 @@ jobs: tox r -vv -e "${{ matrix.scenario.tox_env }}" --notest fi - name: "Az CLI login" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -234,7 +300,7 @@ jobs: custom-locations-oid: ${{ env.CUSTOM_LOCATIONS_OID }} enable-workload-identity: ${{ matrix.scenario.needs_trust && 'true' || 'false' }} - name: "Az CLI login" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -243,7 +309,7 @@ jobs: id: "init_reqs" uses: ./.github/actions/setup-cluster-resources with: - suffix: ${{ env.CLUSTER_NAME }} + suffix: ${{ steps.env_out.outputs.RESOURCE_SUFFIX }} resource-group: ${{ env.RESOURCE_GROUP }} - name: "OIDC Token exchange service" run: | @@ -257,12 +323,13 @@ jobs: done & - name: "Tox INIT Integration Tests" env: + azext_edge_junit_path: ${{ github.workspace }}/junit/init.xml azext_edge_init_continue_on_error: ${{ inputs.init-continue-on-error || '' }} azext_edge_rg: ${{ steps.env_out.outputs.RESOURCE_GROUP }} azext_edge_cluster: ${{ steps.env_out.outputs.CLUSTER_NAME }} azext_edge_instance: ${{ steps.env_out.outputs.INSTANCE_NAME }} - azext_edge_init_args: ${{ matrix.scenario.init_args }} ${{ needs.setup.outputs.runtime_init_args }} - azext_edge_create_args: ${{ matrix.scenario.create_args }} ${{ needs.setup.outputs.runtime_create_args }} + azext_edge_init_args: ${{ matrix.scenario.init_args }} + azext_edge_create_args: ${{ matrix.scenario.create_args }} azext_edge_schema_registry_id: ${{ steps.init_reqs.outputs.SCHEMA_REGISTRY_ID }} azext_edge_adr_namespace_id: ${{ steps.init_reqs.outputs.ADR_NAMESPACE_ID }} run: | @@ -282,13 +349,6 @@ jobs: fi tox r -e "${{ matrix.scenario.tox_env }}" --skip-pkg-install -- $PYTEST_ARGS tox r -e report - - name: "Upload coverage artifacts" - if: ${{ always() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage-${{ matrix.scenario.name }} - path: ./.coverage - include-hidden-files: true - name: "Run smoke tests" run: | az iot ops support create-bundle @@ -335,11 +395,12 @@ jobs: - name: "Redeploy cluster via tox" if: ${{ matrix.scenario.test_redeploy }} env: + azext_edge_junit_path: ${{ github.workspace }}/junit/redeploy.xml azext_edge_rg: ${{ steps.env_out.outputs.RESOURCE_GROUP }} azext_edge_cluster: ${{ steps.env_out.outputs.CLUSTER_NAME }} azext_edge_instance: ${{ steps.env_out.outputs.INSTANCE_NAME }} - azext_edge_init_args: ${{ matrix.scenario.init_args }} ${{ needs.setup.outputs.runtime_init_args }} - azext_edge_create_args: ${{ matrix.scenario.create_args }} ${{ needs.setup.outputs.runtime_create_args }} + azext_edge_init_args: ${{ matrix.scenario.init_args }} + azext_edge_create_args: ${{ matrix.scenario.create_args }} azext_edge_schema_registry_id: ${{ steps.init_reqs.outputs.SCHEMA_REGISTRY_ID }} azext_edge_adr_namespace_id: ${{ steps.init_reqs.outputs.ADR_NAMESPACE_ID }} run: | @@ -368,6 +429,22 @@ jobs: else echo "No storage account to delete" fi + - name: "Upload coverage artifacts" + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ matrix.scenario.name }} + path: ./.coverage + include-hidden-files: true + - name: "Upload channel-specific test results and candidate fingerprint" + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: integration-results-${{ matrix.scenario.name }} + path: | + junit/ + .artifacts/candidate/SHA256SUMS + include-hidden-files: true combine-coverage: name: "Combine coverage reports" if: ${{ always() }} diff --git a/.github/workflows/publish_test_container_image.yml b/.github/workflows/publish_test_container_image.yml index c1ed28c74..19d7dfae8 100644 --- a/.github/workflows/publish_test_container_image.yml +++ b/.github/workflows/publish_test_container_image.yml @@ -34,7 +34,7 @@ jobs: - name: "Checkout GitHub Action" uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Az CLI login" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/release_build.yml b/.github/workflows/release_build.yml index 90bef5d55..1803bade1 100644 --- a/.github/workflows/release_build.yml +++ b/.github/workflows/release_build.yml @@ -15,7 +15,7 @@ jobs: python-version: "3.13" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Az CLI login" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/security_checks.yml b/.github/workflows/security_checks.yml index 614d1bfdc..4957dcd58 100644 --- a/.github/workflows/security_checks.yml +++ b/.github/workflows/security_checks.yml @@ -45,7 +45,7 @@ jobs: # Upload alerts to the Security tab - name: Upload alerts to Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: sarif_file: ${{ steps.msdo.outputs.sarifFile }} diff --git a/.github/workflows/update_private_index.yml b/.github/workflows/update_private_index.yml index 53aea5529..8e1b2e4a3 100644 --- a/.github/workflows/update_private_index.yml +++ b/.github/workflows/update_private_index.yml @@ -34,7 +34,7 @@ jobs: curl -so azcopy.tar.gz -L 'https://aka.ms/downloadazcopy-v10-linux' tar -zxvf azcopy.tar.gz --strip 1 - name: "Az CLI login" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/upload_wheel.yml b/.github/workflows/upload_wheel.yml index 6cae0b18d..8f0b63240 100644 --- a/.github/workflows/upload_wheel.yml +++ b/.github/workflows/upload_wheel.yml @@ -36,7 +36,7 @@ jobs: curl -so azcopy.tar.gz -L 'https://aka.ms/downloadazcopy-v10-linux' tar -zxvf azcopy.tar.gz --strip 1 - name: "Az CLI login" - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/azext_edge/__init__.py b/azext_edge/__init__.py index fd30c1e15..7b6745f90 100644 --- a/azext_edge/__init__.py +++ b/azext_edge/__init__.py @@ -6,7 +6,7 @@ from azure.cli.core import AzCommandsLoader from azext_edge.constants import VERSION -from knack.events import EVENT_INVOKER_POST_PARSE_ARGS +from knack.events import EVENT_INVOKER_POST_PARSE_ARGS, EVENT_INVOKER_PRE_PARSE_ARGS def version_check_handler(cli_ctx, **kwargs): @@ -24,14 +24,25 @@ def version_check_handler(cli_ctx, **kwargs): class OpsExtensionCommandsLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): - super(OpsExtensionCommandsLoader, self).__init__(cli_ctx=cli_ctx) + from .edge.providers.orchestration.runtime_commands import ( + RuntimeCommandGroup, runtime_notice_handler, runtime_validation_handler, + ) + + super(OpsExtensionCommandsLoader, self).__init__(cli_ctx=cli_ctx, command_group_cls=RuntimeCommandGroup) if cli_ctx: + if not cli_ctx.data.get("runtime_notice_registered"): + cli_ctx.register_event(EVENT_INVOKER_PRE_PARSE_ARGS, runtime_notice_handler) + cli_ctx.data["runtime_notice_registered"] = True cli_ctx.register_event(EVENT_INVOKER_POST_PARSE_ARGS, version_check_handler) + cli_ctx.register_event(EVENT_INVOKER_POST_PARSE_ARGS, runtime_validation_handler) def load_command_table(self, args): from azext_edge.edge.command_map import load_iotops_commands + from .edge.providers.orchestration.runtime_commands import get_runtime_notice_targets load_iotops_commands(self, args) + if self.cli_ctx: + self.cli_ctx.data["runtime_notice_targets"] = get_runtime_notice_targets(self.command_table) return self.command_table diff --git a/azext_edge/constants.py b/azext_edge/constants.py index a2a508a32..6bc16bc1d 100644 --- a/azext_edge/constants.py +++ b/azext_edge/constants.py @@ -7,8 +7,13 @@ import os -VERSION = "2.10.0" +VERSION = "2.11.0a2" EXTENSION_NAME = "azure-iot-ops" EXTENSION_ROOT = os.path.dirname(os.path.abspath(__file__)) USER_AGENT = "IotOperationsCliExtension/{}".format(VERSION) -AIO_RELEASE = "2609" +AIO_RELEASE = "2610" +PREVIEW_AGREEMENT_URL = "https://azure.microsoft.com/en-us/support/legal/preview-supplemental-terms/" +PREVIEW_NOTICE = ( + "Creating an Azure IoT Operations preview instance is subject to the Supplemental Terms of Use " + "for Microsoft Azure Previews at the following URL." +) diff --git a/azext_edge/edge/_help.py b/azext_edge/edge/_help.py index df90b6cbb..abe33e200 100644 --- a/azext_edge/edge/_help.py +++ b/azext_edge/edge/_help.py @@ -9,6 +9,7 @@ from knack.help_files import helps +from azext_edge.constants import PREVIEW_AGREEMENT_URL, PREVIEW_NOTICE from azext_edge.edge.providers.edge_api import ( ARCCONTAINERSTORAGE_API_V1, CERTMANAGER_API_V1, @@ -1945,7 +1946,7 @@ def load_iotops_help(): helps[ "iot ops create" - ] = """ + ] = f""" type: command short-summary: Create an IoT Operations instance. long-summary: | @@ -1954,15 +1955,30 @@ def load_iotops_help(): The result of the command nets an IoT Operations instance with a set of default resources configured for cohesive function. - To enable broker disk persistence at least a value for --persist-max-size - must be provided. When enabled the default configuration is constrained to + Use --use-preview to select the bundled preview runtime. + {PREVIEW_NOTICE} + {PREVIEW_AGREEMENT_URL} + Using --use-preview constitutes acceptance of these terms. + + On GA, broker disk persistence requires a value for --persist-max-size. + When enabled the default configuration is constrained to dynamic persistence across state store, retain messages and subscriber queues. + The bundled preview runtime enables disk persistence by default with a 3Gi + buffer and dynamic state store persistence. The --persist-* options do not + disable it. Use --broker-config-file to replace the generated broker + configuration when different persistence settings are required. + To enable edge to cloud resource hydration please use the `az iot ops enable-rsync` command post instance creation. examples: + - name: Create an Essentials SKU instance. Essentials is intended for + single-node deployments and disables OPC UA. + text: > + az iot ops create --cluster mycluster -g myresourcegroup --name myinstance --sr-resource-id $SCHEMA_REGISTRY_RESOURCE_ID + --ns-resource-id $NAMESPACE_RESOURCE_ID --sku Essentials - name: Create the target instance with minimum input. text: > az iot ops create --cluster mycluster -g myresourcegroup --name myinstance --sr-resource-id $SCHEMA_REGISTRY_RESOURCE_ID @@ -2101,6 +2117,8 @@ def load_iotops_help(): deployed cluster side services that make up IoT Operations and compare them with the built-in deployment that would be executed with `az iot ops init` and `az iot ops create`. + Version-pinned upgrades and repairs require `autoUpgradeMinorVersion=false`. + Disable Arc automatic upgrades explicitly before proceeding. examples: - name: Upgrade the instance with minimal inputs. text: > @@ -3162,11 +3180,22 @@ def load_iotops_help(): ] = f""" type: command short-summary: Opens the version guide located at {GET_VERSIONS_URL} in the default browser. + long-summary: | + Use --inline to report local package versions and bundled runtime profiles + without authentication. + The extensions field retains the shared foundation and default create versions. + runtimeProfiles lists each bundled channel's target version, actual deployment + train and source provenance. + These are package inputs, not installed cluster versions or a list of qualified + upgrade paths. examples: - name: Route to the version guide in a new browser window. text: > az iot ops get-versions + - name: Show bundled runtime profiles without contacting Azure. + text: > + az iot ops get-versions --inline --query runtimeProfiles """ helps[ diff --git a/azext_edge/edge/commands_edge.py b/azext_edge/edge/commands_edge.py index 718d548e5..e5a15420f 100644 --- a/azext_edge/edge/commands_edge.py +++ b/azext_edge/edge/commands_edge.py @@ -19,6 +19,7 @@ DEFAULT_HEALTH_CHECKS_INTERVAL, DEFAULT_HEALTH_CHECKS_MAX, IdentityUsageType, + IoTOperationsSku, MqMemoryProfile, ) from .providers.orchestration.resources import Instances @@ -34,6 +35,28 @@ def _validate_health_check_args(health_checks_max: int, health_checks_interval: raise InvalidArgumentValueError("--health-checks-int must be >= 0.") +def _apply_essentials_sku(instance_features: Optional[List[str]]) -> List[str]: + from .providers.orchestration.resources.instances import parse_feature_kvp_nargs + + parsed_features = parse_feature_kvp_nargs(instance_features, strict=True) or {} + opcua_mode = (parsed_features.get("opcua") or {}).get("mode") + + if opcua_mode in ("Stable", "Preview"): + logger.warning( + "OPC UA mode '%s' is not supported with the Essentials SKU and will be ignored. " + "The instance will use opcua.mode=Disabled.", + opcua_mode, + ) + + effective_features = [ + feature + for feature in (instance_features or []) + if feature.partition("=")[0] != "opcua.mode" + ] + effective_features.append("opcua.mode=Disabled") + return effective_features + + def support_bundle( cmd, log_age_seconds: int = 60 * 60 * 24, @@ -164,6 +187,7 @@ def create_instance( custom_location_name: Optional[str] = None, instance_description: Optional[str] = None, instance_features: Optional[List[str]] = None, + sku: Optional[str] = None, dataflow_profile_instances: int = 1, trust_settings: Optional[List[str]] = None, # Ops Extension @@ -191,10 +215,14 @@ def create_instance( no_preflight: Optional[bool] = None, health_checks_max: int = DEFAULT_HEALTH_CHECKS_MAX, health_checks_interval: int = DEFAULT_HEALTH_CHECKS_INTERVAL, + use_preview: Optional[bool] = None, **kwargs, ) -> Union[Dict[str, Any], None]: _validate_health_check_args(health_checks_max, health_checks_interval) + if sku == IoTOperationsSku.ESSENTIALS.value: + instance_features = _apply_essentials_sku(instance_features) + from .providers.orchestration.work import WorkManager from .util import read_file_content @@ -208,6 +236,7 @@ def create_instance( show_progress=not no_progress, pre_flight=not (no_preflight or feature_config.is_enabled(FeatureFlag.PREFLIGHT_DISABLED)), apply_foundation=False, + use_preview=use_preview, cluster_name=cluster_name, resource_group_name=resource_group_name, cluster_namespace=cluster_namespace, @@ -218,6 +247,7 @@ def create_instance( instance_name=instance_name, instance_description=instance_description, instance_features=instance_features, + sku=sku, add_insecure_listener=add_insecure_listener, dataflow_profile_instances=dataflow_profile_instances, trust_settings=trust_settings, @@ -326,7 +356,9 @@ def delete( def show_instance(cmd, instance_name: str, resource_group_name: str, show_tree: Optional[bool] = None) -> dict: - return Instances(cmd).show(name=instance_name, resource_group_name=resource_group_name, show_tree=show_tree) + return Instances(cmd).show( + name=instance_name, resource_group_name=resource_group_name, show_tree=show_tree, resolve_api=True, + ) def list_instances(cmd, resource_group_name: Optional[str] = None) -> Iterable[dict]: @@ -454,19 +486,24 @@ def enable_rsync( def get_versions(inline: Optional[bool] = None): - # TODO: quick and dirty, refactor this in the future. if inline: from ..constants import AIO_RELEASE, VERSION + from .providers.orchestration.runtime_catalog import get_runtime_catalog from .providers.orchestration.targets import InitTargets - targets = InitTargets("", "") + catalog = get_runtime_catalog() + default_profile = catalog.for_create() + targets = InitTargets("", "", runtime_profile=default_profile) return { "cliVersion": VERSION, "iotOpsRelease": AIO_RELEASE, + # Preserve existing query paths for shared dependencies and default create. "extensions": { **targets.get_extension_versions(), **targets.get_extension_versions(False), }, + "defaultRuntimeChannel": default_profile.channel.value, + "runtimeProfiles": catalog.describe_profiles(), } else: import webbrowser diff --git a/azext_edge/edge/common.py b/azext_edge/edge/common.py index 70e223955..d4628cd2b 100644 --- a/azext_edge/edge/common.py +++ b/azext_edge/edge/common.py @@ -147,6 +147,7 @@ class OpsServiceType(ListableEnum): billing = "billing" dataflow = "dataflow" schemaregistry = "schemaregistry" + edgeregistry = "edgeregistry" arccontainerstorage = "acs" secretstore = "secretstore" azuremonitor = "azuremonitor" diff --git a/azext_edge/edge/params.py b/azext_edge/edge/params.py index 13492016d..bc33a889a 100644 --- a/azext_edge/edge/params.py +++ b/azext_edge/edge/params.py @@ -15,6 +15,7 @@ ) from knack.arguments import CaseInsensitiveList +from azext_edge.constants import PREVIEW_AGREEMENT_URL, PREVIEW_NOTICE from azext_edge.edge.providers.edge_api.dataflow import DataflowResourceKinds from ._validators import validate_namespace, validate_resource_name @@ -36,6 +37,7 @@ DataflowEndpointFabricPathType, DataflowEndpointKafkaAcksType, IdentityUsageType, + IoTOperationsSku, KafkaCloudEventAttributeType, KafkaCompressionType, KafkaPartitionStrategyType, @@ -1265,6 +1267,23 @@ def load_iotops_arguments(self, _): ) with self.argument_context("iot ops create") as context: + context.argument( + "sku", + options_list=["--sku"], + arg_type=get_enum_type(IoTOperationsSku), + help="Billing SKU for the IoT Operations instance. If omitted, Standard is used. Essentials supports " + "single-node deployments, does not support OPC UA, and cannot be changed after creation. For Azure IoT " + "Operations pricing and licensing terms, see https://www.microsoft.com/licensing/terms/productoffering/" + "MicrosoftAzure/MOSA#clause-2792-h3-1.", + ) + context.argument( + "use_preview", + options_list=["--use-preview"], + arg_type=get_three_state_flag(), + help="Create using the bundled preview runtime profile instead of the GA profile. " + f"{PREVIEW_NOTICE} {PREVIEW_AGREEMENT_URL} " + "Using --use-preview constitutes acceptance of these terms.", + ) context.argument( "skip_sr_ra", options_list=["--skip-sr-ra"], @@ -1378,7 +1397,9 @@ def load_iotops_arguments(self, _): context.argument( "persist_max_size", options_list=["--persist-max-size"], - help="The max size of the message buffer on disk. Setting a value will enable disk persistence. " + help="The max size of the message buffer on disk. Setting a value enables disk persistence on GA. " + "Preview enables a 3Gi buffer and dynamic state store persistence by default; use --broker-config-file " + "to replace the generated broker configuration. " "Kubernetes resource units must be used e.g. the following value suffixes are supported: " "E, P, T, G, M, K. You can also use the power-of-two equivalents: Ei, Pi, Ti, Gi, Mi, Ki.", arg_group="Disk Persistence", @@ -1533,7 +1554,8 @@ def load_iotops_arguments(self, _): "force", options_list=["--force"], arg_type=get_three_state_flag(), - help="Force the operation to continue. Use to get around guards, such as those preventing downgrade.", + help="Bypass version compatibility checks for stable-to-stable upgrades. " + "Does not bypass preview, cross-train or upgrade ownership restrictions.", arg_group="Extension Config", deprecate_info=context.deprecate(hide=True), ) @@ -2862,5 +2884,6 @@ def load_iotops_arguments(self, _): "inline", options_list=["--inline"], arg_type=get_three_state_flag(), - help="Provides key version attributes in console output.", + help="Reports bundled versions and runtime profiles without contacting a cluster. " + "The extensions field describes shared dependencies and the default create profile.", ) diff --git a/azext_edge/edge/providers/check/mq.py b/azext_edge/edge/providers/check/mq.py index b8dd23714..59d3e2cf1 100644 --- a/azext_edge/edge/providers/check/mq.py +++ b/azext_edge/edge/providers/check/mq.py @@ -22,13 +22,9 @@ validate_runtime_resource_ref, ) -from rich.console import NewLine from rich.padding import Padding -from ...common import ( - AIO_BROKER_DIAGNOSTICS_SERVICE, - CheckTaskStatus, -) +from ...common import CheckTaskStatus from .common import ( AIO_BROKER_DIAGNOSTICS_PROBE_PREFIX, @@ -589,13 +585,6 @@ def evaluate_brokers( resource_name=broker_name, ) - _evaluate_broker_diagnostics_service( - check_manager=check_manager, - target_brokers=target_brokers, - namespace=namespace, - detail_level=detail_level, - ) - if brokers_count > 0: check_manager.add_display( target_name=target_brokers, @@ -615,7 +604,6 @@ def evaluate_brokers( AIO_BROKER_BACKEND_PREFIX, AIO_BROKER_AUTH_PREFIX, AIO_BROKER_HEALTH_MANAGER, - AIO_BROKER_DIAGNOSTICS_SERVICE, AIO_BROKER_OPERATOR, # AIO_BROKER_FLUENT_BIT, # TODO: Fluent Bit is deployed to all nodes and stays in a pending state until an @@ -1201,92 +1189,6 @@ def _evaluate_listener_service( pass -def _evaluate_broker_diagnostics_service( - check_manager: CheckManager, - target_brokers: str, - namespace: str, - detail_level: int = ResourceOutputDetailLevel.summary.value, -) -> None: - try: - diagnostics_service = get_namespaced_service( - name=AIO_BROKER_DIAGNOSTICS_SERVICE, namespace=namespace, as_dict=True - ) - except ClusterAccessDeniedError as access_error: - # Denied on the secondary diagnostics service read: keep the broker findings and report inline. - check_manager.add_target_eval( - target_name=target_brokers, - namespace=namespace, - status=CheckTaskStatus.error.value, - value=f"Access denied (HTTP {access_error.status}) reading service/{AIO_BROKER_DIAGNOSTICS_SERVICE}", - resource_name=f"service/{AIO_BROKER_DIAGNOSTICS_SERVICE}", - ) - check_manager.add_display( - target_name=target_brokers, - namespace=namespace, - display=Padding( - "\n" + build_access_denied_text(access_error.status, f"service {AIO_BROKER_DIAGNOSTICS_SERVICE}"), - (0, 0, 0, 12), - ), - ) - return - if not diagnostics_service: - check_manager.add_target_eval( - target_name=target_brokers, - namespace=namespace, - status=CheckTaskStatus.error.value, - value=f"service/{AIO_BROKER_DIAGNOSTICS_SERVICE} not found in namespace {namespace}", - resource_name=f"service/{AIO_BROKER_DIAGNOSTICS_SERVICE}", - ) - diag_service_desc_suffix = colorize_string(color="red", value="not detected") - diag_service_desc = ( - f"Diagnostics Service {{{colorize_string(AIO_BROKER_DIAGNOSTICS_SERVICE)}}} {diag_service_desc_suffix}." - ) - check_manager.add_display( - target_name=target_brokers, - namespace=namespace, - display=Padding( - diag_service_desc, - (0, 0, 0, 12), - ), - ) - else: - clusterIP = diagnostics_service.get("spec", {}).get("clusterIP") - ports: List[dict] = diagnostics_service.get("spec", {}).get("ports", []) - - check_manager.add_target_eval( - target_name=target_brokers, - namespace=namespace, - status=CheckTaskStatus.success.value, - value={"spec": {"clusterIP": clusterIP, "ports": ports}}, - resource_name=f"service/{AIO_BROKER_DIAGNOSTICS_SERVICE}", - ) - diag_service_desc_suffix = colorize_string(color="green", value="detected") - diag_service_desc = ( - f"\nDiagnostics Service {{{colorize_string(AIO_BROKER_DIAGNOSTICS_SERVICE)}}} {diag_service_desc_suffix}." - ) - check_manager.add_display( - target_name=target_brokers, - namespace=namespace, - display=Padding( - diag_service_desc, - (0, 0, 0, 12), - ), - ) - if ports and detail_level != ResourceOutputDetailLevel.summary.value: - for port in ports: - check_manager.add_display( - target_name=target_brokers, - namespace=namespace, - display=Padding( - f"{colorize_string(port.get('name'))} " - f"port {colorize_string(port.get('port'))} " - f"protocol {colorize_string(port.get('protocol'))}", - (0, 0, 0, 16), - ), - ) - check_manager.add_display(target_name=target_brokers, namespace=namespace, display=NewLine()) - - def _display_sub_check_results( check_manager: CheckManager, target_name: str, diff --git a/azext_edge/edge/providers/orchestration/common.py b/azext_edge/edge/providers/orchestration/common.py index a725b2d82..6c1ff926f 100644 --- a/azext_edge/edge/providers/orchestration/common.py +++ b/azext_edge/edge/providers/orchestration/common.py @@ -105,6 +105,11 @@ class MqMemoryProfile(Enum): high = "High" +class IoTOperationsSku(Enum): + ESSENTIALS = "Essentials" + STANDARD = "Standard" + + class MqServiceType(Enum): CLUSTERIP = "ClusterIp" LOADBALANCER = "LoadBalancer" @@ -444,7 +449,7 @@ class TrustedSigningKeyType(Enum): OPCUA_CONNECTOR_AIO_MIN_VERSION = "1.2.100" # Connectors component image tag stamped on the template (distinct from the AIO extension # version). Bump per release from the publishing capture step's connectors.image.tag. -OPCUA_CONNECTOR_VERSION = "1.4.14" +OPCUA_CONNECTOR_VERSION = "1.5.18" # Management Actions EG_TOPICSPACES_PUBLISHER_ROLE_ID = "a12b0b94-b317-4dcd-84a8-502ce99884c6" diff --git a/azext_edge/edge/providers/orchestration/resources/clusters.py b/azext_edge/edge/providers/orchestration/resources/clusters.py index 9d37058cc..d51785f83 100644 --- a/azext_edge/edge/providers/orchestration/resources/clusters.py +++ b/azext_edge/edge/providers/orchestration/resources/clusters.py @@ -26,7 +26,7 @@ def __init__(self, cmd, subscription_id: Optional[str] = None): **self._get_client_kwargs(subscription_id=self.subscriptions[0]) ) self.ops: "ConnectedClusterOperations" = self.connectedk8s_mgmt_client.connected_cluster - self.extensions: ClusterExtensions = ClusterExtensions(cmd) + self.extensions: ClusterExtensions = ClusterExtensions(cmd, subscription_id=self.subscriptions[0]) def show(self, resource_group_name: str, cluster_name: str) -> dict: return self.ops.get( @@ -36,10 +36,10 @@ def show(self, resource_group_name: str, cluster_name: str) -> dict: class ClusterExtensions(Queryable): - def __init__(self, cmd): - super().__init__(cmd=cmd) + def __init__(self, cmd, subscription_id: Optional[str] = None): + super().__init__(cmd=cmd, subscriptions=[subscription_id] if subscription_id else None) self.clusterconfig_mgmt_client = get_clusterconfig_mgmt_client( - **self._get_client_kwargs() + **self._get_client_kwargs(subscription_id=self.subscriptions[0]) ) self.ops: "ExtensionsOperations" = self.clusterconfig_mgmt_client.extensions diff --git a/azext_edge/edge/providers/orchestration/resources/connector_templates.py b/azext_edge/edge/providers/orchestration/resources/connector_templates.py index 922c528d4..c2907552a 100644 --- a/azext_edge/edge/providers/orchestration/resources/connector_templates.py +++ b/azext_edge/edge/providers/orchestration/resources/connector_templates.py @@ -43,6 +43,7 @@ from .instances import Instances if TYPE_CHECKING: + from ..runtime_profiles import RuntimeProfile from ....vendor.clients.iotopsmgmt.operations import ( AkriConnectorTemplateOperations, ) @@ -60,9 +61,9 @@ class ConnectorTemplates(Queryable): """Provider for connector template operations.""" - def __init__(self, cmd): + def __init__(self, cmd, instances: Optional[Instances] = None): super().__init__(cmd=cmd) - self.instances = Instances(cmd=cmd) + self.instances = instances if instances is not None else Instances(cmd=cmd) self.iotops_mgmt_client = self.instances.iotops_mgmt_client self.ops: "AkriConnectorTemplateOperations" = ( self.iotops_mgmt_client.akri_connector_template @@ -209,6 +210,15 @@ def create( ) return wait_for_terminal_state(poller=poller, logger=logger) + @staticmethod + def default_opcua_endpoint_types(runtime_profile: Optional["RuntimeProfile"] = None) -> List[dict]: + from ..common import OPCUA_CONNECTOR_ENDPOINT_TYPE + + if runtime_profile is None: + return [{"endpointType": OPCUA_CONNECTOR_ENDPOINT_TYPE}] + blueprint = runtime_profile.copy_instance_blueprint() + return blueprint.get_resource_by_key("opcUaConnectorTemplate")["properties"]["deviceInboundEndpointTypes"] + def create_default_opcua_template( self, resource_group_name: str, @@ -217,13 +227,14 @@ def create_default_opcua_template( template_name: Optional[str] = None, headers: Optional[dict] = None, no_status: Optional[bool] = None, + runtime_profile: Optional["RuntimeProfile"] = None, ) -> dict: """ Create the default OPC UA ``akriConnectorTemplates`` resource for an instance. OPC UA is supervisor-managed, so the template is built directly to mirror the product Bicep rather than derived from OCI metadata: the connector image is the supervisor image - and the endpoint type is ``Microsoft.OpcUa``. The supervisor discovers it by name prefix. + and endpoint types follow the selected runtime profile. The supervisor discovers it by name prefix. Args: resource_group_name: Instance resource group name. @@ -232,13 +243,13 @@ def create_default_opcua_template( template_name: Optional explicit template name. Defaults to a prefixed, instance-derived name. headers: Optional request headers (e.g. correlation id) forwarded to the service. no_status: Suppress the console status spinner (e.g. when already inside a progress bar). + runtime_profile: Reviewed runtime inputs; omitted callers retain the GA endpoint type. Returns: dict: The created connector template resource. """ from ..common import ( OPCUA_CONNECTOR_AIO_MIN_VERSION, - OPCUA_CONNECTOR_ENDPOINT_TYPE, OPCUA_CONNECTOR_METADATA_REF, OPCUA_CONNECTOR_REGISTRY, OPCUA_CONNECTOR_SUPERVISOR_IMAGE_NAME, @@ -247,6 +258,8 @@ def create_default_opcua_template( if not template_name: template_name = self.default_opcua_template_name(instance_name) + endpoint_types = self.default_opcua_endpoint_types(runtime_profile) + template_resource = { "extendedLocation": self.instances.get_ext_loc( name=instance_name, @@ -269,7 +282,7 @@ def create_default_opcua_template( }, }, }, - "deviceInboundEndpointTypes": [{"endpointType": OPCUA_CONNECTOR_ENDPOINT_TYPE}], + "deviceInboundEndpointTypes": endpoint_types, }, } @@ -302,8 +315,8 @@ def check_default_opcua_template_needed( customizations and in-flight provisioning are preserved. If none exists, a new default is needed. Shared by the upgrade backfill and the update re-enable path. - List failures propagate; the caller decides whether to surface them (update, which has - already mutated the instance) or ignore them (upgrade, which re-evaluates every run). + List failures propagate. Update and upgrade callers perform this discovery before + mutations, so a failed lookup cannot silently skip a required backfill. """ from ..common import OPCUA_CONNECTOR_TEMPLATE_NAME_PREFIX, PROVISIONING_STATE_FAILED diff --git a/azext_edge/edge/providers/orchestration/resources/instances.py b/azext_edge/edge/providers/orchestration/resources/instances.py index 36a0deaf8..e62e0042c 100644 --- a/azext_edge/edge/providers/orchestration/resources/instances.py +++ b/azext_edge/edge/providers/orchestration/resources/instances.py @@ -7,6 +7,7 @@ import json import re import time +from copy import deepcopy from contextlib import nullcontext from typing import Dict, Iterable, List, Optional, Tuple from urllib.parse import urlsplit @@ -24,7 +25,10 @@ from rich.console import Console from ....util.az_client import ( + DEFAULT_IOTOPS_MGMT_API_VERSION, ResourceIdContainer, + get_clusterconfig_mgmt_client, + get_connectedk8s_mgmt_client, get_iotops_mgmt_client, get_keyvault_client, get_msi_mgmt_client, @@ -56,6 +60,10 @@ get_ra_user_error_msg, ) from ..resource_map import IoTOperationsResourceMap +from ..runtime import RuntimeContext, get_runtime_cluster_id, resolve_runtime +from ..runtime_profiles import RuntimeIdentity, RuntimeProfile +from ..runtime_catalog import get_runtime_catalog +from ..runtime_requirements import get_runtime_requirements, requested_selections, validate_runtime_requirements logger = get_logger(__name__) @@ -197,6 +205,7 @@ def __init__(self, cmd, subscription_id: Optional[str] = None): # TODO: make sure this works correctly # TODO: longer term pattern? super().__init__(cmd=cmd, subscriptions=[subscription_id] if subscription_id else None) + self.iotops_api_version = DEFAULT_IOTOPS_MGMT_API_VERSION.value self.iotops_mgmt_client = get_iotops_mgmt_client( **self._get_client_kwargs(subscription_id=self.subscriptions[0]) ) @@ -208,7 +217,9 @@ def __init__(self, cmd, subscription_id: Optional[str] = None): ) self.permission_manager = PermissionManager(self.default_subscription_id) - def show(self, name: str, resource_group_name: str, show_tree: Optional[bool] = None) -> Optional[dict]: + def show( + self, name: str, resource_group_name: str, show_tree: Optional[bool] = None, resolve_api: bool = False, + ) -> Optional[dict]: result = self.iotops_mgmt_client.instance.get(instance_name=name, resource_group_name=resource_group_name) if show_tree: @@ -217,6 +228,22 @@ def show(self, name: str, resource_group_name: str, show_tree: Optional[bool] = return result + def use_runtime_profile(self, profile: RuntimeProfile, instance: dict) -> dict: + if self.iotops_api_version == profile.iotops_api_version: + return instance + self.iotops_mgmt_client = get_iotops_mgmt_client( + api_version=profile.iotops_api_version, + **self._get_client_kwargs(subscription_id=self.subscriptions[0]), + ) + self.iotops_api_version = profile.iotops_api_version + resource = parse_resource_id(instance["id"]) + return self.iotops_mgmt_client.instance.get( + instance_name=resource.resource_name, resource_group_name=resource.resource_group_name, + ) + + def _get_instance_for_write(self, name: str, resource_group_name: str) -> dict: + return self.show(name=name, resource_group_name=resource_group_name) + def get_ext_loc( self, name: str, @@ -241,6 +268,35 @@ def get_associated_cl(self, instance: dict) -> dict: resource_id=instance["extendedLocation"]["name"], api_version=CUSTOM_LOCATIONS_API_VERSION ) + def get_runtime_context( + self, instance: dict, qualification_identities: Iterable[RuntimeIdentity] = (), + *, allow_unbundled_integration: bool = False, + ) -> RuntimeContext: + """Read the associated runtime using the cluster's subscription, without mutations. + + No global client defaults change. Independent ADR and diagnostic operations need + not perform this discovery; mutation/capability entry points can opt in explicitly. + """ + custom_location = self.get_associated_cl(instance) + cluster_id = get_runtime_cluster_id(instance, custom_location) + cluster = parse_resource_id(cluster_id) + client_kwargs = self._get_client_kwargs(subscription_id=cluster.subscription_id) + cluster_client = get_connectedk8s_mgmt_client(**client_kwargs) + extension_client = get_clusterconfig_mgmt_client(**client_kwargs) + cluster_record = cluster_client.connected_cluster.get( + resource_group_name=cluster.resource_group_name, cluster_name=cluster.resource_name + ) + extensions = extension_client.extensions.list( + resource_group_name=cluster.resource_group_name, + cluster_rp="Microsoft.Kubernetes", + cluster_resource_name="connectedClusters", + cluster_name=cluster.resource_name, + ) + return resolve_runtime( + instance, custom_location, cluster_record, extensions, qualification_identities, + allow_unbundled_integration=allow_unbundled_integration, + ) + def get_resource_map(self, instance: dict) -> IoTOperationsResourceMap: custom_location = self.get_associated_cl(instance) resource_id_container = parse_resource_id(custom_location["properties"]["hostResourceId"]) @@ -264,7 +320,65 @@ def update( spc_resource_id: Optional[str] = None, **kwargs: dict, ) -> dict: - instance = kwargs.pop("instance", None) or self.show(name=name, resource_group_name=resource_group_name) + if tags is not None and all( + value is None for value in (description, features, adr_namespace_resource_id, spc_resource_id) + ): + status_context = nullcontext() if kwargs.get("no_status") else console.status( + kwargs.get("status_text", "Working...") + ) + with status_context: + return self.iotops_mgmt_client.instance.update( + instance_name=name, + resource_group_name=resource_group_name, + properties={"tags": tags}, + headers=kwargs.get("headers") or {"CommandName": "iot ops update"}, + ) + instance = deepcopy( + kwargs.pop("instance", None) or self.show(name=name, resource_group_name=resource_group_name) + ) + desired_features = parse_feature_kvp_nargs(features, strict=True) if features else None + requested_arguments = { + "instance_features": features, "description": description, "tags": tags, + "adr_namespace_resource_id": adr_namespace_resource_id, "spc_resource_id": spc_resource_id, + } + requirements, parameters = get_runtime_requirements("iot ops update", cmd=self.cmd) + selections = requested_selections(vars(self.cmd).get("runtime_requested_arguments", requested_arguments)) + enable_opcua = (desired_features or {}).get("opcua", {}).get("mode") not in (None, "Disabled") + profile = None + connector_version = None + if enable_opcua or requirements is not None or any(parameter.applies(selections) for parameter in parameters): + catalog = get_runtime_catalog() + runtime = self.get_runtime_context(instance, catalog.qualification_identities) + if enable_opcua: + runtime.require_ready() + validate_runtime_requirements("iot ops update", runtime.identity, requested_arguments, cmd=self.cmd) + if enable_opcua: + profile = catalog.get(runtime.identity.channel) + connector_version = profile.require_opcua_connector_version() + return self._update( + name=name, resource_group_name=resource_group_name, instance=instance, tags=tags, + description=description, features=features, adr_namespace_resource_id=adr_namespace_resource_id, + spc_resource_id=spc_resource_id, connector_version=connector_version, runtime_profile=profile, **kwargs, + ) + + def _update( + self, + name: str, + resource_group_name: str, + tags: Optional[Dict[str, str]] = None, + description: Optional[str] = None, + features: Optional[List[str]] = None, + adr_namespace_resource_id: Optional[str] = None, + spc_resource_id: Optional[str] = None, + connector_version: Optional[str] = None, + runtime_profile: Optional[RuntimeProfile] = None, + **kwargs: dict, + ) -> dict: + """Write an instance after the caller selects its runtime API and validates the operation.""" + instance = deepcopy( + kwargs.pop("instance", None) or self.show(name=name, resource_group_name=resource_group_name) + ) + desired_features = parse_feature_kvp_nargs(features, strict=True) if features else None status_text = kwargs.pop("status_text", "Working...") no_status = kwargs.pop("no_status", False) headers = kwargs.pop("headers", None) @@ -275,9 +389,15 @@ def update( opcua_backfill_requested = False if features: - desired_features = parse_feature_kvp_nargs(features, strict=True) current_features: dict = instance["properties"].get("features", {}) or {} - current_features.update(desired_features) + for component, changes in desired_features.items(): + component_config = current_features.setdefault(component, {}) or {} + current_features[component] = component_config + for key, value in changes.items(): + if key == "settings": + component_config["settings"] = {**(component_config.get("settings") or {}), **value} + else: + component_config[key] = value instance["properties"]["features"] = current_features requested_opcua_mode = (desired_features.get("opcua") or {}).get("mode") # Enabling OPC UA needs the default connector template the create path skips while @@ -293,6 +413,22 @@ def update( if tags or tags == {}: instance["tags"] = tags + # Discover the complete backfill before changing the instance. A failed + # list must not leave OPC UA enabled without its required template. + connector_templates = None + backfill_needed, repair_name = False, None + if opcua_backfill_requested: + from .connector_templates import ConnectorTemplates + + connector_templates = ConnectorTemplates(self.cmd, instances=self) + backfill_needed, repair_name = connector_templates.check_default_opcua_template_needed( + instance_name=name, resource_group_name=resource_group_name + ) + if backfill_needed and not connector_version: + raise ValidationError( + "OPC UA backfill requires a reviewed connector version before updating the instance." + ) + status_context = nullcontext() if no_status else console.status(status_text) with status_context: poller = self.iotops_mgmt_client.instance.begin_create_or_update( @@ -302,23 +438,16 @@ def update( **operation_kwargs, ) result = wait_for_terminal_state(poller, **kwargs) - if opcua_backfill_requested: - from .connector_templates import ConnectorTemplates - from ..common import OPCUA_CONNECTOR_VERSION - - connector_templates = ConnectorTemplates(self.cmd) - needed, repair_name = connector_templates.check_default_opcua_template_needed( - instance_name=name, resource_group_name=resource_group_name + if backfill_needed: + connector_templates.create_default_opcua_template( + resource_group_name=resource_group_name, + instance_name=name, + connector_version=connector_version, + template_name=repair_name, + headers=headers, + no_status=True, + runtime_profile=runtime_profile, ) - if needed: - connector_templates.create_default_opcua_template( - resource_group_name=resource_group_name, - instance_name=name, - connector_version=OPCUA_CONNECTOR_VERSION, - template_name=repair_name, - headers=headers, - no_status=True, - ) return result def remove_mi_user_assigned( @@ -330,7 +459,7 @@ def remove_mi_user_assigned( **kwargs, ): mi_resource_id_container = parse_resource_id(mi_user_assigned) - instance = self.show(name=name, resource_group_name=resource_group_name) + instance = self._get_instance_for_write(name=name, resource_group_name=resource_group_name) # TODO - @digimaun # cluster_resource = self.get_resource_map(instance).connected_cluster.resource @@ -365,7 +494,7 @@ def remove_mi_user_assigned( identity["type"] = "None" instance["identity"] = identity - return self.update(name=name, resource_group_name=resource_group_name, instance=instance, **kwargs) + return self._update(name=name, resource_group_name=resource_group_name, instance=instance, **kwargs) def add_mi_user_assigned( self, @@ -383,11 +512,11 @@ def add_mi_user_assigned( Responsible for federating and building the instance identity object. """ mi_resource_id_container = parse_resource_id(mi_user_assigned) + instance = self._get_instance_for_write(name=name, resource_group_name=resource_group_name) mi_resource = self.resource_client.resources.get_by_id( resource_id=mi_resource_id_container.resource_id, api_version=MANAGED_IDENTITY_API_VERSION ) - instance = self.show(name=name, resource_group_name=resource_group_name) cluster_resource = self.get_resource_map(instance).connected_cluster.resource oidc_issuer = self._ensure_oidc_issuer(cluster_resource, use_self_hosted_issuer) custom_location = self.get_associated_cl(instance) @@ -421,7 +550,7 @@ def add_mi_user_assigned( identity["userAssignedIdentities"][mi_user_assigned] = {} instance["identity"] = identity - updated_instance = self.update(name=name, resource_group_name=resource_group_name, instance=instance, **kwargs) + updated_instance = self._update(name=name, resource_group_name=resource_group_name, instance=instance, **kwargs) if usage_type == IdentityUsageType.SCHEMA.value and not skip_sr_ra: schema_registry_id = instance.get("properties", {}).get("schemaRegistryRef", {}).get("resourceId") if not schema_registry_id: @@ -471,6 +600,7 @@ def enable_secretsync( # TODO: add unit test mi_resource_id_container = parse_resource_id(mi_user_assigned) keyvault_resource_id_container = parse_resource_id(keyvault_resource_id) + instance = self._get_instance_for_write(name=name, resource_group_name=resource_group_name) with console.status("Working...") as status: # TODO self.resource_client.resources.get_by_id( @@ -489,7 +619,6 @@ def enable_secretsync( custom_role_id=custom_role_id, ) - instance = self.show(name=name, resource_group_name=resource_group_name) resource_map = self.get_resource_map(instance) cluster_resource = resource_map.connected_cluster.resource custom_location = self.get_associated_cl(instance) @@ -552,7 +681,7 @@ def enable_secretsync( ) result_spc = wait_for_terminal_state(spc_poller, **kwargs) instance["properties"]["defaultSecretProviderClassRef"] = {"resourceId": result_spc["id"]} - self.update( + self._update( name=name, resource_group_name=resource_group_name, instance=instance, @@ -588,11 +717,11 @@ def disable_secretsync( if should_bail: return - instance: dict = self.show(name=name, resource_group_name=resource_group_name) + instance = self._get_instance_for_write(name=name, resource_group_name=resource_group_name) # remove the default secret provider class reference default_spc_ref = instance["properties"].pop("defaultSecretProviderClassRef", None) if default_spc_ref: - self.update( + self._update( name=name, resource_group_name=resource_group_name, instance=instance, diff --git a/azext_edge/edge/providers/orchestration/resources/registryendpoints.py b/azext_edge/edge/providers/orchestration/resources/registryendpoints.py index 50f668f9b..e410f724f 100644 --- a/azext_edge/edge/providers/orchestration/resources/registryendpoints.py +++ b/azext_edge/edge/providers/orchestration/resources/registryendpoints.py @@ -37,9 +37,9 @@ class RegistryEndpoints(Queryable): - def __init__(self, cmd): + def __init__(self, cmd, instances: Optional[Instances] = None): super().__init__(cmd=cmd) - self.instances = Instances(cmd=cmd) + self.instances = instances if instances is not None else Instances(cmd=cmd) self.iotops_mgmt_client = self.instances.iotops_mgmt_client self.registry_endpoints: "RegistryEndpointOperations" = self.iotops_mgmt_client.registry_endpoint diff --git a/azext_edge/edge/providers/orchestration/runtime.py b/azext_edge/edge/providers/orchestration/runtime.py new file mode 100644 index 000000000..cfd61d656 --- /dev/null +++ b/azext_edge/edge/providers/orchestration/runtime.py @@ -0,0 +1,271 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Read-only runtime discovery and capability checks. Never called while constructing help.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Any, FrozenSet, Iterable, Mapping, Optional, Tuple + +from azure.cli.core.azclierror import ValidationError + +from ...util.id_tools import is_valid_resource_id, parse_resource_id +from .common import EXTENSION_TYPE_OPS +from .runtime_profiles import RuntimeChannel, RuntimeIdentity, parse_runtime_version, resolve_runtime_identity + + +def _lower(value) -> str: + return value.lower() if isinstance(value, str) else "" + + +def _resource_id(value: str, namespace: str, resource_type: str) -> str: + if not isinstance(value, str) or not is_valid_resource_id(value): + raise ValidationError(f"Unable to resolve runtime: invalid {resource_type} resource ID.") + parsed = parse_resource_id(value) + if ( + parsed.get("namespace", "").lower() != namespace.lower() + or parsed.get("type", "").lower() != resource_type.lower() + or parsed.get("last_child_num") + or not parsed.get("resource_group") + ): + raise ValidationError(f"Unable to resolve runtime: expected a {namespace}/{resource_type} resource ID.") + return value + + +def get_runtime_cluster_id(instance: dict, custom_location: dict) -> str: + """Validate the association before using its cluster ID to construct subscription-scoped clients.""" + _resource_id(instance.get("id"), "Microsoft.IoTOperations", "instances") + location_id = _resource_id( + (instance.get("extendedLocation") or {}).get("name"), "Microsoft.ExtendedLocation", "customLocations" + ) + returned_id = _resource_id(custom_location.get("id"), "Microsoft.ExtendedLocation", "customLocations") + if location_id.lower() != returned_id.lower(): + raise ValidationError("The custom location does not match the instance's extended location.") + return _resource_id( + (custom_location.get("properties") or {}).get("hostResourceId"), "Microsoft.Kubernetes", "connectedClusters" + ) + + +class RuntimeIssueCode(str, Enum): + PROVISIONING_STATE = "provisioning_state" + CLUSTER_CONNECTIVITY = "cluster_connectivity" + INVALID_EXTENSION_STATUS = "invalid_extension_status" + EXTENSION_ERROR = "extension_error" + VERSION_MISMATCH = "version_mismatch" + + +@dataclass(frozen=True) +class RuntimeIssue: + code: RuntimeIssueCode + resource: str + message: str + state: Optional[str] = None + + +@dataclass(frozen=True) +class RuntimeContext: + instance_id: str + custom_location_id: str + cluster_id: str + extension_id: str + identity: RuntimeIdentity + requested_version: Optional[str] + provisioning_state: Optional[str] + readiness_issues: Tuple[RuntimeIssue, ...] = () + auto_upgrade_minor_version: Optional[bool] = None + + def require_ready(self) -> None: + if self.readiness_issues: + raise ValidationError("AIO runtime is not ready: " + "; ".join( + issue.message for issue in self.readiness_issues + )) + + def require_upgradeable(self) -> None: + blockers = [ + issue for issue in self.readiness_issues + if not ( + ( + issue.code == RuntimeIssueCode.PROVISIONING_STATE + and issue.resource in {"instance", "custom location", "extension"} + and _lower(issue.state) in {"failed", "canceled"} + ) + or ( + issue.resource == "extension" + and _lower(self.provisioning_state) in {"failed", "canceled"} + and issue.code in {RuntimeIssueCode.EXTENSION_ERROR, RuntimeIssueCode.VERSION_MISMATCH} + ) + ) + ] + if blockers: + raise ValidationError("AIO runtime cannot be upgraded: " + "; ".join(issue.message for issue in blockers)) + + +def resolve_runtime( + instance: dict, + custom_location: dict, + cluster: dict, + extensions: Iterable[dict], + qualification_identities: Iterable[RuntimeIdentity] = (), + *, allow_unbundled_integration: bool = False, +) -> RuntimeContext: + """Validate live ARM records without guessing an installed version from the requested pin.""" + cluster_id = get_runtime_cluster_id(instance, custom_location) + returned_id = _resource_id(cluster.get("id"), "Microsoft.Kubernetes", "connectedClusters") + if cluster_id.lower() != returned_id.lower(): + raise ValidationError("The connected cluster does not match the custom location's host.") + matches = [ + extension for extension in extensions + if _lower((extension.get("properties") or {}).get("extensionType")) == EXTENSION_TYPE_OPS + ] + if len(matches) != 1: + raise ValidationError("Expected exactly one AIO extension on the associated connected cluster.") + extension = matches[0] + extension_id = extension.get("id") + prefix = cluster_id.lower() + "/providers/microsoft.kubernetesconfiguration/extensions/" + if ( + not isinstance(extension_id, str) + or not is_valid_resource_id(extension_id) + or not extension_id.lower().startswith(prefix) + or not extension_id[len(prefix):] + or "/" in extension_id[len(prefix):] + ): + raise ValidationError("The AIO extension does not belong to the associated connected cluster.") + associated_ids = (custom_location.get("properties") or {}).get("clusterExtensionIds") + if not isinstance(associated_ids, list) or extension_id.lower() not in { + value.lower() for value in associated_ids if isinstance(value, str) + }: + raise ValidationError("The custom location is not associated with the AIO extension.") + + properties = extension.get("properties") or {} + installed_version = properties.get("currentVersion") + if not installed_version: + raise ValidationError("Unable to determine installed AIO version: extension currentVersion is missing.") + identity = resolve_runtime_identity( + installed_version, properties.get("releaseTrain"), qualification_identities, + allow_unbundled_integration=allow_unbundled_integration, + ) + issues = [] + for name, record in (("instance", instance), ("custom location", custom_location), ("extension", extension)): + state = (record.get("properties") or {}).get("provisioningState") + if not isinstance(state, str) or state.lower() != "succeeded": + issues.append(RuntimeIssue( + RuntimeIssueCode.PROVISIONING_STATE, name, f"{name} provisioning state is {state or 'unknown'}", state, + )) + connectivity = (cluster.get("properties") or {}).get("connectivityStatus") + if _lower(connectivity) != "connected": + issues.append(RuntimeIssue( + RuntimeIssueCode.CLUSTER_CONNECTIVITY, "cluster", + "connected cluster is disconnected or its connectivity is unknown", connectivity, + )) + extension_state = properties.get("provisioningState") + statuses = properties.get("statuses", []) + if not isinstance(statuses, list) or any(not isinstance(status, dict) for status in statuses): + issues.append(RuntimeIssue( + RuntimeIssueCode.INVALID_EXTENSION_STATUS, "extension", "extension status information is invalid", + extension_state, + )) + elif any(_lower(status.get("level")) == "error" for status in statuses): + issues.append(RuntimeIssue( + RuntimeIssueCode.EXTENSION_ERROR, "extension", "extension reports an error", extension_state, + )) + if properties.get("errorInfo"): + issues.append(RuntimeIssue( + RuntimeIssueCode.EXTENSION_ERROR, "extension", "extension reports an error", extension_state, + )) + requested_version = properties.get("version") + if requested_version is not None: + requested = parse_runtime_version(requested_version) + if requested != parse_runtime_version(installed_version): + issues.append(RuntimeIssue( + RuntimeIssueCode.VERSION_MISMATCH, "extension", + f"requested version {requested_version} differs from installed version {installed_version}", + extension_state, + )) + return RuntimeContext( + instance_id=instance["id"], + custom_location_id=custom_location["id"], + cluster_id=cluster_id, + extension_id=extension_id, + identity=identity, + requested_version=requested_version, + provisioning_state=properties.get("provisioningState"), + readiness_issues=tuple(issues), + auto_upgrade_minor_version=properties.get("autoUpgradeMinorVersion"), + ) + + +@dataclass(frozen=True) +class OperationRequirements: + """Execution eligibility, not the command's CLI Preview label.""" + + name: str + channels: FrozenSet[RuntimeChannel] = frozenset(RuntimeChannel) + minimum_version: Optional[str] = None + maximum_version_exclusive: Optional[str] = None + + def __post_init__(self): + object.__setattr__(self, "channels", frozenset(RuntimeChannel(channel) for channel in self.channels)) + minimum = parse_runtime_version(self.minimum_version) if self.minimum_version else None + maximum = parse_runtime_version(self.maximum_version_exclusive) if self.maximum_version_exclusive else None + if minimum is not None and maximum is not None and minimum >= maximum: + raise ValidationError(f"Invalid runtime version range for {self.name}.") + + def validate(self, runtime: RuntimeContext) -> None: + runtime.require_ready() + self.validate_identity(runtime.identity) + + def validate_identity(self, identity: RuntimeIdentity) -> None: + """Also usable before create, when a target identity exists but no instance does.""" + if identity.channel not in self.channels: + allowed = ", ".join(sorted(channel.value for channel in self.channels)) + raise ValidationError( + f"{self.name} requires a {allowed} runtime; the target uses {identity.channel.value}." + ) + version = parse_runtime_version(identity.version) + if self.minimum_version and version < parse_runtime_version(self.minimum_version): + raise ValidationError(f"{self.name} requires AIO version {self.minimum_version} or newer.") + if self.maximum_version_exclusive and version >= parse_runtime_version(self.maximum_version_exclusive): + raise ValidationError(f"{self.name} requires AIO version older than {self.maximum_version_exclusive}.") + + +@dataclass(frozen=True) +class ParameterRequirement: + parameter: str + requirements: OperationRequirements + values: Optional[FrozenSet[Any]] = None + + def __post_init__(self): + if self.values is not None: + object.__setattr__(self, "values", frozenset(self.values)) + + def applies(self, requested_arguments: Mapping[str, Any]) -> bool: + if self.parameter not in requested_arguments: + return False + value = requested_arguments[self.parameter] + if value is None: + return False + if self.values is None: + return True + values = value if isinstance(value, (list, tuple, set, frozenset)) else (value,) + return any(value in self.values for value in values) + + +def validate_operation( + runtime: RuntimeContext, + requirements: OperationRequirements, + requested_arguments: Optional[Mapping[str, Any]] = None, + parameter_requirements: Iterable[ParameterRequirement] = (), +) -> None: + """Check a command and its requested selections before any writer/feature invocation. + + Callers supply requested selections, not parser defaults. In particular, an explicit + False/0 is not the same as an omitted argument. Shared commands stay shared even if + an individual option or value requires preview. + """ + requirements.validate(runtime) + for parameter in parameter_requirements: + if parameter.applies(requested_arguments or {}): + parameter.requirements.validate(runtime) diff --git a/azext_edge/edge/providers/orchestration/runtime_catalog.py b/azext_edge/edge/providers/orchestration/runtime_catalog.py new file mode 100644 index 000000000..3812113ab --- /dev/null +++ b/azext_edge/edge/providers/orchestration/runtime_catalog.py @@ -0,0 +1,53 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Bundled release inputs, separate from runtime selection and validation machinery.""" + +from typing import Optional, Tuple + +from azext_edge.constants import AIO_RELEASE + +from ...util.az_client import IoTOpsMgmtApiVersion +from .common import OPCUA_CONNECTOR_VERSION +from .runtime_dependencies import DependencyRequirement +from .runtime_profiles import RuntimeChannel, RuntimeIdentity, RuntimeProfile, RuntimeProfileCatalog +from .template import TEMPLATE_BLUEPRINT_INSTANCE +from .template_preview import TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW + + +# Keep the existing GA-bound blueprint and its actual train unchanged. In an alpha +# build this may still deploy from integration; it is not silently promoted to stable. +# Register the preview source as supplied, including its actual integration train. +# Registration selects release inputs; it is not a claim of live qualification. +PREVIEW_PROFILE: Optional[RuntimeProfile] = RuntimeProfile( + channel=RuntimeChannel.PREVIEW, + release="prev2610", + source_ref="preview/v1.6.x/2610", + source_commit="abcf0ed770cb12256c3ffc272cc0636e44dee0ff", + instance_blueprint=TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW, + opcua_connector_version="1.5.18", + iotops_api_version=IoTOpsMgmtApiVersion.V20260901_preview.value, +) +QUALIFICATION_IDENTITIES: Tuple[RuntimeIdentity, ...] = () +# GA and preview share the same init dependency versions and trains. +# No additional dependency compatibility constraints are specified for this release. +# An empty policy leaves the existing shared foundation checks unchanged. +SHARED_DEPENDENCY_REQUIREMENTS: Tuple[DependencyRequirement, ...] = () + + +def get_runtime_catalog() -> RuntimeProfileCatalog: + stable = RuntimeProfile( + channel=RuntimeChannel.STABLE, + release=str(AIO_RELEASE), + source_ref="releases/v1.5.x/2610", + source_commit="67613dc8b60abdfa08bfb138f1d8fa3cbef329f7", + instance_blueprint=TEMPLATE_BLUEPRINT_INSTANCE, + opcua_connector_version=OPCUA_CONNECTOR_VERSION, + ) + profiles = [stable] + if PREVIEW_PROFILE is not None: + profiles.append(PREVIEW_PROFILE) + return RuntimeProfileCatalog(profiles, QUALIFICATION_IDENTITIES, SHARED_DEPENDENCY_REQUIREMENTS) diff --git a/azext_edge/edge/providers/orchestration/runtime_commands.py b/azext_edge/edge/providers/orchestration/runtime_commands.py new file mode 100644 index 000000000..ec8ad8591 --- /dev/null +++ b/azext_edge/edge/providers/orchestration/runtime_commands.py @@ -0,0 +1,173 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Opt-in runtime eligibility for extension commands, independent of CLI maturity. + +Command registrations accept runtime_requirement=OperationRequirements(...), +runtime_parameters=(ParameterRequirement(...), ...), and optionally +runtime_target=RuntimeTarget(instance_name="other_name", resource_group_name="other_rg"). +Parameter rules use handler argument destinations, or dotted --feature keys. A rule +with values restricts only those explicitly selected values, not the whole option. +No handler changes or is_preview tag are needed. The central requirement registries +are also supported. An unusual resource hierarchy can supply a runtime_target callable +(cmd, namespace) returning a RuntimeContext, instead of an argument mapping. +""" + +from copy import deepcopy +from dataclasses import dataclass +from typing import Optional + +from azure.cli.core.azclierror import ValidationError +from azure.cli.core.commands import AzCommandGroup +from knack.log import get_logger + +from .runtime_profiles import RuntimeChannel +from .runtime_requirements import get_runtime_requirements, requested_selections + + +logger = get_logger(__name__) + +PREVIEW_RUNTIME_NOTICE = ( + "[Preview Runtime] This feature requires a supported Azure IoT Operations preview cluster. " + "It is not available on stable clusters. " + "Check the feature's supported runtime versions and prerequisites." +) + + +# These entry points already discover their runtime (or select a create profile) +# and enforce requirements before writes. Keep their repair/readiness semantics. +LIFECYCLE_COMMANDS = frozenset({"iot ops create", "iot ops update", "iot ops upgrade"}) + + +def get_runtime_notice_targets(command_table): + """Select commands and wholly restricted groups before the CLI trims its table.""" + restricted = set() + for name, command in command_table.items(): + if "runtime_target" not in vars(command) or name == "iot ops init": + continue + requirement, _ = get_runtime_requirements(name, command) + if requirement is not None and requirement.channels == {RuntimeChannel.PREVIEW}: + restricted.add(name) + groups = { + " ".join(name.split()[:depth]) + for name in restricted for depth in range(3, len(name.split())) + } + return restricted | { + group for group in groups + if all(name in restricted for name in command_table if name.startswith(group + " ")) + } + + +def runtime_notice_handler(cli_ctx, **_kwargs): + """Emit fixed wording for help and execution without changing help or maturity.""" + command_name = cli_ctx.invocation.data.get("command_string") + if command_name in cli_ctx.data.get("runtime_notice_targets", ()): + logger.warning(PREVIEW_RUNTIME_NOTICE) + + +@dataclass(frozen=True) +class RuntimeTarget: + """Argument destinations for an instance target; subscription defaults to the CLI job context.""" + + instance_name: str = "instance_name" + resource_group_name: str = "resource_group_name" + subscription_id: Optional[str] = None + + def __call__(self, cmd, namespace): + from .resources.instances import Instances + from .runtime_catalog import get_runtime_catalog + + name = getattr(namespace, self.instance_name, None) + resource_group = getattr(namespace, self.resource_group_name, None) + subscription = getattr(namespace, self.subscription_id, None) if self.subscription_id else None + if not name or not resource_group or (self.subscription_id and not subscription): + raise ValidationError( + f"Cannot resolve the AIO runtime for '{cmd.name}'. Supply the instance target arguments " + "or register a runtime_target mapping/resolver." + ) + instances = Instances(cmd, subscription_id=subscription) + instance = instances.show(name=name, resource_group_name=resource_group) + return instances.get_runtime_context(instance, get_runtime_catalog().qualification_identities) + + +class RuntimeCommandGroup(AzCommandGroup): + """Consume extension metadata without passing unsupported keywords to Azure CLI.""" + + def _command(self, name, method_name, custom_command=False, **kwargs): + requirement = kwargs.pop("runtime_requirement", None) + parameters = tuple(kwargs.pop("runtime_parameters", ())) + target = kwargs.pop("runtime_target", RuntimeTarget()) + command_name = super()._command(name, method_name, custom_command=custom_command, **kwargs) + command = self.command_loader.command_table.get(command_name) + if command is not None: + command.runtime_requirement = requirement + command.runtime_parameters = parameters + command.runtime_target = target + return command_name + + def _show_command(self, name, getter_name="get", getter_type=None, custom_command=False, **kwargs): + requirement = kwargs.pop("runtime_requirement", None) + parameters = tuple(kwargs.pop("runtime_parameters", ())) + target = kwargs.pop("runtime_target", RuntimeTarget()) + super()._show_command( + name, getter_name=getter_name, getter_type=getter_type, custom_command=custom_command, **kwargs, + ) + command_name = f"{self.group_name} {name}" if self.group_name else name + command = self.command_loader.command_table.get(command_name) + if command is not None: + command.runtime_requirement = requirement + command.runtime_parameters = parameters + command.runtime_target = target + + +def runtime_validation_handler(cli_ctx, **kwargs): + """Capture selections after parsing; defer discovery until after --ids expansion. + + Azure CLI validates every expanded job before executing any handlers. Chaining + its existing validator here preserves argument validation and per-job subscription + context. Help exits before this event; unmarked commands never discover a runtime. + """ + namespace = kwargs.get("args") + command_name = kwargs.get("command") + if namespace is None or not command_name: + return + command = namespace.func + # Events are global: do not change commands supplied by another extension/loader. + if "runtime_target" not in vars(command): + return + requirement, parameters = get_runtime_requirements(command_name, command) + lifecycle = command_name in LIFECYCLE_COMMANDS + if requirement is None and not parameters and not lifecycle: + return + + invoker = cli_ctx.invocation + parser = invoker.parser.subparser_map[command_name] + specified = frozenset(parser.specified_arguments) + original_validator = getattr(namespace, "_command_validator", None) + + def validate(cmd, namespace): + # Capture the expanded, explicit values before validators inject defaults or + # rewrite aliases. This state belongs to this job, never the shared command. + selections = deepcopy({key: value for key, value in vars(namespace).items() if key in specified}) + cmd.runtime_requested_arguments = selections + if original_validator: + invoker._validate_cmd_level(namespace, original_validator) # pylint: disable=protected-access + else: + invoker._validate_arg_level(namespace) # pylint: disable=protected-access + if lifecycle: + return + requested = requested_selections(selections) + active = [rule.requirements for rule in parameters if rule.applies(requested)] + if requirement is not None: + active.insert(0, requirement) + if not active: + return + runtime = command.runtime_target(cmd, namespace) + runtime.require_ready() + for rule in active: + rule.validate_identity(runtime.identity) + + namespace._command_validator = validate # pylint: disable=protected-access diff --git a/azext_edge/edge/providers/orchestration/runtime_dependencies.py b/azext_edge/edge/providers/orchestration/runtime_dependencies.py new file mode 100644 index 000000000..e56838d84 --- /dev/null +++ b/azext_edge/edge/providers/orchestration/runtime_dependencies.py @@ -0,0 +1,111 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Explicit shared foundation compatibility, not inferred from AIO's channel or version.""" + +from dataclasses import dataclass +from typing import Tuple + +from azure.cli.core.azclierror import ValidationError + +from ...util.machinery import scoped_semver_import +from .common import OPS_EXTENSION_DEPS + + +def _version(value): + try: + return scoped_semver_import().parse(value) + except (TypeError, ValueError) as ex: + raise ValidationError(f"Unable to validate foundation extension version {value!r}.") from ex + + +@dataclass(frozen=True) +class DependencyIdentity: + version: str + train: str + + def __post_init__(self): + _version(self.version) + if not isinstance(self.train, str) or not self.train: + raise ValidationError("Foundation compatibility requires an explicit release train.") + object.__setattr__(self, "train", self.train.lower()) + + +@dataclass(frozen=True) +class DependencyRequirement: + extension_type: str + identities: Tuple[DependencyIdentity, ...] + required_configuration: Tuple[Tuple[str, str], ...] = () + + def __post_init__(self): + if not isinstance(self.extension_type, str) or self.extension_type.lower() not in OPS_EXTENSION_DEPS: + raise ValidationError("Compatibility requirements must identify a shared foundation extension type.") + object.__setattr__(self, "extension_type", self.extension_type.lower()) + object.__setattr__(self, "identities", tuple(self.identities)) + object.__setattr__(self, "required_configuration", tuple(tuple(item) for item in self.required_configuration)) + if not self.identities: + raise ValidationError("Foundation compatibility requires at least one reviewed version/train pair.") + if not all(isinstance(identity, DependencyIdentity) for identity in self.identities): + raise ValidationError("Foundation compatibility requires reviewed version/train identities.") + if any(len(item) != 2 or not all(isinstance(value, str) for value in item) + for item in self.required_configuration): + raise ValidationError("Foundation compatibility configuration must contain string key/value pairs.") + keys = [key for key, _ in self.required_configuration] + if len(set(keys)) != len(keys): + raise ValidationError("Duplicate required foundation configuration keys.") + + def validate_identity(self, version: str, train: str, configuration: dict) -> None: + identity = DependencyIdentity(version, train) + if identity not in self.identities: + supported = ", ".join(f"{item.version}/{item.train}" for item in self.identities) + raise ValidationError( + f"Foundation extension {self.extension_type} uses {version}/{train}; " + f"the bundled compatibility policy supports: {supported}. " + "Use compatible foundation inputs before continuing." + ) + if not isinstance(configuration, dict): + raise ValidationError(f"Foundation extension {self.extension_type} reports invalid configuration.") + for key, value in self.required_configuration: + if configuration.get(key) != value: + raise ValidationError( + f"Foundation extension {self.extension_type} requires configuration {key}={value}." + ) + + def validate_installed(self, extension: dict) -> None: + self.validate_observed(extension) + properties = extension.get("properties") or {} + self.validate_identity( + properties.get("currentVersion"), properties.get("releaseTrain"), + properties.get("configurationSettings", {}), + ) + + def validate_observed(self, extension: dict, allow_repair: bool = False) -> None: + """Validate observed evidence separately from a planned target's compatibility.""" + properties = extension.get("properties") or {} + version = properties.get("currentVersion") + if not version: + raise ValidationError( + f"Unable to determine installed {self.extension_type} version: currentVersion missing." + ) + if str(properties.get("extensionType", "")).lower() != self.extension_type: + raise ValidationError("Foundation compatibility record does not match the required extension type.") + DependencyIdentity(version, properties.get("releaseTrain")) + state = str(properties.get("provisioningState") or "").lower() + repairing = allow_repair and state in {"failed", "canceled"} + if state != "succeeded" and not repairing: + raise ValidationError(f"Foundation extension {self.extension_type} is not ready.") + statuses = properties.get("statuses", []) + if not isinstance(statuses, list) or any(not isinstance(item, dict) for item in statuses): + raise ValidationError(f"Foundation extension {self.extension_type} reports invalid or failed status.") + if not repairing and ( + any(str(item.get("level", "")).lower() == "error" for item in statuses) or properties.get("errorInfo") + ): + raise ValidationError(f"Foundation extension {self.extension_type} reports invalid or failed status.") + requested = properties.get("version") + if requested is not None and _version(requested) != _version(version) and not repairing: + raise ValidationError( + f"Foundation extension {self.extension_type} requested and installed versions differ." + ) diff --git a/azext_edge/edge/providers/orchestration/runtime_profiles.py b/azext_edge/edge/providers/orchestration/runtime_profiles.py new file mode 100644 index 000000000..6d4f99178 --- /dev/null +++ b/azext_edge/edge/providers/orchestration/runtime_profiles.py @@ -0,0 +1,199 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Runtime release inputs, independent of the CLI package version and interface maturity. + +Release-specific inputs are supplied by the profile catalog after generation and review. +An integration train is never implicitly treated as Public Preview. +""" + +import re +from dataclasses import InitVar, dataclass, field +from enum import Enum +from typing import Iterable, Optional + +from azure.cli.core.azclierror import ValidationError + +from ...util.az_client import DEFAULT_IOTOPS_MGMT_API_VERSION +from ...util.machinery import scoped_semver_import +from .common import EXTENSION_MONIKER_OPS +from .runtime_dependencies import DependencyRequirement +from .template import TemplateBlueprint + + +class RuntimeChannel(str, Enum): + STABLE = "stable" + PREVIEW = "preview" + + +def parse_runtime_version(version: str): + try: + return scoped_semver_import().parse(version) + except (TypeError, ValueError) as ex: + raise ValidationError(f"Unable to validate AIO runtime version {version!r}.") from ex + + +@dataclass(frozen=True) +class RuntimeIdentity: + channel: RuntimeChannel + version: str + train: str + + def __post_init__(self): + object.__setattr__(self, "channel", RuntimeChannel(self.channel)) + if not isinstance(self.train, str) or not self.train: + raise ValidationError("Unable to determine AIO release train.") + object.__setattr__(self, "train", self.train.lower()) + if self.train not in (self.channel.value, "integration"): + raise ValidationError(f"Train {self.train!r} conflicts with the {self.channel.value} runtime profile.") + version = parse_runtime_version(self.version) + if self.channel == RuntimeChannel.STABLE: + valid = version.prerelease is None + else: + valid = version.patch == 0 and re.fullmatch(r"preview\.[1-9][0-9]*", version.prerelease or "") + if not valid: + raise ValidationError(f"Version {self.version!r} conflicts with the {self.channel.value} runtime profile.") + + +@dataclass(frozen=True) +class RuntimeProfile: + channel: RuntimeChannel + release: str + source_ref: str + source_commit: str + instance_blueprint: InitVar[TemplateBlueprint] + opcua_connector_version: Optional[str] = None + iotops_api_version: str = DEFAULT_IOTOPS_MGMT_API_VERSION.value + identity: RuntimeIdentity = field(init=False) + _instance_blueprint: TemplateBlueprint = field(init=False, repr=False, compare=False) + + def __post_init__(self, instance_blueprint: TemplateBlueprint): + object.__setattr__(self, "channel", RuntimeChannel(self.channel)) + if not all(isinstance(value, str) and value for value in (self.release, self.source_ref, self.source_commit)): + raise ValidationError("Runtime profiles require a release, source ref and resolved source commit.") + blueprint = instance_blueprint.copy() + variables = blueprint.content.get("variables", {}) + identity = RuntimeIdentity( + channel=self.channel, + version=variables.get("VERSIONS", {}).get(EXTENSION_MONIKER_OPS), + train=variables.get("TRAINS", {}).get(EXTENSION_MONIKER_OPS), + ) + object.__setattr__(self, "identity", identity) + object.__setattr__(self, "_instance_blueprint", blueprint) + + def copy_instance_blueprint(self) -> TemplateBlueprint: + """Return a private working copy for both validation and deployment preparation.""" + return self._instance_blueprint.copy() + + def require_opcua_connector_version(self) -> str: + if not self.opcua_connector_version: + raise ValidationError( + f"The {self.channel.value} profile has no reviewed OPC UA connector backfill version." + ) + return self.opcua_connector_version + + def validate_overrides(self, version: Optional[str] = None, train: Optional[str] = None) -> RuntimeIdentity: + target = RuntimeIdentity(self.channel, version or self.identity.version, train or self.identity.train) + if target.train != self.identity.train: + raise ValidationError("AIO train overrides cannot change the selected runtime profile's deployment train.") + # Integration identities require an explicit, reviewed version/profile association. + if target.train == "integration" and target.version != self.identity.version: + raise ValidationError("An integration version override requires matching reviewed profile inputs.") + return target + + +class RuntimeProfileCatalog: + """One create/upgrade target per channel; not a list of every supported source runtime.""" + + def __init__( + self, profiles: Iterable[RuntimeProfile], qualification_identities: Iterable[RuntimeIdentity] = (), + dependency_requirements: Iterable[DependencyRequirement] = (), + ): + self._profiles = {} + for profile in profiles: + if profile.channel in self._profiles: + raise ValidationError(f"Multiple target profiles registered for {profile.channel.value}.") + self._profiles[profile.channel] = profile + self.qualification_identities = tuple(qualification_identities) + tuple( + profile.identity for profile in self._profiles.values() if profile.identity.train == "integration" + ) + self.dependency_requirements = tuple(dependency_requirements) + types = [requirement.extension_type for requirement in self.dependency_requirements] + if len(set(types)) != len(types): + raise ValidationError("Duplicate shared foundation compatibility requirements.") + + def get(self, channel: RuntimeChannel) -> RuntimeProfile: + channel = RuntimeChannel(channel) + if channel not in self._profiles: + raise ValidationError(f"No reviewed {channel.value} runtime profile is bundled in this CLI.") + return self._profiles[channel] + + def for_create(self, use_preview: bool = False) -> RuntimeProfile: + return self.get(RuntimeChannel.PREVIEW if use_preview else RuntimeChannel.STABLE) + + def for_upgrade(self, installed: RuntimeIdentity) -> RuntimeProfile: + return self.get(installed.channel) + + def describe_profiles(self) -> dict: + """Report bundled targets, not installed runtimes or qualified upgrade paths.""" + return { + channel.value: { + "release": profile.release, + "version": profile.identity.version, + "train": profile.identity.train, + "sourceRef": profile.source_ref, + "sourceCommit": profile.source_commit, + "opcuaConnectorVersion": profile.opcua_connector_version, + } + for channel in RuntimeChannel + if (profile := self._profiles.get(channel)) is not None + } + + +def resolve_runtime_identity( + version: str, train: str, qualification_identities: Iterable[RuntimeIdentity] = (), + *, allow_unbundled_integration: bool = False, +) -> RuntimeIdentity: + """Resolve public trains directly; integration requires an exact internal qualification mapping. + + A version suffix alone is not proof that a runtime is an approved Public Preview. + Historical integration baselines can be supplied independently of the target catalog. + Upgrade callers may opt into channel classification for unbundled integration builds. + """ + if not isinstance(train, str) or not train: + raise ValidationError("Unable to determine AIO release train.") + normalized_train = train.lower() + if normalized_train in (RuntimeChannel.STABLE.value, RuntimeChannel.PREVIEW.value): + return RuntimeIdentity(RuntimeChannel(normalized_train), version, normalized_train) + if normalized_train == "integration": + matches = { + identity for identity in qualification_identities + if identity.train == normalized_train and identity.version == version + } + if len(matches) == 1: + return matches.pop() + if not matches and allow_unbundled_integration: + channel = ( + RuntimeChannel.STABLE if parse_runtime_version(version).prerelease is None else RuntimeChannel.PREVIEW + ) + return RuntimeIdentity(channel, version, normalized_train) + raise ValidationError(f"AIO runtime {version!r} on train {train!r} has no supported runtime profile mapping.") + + +def validate_upgrade_boundary(installed: RuntimeIdentity, target: RuntimeIdentity) -> None: + """Mandatory boundary only; passing does NOT qualify a source/target migration path. + + This deliberately has no force/preflight override. Path-specific upgrade policies + must run separately after this check and before any mutations. + """ + if installed.channel != target.channel or installed.train != target.train: + raise ValidationError( + f"Cannot upgrade AIO from {installed.channel.value}/{installed.train} " + f"to {target.channel.value}/{target.train}. " + "GA clusters must remain GA; preview clusters must remain preview." + ) + if parse_runtime_version(target.version) < parse_runtime_version(installed.version): + raise ValidationError(f"AIO downgrade from {installed.version} to {target.version} is not supported.") diff --git a/azext_edge/edge/providers/orchestration/runtime_requirements.py b/azext_edge/edge/providers/orchestration/runtime_requirements.py new file mode 100644 index 000000000..eb9da6d2c --- /dev/null +++ b/azext_edge/edge/providers/orchestration/runtime_requirements.py @@ -0,0 +1,48 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Owner-supplied capability restrictions, independent of command Preview labels.""" + +from typing import Dict, Tuple + +from ...util import parse_kvp_nargs +from .runtime import OperationRequirements, ParameterRequirement + + +# Register only communicated restrictions; a Preview help label alone is not a +# runtime restriction. Keys for component settings use their dotted --feature syntax. +COMMAND_REQUIREMENTS: Dict[str, OperationRequirements] = {} +PARAMETER_REQUIREMENTS: Dict[str, Tuple[ParameterRequirement, ...]] = {} + + +def get_runtime_requirements(command: str, cmd=None): + metadata = vars(cmd) if cmd is not None else {} + requirement = metadata.get("runtime_requirement") or COMMAND_REQUIREMENTS.get(command) + parameters = tuple(PARAMETER_REQUIREMENTS.get(command, ())) + tuple(metadata.get("runtime_parameters", ())) + return requirement, parameters + + +def requested_selections(arguments: dict) -> dict: + selections = dict(arguments) + selections.update(parse_kvp_nargs(arguments.get("instance_features")) or {}) + return selections + + +def validate_runtime_requirements(command: str, identity, arguments: dict, cmd=None) -> None: + """Lifecycle check using explicit CLI selections, or supplied programmatic arguments. + + The invocation hook records selections on the per-target command copy. Reading + that snapshot also avoids mistaking handler defaults/renamed arguments for user + requests. Direct provider callers retain validation of their supplied arguments. + """ + requirements, parameters = get_runtime_requirements(command, cmd) + if requirements is not None: + requirements.validate_identity(identity) + arguments = vars(cmd).get("runtime_requested_arguments", arguments) if cmd is not None else arguments + selections = requested_selections(arguments) + for parameter in parameters: + if parameter.applies(selections): + parameter.requirements.validate_identity(identity) diff --git a/azext_edge/edge/providers/orchestration/targets.py b/azext_edge/edge/providers/orchestration/targets.py index 777a0b2d8..4836e3cf5 100644 --- a/azext_edge/edge/providers/orchestration/targets.py +++ b/azext_edge/edge/providers/orchestration/targets.py @@ -4,7 +4,9 @@ # Licensed under the MIT License. See License file in the project root for license information. # ---------------------------------------------------------------------------------------------- +from copy import deepcopy from enum import IntEnum +from json import dumps from typing import Dict, List, NamedTuple, Optional, Set, Tuple from azure.cli.core.azclierror import InvalidArgumentValueError @@ -23,12 +25,14 @@ EXTENSION_MONIKER_CM, EXTENSION_MONIKER_OPS, EXTENSION_MONIKER_SSC, + EXTENSION_TYPE_TO_MONIKER_MAP, TRUST_ISSUER_KIND_KEY, TRUST_SETTING_KEYS, ) from ..orchestration.resources.brokers import Brokers from ..orchestration.resources.connector_templates import ConnectorTemplates from ..orchestration.resources.instances import parse_feature_kvp_nargs +from .runtime_profiles import RuntimeChannel, RuntimeProfile from .template import ( TEMPLATE_BLUEPRINT_ENABLEMENT, TEMPLATE_BLUEPRINT_INSTANCE, @@ -75,6 +79,7 @@ def __init__( instance_name: Optional[str] = None, instance_description: Optional[str] = None, instance_features: Optional[List[str]] = None, + sku: Optional[str] = None, tags: Optional[dict] = None, # Extension config ops_config: Optional[List[str]] = None, @@ -104,8 +109,16 @@ def __init__( # User Trust Config user_trust: Optional[bool] = None, trust_settings: Optional[List[str]] = None, + runtime_profile: Optional[RuntimeProfile] = None, **_, ): + self.runtime_profile = runtime_profile + if runtime_profile is not None: + runtime_profile.validate_overrides(version=ops_version, train=ops_train) + self._instance_blueprint = ( + runtime_profile.copy_instance_blueprint() + if runtime_profile is not None else TEMPLATE_BLUEPRINT_INSTANCE.copy() + ) self.cluster_name = cluster_name self.resource_group_name = resource_group_name self.schema_registry_resource_id = ensure_resource_id( @@ -139,6 +152,7 @@ def __init__( self.instance_name = self._sanitize_k8s_name(instance_name) self.instance_description = instance_description self.instance_features = parse_feature_kvp_nargs(instance_features, strict=True) + self.sku = sku self.tags = tags # Extensions @@ -220,6 +234,8 @@ def get_extension_versions(self, for_enablement: bool = True) -> dict: template, _ = get_template_method() template_vars = template["variables"] for moniker in template_vars["VERSIONS"]: + if moniker not in EXTENSION_TYPE_TO_MONIKER_MAP.values(): + continue version_map[moniker] = {"version": template_vars["VERSIONS"][moniker]} for moniker in template_vars["TRAINS"]: version_map[moniker]["train"] = template_vars["TRAINS"][moniker] @@ -257,14 +273,16 @@ def get_ops_instance_template( "clusterNamespace": self.cluster_namespace, "clusterLocation": self.location, "customLocationName": self.custom_location_name, + "aioInstanceName": self.instance_name, "clExtensionIds": cl_extension_ids, "schemaRegistryId": self.schema_registry_resource_id, "adrNamespaceId": self.adr_namespace_resource_id, + "sku": self.sku, "defaultDataflowInstanceCount": self.dataflow_profile_instances, "brokerConfig": self.broker_config, "trustConfig": self.trust_config, }, - template_blueprint=TEMPLATE_BLUEPRINT_INSTANCE, + template_blueprint=self._instance_blueprint, ) self.extension_manager.apply_to_template(template.content, template_type="instance") @@ -278,10 +296,9 @@ def get_ops_instance_template( artifact_registry_endpoint = template.get_resource_by_key("artifactRegistryEndpoint") opcua_connector_template = template.get_resource_by_key("opcUaConnectorTemplate") - instance["properties"] = get_default_instance_config( - description=self.instance_description, - features=self.instance_features, - ) + properties = instance.setdefault("properties", {}) + properties["description"] = self.instance_description + self._apply_instance_features(template, parameters, properties) if self.instance_name: instance["name"] = self.instance_name @@ -294,6 +311,13 @@ def get_ops_instance_template( opcua_connector_template["name"] = ( f"{self.instance_name}/{ConnectorTemplates.default_opcua_template_name(self.instance_name)}" ) + mcp_default_policy = template.content["resources"].get("mcpDefaultPolicy") + if mcp_default_policy is not None: + mcp_default_policy["name"] = f"{self.instance_name}/aio-mcp-policy-v1" + mcp_aio_connection = template.content["resources"].get("mcpAioConnection") + if mcp_aio_connection is not None: + mcp_aio_connection["name"] = f"{self.instance_name}/aio" + mcp_aio_connection["properties"]["service"]["name"] = f"{self.instance_name}-mcp" template.content["outputs"]["aio"]["value"]["name"] = self.instance_name @@ -308,7 +332,15 @@ def get_ops_instance_template( if self.add_insecure_listener: template.add_resource( resource_key="brokerListenerInsecure", - resource_def=get_insecure_listener(instance_name=self.instance_name, broker_name=DEFAULT_BROKER), + resource_def=get_insecure_listener( + instance_name=self.instance_name, + broker_name=DEFAULT_BROKER, + api_version=( + broker_listener["apiVersion"] + if self.runtime_profile is not None and self.runtime_profile.channel == RuntimeChannel.PREVIEW + else None + ), + ), ) resources: Dict[str, Dict[str, dict]] = template.content.get("resources", {}) @@ -336,6 +368,29 @@ def get_ops_instance_template( return template.content, parameters + def _apply_instance_features(self, template: TemplateBlueprint, parameters: dict, properties: dict) -> None: + if not self.instance_features: + return + feature_parameter = template.parameters.get("features") + generated_features = properties.get("features") + if feature_parameter is not None: + defaults = feature_parameter.get("defaultValue") + overrides = deepcopy(self.instance_features) + for feature in overrides.values(): + feature.setdefault("settings", {}) + merged = merge_template_object(defaults, overrides) + if isinstance(merged, dict): + parameters["features"] = {"value": merged} + else: + # Template expressions must evaluate in the template scope, not in + # the outer deployment parameter file's scope. + feature_parameter["defaultValue"] = merged + if isinstance(generated_features, str): + # Retain the generated expression and its source-controlled default + # logic. The supported features parameter supplies the user input. + return + properties["features"] = merge_template_object(generated_features, self.instance_features) + def get_broker_config_target_map(self): to_process_config_map = { "frontendReplicas": self.broker_frontend_replicas, @@ -348,11 +403,14 @@ def get_broker_config_target_map(self): processed_config_map = {} validation_errors = [] - broker_config_def = TEMPLATE_BLUEPRINT_INSTANCE.get_type_definition("_1.BrokerConfig")["properties"] + broker_config_def = self._instance_blueprint.copy().get_type_definition("_1.BrokerConfig")["properties"] if not broker_config_def: return to_process_config_map # TODO @digimaun - replace with longer term pattern - broker_config_def["backendRedundancyFactor"]["minValue"] = 2 + if "backendRedundancyFactor" in broker_config_def: + broker_config_def["backendRedundancyFactor"]["minValue"] = max( + 2, broker_config_def["backendRedundancyFactor"].get("minValue") or 2 + ) for config in to_process_config_map: if to_process_config_map[config] is None: @@ -367,7 +425,10 @@ def get_broker_config_target_map(self): if all([min_value is None, max_value is None]): continue - if any([to_process_config_map[config] < min_value, to_process_config_map[config] > max_value]): + if ( + (min_value is not None and to_process_config_map[config] < min_value) + or (max_value is not None and to_process_config_map[config] > max_value) + ): error_msg = f"{config} value range" if min_value: @@ -431,16 +492,21 @@ def del_if_not_in(resources: Dict[str, Dict[str, dict]], include_keys: Set[str]) del resources[k] -def get_default_instance_config( - description: Optional[str] = None, - features: Optional[dict] = None, -) -> dict: - return { - "schemaRegistryRef": {"resourceId": "[parameters('schemaRegistryId')]"}, - "adrNamespaceRef": {"resourceId": "[parameters('adrNamespaceId')]"}, - "description": description, - "features": features, - } +def merge_template_object(defaults, overrides: dict): + """Overlay explicit object settings without evaluating or discarding ARM expressions.""" + if defaults is None: + return deepcopy(overrides) + if isinstance(defaults, str) and defaults.startswith("[") and defaults.endswith("]"): + # ARM union recursively merges objects; apostrophes inside its JSON string + # literal must be doubled. Neither the generated source nor hash is edited. + literal = dumps(overrides, separators=(",", ":")).replace("'", "''") + return f"[union(coalesce({defaults[1:-1]}, createObject()), json('{literal}'))]" + if not isinstance(defaults, dict): + raise InvalidArgumentValueError("Expected an object or ARM object expression in the selected template.") + result = deepcopy(defaults) + for key, value in overrides.items(): + result[key] = merge_template_object(result.get(key), value) if isinstance(value, dict) else deepcopy(value) + return result def ensure_resource_id( diff --git a/azext_edge/edge/providers/orchestration/template.py b/azext_edge/edge/providers/orchestration/template.py index bd54a3f2c..41d23734e 100644 --- a/azext_edge/edge/providers/orchestration/template.py +++ b/azext_edge/edge/providers/orchestration/template.py @@ -49,12 +49,14 @@ def copy(self) -> "TemplateBlueprint": TEMPLATE_BLUEPRINT_ENABLEMENT = TemplateBlueprint( - commit_id="f9e05bed5c435135403c5a32264afebf14a409ec", + commit_id="8ce2157e7105f2e515f88c2fa10f5000d0f69cf4", content={ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "languageVersion": "2.0", "contentVersion": "1.0.0.0", - "metadata": {"_generator": {"name": "bicep", "version": "0.47.16.16243", "templateHash": "16735839634517342696"}}, + "metadata": { + "_generator": {"name": "bicep", "version": "0.48.1.52935", "templateHash": "11887568661411714443"} + }, "definitions": { "_1.AdvancedConfig": { "type": "object", @@ -99,7 +101,12 @@ def copy(self) -> "TemplateBlueprint": }, "connectors": { "type": "object", - "properties": {"version": {"type": "string", "nullable": True}}, + "properties": { + "version": {"type": "string", "nullable": True}, + "registry": {"type": "string", "nullable": True}, + "repository": {"type": "string", "nullable": True}, + "imageRegistry": {"type": "string", "nullable": True}, + }, "nullable": True, }, "secretSyncController": { @@ -333,7 +340,9 @@ def copy(self) -> "TemplateBlueprint": "stateStore": { "$ref": "#/definitions/_1.BrokerStateStorePolicy", "nullable": True, - "metadata": {"description": "Controls which keys should be persisted to disk for the state store."}, + "metadata": { + "description": "Controls which keys should be persisted to disk for the state store." + }, }, "subscriberQueue": { "$ref": "#/definitions/_1.BrokerSubscriberQueuePolicy", @@ -401,7 +410,10 @@ def copy(self) -> "TemplateBlueprint": "propertyName": "mode", "mapping": { "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, - "None": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, "Custom": {"$ref": "#/definitions/_1.BrokerRetainMessagesCustomPolicy"}, }, }, @@ -463,7 +475,10 @@ def copy(self) -> "TemplateBlueprint": "propertyName": "mode", "mapping": { "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, - "None": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, "Custom": {"$ref": "#/definitions/_1.BrokerStateStoreCustomPolicy"}, }, }, @@ -561,7 +576,10 @@ def copy(self) -> "TemplateBlueprint": "propertyName": "mode", "mapping": { "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, - "None": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, "Custom": {"$ref": "#/definitions/_1.BrokerSubscriberQueueCustomPolicy"}, }, }, @@ -678,7 +696,7 @@ def copy(self) -> "TemplateBlueprint": "advancedConfig": {"$ref": "#/definitions/_1.AdvancedConfig", "defaultValue": {}}, }, "variables": { - "VERSIONS": {"certManager": "1.1.2", "secretStore": "1.5.3"}, + "VERSIONS": {"certManager": "1.2.0", "secretStore": "1.5.5"}, "TRAINS": {"certManager": "stable", "secretStore": "stable"}, }, "resources": { @@ -758,12 +776,12 @@ def copy(self) -> "TemplateBlueprint": ) TEMPLATE_BLUEPRINT_INSTANCE = TemplateBlueprint( - commit_id="1739de59728aa4f6e45b3096c64cc179836e806a", + commit_id="ed3a14b063cdd62eed67f17c3aae452290f6986f", content={ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", "languageVersion": "2.0", "contentVersion": "1.0.0.0", - "metadata": {"_generator": {"name": "bicep", "version": "0.47.16.16243", "templateHash": "8063133346727960478"}}, + "metadata": {"_generator": {"name": "bicep", "version": "0.48.1.52935", "templateHash": "4071194434988009354"}}, "definitions": { "_1.AdvancedConfig": { "type": "object", @@ -808,7 +826,12 @@ def copy(self) -> "TemplateBlueprint": }, "connectors": { "type": "object", - "properties": {"version": {"type": "string", "nullable": True}}, + "properties": { + "version": {"type": "string", "nullable": True}, + "registry": {"type": "string", "nullable": True}, + "repository": {"type": "string", "nullable": True}, + "imageRegistry": {"type": "string", "nullable": True}, + }, "nullable": True, }, "secretSyncController": { @@ -1042,7 +1065,9 @@ def copy(self) -> "TemplateBlueprint": "stateStore": { "$ref": "#/definitions/_1.BrokerStateStorePolicy", "nullable": True, - "metadata": {"description": "Controls which keys should be persisted to disk for the state store."}, + "metadata": { + "description": "Controls which keys should be persisted to disk for the state store." + }, }, "subscriberQueue": { "$ref": "#/definitions/_1.BrokerSubscriberQueuePolicy", @@ -1110,7 +1135,10 @@ def copy(self) -> "TemplateBlueprint": "propertyName": "mode", "mapping": { "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, - "None": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, "Custom": {"$ref": "#/definitions/_1.BrokerRetainMessagesCustomPolicy"}, }, }, @@ -1172,7 +1200,10 @@ def copy(self) -> "TemplateBlueprint": "propertyName": "mode", "mapping": { "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, - "None": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, "Custom": {"$ref": "#/definitions/_1.BrokerStateStoreCustomPolicy"}, }, }, @@ -1270,7 +1301,10 @@ def copy(self) -> "TemplateBlueprint": "propertyName": "mode", "mapping": { "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, - "None": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, "Custom": {"$ref": "#/definitions/_1.BrokerSubscriberQueueCustomPolicy"}, }, }, @@ -1445,15 +1479,15 @@ def copy(self) -> "TemplateBlueprint": "schemaRegistryId": {"type": "string"}, "adrNamespaceId": {"type": "string", "nullable": True}, "features": {"$ref": "#/definitions/_1.Features", "nullable": True}, + "sku": {"type": "string", "allowedValues": ["Essentials", "Standard"], "nullable": True}, "brokerConfig": {"$ref": "#/definitions/_1.BrokerConfig", "nullable": True}, "trustConfig": {"$ref": "#/definitions/_1.TrustConfig", "defaultValue": {"source": "SelfSigned"}}, "defaultDataflowInstanceCount": {"type": "int", "defaultValue": 1}, - "disableOpcUaFeature": {"type": "bool", "defaultValue": False}, "advancedConfig": {"$ref": "#/definitions/_1.AdvancedConfig", "defaultValue": {}}, }, "variables": { - "VERSIONS": {"iotOperations": "1.4.112"}, - "TRAINS": {"iotOperations": "stable"}, + "VERSIONS": {"iotOperations": "1.5.33", "connectors": "1.5.18"}, + "TRAINS": {"iotOperations": "integration"}, "HASH": "[coalesce(tryGet(parameters('advancedConfig'), 'resourceSuffix'), take(uniqueString(resourceGroup().id, parameters('clusterName'), parameters('clusterNamespace')), 5))]", "AIO_EXTENSION_SUFFIX": "[take(uniqueString(resourceId('Microsoft.Kubernetes/connectedClusters', parameters('clusterName'))), 5)]", "CUSTOM_LOCATION_NAMESPACE": "[parameters('clusterNamespace')]", @@ -1462,7 +1496,10 @@ def copy(self) -> "TemplateBlueprint": "ISSUER_NAME": "[if(variables('customerManagedTrust'), parameters('trustConfig').settings.issuerName, format('{0}-aio-certificate-issuer', parameters('clusterNamespace')))]", "TRUST_CONFIG_MAP": "[if(variables('customerManagedTrust'), parameters('trustConfig').settings.configMapName, format('{0}-aio-ca-trust-bundle', parameters('clusterNamespace')))]", "TRUST_CONFIG_MAP_KEY": "[if(variables('customerManagedTrust'), parameters('trustConfig').settings.configMapKey, 'ca.crt')]", - "OPCUA_CONNECTOR_VERSION": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'version'), '1.4.14')]", + "OPCUA_CONNECTOR_VERSION": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'version'), variables('VERSIONS').connectors)]", + "CONNECTORS_CHART_REGISTRY": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'registry'), 'mcr.microsoft.com')]", + "CONNECTORS_CHART_REPOSITORY": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'repository'), 'azureiotoperations/aio-connectors/helmchart/microsoft-aio-connectors')]", + "CONNECTORS_IMAGE_REGISTRY": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'imageRegistry'), 'mcr.microsoft.com')]", "MQTT_SETTINGS": { "brokerListenerServiceName": "aio-broker", "brokerListenerPort": 18883, @@ -1482,10 +1519,18 @@ def copy(self) -> "TemplateBlueprint": }, "defaultAioConfigurationSettings": { "AgentOperationTimeoutInMinutes": "120", + "connectors.image.tag": "[variables('OPCUA_CONNECTOR_VERSION')]", + "connectors.image.registry": "[variables('CONNECTORS_CHART_REGISTRY')]", + "connectors.image.repository": "[variables('CONNECTORS_CHART_REPOSITORY')]", + "connectors.values.image.registry": "[variables('CONNECTORS_IMAGE_REGISTRY')]", "connectors.values.mqttBroker.address": "[format('mqtts://{0}:{1}', variables('MQTT_SETTINGS').brokerListenerHost, variables('MQTT_SETTINGS').brokerListenerPort)]", "connectors.values.mqttBroker.serviceAccountTokenAudience": "[variables('MQTT_SETTINGS').serviceAccountAudience]", "connectors.values.securityPki.applicationUri": "[format('urn:microsoft.com:aio:opc:ua:broker:{0}', variables('AIO_EXTENSION_SUFFIX'))]", "connectors.values.securityPki.subjectName": "[format('CN=aio-opc-opcuabroker-{0}', variables('AIO_EXTENSION_SUFFIX'))]", + "connectors.values.opcUaConnector.environment[0].name": "opcuabroker_FeatureManagement__EdgeRegistryPreview", + "connectors.values.opcUaConnector.environment[0].value": "false", + "connectors.values.opcUaConnector.environment[1].name": "aioconnectors_FeatureManagement__EdgeRegistryPreview", + "connectors.values.opcUaConnector.environment[1].value": "false", "dataFlows.values.tinyKube.mqttBroker.hostName": "[variables('MQTT_SETTINGS').brokerListenerHost]", "dataFlows.values.tinyKube.mqttBroker.port": "[variables('MQTT_SETTINGS').brokerListenerPort]", "dataFlows.values.tinyKube.mqttBroker.authentication.serviceAccountTokenAudience": "[variables('MQTT_SETTINGS').serviceAccountAudience]", @@ -1505,10 +1550,7 @@ def copy(self) -> "TemplateBlueprint": "name": "[resourceId('Microsoft.ExtendedLocation/customLocations', coalesce(parameters('customLocationName'), format('location-{0}', variables('HASH'))))]", "type": "CustomLocation", }, - "effectiveFeatures": "[if(parameters('disableOpcUaFeature'), union(coalesce(parameters('features'), createObject()), createObject('opcua', createObject('mode', 'Disabled', 'settings', createObject()))), parameters('features'))]", "opcUaConnectorTemplateName": "[format('azureiotoperationsconnectorforopcua-{0}', substring(uniqueString(resourceId('Microsoft.IoTOperations/instances', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))))), 0, 4))]", - "opcUaFeature": "[coalesce(coalesce(tryGet(parameters('features'), 'opcua'), tryGet(parameters('features'), 'connectors')), createObject())]", - "opcUaFeatureMode": "[if(parameters('disableOpcUaFeature'), 'Disabled', coalesce(tryGet(variables('opcUaFeature'), 'mode'), 'Stable'))]", }, "resources": { "cluster": { @@ -1547,22 +1589,23 @@ def copy(self) -> "TemplateBlueprint": }, "aioInstance": { "type": "Microsoft.IoTOperations/instances", - "apiVersion": "2026-07-01", + "apiVersion": "2026-09-01-preview", "name": "[coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH')))]", "location": "[parameters('clusterLocation')]", "extendedLocation": "[variables('extendedLocation')]", "identity": "[_2.buildIdentity(createArray(parameters('userAssignedIdentity')))]", + "sku": "[if(not(equals(parameters('sku'), null())), createObject('name', parameters('sku')), null())]", "properties": { "description": "An AIO instance.", "schemaRegistryRef": {"resourceId": "[parameters('schemaRegistryId')]"}, - "features": "[variables('effectiveFeatures')]", + "features": "[parameters('features')]", "adrNamespaceRef": "[if(not(empty(parameters('adrNamespaceId'))), createObject('resourceId', parameters('adrNamespaceId')), null())]", }, "dependsOn": ["customLocation"], }, "broker": { "type": "Microsoft.IoTOperations/instances/brokers", - "apiVersion": "2026-07-01", + "apiVersion": "2026-09-01-preview", "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default')]", "extendedLocation": "[variables('extendedLocation')]", "properties": { @@ -1586,7 +1629,7 @@ def copy(self) -> "TemplateBlueprint": }, "brokerAuthn": { "type": "Microsoft.IoTOperations/instances/brokers/authentications", - "apiVersion": "2026-07-01", + "apiVersion": "2026-09-01-preview", "name": "[format('{0}/{1}/{2}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default', 'default')]", "extendedLocation": "[variables('extendedLocation')]", "properties": { @@ -1603,7 +1646,7 @@ def copy(self) -> "TemplateBlueprint": }, "brokerListener": { "type": "Microsoft.IoTOperations/instances/brokers/listeners", - "apiVersion": "2026-07-01", + "apiVersion": "2026-09-01-preview", "name": "[format('{0}/{1}/{2}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default', 'default')]", "extendedLocation": "[variables('extendedLocation')]", "properties": { @@ -1630,7 +1673,7 @@ def copy(self) -> "TemplateBlueprint": }, "dataflowProfile": { "type": "Microsoft.IoTOperations/instances/dataflowProfiles", - "apiVersion": "2026-07-01", + "apiVersion": "2026-09-01-preview", "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default')]", "extendedLocation": "[variables('extendedLocation')]", "properties": {"instanceCount": "[parameters('defaultDataflowInstanceCount')]"}, @@ -1638,7 +1681,7 @@ def copy(self) -> "TemplateBlueprint": }, "dataflowEndpoint": { "type": "Microsoft.IoTOperations/instances/dataflowEndpoints", - "apiVersion": "2026-07-01", + "apiVersion": "2026-09-01-preview", "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default')]", "extendedLocation": "[variables('extendedLocation')]", "properties": { @@ -1651,14 +1694,17 @@ def copy(self) -> "TemplateBlueprint": "audience": "[variables('MQTT_SETTINGS').serviceAccountAudience]" }, }, - "tls": {"mode": "Enabled", "trustedCaCertificateConfigMapRef": "[variables('TRUST_CONFIG_MAP')]"}, + "tls": { + "mode": "Enabled", + "trustedCaCertificateConfigMapRef": "[variables('TRUST_CONFIG_MAP')]", + }, }, }, "dependsOn": ["aioInstance", "customLocation"], }, "artifactRegistryEndpoint": { "type": "Microsoft.IoTOperations/instances/registryEndpoints", - "apiVersion": "2026-07-01", + "apiVersion": "2026-09-01-preview", "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default')]", "extendedLocation": "[variables('extendedLocation')]", "properties": { @@ -1668,9 +1714,8 @@ def copy(self) -> "TemplateBlueprint": "dependsOn": ["aioInstance", "customLocation"], }, "opcUaConnectorTemplate": { - "condition": "[not(equals(variables('opcUaFeatureMode'), 'Disabled'))]", "type": "Microsoft.IoTOperations/instances/akriConnectorTemplates", - "apiVersion": "2026-07-01", + "apiVersion": "2026-09-01-preview", "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), variables('opcUaConnectorTemplateName'))]", "extendedLocation": "[variables('extendedLocation')]", "properties": { @@ -1693,7 +1738,10 @@ def copy(self) -> "TemplateBlueprint": }, }, }, - "deviceInboundEndpointTypes": [{"endpointType": "Microsoft.OpcUa"}], + "deviceInboundEndpointTypes": [ + {"endpointType": "Microsoft.OpcUa"}, + {"endpointType": "Microsoft.OpcUa.WoT"}, + ], }, "dependsOn": ["aioInstance", "customLocation"], }, @@ -1735,10 +1783,10 @@ def copy(self) -> "TemplateBlueprint": ) -def get_insecure_listener(instance_name: str, broker_name: str) -> dict: +def get_insecure_listener(instance_name: str, broker_name: str, api_version: Optional[str] = None) -> dict: return { "type": "Microsoft.IoTOperations/instances/brokers/listeners", - "apiVersion": DEFAULT_IOTOPS_MGMT_API_VERSION.value, + "apiVersion": api_version or DEFAULT_IOTOPS_MGMT_API_VERSION.value, "name": f"{instance_name}/{broker_name}/{AIO_INSECURE_LISTENER_NAME}", "extendedLocation": { "name": "[resourceId('Microsoft.ExtendedLocation/customLocations', parameters('customLocationName'))]", diff --git a/azext_edge/edge/providers/orchestration/template_preview.py b/azext_edge/edge/providers/orchestration/template_preview.py new file mode 100644 index 000000000..1f9d74297 --- /dev/null +++ b/azext_edge/edge/providers/orchestration/template_preview.py @@ -0,0 +1,1118 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + + +"""Generated preview instance blueprint for internal qualification. + +Development connector registries are retained for internal qualification by approval. +Public release is blocked until the development registry, repository, and tag +coordinates are replaced with release-owner-approved public artifact locations. +The source GDS parameter and configuration reader are removed before compiling. +Runtime catalog registration preserves these inputs and the actual deployment train. +""" + +# selected source preview/v1.6.x/2610 @ abcf0ed770cb12256c3ffc272cc0636e44dee0ff +# source release prev2610 +# no loaded-source redactions required +from .template import TemplateBlueprint + +TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW = TemplateBlueprint( + commit_id="f91dee8af923eceea4b8c68ab9a863aee53630c8", + content={ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": {"_generator": {"name": "bicep", "version": "0.48.1.52935", "templateHash": "1528966886690281907"}}, + "definitions": { + "_1.AdvancedConfig": { + "type": "object", + "properties": { + "platform": { + "type": "object", + "properties": { + "version": {"type": "string", "nullable": True}, + "train": {"type": "string", "nullable": True}, + }, + "nullable": True, + }, + "certManager": { + "type": "object", + "properties": { + "version": {"type": "string", "nullable": True}, + "train": {"type": "string", "nullable": True}, + "telemetry": { + "type": "object", + "properties": {"enabled": {"type": "string", "nullable": True}}, + "nullable": True, + }, + "secretTargets": { + "type": "object", + "properties": { + "enabled": {"type": "string", "nullable": True}, + "authorizedSecretsAll": {"type": "string", "nullable": True}, + }, + "nullable": True, + }, + }, + "nullable": True, + }, + "aio": { + "type": "object", + "properties": { + "version": {"type": "string", "nullable": True}, + "train": {"type": "string", "nullable": True}, + "configurationSettingsOverride": {"type": "object", "nullable": True}, + }, + "nullable": True, + }, + "connectors": { + "type": "object", + "properties": { + "version": {"type": "string", "nullable": True}, + "registry": {"type": "string", "nullable": True}, + "repository": {"type": "string", "nullable": True}, + "imageRegistry": {"type": "string", "nullable": True}, + }, + "nullable": True, + }, + "secretSyncController": { + "type": "object", + "properties": { + "version": {"type": "string", "nullable": True}, + "train": {"type": "string", "nullable": True}, + }, + "nullable": True, + }, + "observability": { + "type": "object", + "properties": { + "enabled": {"type": "bool", "nullable": True}, + "otelCollectorAddress": {"type": "string", "nullable": True}, + "otelExportIntervalSeconds": {"type": "int", "nullable": True}, + }, + "nullable": True, + }, + "openServiceMesh": { + "type": "object", + "properties": { + "version": {"type": "string", "nullable": True}, + "train": {"type": "string", "nullable": True}, + }, + "nullable": True, + }, + "edgeStorageAccelerator": { + "type": "object", + "properties": { + "version": {"type": "string", "nullable": True}, + "train": {"type": "string", "nullable": True}, + "diskStorageClass": {"type": "string", "nullable": True}, + "faultToleranceEnabled": {"type": "bool", "nullable": True}, + "diskMountPoint": {"type": "string", "nullable": True}, + }, + "nullable": True, + }, + "resourceSuffix": {"type": "string", "nullable": True}, + }, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "types.bicep"}}, + }, + "_1.BrokerConfig": { + "type": "object", + "properties": { + "frontendReplicas": { + "type": "int", + "nullable": True, + "minValue": 1, + "maxValue": 16, + "metadata": {"description": "Number of AIO Broker frontend replicas. The default is 2."}, + }, + "frontendWorkers": { + "type": "int", + "nullable": True, + "minValue": 1, + "maxValue": 16, + "metadata": {"description": "Number of AIO Broker frontend workers. The default is 2."}, + }, + "backendRedundancyFactor": { + "type": "int", + "nullable": True, + "minValue": 1, + "maxValue": 5, + "metadata": {"description": "The AIO Broker backend redundancy factory. The default is 2."}, + }, + "backendWorkers": { + "type": "int", + "nullable": True, + "minValue": 1, + "maxValue": 16, + "metadata": {"description": "Number of AIO Broker backend workers. The default is 2."}, + }, + "backendPartitions": { + "type": "int", + "nullable": True, + "minValue": 1, + "maxValue": 16, + "metadata": {"description": "Number of AIO Broker backend partitions. The default is 2."}, + }, + "memoryProfile": { + "type": "string", + "allowedValues": ["High", "Low", "Medium", "Tiny"], + "nullable": True, + "metadata": {"description": 'The AIO Broker memory profile. The default is "Medium".'}, + }, + "serviceType": { + "type": "string", + "allowedValues": ["ClusterIp", "LoadBalancer", "NodePort"], + "nullable": True, + "metadata": {"description": 'The AIO Broker service type. The default is "ClusterIp".'}, + }, + "persistence": { + "$ref": "#/definitions/_1.BrokerPersistence", + "nullable": True, + "metadata": { + "description": "The persistence settings of the Broker, preserved as supplied when enablePersistence is true. Customize maxSize and persistentVolumeClaimSpec.storageClassName here. When omitted, deployment templates use the recommended persistence default; set enablePersistence to false for an in-memory broker." + }, + }, + "diagnostics": { + "$ref": "#/definitions/_1.BrokerDiagnostics", + "nullable": True, + "metadata": {"description": "The AIO Broker diagnostics settings."}, + }, + }, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "types.bicep"}}, + }, + "_1.BrokerDiagnostics": { + "type": "object", + "properties": { + "logs": { + "type": "object", + "properties": { + "level": { + "type": "string", + "nullable": True, + "metadata": { + "description": 'The log level. Examples - "debug", "info", "warn", "error", "trace".' + }, + } + }, + "nullable": True, + "metadata": {"description": "The log settings of the broker."}, + }, + "metrics": { + "type": "object", + "properties": { + "prometheusPort": { + "type": "int", + "nullable": True, + "metadata": {"description": "The prometheus port to expose the metrics."}, + } + }, + "nullable": True, + "metadata": {"description": "The metrics properties."}, + }, + "selfCheck": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/_1.OperationalMode", + "nullable": True, + "metadata": { + "description": 'The toggle to enable/disable self check. Allowed values: "Enabled", "enabled", "Disabled", "disabled".' + }, + }, + "intervalSeconds": { + "type": "int", + "nullable": True, + "metadata": {"description": "The self check interval in seconds."}, + }, + "timeoutSeconds": { + "type": "int", + "nullable": True, + "metadata": {"description": "The timeout for self check in seconds."}, + }, + }, + "nullable": True, + "metadata": {"description": "The self check properties."}, + }, + "traces": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/_1.OperationalMode", + "nullable": True, + "metadata": { + "description": 'The toggle to enable/disable traces. Allowed values: "Enabled", "enabled", "Disabled", "disabled".' + }, + }, + "cacheSizeMegabytes": { + "type": "int", + "nullable": True, + "metadata": {"description": "The cache size in megabytes."}, + }, + "selfTracing": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/_1.OperationalMode", + "nullable": True, + "metadata": { + "description": 'The toggle to enable/disable self tracing. Allowed values: "Enabled", "enabled", "Disabled", "disabled".' + }, + }, + "intervalSeconds": { + "type": "int", + "nullable": True, + "metadata": {"description": "The self tracing interval in seconds."}, + }, + }, + "nullable": True, + "metadata": {"description": "The self tracing properties."}, + }, + "spanChannelCapacity": { + "type": "int", + "nullable": True, + "metadata": {"description": "The span channel capacity."}, + }, + }, + "nullable": True, + "metadata": {"description": "The trace properties."}, + }, + }, + "metadata": { + "description": "Defines the diagnostics settings for the Broker CRD.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerPersistence": { + "type": "object", + "properties": { + "maxSize": { + "type": "string", + "metadata": { + "description": "The max size of the message buffer on disk. The recommended deployment default is 3Gi per Backend replica.\nIf a PVC template is specified, this size is used as the request and limit sizes of that template.\n" + }, + }, + "persistentVolumeClaimSpec": { + "$ref": "#/definitions/_1.VolumeClaimSpec", + "nullable": True, + "metadata": { + "description": "Use the specified PersistentVolumeClaim template to mount a persistent volume.\nIf unset, a default PVC using the cluster default StorageClass will be used.\n" + }, + }, + "retain": { + "$ref": "#/definitions/_1.BrokerRetainMessagesPolicy", + "nullable": True, + "metadata": { + "description": "Controls which topic's retained messages should be persisted to disk." + }, + }, + "stateStore": { + "$ref": "#/definitions/_1.BrokerStateStorePolicy", + "nullable": True, + "metadata": { + "description": "Controls which keys should be persisted to disk for the state store." + }, + }, + "subscriberQueue": { + "$ref": "#/definitions/_1.BrokerSubscriberQueuePolicy", + "nullable": True, + "metadata": { + "description": "Controls which subscriber message queues should be persisted to disk.\nSession state metadata are always written to disk if any persistence is specified.\n" + }, + }, + "encryption": { + "$ref": "#/definitions/_1.BrokerPersistenceEncryption", + "nullable": True, + "metadata": { + "description": "Controls settings related to encryption of the persistence database.\nOptional, defaults to enabling encryption.\n" + }, + }, + }, + "metadata": { + "description": "Disk persistence configuration for the Broker.\nDeployment templates supply the recommended default when omitted unless enablePersistence is false.\nNote: if configured, all MQTT session states are written to disk.\n", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerPersistenceEncryption": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/_1.OperationalMode", + "metadata": {"description": "Determines if encryption is enabled."}, + } + }, + "metadata": { + "description": "Encryption settings for the persistence database.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerRetainMessagesCustomPolicy": { + "type": "object", + "properties": { + "mode": {"type": "string", "allowedValues": ["Custom"]}, + "retainSettings": { + "$ref": "#/definitions/_1.BrokerRetainMessagesSettings", + "metadata": {"description": "Settings for the Custom mode."}, + }, + }, + "metadata": { + "description": "Custom retain messages policy for the Broker.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerRetainMessagesDynamic": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/_1.OperationalMode", + "metadata": {"description": "Mode of dynamic retain settings."}, + } + }, + "metadata": { + "description": "Dynamic toggles for retain messages policy.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerRetainMessagesPolicy": { + "type": "object", + "discriminator": { + "propertyName": "mode", + "mapping": { + "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, + "Custom": {"$ref": "#/definitions/_1.BrokerRetainMessagesCustomPolicy"}, + }, + }, + "metadata": { + "description": "Controls which retained messages are persisted.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerRetainMessagesSettings": { + "type": "object", + "properties": { + "topics": { + "type": "array", + "items": {"type": "string"}, + "nullable": True, + "metadata": {"description": "Topics to persist (wildcards # and + supported)."}, + }, + "dynamic": { + "$ref": "#/definitions/_1.BrokerRetainMessagesDynamic", + "nullable": True, + "metadata": {"description": "Dynamic toggle via MQTTv5 user property."}, + }, + }, + "metadata": { + "description": "Settings for a custom retain messages policy.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerStateStoreCustomPolicy": { + "type": "object", + "properties": { + "mode": {"type": "string", "allowedValues": ["Custom"]}, + "stateStoreSettings": { + "$ref": "#/definitions/_1.BrokerStateStorePolicySettings", + "metadata": {"description": "Settings for the Custom mode."}, + }, + }, + "metadata": { + "description": "Custom state store policy for the Broker.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerStateStoreDynamic": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/_1.OperationalMode", + "metadata": {"description": "Mode of dynamic state store settings."}, + } + }, + "metadata": { + "description": "Dynamic toggles for state store policy.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerStateStorePolicy": { + "type": "object", + "discriminator": { + "propertyName": "mode", + "mapping": { + "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, + "Custom": {"$ref": "#/definitions/_1.BrokerStateStoreCustomPolicy"}, + }, + }, + "metadata": { + "description": "Controls which state store entries are persisted.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerStateStorePolicyResources": { + "type": "object", + "properties": { + "keyType": { + "type": "string", + "allowedValues": ["Binary", "Pattern", "String"], + "metadata": {"description": "Type of key matching."}, + }, + "keys": { + "type": "array", + "items": {"type": "string"}, + "metadata": {"description": "List of keys to persist."}, + }, + }, + "metadata": { + "description": "A key-type and its associated keys for state store persistence.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerStateStorePolicySettings": { + "type": "object", + "properties": { + "stateStoreResources": { + "type": "array", + "items": {"$ref": "#/definitions/_1.BrokerStateStorePolicyResources"}, + "nullable": True, + "metadata": {"description": "Resources to persist (keyType and list of keys)."}, + }, + "dynamic": { + "$ref": "#/definitions/_1.BrokerStateStoreDynamic", + "nullable": True, + "metadata": {"description": "Dynamic toggle via MQTTv5 user property."}, + }, + }, + "metadata": { + "description": "Settings for a custom state store policy.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerSubscriberQueueCustomPolicy": { + "type": "object", + "properties": { + "mode": {"type": "string", "allowedValues": ["Custom"]}, + "subscriberQueueSettings": { + "$ref": "#/definitions/_1.BrokerSubscriberQueueCustomPolicySettings", + "metadata": {"description": "Settings for the Custom mode."}, + }, + }, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "types.bicep"}}, + }, + "_1.BrokerSubscriberQueueCustomPolicySettings": { + "type": "object", + "properties": { + "subscriberClientIds": { + "type": "array", + "items": {"type": "string"}, + "nullable": True, + "metadata": {"description": "Subscriber client IDs to persist (wildcard * supported)."}, + }, + "dynamic": { + "$ref": "#/definitions/_1.BrokerSubscriberQueueDynamic", + "nullable": True, + "metadata": {"description": "Dynamic toggle via MQTTv5 user property."}, + }, + }, + "metadata": { + "description": "Settings for a custom subscriber queue policy.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerSubscriberQueueDynamic": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/_1.OperationalMode", + "metadata": {"description": "Mode of dynamic subscriber queue settings."}, + } + }, + "metadata": { + "description": "Dynamic toggles for subscriber queue policy.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.BrokerSubscriberQueuePolicy": { + "type": "object", + "discriminator": { + "propertyName": "mode", + "mapping": { + "All": {"type": "object", "properties": {"mode": {"type": "string", "allowedValues": ["All"]}}}, + "None": { + "type": "object", + "properties": {"mode": {"type": "string", "allowedValues": ["None"]}}, + }, + "Custom": {"$ref": "#/definitions/_1.BrokerSubscriberQueueCustomPolicy"}, + }, + }, + "metadata": { + "description": "Controls which subscriber queues are persisted.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.CustomerManaged": { + "type": "object", + "properties": { + "source": {"type": "string", "allowedValues": ["CustomerManaged"]}, + "settings": {"$ref": "#/definitions/_1.TrustBundleSettings"}, + }, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "types.bicep"}}, + }, + "_1.Features": { + "type": "object", + "properties": {}, + "additionalProperties": { + "$ref": "#/definitions/_1.InstanceFeature", + "metadata": {"description": "Object of features"}, + }, + "metadata": { + "description": "AIO Instance features.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.InstanceFeature": { + "type": "object", + "properties": { + "mode": {"$ref": "#/definitions/_1.InstanceFeatureMode", "nullable": True}, + "settings": { + "type": "object", + "properties": {}, + "additionalProperties": {"$ref": "#/definitions/_1.InstanceFeatureSettingValue"}, + "nullable": True, + }, + }, + "metadata": { + "description": "Individual feature object within the AIO instance.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.InstanceFeatureMode": { + "type": "string", + "allowedValues": ["Disabled", "Preview", "Stable"], + "metadata": { + "description": 'The mode of the AIO instance feature. Either "Stable", "Preview" or "Disabled".', + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.InstanceFeatureSettingValue": { + "$ref": "#/definitions/_1.OperationalMode", + "metadata": { + "description": 'The setting value of the AIO instance feature. Either "Enabled" or "Disabled".', + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.OperationalMode": { + "type": "string", + "allowedValues": ["Disabled", "Enabled"], + "metadata": { + "description": 'Defines operational mode. Either "Enabled" or "Disabled".', + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_1.SelfSigned": { + "type": "object", + "properties": {"source": {"type": "string", "allowedValues": ["SelfSigned"]}}, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "types.bicep"}}, + }, + "_1.TrustBundleSettings": { + "type": "object", + "properties": { + "issuerName": {"type": "string"}, + "issuerKind": {"type": "string", "allowedValues": ["ClusterIssuer", "Issuer"]}, + "configMapName": {"type": "string"}, + "configMapKey": {"type": "string"}, + }, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "types.bicep"}}, + }, + "_1.TrustConfig": { + "type": "object", + "discriminator": { + "propertyName": "source", + "mapping": { + "SelfSigned": {"$ref": "#/definitions/_1.SelfSigned"}, + "CustomerManaged": {"$ref": "#/definitions/_1.CustomerManaged"}, + }, + }, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "types.bicep"}}, + }, + "_1.VolumeClaimSpec": { + "type": "object", + "properties": { + "volumeName": {"type": "string", "nullable": True}, + "volumeMode": {"type": "string", "nullable": True}, + "storageClassName": {"type": "string", "nullable": True}, + "accessModes": {"type": "array", "items": {"type": "string"}, "nullable": True}, + "dataSource": {"type": "object", "nullable": True}, + "dataSourceRef": {"type": "object", "nullable": True}, + "resources": {"type": "object", "nullable": True}, + "selector": {"type": "object", "nullable": True}, + }, + "metadata": { + "description": "Kubernetes PersistentVolumeClaim spec.", + "__bicep_imported_from!": {"sourceTemplate": "types.bicep"}, + }, + }, + "_2.Identity": { + "type": "object", + "discriminator": { + "propertyName": "type", + "mapping": { + "None": {"$ref": "#/definitions/_2.NoIdentity"}, + "UserAssigned": {"$ref": "#/definitions/_2.UserAssignedIdentity"}, + }, + }, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "utils.bicep"}}, + }, + "_2.NoIdentity": { + "type": "object", + "properties": {"type": {"type": "string", "allowedValues": ["None"]}}, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "utils.bicep"}}, + }, + "_2.UserAssignedIdentity": { + "type": "object", + "properties": { + "type": {"type": "string", "allowedValues": ["UserAssigned"]}, + "userAssignedIdentities": { + "type": "object", + "properties": {}, + "additionalProperties": {"type": "object", "properties": {}}, + }, + }, + "metadata": {"__bicep_imported_from!": {"sourceTemplate": "utils.bicep"}}, + }, + }, + "functions": [ + { + "namespace": "_2", + "members": { + "buildBrokerPersistence": { + "parameters": [ + {"type": "bool", "name": "enablePersistence"}, + {"$ref": "#/definitions/_1.BrokerPersistence", "nullable": True, "name": "persistence"}, + ], + "output": { + "$ref": "#/definitions/_1.BrokerPersistence", + "nullable": True, + "value": "[if(not(parameters('enablePersistence')), null(), coalesce(parameters('persistence'), createObject('maxSize', '3Gi', 'retain', createObject('mode', 'None'), 'subscriberQueue', createObject('mode', 'None'), 'stateStore', createObject('mode', 'Custom', 'stateStoreSettings', createObject('dynamic', createObject('mode', 'Enabled'))))))]", + }, + "metadata": { + "description": "Use the supplied persistence configuration unchanged, or the recommended default for new deployments. Explicit opt-out takes precedence.", + "__bicep_imported_from!": {"sourceTemplate": "utils.bicep"}, + }, + }, + "buildIdentity": { + "parameters": [ + { + "type": "array", + "items": {"type": "string", "nullable": True}, + "nullable": True, + "name": "identities", + } + ], + "output": { + "$ref": "#/definitions/_2.Identity", + "value": "[if(or(empty(parameters('identities')), equals(length(filter(parameters('identities'), lambda('id', not(empty(lambdaVariables('id')))))), 0)), createObject('type', 'None'), createObject('type', 'UserAssigned', 'userAssignedIdentities', toObject(filter(parameters('identities'), lambda('identity', not(empty(lambdaVariables('identity'))))), lambda('identity', lambdaVariables('identity')), lambda('identity', createObject()))))]", + }, + "metadata": { + "description": 'Builds a UserAssigned identity object for the given array of identities.\nIf the list is empty, it will return {type: \'None\'}\ne.g\n```bicep\nvar identites = [\'/subscriptions/.../id1\', \'/subscriptions/.../id2\']\noutput userIdentities object = buildUserIdentities(identites)\n// The output will be:\n// {\n// "type": "UserAssigned",\n// "userAssignedIdentities": {\n// "/subscriptions/.../id1": {},\n// "/subscriptions/.../id2": {}\n// }\n// }\n}\n', + "__bicep_imported_from!": {"sourceTemplate": "utils.bicep"}, + }, + }, + "mcpDefaultPolicies": { + "parameters": [], + "output": { + "type": "array", + "items": {"type": "object"}, + "value": [ + { + "action": "tool-call", + "principal": {"principalType": "Role", "role": "Reader"}, + "resources": [ + "documentation_overview", + "documentation", + "list_edge_registry_groups", + "get_edge_registry_group", + "get_edge_registry_generic_resource", + "get_edge_registry_resource", + "list_edge_registry_resources", + "list_edge_registry_resource_versions", + "get_edge_registry_schema_from_asset", + "list_assets", + "get_asset", + "asset_details", + "list_devices", + "get_device", + "device_details", + "list_connectors", + "get_connector", + "connector_details", + "find_connector_by_device", + "list_brokers", + "get_broker", + "broker_details", + "list_dataflows", + "get_dataflow", + "dataflow_details", + "list_dataflow_graphs", + "get_dataflow_graph", + "dataflow_graph_details", + ], + }, + { + "action": "tool-call", + "principal": {"principalType": "Role", "role": "DataReader"}, + "resources": ["mq_messages", "dss_get"], + }, + { + "action": "tool-call", + "principal": {"principalType": "Role", "role": "Admin"}, + "resources": ["*"], + }, + ], + }, + "metadata": { + "description": "Returns the default Role-based MCP authorization policy rules (aio-mcp-policy-v1). Shared by the instance deployment (azure-iot-operations-instance.bicep) and the MCP E2E test fixture (aio-mcp.bicep) so both stay in sync.", + "__bicep_imported_from!": {"sourceTemplate": "utils.bicep"}, + }, + }, + }, + } + ], + "parameters": { + "clusterName": {"type": "string"}, + "clusterNamespace": {"type": "string", "defaultValue": "azure-iot-operations"}, + "clusterLocation": {"type": "string", "defaultValue": "[resourceGroup().location]"}, + "customLocationName": {"type": "string", "nullable": True}, + "clExtensionIds": {"type": "array", "items": {"type": "string"}}, + "aioInstanceName": {"type": "string", "nullable": True}, + "userAssignedIdentity": {"type": "string", "nullable": True}, + "schemaRegistryId": {"type": "string"}, + "adrNamespaceId": {"type": "string", "nullable": True}, + "features": {"$ref": "#/definitions/_1.Features", "nullable": True}, + "sku": {"type": "string", "allowedValues": ["Essentials", "Standard"], "nullable": True}, + "brokerConfig": {"$ref": "#/definitions/_1.BrokerConfig", "nullable": True}, + "enablePersistence": {"type": "bool", "defaultValue": True}, + "trustConfig": {"$ref": "#/definitions/_1.TrustConfig", "defaultValue": {"source": "SelfSigned"}}, + "defaultDataflowInstanceCount": {"type": "int", "defaultValue": 1}, + "advancedConfig": {"$ref": "#/definitions/_1.AdvancedConfig", "defaultValue": {}}, + }, + "variables": { + "VERSIONS": {"iotOperations": "1.6.0-preview.43", "connectors": "1.5.18"}, + "TRAINS": {"iotOperations": "integration"}, + "HASH": "[coalesce(tryGet(parameters('advancedConfig'), 'resourceSuffix'), take(uniqueString(resourceGroup().id, parameters('clusterName'), parameters('clusterNamespace')), 5))]", + "AIO_EXTENSION_SUFFIX": "[take(uniqueString(resourceId('Microsoft.Kubernetes/connectedClusters', parameters('clusterName'))), 5)]", + "CUSTOM_LOCATION_NAMESPACE": "[parameters('clusterNamespace')]", + "AIO_EXTENSION_SCOPE": {"cluster": {"releaseNamespace": "[parameters('clusterNamespace')]"}}, + "customerManagedTrust": "[equals(parameters('trustConfig').source, 'CustomerManaged')]", + "ISSUER_NAME": "[if(variables('customerManagedTrust'), parameters('trustConfig').settings.issuerName, format('{0}-aio-certificate-issuer', parameters('clusterNamespace')))]", + "TRUST_CONFIG_MAP": "[if(variables('customerManagedTrust'), parameters('trustConfig').settings.configMapName, format('{0}-aio-ca-trust-bundle', parameters('clusterNamespace')))]", + "TRUST_CONFIG_MAP_KEY": "[if(variables('customerManagedTrust'), parameters('trustConfig').settings.configMapKey, 'ca.crt')]", + "OPCUA_CONNECTOR_VERSION": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'version'), variables('VERSIONS').connectors)]", + "CONNECTORS_CHART_REGISTRY": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'registry'), 'aioconnectorsdev.azurecr.io')]", + "CONNECTORS_CHART_REPOSITORY": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'repository'), 'aio-connectors/helmchart/microsoft-aio-connectors')]", + "CONNECTORS_IMAGE_REGISTRY": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'imageRegistry'), 'aioconnectorsdev.azurecr.io')]", + "MQTT_SETTINGS": { + "brokerListenerServiceName": "aio-broker", + "brokerListenerPort": 18883, + "brokerListenerHost": "[format('aio-broker.{0}', variables('CUSTOM_LOCATION_NAMESPACE'))]", + "serviceAccountAudience": "aio-internal", + }, + "BROKER_CONFIG": { + "frontendReplicas": "[coalesce(tryGet(parameters('brokerConfig'), 'frontendReplicas'), 2)]", + "frontendWorkers": "[coalesce(tryGet(parameters('brokerConfig'), 'frontendWorkers'), 2)]", + "backendRedundancyFactor": "[coalesce(tryGet(parameters('brokerConfig'), 'backendRedundancyFactor'), 2)]", + "backendWorkers": "[coalesce(tryGet(parameters('brokerConfig'), 'backendWorkers'), 2)]", + "backendPartitions": "[coalesce(tryGet(parameters('brokerConfig'), 'backendPartitions'), 2)]", + "memoryProfile": "[coalesce(tryGet(parameters('brokerConfig'), 'memoryProfile'), 'Medium')]", + "serviceType": "[coalesce(tryGet(parameters('brokerConfig'), 'serviceType'), 'ClusterIp')]", + "persistence": "[_2.buildBrokerPersistence(parameters('enablePersistence'), tryGet(parameters('brokerConfig'), 'persistence'))]", + "diagnostics": "[tryGet(parameters('brokerConfig'), 'diagnostics')]", + }, + "defaultAioConfigurationSettings": { + "AgentOperationTimeoutInMinutes": "120", + "connectors.image.tag": "[variables('OPCUA_CONNECTOR_VERSION')]", + "connectors.image.registry": "[variables('CONNECTORS_CHART_REGISTRY')]", + "connectors.image.repository": "[variables('CONNECTORS_CHART_REPOSITORY')]", + "connectors.values.image.registry": "[variables('CONNECTORS_IMAGE_REGISTRY')]", + "connectors.values.mqttBroker.address": "[format('mqtts://{0}:{1}', variables('MQTT_SETTINGS').brokerListenerHost, variables('MQTT_SETTINGS').brokerListenerPort)]", + "connectors.values.mqttBroker.serviceAccountTokenAudience": "[variables('MQTT_SETTINGS').serviceAccountAudience]", + "connectors.values.securityPki.applicationUri": "[format('urn:microsoft.com:aio:opc:ua:broker:{0}', variables('AIO_EXTENSION_SUFFIX'))]", + "connectors.values.securityPki.subjectName": "[format('CN=aio-opc-opcuabroker-{0}', variables('AIO_EXTENSION_SUFFIX'))]", + "dataFlows.values.tinyKube.mqttBroker.hostName": "[variables('MQTT_SETTINGS').brokerListenerHost]", + "dataFlows.values.tinyKube.mqttBroker.port": "[variables('MQTT_SETTINGS').brokerListenerPort]", + "dataFlows.values.tinyKube.mqttBroker.authentication.serviceAccountTokenAudience": "[variables('MQTT_SETTINGS').serviceAccountAudience]", + "dataFlows.values.wasmGraphController.mqttBroker.caCertConfigMapRef": "[variables('TRUST_CONFIG_MAP')]", + "dataFlows.values.wasmGraphController.mqttBroker.caCertFileName": "[variables('TRUST_CONFIG_MAP_KEY')]", + "observability.metrics.enabled": "[format('{0}', coalesce(tryGet(tryGet(parameters('advancedConfig'), 'observability'), 'enabled'), false()))]", + "observability.metrics.openTelemetryCollectorAddress": "[if(coalesce(tryGet(tryGet(parameters('advancedConfig'), 'observability'), 'enabled'), false()), format('{0}', tryGet(tryGet(parameters('advancedConfig'), 'observability'), 'otelCollectorAddress')), '')]", + "trustSource": "[parameters('trustConfig').source]", + "trustBundleSettings.issuer.name": "[variables('ISSUER_NAME')]", + "trustBundleSettings.issuer.kind": "[coalesce(tryGet(tryGet(parameters('trustConfig'), 'settings'), 'issuerKind'), '')]", + "trustBundleSettings.configMap.name": "[coalesce(tryGet(tryGet(parameters('trustConfig'), 'settings'), 'configMapName'), '')]", + "trustBundleSettings.configMap.key": "[coalesce(tryGet(tryGet(parameters('trustConfig'), 'settings'), 'configMapKey'), '')]", + "schemaRegistry.values.mqttBroker.host": "[format('mqtts://{0}:{1}', variables('MQTT_SETTINGS').brokerListenerHost, variables('MQTT_SETTINGS').brokerListenerPort)]", + "schemaRegistry.values.mqttBroker.serviceAccountTokenAudience": "[variables('MQTT_SETTINGS').serviceAccountAudience]", + }, + "extendedLocation": { + "name": "[resourceId('Microsoft.ExtendedLocation/customLocations', coalesce(parameters('customLocationName'), format('location-{0}', variables('HASH'))))]", + "type": "CustomLocation", + }, + "opcUaConnectorTemplateName": "[format('azureiotoperationsconnectorforopcua-{0}', substring(uniqueString(resourceId('Microsoft.IoTOperations/instances', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))))), 0, 4))]", + }, + "resources": { + "cluster": { + "existing": True, + "type": "Microsoft.Kubernetes/connectedClusters", + "apiVersion": "2021-03-01", + "name": "[parameters('clusterName')]", + }, + "aioExtension": { + "type": "Microsoft.KubernetesConfiguration/extensions", + "apiVersion": "2023-05-01", + "scope": "[resourceId('Microsoft.Kubernetes/connectedClusters', parameters('clusterName'))]", + "name": "[format('azure-iot-operations-{0}', variables('AIO_EXTENSION_SUFFIX'))]", + "identity": {"type": "SystemAssigned"}, + "properties": { + "extensionType": "microsoft.iotoperations", + "version": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'aio'), 'version'), variables('VERSIONS').iotOperations)]", + "releaseTrain": "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'aio'), 'train'), variables('TRAINS').iotOperations)]", + "autoUpgradeMinorVersion": False, + "scope": "[variables('AIO_EXTENSION_SCOPE')]", + "configurationSettings": "[union(variables('defaultAioConfigurationSettings'), coalesce(tryGet(tryGet(parameters('advancedConfig'), 'aio'), 'configurationSettingsOverride'), createObject()))]", + }, + }, + "customLocation": { + "type": "Microsoft.ExtendedLocation/customLocations", + "apiVersion": "2021-08-31-preview", + "name": "[coalesce(parameters('customLocationName'), format('location-{0}', variables('HASH')))]", + "location": "[parameters('clusterLocation')]", + "properties": { + "hostResourceId": "[resourceId('Microsoft.Kubernetes/connectedClusters', parameters('clusterName'))]", + "namespace": "[parameters('clusterNamespace')]", + "displayName": "[coalesce(parameters('customLocationName'), format('location-{0}', variables('HASH')))]", + "clusterExtensionIds": "[flatten(createArray(parameters('clExtensionIds'), createArray(extensionResourceId(resourceId('Microsoft.Kubernetes/connectedClusters', parameters('clusterName')), 'Microsoft.KubernetesConfiguration/extensions', format('azure-iot-operations-{0}', variables('AIO_EXTENSION_SUFFIX'))))))]", + }, + "dependsOn": ["aioExtension"], + }, + "aioInstance": { + "type": "Microsoft.IoTOperations/instances", + "apiVersion": "2026-09-01-preview", + "name": "[coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH')))]", + "location": "[parameters('clusterLocation')]", + "extendedLocation": "[variables('extendedLocation')]", + "identity": "[_2.buildIdentity(createArray(parameters('userAssignedIdentity')))]", + "sku": "[if(not(equals(parameters('sku'), null())), createObject('name', parameters('sku')), null())]", + "properties": { + "description": "An AIO instance.", + "schemaRegistryRef": {"resourceId": "[parameters('schemaRegistryId')]"}, + "features": "[parameters('features')]", + "adrNamespaceRef": "[if(not(empty(parameters('adrNamespaceId'))), createObject('resourceId', parameters('adrNamespaceId')), null())]", + }, + "dependsOn": ["customLocation"], + }, + "broker": { + "type": "Microsoft.IoTOperations/instances/brokers", + "apiVersion": "2026-09-01-preview", + "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default')]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": { + "memoryProfile": "[variables('BROKER_CONFIG').memoryProfile]", + "generateResourceLimits": {"cpu": "Disabled"}, + "cardinality": { + "backendChain": { + "partitions": "[variables('BROKER_CONFIG').backendPartitions]", + "workers": "[variables('BROKER_CONFIG').backendWorkers]", + "redundancyFactor": "[variables('BROKER_CONFIG').backendRedundancyFactor]", + }, + "frontend": { + "replicas": "[variables('BROKER_CONFIG').frontendReplicas]", + "workers": "[variables('BROKER_CONFIG').frontendWorkers]", + }, + }, + "persistence": "[tryGet(variables('BROKER_CONFIG'), 'persistence')]", + "diagnostics": "[tryGet(variables('BROKER_CONFIG'), 'diagnostics')]", + }, + "dependsOn": ["aioInstance", "customLocation"], + }, + "brokerAuthn": { + "type": "Microsoft.IoTOperations/instances/brokers/authentications", + "apiVersion": "2026-09-01-preview", + "name": "[format('{0}/{1}/{2}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default', 'default')]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": { + "authenticationMethods": [ + { + "method": "ServiceAccountToken", + "serviceAccountTokenSettings": { + "audiences": ["[variables('MQTT_SETTINGS').serviceAccountAudience]"] + }, + } + ] + }, + "dependsOn": ["broker", "customLocation"], + }, + "brokerListener": { + "type": "Microsoft.IoTOperations/instances/brokers/listeners", + "apiVersion": "2026-09-01-preview", + "name": "[format('{0}/{1}/{2}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default', 'default')]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": { + "serviceType": "[variables('BROKER_CONFIG').serviceType]", + "serviceName": "[variables('MQTT_SETTINGS').brokerListenerServiceName]", + "ports": [ + { + "authenticationRef": "default", + "port": "[variables('MQTT_SETTINGS').brokerListenerPort]", + "tls": { + "mode": "Automatic", + "certManagerCertificateSpec": { + "issuerRef": { + "name": "[variables('ISSUER_NAME')]", + "kind": "[if(variables('customerManagedTrust'), parameters('trustConfig').settings.issuerKind, 'ClusterIssuer')]", + "group": "cert-manager.io", + } + }, + }, + } + ], + }, + "dependsOn": ["broker", "brokerAuthn", "customLocation"], + }, + "dataflowProfile": { + "type": "Microsoft.IoTOperations/instances/dataflowProfiles", + "apiVersion": "2026-09-01-preview", + "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default')]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": {"instanceCount": "[parameters('defaultDataflowInstanceCount')]"}, + "dependsOn": ["aioInstance", "broker", "customLocation"], + }, + "dataflowEndpoint": { + "type": "Microsoft.IoTOperations/instances/dataflowEndpoints", + "apiVersion": "2026-09-01-preview", + "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default')]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": { + "endpointType": "Mqtt", + "mqttSettings": { + "host": "[format('{0}:{1}', variables('MQTT_SETTINGS').brokerListenerHost, variables('MQTT_SETTINGS').brokerListenerPort)]", + "authentication": { + "method": "ServiceAccountToken", + "serviceAccountTokenSettings": { + "audience": "[variables('MQTT_SETTINGS').serviceAccountAudience]" + }, + }, + "tls": { + "mode": "Enabled", + "trustedCaCertificateConfigMapRef": "[variables('TRUST_CONFIG_MAP')]", + }, + }, + }, + "dependsOn": ["aioInstance", "customLocation"], + }, + "artifactRegistryEndpoint": { + "type": "Microsoft.IoTOperations/instances/registryEndpoints", + "apiVersion": "2026-09-01-preview", + "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'default')]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": { + "host": "mcr.microsoft.com", + "authentication": {"method": "Anonymous", "anonymousSettings": {}}, + }, + "dependsOn": ["aioInstance", "customLocation"], + }, + "opcUaConnectorTemplate": { + "type": "Microsoft.IoTOperations/instances/akriConnectorTemplates", + "apiVersion": "2026-09-01-preview", + "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), variables('opcUaConnectorTemplateName'))]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": { + "connectorMetadataRef": "[format('mcr.microsoft.com/azureiotoperations/aio-connectors/opcua-metadata:{0}', variables('OPCUA_CONNECTOR_VERSION'))]", + "aioMetadata": {"aioMinVersion": "1.2.100"}, + "runtimeConfiguration": { + "runtimeConfigurationType": "ManagedConfiguration", + "managedConfigurationSettings": { + "managedConfigurationType": "ImageConfiguration", + "imageConfigurationSettings": { + "registrySettings": { + "registrySettingsType": "ContainerRegistry", + "containerRegistrySettings": {"registry": "mcr.microsoft.com"}, + }, + "imageName": "azureiotoperations/aio-connectors/supervisor", + "tagDigestSettings": { + "tagDigestType": "Tag", + "tag": "[variables('OPCUA_CONNECTOR_VERSION')]", + }, + }, + }, + }, + "deviceInboundEndpointTypes": [ + {"endpointType": "Microsoft.OpcUa"}, + {"endpointType": "Microsoft.OpcUa.WoT"}, + ], + }, + "dependsOn": ["aioInstance", "customLocation"], + }, + "mcpDefaultPolicy": { + "type": "Microsoft.IoTOperations/instances/mcpAuthorizationPolicies", + "apiVersion": "2026-09-02-preview", + "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'aio-mcp-policy-v1')]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": {"policies": "[_2.mcpDefaultPolicies()]"}, + "dependsOn": ["aioInstance", "customLocation"], + }, + "mcpAioConnection": { + "type": "Microsoft.IoTOperations/instances/mcpServerConnections", + "apiVersion": "2026-09-02-preview", + "name": "[format('{0}/{1}', coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH'))), 'aio')]", + "extendedLocation": "[variables('extendedLocation')]", + "properties": { + "service": {"name": "aio-mcp", "path": "/mcp"}, + "authorization": {"mode": "PolicyBased", "policyRef": {"name": "aio-mcp-policy-v1"}}, + }, + "dependsOn": ["aioInstance", "customLocation", "mcpDefaultPolicy"], + }, + }, + "outputs": { + "aioExtension": { + "type": "object", + "value": { + "name": "[format('azure-iot-operations-{0}', variables('AIO_EXTENSION_SUFFIX'))]", + "id": "[extensionResourceId(resourceId('Microsoft.Kubernetes/connectedClusters', parameters('clusterName')), 'Microsoft.KubernetesConfiguration/extensions', format('azure-iot-operations-{0}', variables('AIO_EXTENSION_SUFFIX')))]", + "version": "[reference('aioExtension').version]", + "releaseTrain": "[reference('aioExtension').releaseTrain]", + "config": {"trustConfig": "[parameters('trustConfig')]"}, + "identityPrincipalId": "[reference('aioExtension', '2023-05-01', 'full').identity.principalId]", + }, + }, + "aio": { + "type": "object", + "value": { + "name": "[coalesce(parameters('aioInstanceName'), format('aio-{0}', variables('HASH')))]", + "broker": { + "name": "default", + "listener": "default", + "authn": "default", + "settings": "[shallowMerge(createArray(variables('BROKER_CONFIG'), variables('MQTT_SETTINGS')))]", + }, + }, + }, + "customLocation": { + "type": "object", + "value": { + "id": "[resourceId('Microsoft.ExtendedLocation/customLocations', coalesce(parameters('customLocationName'), format('location-{0}', variables('HASH'))))]", + "name": "[coalesce(parameters('customLocationName'), format('location-{0}', variables('HASH')))]", + }, + }, + "location": {"type": "string", "value": "[parameters('clusterLocation')]"}, + }, + }, +) diff --git a/azext_edge/edge/providers/orchestration/upgrade2.py b/azext_edge/edge/providers/orchestration/upgrade2.py index 24a7cbebf..ad50eb030 100644 --- a/azext_edge/edge/providers/orchestration/upgrade2.py +++ b/azext_edge/edge/providers/orchestration/upgrade2.py @@ -37,8 +37,6 @@ MIN_INSTANCE_VERSION_FOR_OPCUA_CONNECTOR_TEMPLATE, MIN_INSTANCE_VERSION_V1_FOR_V2_UPGRADE, MIN_INSTANCE_VERSION_V2, - OPCUA_CONNECTOR_ENDPOINT_TYPE, - OPCUA_CONNECTOR_VERSION, PROVISIONING_STATE_SUCCESS, ConfigSyncModeType, ) @@ -47,6 +45,15 @@ from .resources.connector_templates import ConnectorTemplates from .resources.instances import SECRET_SYNC_RESOURCE_TYPE, SPC_RESOURCE_TYPE, Instances from .targets import InitTargets +from .runtime_catalog import get_runtime_catalog +from .runtime_profiles import ( + RuntimeChannel, + RuntimeIdentity, + parse_runtime_version, + resolve_runtime_identity, + validate_upgrade_boundary, +) +from .runtime_requirements import validate_runtime_requirements logger = get_logger(__name__) @@ -127,18 +134,27 @@ def __init__( self.force = force self.no_cm_install = no_cm_install self.instances = Instances(self.cmd) - self.registry_endpoints = RegistryEndpoints(self.cmd) - self.connector_templates = ConnectorTemplates(self.cmd) # Name of an existing failed OPC UA template to repair in place (set during the check). self._opcua_template_name_to_repair = None self.instance_record = self.instances.show( name=self.instance_name, resource_group_name=self.resource_group_name ) + self.runtime_catalog = get_runtime_catalog() + self.runtime_context = self.instances.get_runtime_context( + self.instance_record, self.runtime_catalog.qualification_identities, + allow_unbundled_integration=True, + ) + self.runtime_context.require_upgradeable() + self.runtime_profile = self.runtime_catalog.for_upgrade(self.runtime_context.identity) + self.instance_record = self.instances.use_runtime_profile(self.runtime_profile, self.instance_record) + self.registry_endpoints = RegistryEndpoints(self.cmd, instances=self.instances) + self.connector_templates = ConnectorTemplates(self.cmd, instances=self.instances) self.resource_map = self.instances.get_resource_map(self.instance_record) self.targets = InitTargets( cluster_name=self.resource_map.connected_cluster.cluster_name, resource_group_name=resource_group_name, adr_namespace_resource_id=adr_namespace_resource_id, + runtime_profile=self.runtime_profile, ) self.secretsync_migration = SecretSyncMigrationManager( cmd=self.cmd, @@ -162,6 +178,7 @@ def get_desired_config(self) -> Dict[str, str]: # } def analyze_cluster(self, **override_kwargs: dict) -> "ClusterUpgradeState": + validate_runtime_requirements("iot ops upgrade", self.runtime_context.identity, override_kwargs, cmd=self.cmd) with Progress( SpinnerColumn("star"), *Progress.get_default_columns(), @@ -174,10 +191,8 @@ def analyze_cluster(self, **override_kwargs: dict) -> "ClusterUpgradeState": if not self.resource_map.connected_cluster.connected: raise ValidationError(f"Cluster {self.resource_map.connected_cluster.cluster_name} is not connected.") - return ClusterUpgradeState( - extensions_map=self.resource_map.connected_cluster.get_extensions_by_type( - *list(EXTENSION_TYPE_TO_MONIKER_MAP.keys()) - ), + state = ClusterUpgradeState( + extensions_map=self._get_extensions_for_plan(), init_version_map={ **self.targets.get_extension_versions(), **self.targets.get_extension_versions(False), @@ -191,7 +206,66 @@ def analyze_cluster(self, **override_kwargs: dict) -> "ClusterUpgradeState": secretsync_migration=self.secretsync_migration, force=self.force, no_cm_install=self.no_cm_install, + qualification_identities=self.runtime_catalog.qualification_identities, ) + self._validate_foundation_plan(state) + if state.connector_template_needed: + state.connector_template_version = self.runtime_profile.require_opcua_connector_version() + state.connector_template_endpoint_types = ConnectorTemplates.default_opcua_endpoint_types( + self.runtime_profile + ) + return state + + def _validate_foundation_plan(self, state: "ClusterUpgradeState") -> None: + by_type = {extension.extension_type: extension for extension in state.extension_upgrades} + for requirement in self.runtime_catalog.dependency_requirements: + extension = by_type.get(requirement.extension_type) + if extension is None and requirement.extension_type == EXTENSION_TYPE_CM: + # Preserve the supported customer-managed trust topology. + continue + if extension is None or extension.operation_type == ExtensionOperation.DELETE: + raise ValidationError( + f"Required foundation extension {requirement.extension_type} is missing from plan." + ) + if extension.operation_type == ExtensionOperation.CREATE: + properties = self._build_creation_payload(extension)["properties"] + version, train = properties.get("version"), properties.get("releaseTrain") + configuration = properties.get("configurationSettings") or {} + else: + properties = extension.extension.get("properties") or {} + patch = extension.get_patch().get("properties", {}) + if not patch: + requirement.validate_installed(extension.extension) + continue + requirement.validate_observed(extension.extension, allow_repair=bool(patch.get("version"))) + version = patch.get("version", properties.get("currentVersion")) + train = patch.get("releaseTrain", properties.get("releaseTrain")) + configuration = {**(properties.get("configurationSettings") or {})} + configuration.update(patch.get("configurationSettings") or {}) + requirement.validate_identity(version, train, configuration) + + def _get_extensions_for_plan(self) -> Dict[str, dict]: + extensions = {} + for extension in self.resource_map.connected_cluster.extensions: + ext_type = (extension.get("properties", {}).get("extensionType") or "").lower() + if ext_type not in EXTENSION_TYPE_TO_MONIKER_MAP: + continue + if ext_type in extensions: + raise ValidationError(f"Multiple extensions of type {ext_type} detected; upgrade cannot continue.") + extensions[ext_type] = extension + ops = extensions.get(EXTENSION_TYPE_OPS, {}) + properties = ops.get("properties", {}) + observed = self.runtime_context + if ( + (ops.get("id") or "").lower() != observed.extension_id.lower() + or properties.get("currentVersion") != observed.identity.version + or (properties.get("releaseTrain") or "").lower() != observed.identity.train + or properties.get("version") != observed.requested_version + or properties.get("provisioningState") != observed.provisioning_state + or properties.get("autoUpgradeMinorVersion") is not observed.auto_upgrade_minor_version + ): + raise ValidationError("AIO runtime changed during discovery. Retry after the active operation completes.") + return extensions def _check_default_registry_needed(self) -> bool: try: @@ -204,8 +278,7 @@ def _check_default_registry_needed(self) -> bool: return False return True except HttpResponseError as e: - logger.debug(f"Error checking registry endpoints: {e}") - return False + raise ValidationError("Unable to validate the default registry endpoint before upgrade.") from e def _check_opcua_connector_template_needed(self) -> Tuple[bool, Optional[str]]: """Return (needed, repair_name) for the default OPC UA connector template. @@ -225,10 +298,7 @@ def _check_opcua_connector_template_needed(self) -> Tuple[bool, Optional[str]]: instance_name=self.instance_name, resource_group_name=self.resource_group_name ) except HttpResponseError as e: - # Upgrade re-evaluates every run and has not mutated the instance, so a transient list - # failure is non-fatal here; treat it as nothing to do. - logger.debug(f"Error checking OPC UA connector template: {e}") - return False, None + raise ValidationError("Unable to validate the OPC UA connector template before upgrade.") from e self._opcua_template_name_to_repair = repair_name return needed, repair_name @@ -237,15 +307,20 @@ def _create_default_opcua_connector_template(self, headers: dict) -> dict: resource_group_name=self.resource_group_name, instance_name=self.instance_name, template_name=self._opcua_template_name_to_repair, - connector_version=OPCUA_CONNECTOR_VERSION, + connector_version=self.runtime_profile.require_opcua_connector_version(), headers=headers, no_status=True, + runtime_profile=self.runtime_profile, ) def apply_upgrades( self, upgrade_state: "ClusterUpgradeState", ) -> List[dict]: + # Validate every patch before the first DELETE/CREATE/UPDATE, not midway + # through a dependency migration. Validation errors never become partial writes. + upgrade_state.validate_plan() + self._validate_foundation_plan(upgrade_state) with Progress( SpinnerColumn("star"), TextColumn("[progress.description]{task.description}"), @@ -351,7 +426,7 @@ def _apply_instance_update(self, needs_adr_update: bool, needs_spc_update: bool, if needs_spc_update and self.secretsync_migration and self.secretsync_migration.spc_default: spc_resource_id = self.secretsync_migration.spc_default.get("id") - return self.instances.update( + return self.instances._update( name=self.instance_name, resource_group_name=self.resource_group_name, instance=self.instance_record, @@ -381,10 +456,11 @@ def _group_by_operation(self, extensions: List["ExtensionUpgradeState"]) -> Dict def _apply_single_operation(self, ext: "ExtensionUpgradeState", op_type: ExtensionOperation, headers: dict) -> dict: cluster_name = self.resource_map.connected_cluster.cluster_name + cluster_resource_group = self.resource_map.connected_cluster.resource_group_name if op_type == ExtensionOperation.DELETE: self.resource_map.connected_cluster.clusters.extensions.delete_cluster_extension( - resource_group_name=self.resource_group_name, + resource_group_name=cluster_resource_group, cluster_name=cluster_name, extension_name=ext.extension["name"], headers=headers, @@ -396,7 +472,7 @@ def _apply_single_operation(self, ext: "ExtensionUpgradeState", op_type: Extensi } elif op_type == ExtensionOperation.CREATE: return self.resource_map.connected_cluster.clusters.extensions.create_cluster_extension( - resource_group_name=self.resource_group_name, + resource_group_name=cluster_resource_group, cluster_name=cluster_name, extension_name="cert-manager", create_payload=self._build_creation_payload(ext), @@ -404,7 +480,7 @@ def _apply_single_operation(self, ext: "ExtensionUpgradeState", op_type: Extensi ) else: # UPDATE result = self.resource_map.connected_cluster.clusters.extensions.update_cluster_extension( - resource_group_name=self.resource_group_name, + resource_group_name=cluster_resource_group, cluster_name=cluster_name, extension_name=ext.extension["name"], update_payload=ext.get_patch(), @@ -675,6 +751,9 @@ def render_upgrade_table(upgrade_state: "ClusterUpgradeState"): # noqa: C901 if upgrade_state.connector_template_needed: try: repair_name = getattr(upgrade_state, "connector_template_repair_name", None) + endpoint_types = ", ".join( + endpoint["endpointType"] for endpoint in upgrade_state.connector_template_endpoint_types + ) if repair_name: table.add_row( "opc-ua connector template", @@ -682,8 +761,8 @@ def render_upgrade_table(upgrade_state: "ClusterUpgradeState"): # noqa: C901 "[yellow]Replaced[/yellow]", f"[yellow]•[/yellow] Replace failed template [bold]{repair_name}[/bold]\n" f"[yellow]•[/yellow] Existing template settings are reset to defaults\n" - f"[green]•[/green] Endpoint: [bold]{OPCUA_CONNECTOR_ENDPOINT_TYPE}[/bold]\n" - f"[green]•[/green] Version: [bold]{OPCUA_CONNECTOR_VERSION}[/bold]", + f"[green]•[/green] Endpoints: [bold]{endpoint_types}[/bold]\n" + f"[green]•[/green] Version: [bold]{upgrade_state.connector_template_version}[/bold]", ) else: table.add_row( @@ -691,8 +770,8 @@ def render_upgrade_table(upgrade_state: "ClusterUpgradeState"): # noqa: C901 "[dim]Not configured[/dim]", "[green]Created[/green]", f"[green]•[/green] Create default OPC UA connector template\n" - f"[green]•[/green] Endpoint: [bold]{OPCUA_CONNECTOR_ENDPOINT_TYPE}[/bold]\n" - f"[green]•[/green] Version: [bold]{OPCUA_CONNECTOR_VERSION}[/bold]", + f"[green]•[/green] Endpoints: [bold]{endpoint_types}[/bold]\n" + f"[green]•[/green] Version: [bold]{upgrade_state.connector_template_version}[/bold]", ) table.add_section() except Exception as e: @@ -765,6 +844,7 @@ def __init__( secretsync_migration: Optional["SecretSyncMigrationManager"] = None, force: Optional[bool] = None, no_cm_install: Optional[bool] = None, + qualification_identities: Tuple[RuntimeIdentity, ...] = (), ): self.extensions_map = extensions_map self.init_version_map = init_version_map @@ -777,8 +857,11 @@ def __init__( self.secretsync_migration = secretsync_migration self.force = force self.no_cm_install = no_cm_install + self.qualification_identities = qualification_identities self.semver = scoped_semver_import() self.connector_template_repair_name = None + self.connector_template_version = None + self.connector_template_endpoint_types = [] self.extension_upgrades = self._refresh_upgrade_state() self.instance_upgrade = self._check_instance_upgrade() self.registry_endpoint_needed = self._check_registry_endpoint_needed() @@ -794,6 +877,12 @@ def has_upgrades(self) -> bool: or bool(self.secretsync_migration_needed) ) + def validate_plan(self) -> None: + for extension in self.extension_upgrades: + extension.validate_upgrade() + if extension.operation_type == ExtensionOperation.UPDATE: + extension.get_patch() + def get_stale_cli_extensions(self) -> List[str]: return [ext_state.moniker for ext_state in self.extension_upgrades if ext_state.is_cli_behind_cluster()] @@ -933,6 +1022,7 @@ def _refresh_upgrade_state(self) -> List["ExtensionUpgradeState"]: desired_config=self.desired_config_map.get(ops_moniker), override=self.override_map.get(ops_moniker), force=self.force, + qualification_identities=self.qualification_identities, ) ops_upgrade_state.validate_upgrade() @@ -998,6 +1088,10 @@ def _refresh_upgrade_state(self) -> List["ExtensionUpgradeState"]: ) ) + for state in ext_queue: + state.validate_upgrade() + if state.operation_type == ExtensionOperation.UPDATE: + state.get_patch() return ext_queue def _should_delete_platform(self) -> bool: @@ -1038,12 +1132,13 @@ def _get_target_ops_version(self) -> Optional[str]: ops_override = self.override_map.get(EXTENSION_MONIKER_OPS) or ConfigOverride() - # Priority: override > init_version_map > current version - return ( - ops_override.version - or self.init_version_map.get(EXTENSION_MONIKER_OPS, {}).get("version") - or ops_extension.get("properties", {}).get("version") - ) + if ops_override.version: + return ops_override.version + current = ops_extension.get("properties", {}).get("currentVersion") + target = self.init_version_map.get(EXTENSION_MONIKER_OPS, {}).get("version") + if current and target: + return max((current, target), key=parse_runtime_version) + return current or target def _is_target_version_at_least(self, min_version: str) -> bool: target_version = self._get_target_ops_version() @@ -1065,6 +1160,7 @@ def __init__( force: Optional[bool] = None, operation_type: Optional[ExtensionOperation] = None, extension_type: Optional[str] = None, + qualification_identities: Tuple[RuntimeIdentity, ...] = (), ): self.extension = extension self.extension_type = extension_type or ( @@ -1078,6 +1174,7 @@ def __init__( self.operation_type = operation_type or ExtensionOperation.UPDATE self._mqtt_migration_config = None self.semver = scoped_semver_import() + self.qualification_identities = qualification_identities @property def moniker(self) -> str: @@ -1090,13 +1187,17 @@ def current_version(self) -> Tuple[Optional[str], Optional[str]]: if not self.extension: return (None, None) props = self.extension.get("properties", {}) - return (props.get("version"), props.get("releaseTrain")) + version_key = "currentVersion" if self.moniker == EXTENSION_MONIKER_OPS else "version" + return (props.get(version_key), props.get("releaseTrain")) @property def desired_version(self) -> Tuple[Optional[str], Optional[str]]: + train = self.override.train or self.desired_version_map.get("train") + if self.moniker == EXTENSION_MONIKER_OPS and self.override.version and not self.override.train: + train = self.current_version[1] or train return ( self.override.version or self.desired_version_map.get("version"), - self.override.train or self.desired_version_map.get("train"), + train, ) @property @@ -1144,6 +1245,8 @@ def get_patch(self) -> dict: if self.operation_type != ExtensionOperation.UPDATE: return {} + if self.moniker == EXTENSION_MONIKER_OPS: + self._validate_ops_boundary() if not self.can_upgrade(): return {} @@ -1195,8 +1298,11 @@ def validate_upgrade(self) -> None: self._validate_and_resolve_version() def _validate_and_resolve_version(self) -> Optional[str]: + if self.moniker == EXTENSION_MONIKER_OPS: + self._validate_ops_boundary() if self._has_delta_in_version(): self._validate_version_upgrade() + self._require_manual_upgrade() return self.desired_version[0] if self._has_non_success_state(): @@ -1211,10 +1317,68 @@ def _validate_and_resolve_version(self) -> Optional[str]: # reapplies the current version and sends no train. if self._has_delta_in_train(): self._validate_version_upgrade(target_version=reconcile_version) + self._require_manual_upgrade() return reconcile_version return None + def _require_manual_upgrade(self) -> None: + if self.moniker != EXTENSION_MONIKER_OPS: + return + if self.extension["properties"].get("autoUpgradeMinorVersion") is not False: + raise ValidationError( + "Cannot pin an AIO extension version unless autoUpgradeMinorVersion is explicitly false. " + "Arc-managed upgrades must be disabled through an explicit ownership decision before CLI upgrade " + "or repair. To explicitly transfer ownership, run 'az k8s-extension update " + "--subscription --resource-group " + "--cluster-name --cluster-type connectedClusters " + "--name --auto-upgrade false', then retry." + ) + + def _resolve_ops_identity(self, version: str, train: str, *, installed: bool = False) -> RuntimeIdentity: + return resolve_runtime_identity( + version, train, self.qualification_identities, + allow_unbundled_integration=installed or bool(self.force and self.override.version), + ) + + def _resolve_ops_target_identity(self, installed: RuntimeIdentity, version: str, train: str) -> RuntimeIdentity: + if not self.override.version and (version, train.lower()) == (installed.version, installed.train): + return installed + return self._resolve_ops_identity(version, train) + + def _validate_ops_boundary(self) -> None: + current_version, current_train = self.current_version + if not current_version: + raise ValidationError( + "Unable to determine installed version for iotOperations extension (currentVersion missing). " + "Cannot validate upgrade path." + ) + if not current_train: + raise ValidationError( + "Unable to determine release train for installed iotOperations extension. Cannot validate upgrade path." + ) + installed = self._resolve_ops_identity(current_version, current_train, installed=True) + desired_version = self.desired_version[0] + # A stale CLI leaves the installed version alone unless explicitly overridden. + target_version = self.override.version or current_version + if not self.override.version and desired_version: + target_version = max((current_version, desired_version), key=parse_runtime_version) + target_train = current_train + if self._has_delta_in_train(): + target_train = self.desired_version[1] + target = None + if ( + self.force and installed.channel == RuntimeChannel.STABLE + and target_train.lower() in (RuntimeChannel.STABLE.value, "integration") + ): + target = self._resolve_ops_target_identity(installed, target_version, target_train) + if target.channel == RuntimeChannel.STABLE: + return + if target_train.lower() != current_train.lower(): + raise ValidationError("Cross-train upgrades are not supported. GA stays GA; preview stays preview.") + target = target or self._resolve_ops_target_identity(installed, target_version, target_train) + validate_upgrade_boundary(installed, target) + def _get_reconcile_version(self) -> Optional[str]: if self.current_version[0]: return self.current_version[0] @@ -1365,7 +1529,7 @@ def _validate_version_upgrade(self, target_version: Optional[str] = None): if self.operation_type in [ExtensionOperation.CREATE, ExtensionOperation.DELETE]: return - if self.force: + if self.force and self.moniker != EXTENSION_MONIKER_OPS: return target_version = target_version or self.desired_version[0] @@ -1390,12 +1554,15 @@ def _validate_version_upgrade(self, target_version: Optional[str] = None): "Cannot validate upgrade path." ) + if self.moniker == EXTENSION_MONIKER_OPS: + installed = self._resolve_ops_identity(self.current_version[0], self.current_version[1], installed=True) + target = self._resolve_ops_target_identity(installed, target_version, self.desired_version[1]) + if self.force and installed.channel == target.channel == RuntimeChannel.STABLE: + return + parsed_current = self.semver.parse(self.current_version[0]) parsed_desired = self.semver.parse(target_version) - current_is_preview = self.current_version[1].lower() != "stable" - desired_is_preview = self.desired_version[1].lower() != "stable" - # Check for downgrade if parsed_desired < parsed_current: raise ValidationError( @@ -1406,6 +1573,17 @@ def _validate_version_upgrade(self, target_version: Optional[str] = None): if self.moniker != EXTENSION_MONIKER_OPS: return + if installed.channel == RuntimeChannel.PREVIEW: + if parsed_desired.major != parsed_current.major: + raise ValidationError( + "Preview upgrades across major versions are not supported." + ) + if parsed_desired.minor - parsed_current.minor > 1: + raise ValidationError( + "Preview upgrades more than one minor version ahead are not supported." + ) + return + # Check version compatibility (within 2 minor versions) if parsed_desired.major != parsed_current.major: raise ValidationError( @@ -1431,14 +1609,6 @@ def _validate_version_upgrade(self, target_version: Optional[str] = None): "See https://aka.ms/aio-versions for version details." ) - if current_is_preview or desired_is_preview: - if parsed_current != parsed_desired or self.current_version[1].lower() != self.desired_version[1].lower(): - raise ValidationError( - f"Installed {self.moniker} extension is on train {self.current_version[1]}.\n" - f"Desired version would be on train {self.desired_version[1]}.\n" - f"Upgrades to or from non-stable release trains are not supported." - ) - def calculate_config_delta(current: Dict[str, str], target: Dict[str, str], sync_mode: Optional[str] = None) -> dict: """Calculate configuration delta between current and target state. diff --git a/azext_edge/edge/providers/orchestration/work.py b/azext_edge/edge/providers/orchestration/work.py index fbb03ed42..e09ad2599 100644 --- a/azext_edge/edge/providers/orchestration/work.py +++ b/azext_edge/edge/providers/orchestration/work.py @@ -49,6 +49,8 @@ from .permissions import ROLE_DEF_FORMAT_STR, PermissionManager, PrincipalType, get_ra_user_error_msg from .resource_map import IoTOperationsResourceMap from .resources.custom_locations import CustomLocations +from .runtime_catalog import get_runtime_catalog +from .runtime_requirements import validate_runtime_requirements from .rp_namespace import HEALTH_PROVIDER, register_providers from .targets import InitTargets, InstancePhase @@ -247,6 +249,13 @@ def _process_extension_dependencies(self): "Cluster was enabled with system cert-manager, " "trust settings (--trust-settings) are not applicable to this cluster." ) + for requirement in self._dependency_requirements: + extension = dependencies.get(requirement.extension_type) + if extension is None and requirement.extension_type == EXTENSION_TYPE_CM and is_user_trust: + continue + if extension is None: + raise ValidationError(f"Required foundation extension {requirement.extension_type} is not installed.") + requirement.validate_installed(extension) def _apply_sr_role_assignment(self) -> Optional[str]: ops_ext = self.ops_extension @@ -323,8 +332,17 @@ def execute_ops_init( context_name: Optional[str] = None, show_progress: bool = True, pre_flight: bool = True, + use_preview: bool = False, **kwargs, ): + self._dependency_requirements = () + if not apply_foundation: + catalog = get_runtime_catalog() + profile = catalog.for_create(use_preview=use_preview) + self._dependency_requirements = catalog.dependency_requirements + identity = profile.validate_overrides(version=kwargs.get("ops_version"), train=kwargs.get("ops_train")) + validate_runtime_requirements("iot ops create", identity, kwargs, cmd=self.cmd) + kwargs["runtime_profile"] = profile self._bootstrap_ux(show_progress=show_progress) self._work_id = str(uuid4()) self._work_format_str = f"aziotops.{{op}}.{self._work_id}" @@ -347,6 +365,7 @@ def execute_ops_init( self._warnings: List[str] = [] self._ops_ext_dependencies = None self._ops_ext = None + self._existing_ops_extensions = [] self._skip_sr_ra = kwargs.pop("skip_sr_ra", False) self._custom_sr_role_id = kwargs.pop("custom_sr_role_id", None) self._health_checks_max = kwargs.pop("health_checks_max", DEFAULT_HEALTH_CHECKS_MAX) @@ -371,6 +390,10 @@ def _do_work(self): elif self._apply_foundation: self._headers["CommandName"] = "iot ops init" + if self._targets.instance_name and not self._apply_foundation: + self._process_extension_dependencies() + self._raise_if_ops_deployed() + # Pre-Flight workflow if self._pre_flight: # WorkStepKey.REG_RP @@ -444,8 +467,9 @@ def _do_work(self): api_version=DEFAULT_DEVICEREGISTRY_MGMT_API_VERSION.value, ) - self._process_extension_dependencies() - self._raise_if_ops_deployed() + if self._apply_foundation: + self._process_extension_dependencies() + self._raise_if_ops_deployed() dependency_ext_ids = [self.ops_extension_dependencies[EXTENSION_TYPE_SSC]["id"]] self._render_display(category=WorkCategoryKey.DEPLOY_IOT_OPS, active_step=WorkStepKey.DEPLOY_INSTANCE) self._create_or_update_custom_location(extension_ids=dependency_ext_ids) @@ -614,9 +638,20 @@ def _stop_display(self): @property def ops_extension_dependencies(self) -> Dict[str, Optional[dict]]: if not self._ops_ext_dependencies: - self._ops_ext_dependencies = self._resource_map.connected_cluster.get_extensions_by_type( - *OPS_EXTENSION_DEPS - ) + extensions = list(self._resource_map.connected_cluster.extensions) + self._existing_ops_extensions = [ + ext for ext in extensions + if (ext.get("properties", {}).get("extensionType") or "").lower() == EXTENSION_TYPE_OPS + ] + self._ops_ext_dependencies = {} + for ext_type in OPS_EXTENSION_DEPS: + matches = [ + ext for ext in extensions + if (ext.get("properties", {}).get("extensionType") or "").lower() == ext_type + ] + if len(matches) > 1: + raise ValidationError(f"Multiple foundational extensions of type {ext_type} detected.") + self._ops_ext_dependencies[ext_type] = matches[0] if matches else None return self._ops_ext_dependencies @property @@ -688,7 +723,7 @@ def _build_cluster_check_kwargs(self) -> Dict[str, dict]: return cluster_check_kwargs def _raise_if_ops_deployed(self): - if self._resource_map.connected_cluster.get_aio_custom_locations(): + if self._existing_ops_extensions or self._resource_map.connected_cluster.get_aio_custom_locations(): raise ValidationError( "IoT Operations is detected on the cluster.\n" "Re-deployment or multiple instances are not supported at this time. Please run:\n\n" diff --git a/azext_edge/edge/providers/support/edgeregistry.py b/azext_edge/edge/providers/support/edgeregistry.py new file mode 100644 index 000000000..a30322bd8 --- /dev/null +++ b/azext_edge/edge/providers/support/edgeregistry.py @@ -0,0 +1,67 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +from functools import partial + +from .base import ( + DAY_IN_SECONDS, + process_config_maps, + process_persistent_volume_claims, + process_services, + process_statefulset, + process_v1_pods, +) +from .common import NAME_LABEL_FORMAT + + +EDGE_REGISTRY_NAME_LABEL = NAME_LABEL_FORMAT.format(label="microsoft-iotoperations-registry") +EDGE_REGISTRY_DIRECTORY_PATH = "edgeregistry" + + +def fetch_stateful_sets(): + return process_statefulset( + directory_path=EDGE_REGISTRY_DIRECTORY_PATH, + label_selector=EDGE_REGISTRY_NAME_LABEL, + ) + + +def fetch_pods(since_seconds: int = DAY_IN_SECONDS): + return process_v1_pods( + directory_path=EDGE_REGISTRY_DIRECTORY_PATH, + label_selector=EDGE_REGISTRY_NAME_LABEL, + since_seconds=since_seconds, + ) + + +def fetch_config_map(): + return process_config_maps( + directory_path=EDGE_REGISTRY_DIRECTORY_PATH, + label_selector=EDGE_REGISTRY_NAME_LABEL, + ) + + +def fetch_services(): + return process_services( + directory_path=EDGE_REGISTRY_DIRECTORY_PATH, + label_selector=EDGE_REGISTRY_NAME_LABEL, + ) + + +def fetch_persistent_volume_claims(): + return process_persistent_volume_claims( + directory_path=EDGE_REGISTRY_DIRECTORY_PATH, + label_selector=EDGE_REGISTRY_NAME_LABEL, + ) + + +def prepare_bundle(log_age_seconds: int = DAY_IN_SECONDS) -> dict: + return { + "pods": partial(fetch_pods, since_seconds=log_age_seconds), + "statefulsets": fetch_stateful_sets, + "configmaps": fetch_config_map, + "services": fetch_services, + "persistentvolumeclaims": fetch_persistent_volume_claims, + } diff --git a/azext_edge/edge/providers/support_bundle.py b/azext_edge/edge/providers/support_bundle.py index f735f6ee8..5ab7a9df8 100644 --- a/azext_edge/edge/providers/support_bundle.py +++ b/azext_edge/edge/providers/support_bundle.py @@ -71,6 +71,7 @@ def build_bundle( from .support.connectors import prepare_bundle as prepare_connector_bundle from .support.dataflow import prepare_bundle as prepare_dataflow_bundle from .support.deviceregistry import prepare_bundle as prepare_deviceregistry_bundle + from .support.edgeregistry import prepare_bundle as prepare_edge_registry_bundle from .support.meso import prepare_bundle as prepare_meso_bundle from .support.meta import prepare_bundle as prepare_meta_bundle from .support.mq import prepare_bundle as prepare_mq_bundle @@ -117,6 +118,10 @@ def collect_default_works( "apis": None, "prepare_bundle": prepare_schema_registry_bundle, }, + OpsServiceType.edgeregistry.value: { + "apis": None, + "prepare_bundle": prepare_edge_registry_bundle, + }, OpsServiceType.arccontainerstorage.value: { "apis": COMPAT_ARCCONTAINERSTORAGE_APIS, "prepare_bundle": prepare_arccontainerstorage_bundle, @@ -153,6 +158,7 @@ def collect_default_works( if not deployed_apis and service_moniker not in [ OpsServiceType.schemaregistry.value, + OpsServiceType.edgeregistry.value, OpsServiceType.connectors.value, OpsServiceType.meso.value, OpsServiceType.arccontainerstorage.value, # No longer required. @@ -174,6 +180,7 @@ def collect_default_works( bundle = bundle_method(log_age_seconds, deployed_apis, include_mq_traces) elif service_moniker in [ OpsServiceType.schemaregistry.value, + OpsServiceType.edgeregistry.value, OpsServiceType.connectors.value, OpsServiceType.meso.value, ]: diff --git a/azext_edge/edge/util/az_client.py b/azext_edge/edge/util/az_client.py index 5453ab23f..4b88888c0 100644 --- a/azext_edge/edge/util/az_client.py +++ b/azext_edge/edge/util/az_client.py @@ -207,6 +207,8 @@ def get_registry_mgmt_client( class IoTOpsMgmtApiVersion(Enum): + V20261001 = "2026-10-01" + V20260901_preview = "2026-09-01-preview" V20260701 = "2026-07-01" V20260301 = "2026-03-01" V20251001 = "2025-10-01" @@ -214,7 +216,7 @@ class IoTOpsMgmtApiVersion(Enum): V20241101 = "2024-11-01" -DEFAULT_IOTOPS_MGMT_API_VERSION = IoTOpsMgmtApiVersion.V20260701 +DEFAULT_IOTOPS_MGMT_API_VERSION = IoTOpsMgmtApiVersion.V20261001 def get_iotops_mgmt_client( diff --git a/azext_edge/tests/conftest.py b/azext_edge/tests/conftest.py index bc00a18a0..f61e4da8d 100644 --- a/azext_edge/tests/conftest.py +++ b/azext_edge/tests/conftest.py @@ -32,11 +32,15 @@ class MarkerDefinition(NamedTuple): MarkerDefinition("e2e", "mark end-to-end containerized tests (support bundles, checks)", False), MarkerDefinition("rpsaas", "mark tests that are cloud-side", True), MarkerDefinition("upgrade", "mark tests that will run az iot ops upgrade", True), + MarkerDefinition("runtime_channel", "isolated serial runtime-channel integration checks", True), MarkerDefinition("mgmtactions", "mark tests that exercise az iot ops mgmt-actions end to end", True), MarkerDefinition("init_scenario_test", "mark tests that will run az iot ops init", True), MarkerDefinition("require_wlif_setup", "mark tests that require workload identity trust setup", True), MarkerDefinition("long_running", "mark tests that take a long time to run", False), MarkerDefinition("serial", "mark tests that must run serially (not parallelized by xdist)", False), + MarkerDefinition("integration", "integration tests that may require network access", False), + MarkerDefinition("requires_network", "tests requiring network connectivity", False), + MarkerDefinition("acr", "tests requiring an Azure Container Registry", False), ] @@ -60,6 +64,22 @@ def pytest_collection_modifyitems(items): item.add_marker(pytest.mark.edge) +@pytest.fixture(scope="session", autouse=True) +def verify_integration_runtime(request): + """Qualification jobs must not silently run on the wrong channel or an unready runtime.""" + channel = os.environ.get("azext_edge_runtime_channel") + integration = [item for item in request.session.items if item.path.name.endswith("_int.py")] + if not channel or not integration: + return + # Init performs this assertion immediately after creating the instance, outside + # the legacy continue-on-error block. Other suites validate before their first test. + if all(item.get_closest_marker("init_scenario_test") for item in integration): + return + from .runtime_checks import assert_runtime, configured_baseline + + assert_runtime(os.environ["azext_edge_instance"], os.environ["azext_edge_rg"], channel, configured_baseline()) + + # Sets current working directory to the directory of the executing file @pytest.fixture def set_cwd(request): diff --git a/azext_edge/tests/edge/checks/base/test_access_denied_unit.py b/azext_edge/tests/edge/checks/base/test_access_denied_unit.py index 00b47e8e4..2da06a7ec 100644 --- a/azext_edge/tests/edge/checks/base/test_access_denied_unit.py +++ b/azext_edge/tests/edge/checks/base/test_access_denied_unit.py @@ -291,8 +291,6 @@ def test_evaluate_brokers_pod_read_denied_preserves_findings(mocker, status): "azext_edge.edge.providers.edge_api.base.EdgeResourceApi.get_resources", return_value={"items": [broker]}, ) - # diagnostics service read succeeds; pod reads are denied - mocker.patch("azext_edge.edge.providers.check.mq.get_namespaced_service", return_value={"spec": {}}) mocker.patch( "azext_edge.edge.providers.check.mq.get_namespaced_pods_by_prefix", side_effect=ClusterAccessDeniedError(status=status, resource="pods"), @@ -309,25 +307,6 @@ def test_evaluate_brokers_pod_read_denied_preserves_findings(mocker, status): assert any("Access denied" not in str(e.get("value")) for e in evals) -@pytest.mark.parametrize("status", [401, 403]) -def test_broker_diagnostics_service_denied_reports_inline(mocker, status): - # A denied diagnostics service read renders an inline access-denied row without raising. - from azext_edge.edge.providers.check.mq import _evaluate_broker_diagnostics_service - from azext_edge.edge.providers.check.base import CheckManager - - mocker.patch( - "azext_edge.edge.providers.check.mq.get_namespaced_service", - side_effect=ClusterAccessDeniedError(status=status, resource="service/aio-broker-diagnostics-service"), - ) - check_manager = CheckManager(check_name="evalBrokers", check_desc="Evaluate MQTT Brokers") - check_manager.add_target(target_name="brokers", namespace="ns") - _evaluate_broker_diagnostics_service(check_manager=check_manager, target_brokers="brokers", namespace="ns") - - text = _target_display_text(check_manager.as_dict(as_list=True), "brokers") - assert "Access denied" in text - assert str(status) in text - - @pytest.mark.parametrize("status", [401, 403]) def test_listener_service_denied_reports_inline(mocker, status): # A denied listener service read renders an inline access-denied row without raising. diff --git a/azext_edge/tests/edge/checks/test_mq_checks_unit.py b/azext_edge/tests/edge/checks/test_mq_checks_unit.py index 7bc4b4b1a..1afd7cf67 100644 --- a/azext_edge/tests/edge/checks/test_mq_checks_unit.py +++ b/azext_edge/tests/edge/checks/test_mq_checks_unit.py @@ -287,11 +287,13 @@ def test_broker_checks( "azext_edge.edge.providers.edge_api.base.EdgeResourceApi.get_resources", return_value={"items": [broker]}, ) - mocker.patch( + service_reader = mocker.patch( "azext_edge.edge.providers.check.mq.get_namespaced_service", - return_value=service, + side_effect=AssertionError("Broker checks must not query the retired diagnostics Service"), ) result = evaluate_brokers(detail_level=detail_level, resource_name=resource_name) + service_reader.assert_not_called() + assert "aio-broker-diagnostics-service" not in str(result) # all evalBroker assertions assert result["name"] == "evalBrokers" diff --git a/azext_edge/tests/edge/init/int/test_init_int.py b/azext_edge/tests/edge/init/int/test_init_int.py index 95d96c7cf..c772c7345 100644 --- a/azext_edge/tests/edge/init/int/test_init_int.py +++ b/azext_edge/tests/edge/init/int/test_init_int.py @@ -5,6 +5,9 @@ # ---------------------------------------------------------------------------------------------- import json +import os +import shlex +import subprocess from os.path import isfile from typing import List, Optional @@ -24,6 +27,7 @@ from ....generators import generate_random_string from ....helpers import assert_role_assignment, process_additional_args, run, strip_quotes +from ....runtime_checks import assert_runtime, configured_baseline logger = get_logger(__name__) @@ -125,7 +129,30 @@ def test_init_scenario(init_test_setup: dict, tracked_files: list): f"--no-progress {additional_create_args} " ) # TODO: assert create when return be returning - run(create_command) + baseline = configured_baseline() + if baseline: + # Candidate init remains shared and unchanged. Only source-runtime provisioning + # uses an explicitly pinned older wheel; every assertion/upgrade uses the candidate. + baseline_root = os.environ["azext_edge_baseline_extension_dir"] + environment = dict(os.environ, AZURE_EXTENSION_DIR=baseline_root) + environment["PYTHONPATH"] = os.path.join(baseline_root, "azure-iot-ops") + baseline_command = [ + "az", "iot", "ops", "create", "-g", resource_group, "--cluster", cluster_name, "-n", instance_name, + "--sr-resource-id", registry_id, "--ns-resource-id", adr_namespace_id, "--no-progress", + *shlex.split(baseline["create_args"]), + ] + subprocess.run(baseline_command, env=environment, check=True) + else: + run(create_command) + + channel = os.environ.get("azext_edge_runtime_channel") + if channel: + # Hard gate: never downgrade identity/version/readiness failures to pytest.skip. + assert_runtime(instance_name, resource_group, channel, baseline) + if baseline: + # Target-template defaults do not describe an older source deployment. + # Its readiness/identity were checked above; upgrade tests check the target. + return if init_test_setup["redeployment"]: run(f"az iot ops delete --name {instance_name} -g {resource_group} -y --no-progress --force") @@ -137,6 +164,8 @@ def test_init_scenario(init_test_setup: dict, tracked_files: list): ) run(create_command) + if channel: + assert_runtime(instance_name, resource_group, channel) # Missing: # init @@ -166,7 +195,7 @@ def test_init_scenario(init_test_setup: dict, tracked_files: list): # KeyError: one of the expected keys in the result is not present # TypeError: one of the values changes expected types and cannot be evaluated correctly (ex: len(None)) # and more - if init_test_setup["continueOnError"]: + if init_test_setup["continueOnError"] and not channel: pytest.skip(f"Deployment succeeded but init assertions failed. \n{e}") raise e @@ -222,6 +251,8 @@ def assert_aio_instance( tags: Optional[str] = None, trust_settings: Optional[dict] = None, feature: Optional[str] = None, + sku: Optional[str] = None, + use_preview: bool = False, **_, ): # check extensions installed @@ -266,9 +297,15 @@ def assert_aio_instance( assert custom_location == expected_custom_location instance_props = instance_show["properties"] - assert instance_props.get("description") == description + assert instance_props.get("description") == description, "Unexpected instance description." assert instance_props["schemaRegistryRef"] == {"resourceId": schema_registry_id} assert instance_props["adrNamespaceRef"] == {"resourceId": adr_namespace_id} + if sku: + assert instance_show.get("sku", {}).get("name") == sku + instances = run(f"az iot ops list -g {resource_group}") + listed_instance = next((instance for instance in instances if instance["name"] == instance_name), None) + assert listed_instance, f"Instance '{instance_name}' was not returned by 'az iot ops list'." + assert listed_instance.get("sku", {}).get("name") == sku # Verify the schema registry role assignment includes Azure Device Registry Administrator assert iot_ops_ext_principal_id, "IoT Operations extension is missing 'identity.principalId'." @@ -285,7 +322,10 @@ def assert_aio_instance( assert "cert-manager" in tree # OPC UA disabled: the instance must reflect it and no default connector template should exist. - if feature and "opcua.mode=disabled" in str(feature).lower(): + opcua_disabled = (feature and "opcua.mode=disabled" in str(feature).lower()) or ( + sku and sku.lower() == "essentials" + ) + if opcua_disabled: assert instance_props.get("features", {}).get("opcua", {}).get("mode") == "Disabled" templates = run(f"az iot ops connector template list -g {resource_group} --instance {instance_name}") or [] default_templates = [ @@ -317,6 +357,7 @@ def assert_broker_args( fw: Optional[str] = None, lt: Optional[str] = None, mp: Optional[str] = None, + use_preview: bool = False, **_, ): if bp: @@ -340,9 +381,11 @@ def assert_broker_args( broker_name = broker["name"] assert broker_name == "default" + broker_config = {} if broker_config_file: with open(broker_config_file, "r", encoding="utf-8") as bcf: - broker_config = json.loads(bcf) + broker_config = json.load(bcf) + broker_config = broker_config.get("properties", broker_config) broker_mem_profile = broker_config.get("memoryProfile", "").lower() broker_backend = broker_config.get("cardinality", {}).get("backendChain", {}) @@ -365,19 +408,41 @@ def assert_broker_args( assert cardinality["frontend"]["workers"] == (broker_frontend_workers or 2) # there is diagnostics + generateResourceLimits but nothing from init yet - if persist_max_size: - persistence = broker_props["persistence"] - assert persistence["maxSize"] == persist_max_size - assert persistence["encryption"]["mode"] == "Enabled" - assert persistence["persistentVolumeClaimSpec"]["accessModes"] == ["ReadWriteOncePod"] - assert persistence["retain"] == {"mode": "Custom", "retainSettings": {"dynamic": {"mode": "Enabled"}}} - assert persistence["stateStore"] == {"mode": "Custom", "stateStoreSettings": {"dynamic": {"mode": "Enabled"}}} - assert persistence["subscriberQueue"] == { - "mode": "Custom", - "subscriberQueueSettings": {"dynamic": {"mode": "Enabled"}}, + if broker_config_file: + expected_persistence = broker_config.get("persistence") + elif persist_max_size or use_preview: + expected_persistence = { + "maxSize": persist_max_size or "3Gi", + "encryption": {"mode": "Enabled"}, + "persistentVolumeClaimSpec": {"accessModes": ["ReadWriteOncePod"]}, + "retain": ( + {"mode": "Custom", "retainSettings": {"dynamic": {"mode": "Enabled"}}} + if persist_max_size else {"mode": "None"} + ), + "stateStore": {"mode": "Custom", "stateStoreSettings": {"dynamic": {"mode": "Enabled"}}}, + "subscriberQueue": ( + {"mode": "Custom", "subscriberQueueSettings": {"dynamic": {"mode": "Enabled"}}} + if persist_max_size else {"mode": "None"} + ), } else: + expected_persistence = None + + if expected_persistence is None: assert "persistence" not in broker_props + else: + persistence = broker_props.get("persistence") + assert isinstance(persistence, dict), "Expected broker disk persistence." + for setting, expected in expected_persistence.items(): + actual = persistence.get(setting) + if isinstance(expected, dict): + assert isinstance(actual, dict), f"Missing broker persistence {setting}." + for property_name, property_value in expected.items(): + assert actual.get(property_name) == property_value, ( + f"Unexpected broker persistence {setting}.{property_name}." + ) + else: + assert actual == expected, f"Unexpected broker persistence {setting}." # nothing interesting in the authn authns = run(f"az iot ops broker authn list -g {resource_group} -i {instance_name} -b {broker_name}") diff --git a/azext_edge/tests/edge/orchestration/resources/connector/akri/test_connector_templates_unit.py b/azext_edge/tests/edge/orchestration/resources/connector/akri/test_connector_templates_unit.py index dfa3a4137..5e0415b73 100644 --- a/azext_edge/tests/edge/orchestration/resources/connector/akri/test_connector_templates_unit.py +++ b/azext_edge/tests/edge/orchestration/resources/connector/akri/test_connector_templates_unit.py @@ -27,7 +27,8 @@ def get_connector_template_endpoint( - instance_name: str, resource_group_name: Optional[str] = None, template_name: Optional[str] = None + instance_name: str, resource_group_name: Optional[str] = None, template_name: Optional[str] = None, + api_version: str = INSTANCES_API_VERSION, ) -> str: resource_path = f"/instances/{instance_name}/akriConnectorTemplates" if template_name: @@ -36,7 +37,7 @@ def get_connector_template_endpoint( resource_group_name=resource_group_name, resource_path=resource_path, resource_provider=RESOURCE_PROVIDER, - api_version=INSTANCES_API_VERSION, + api_version=api_version, ) diff --git a/azext_edge/tests/edge/orchestration/resources/registry_endpoint/test_registry_endpoints_unit.py b/azext_edge/tests/edge/orchestration/resources/registry_endpoint/test_registry_endpoints_unit.py index 08c74ff0e..807b8e027 100644 --- a/azext_edge/tests/edge/orchestration/resources/registry_endpoint/test_registry_endpoints_unit.py +++ b/azext_edge/tests/edge/orchestration/resources/registry_endpoint/test_registry_endpoints_unit.py @@ -22,7 +22,9 @@ RegistryEndpointAuthenticationType, ) from azext_edge.edge.providers.orchestration.resources import RegistryEndpoints -from azext_edge.tests.edge.orchestration.resources.conftest import get_base_endpoint, get_mock_resource +from azext_edge.tests.edge.orchestration.resources.conftest import ( + INSTANCES_API_VERSION, get_base_endpoint, get_mock_resource, +) from azext_edge.tests.edge.orchestration.resources.test_instances_unit import ( get_instance_endpoint, get_mock_instance_record, @@ -31,12 +33,15 @@ def get_registry_endpoint_endpoint( - instance_name: str, resource_group_name: str, registry_endpoint_name: Optional[str] = None + instance_name: str, resource_group_name: str, registry_endpoint_name: Optional[str] = None, + api_version: str = INSTANCES_API_VERSION, ) -> str: resource_path = f"/instances/{instance_name}/registryEndpoints" if registry_endpoint_name: resource_path += f"/{registry_endpoint_name}" - return get_base_endpoint(resource_group_name=resource_group_name, resource_path=resource_path) + return get_base_endpoint( + resource_group_name=resource_group_name, resource_path=resource_path, api_version=api_version, + ) def get_mock_registry_endpoint_record( diff --git a/azext_edge/tests/edge/orchestration/resources/test_instances_unit.py b/azext_edge/tests/edge/orchestration/resources/test_instances_unit.py index 0b6d300b5..6713ae174 100644 --- a/azext_edge/tests/edge/orchestration/resources/test_instances_unit.py +++ b/azext_edge/tests/edge/orchestration/resources/test_instances_unit.py @@ -7,6 +7,7 @@ import json import re +from copy import deepcopy from typing import Optional from unittest.mock import Mock @@ -29,6 +30,7 @@ ) from azext_edge.edge.providers.orchestration.resources import Instances from azext_edge.edge.providers.orchestration.resources.connector_templates import ConnectorTemplates +from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel from azext_edge.edge.providers.orchestration.resources.instances import ( KEYVAULT_ROLE_ID_READER, KEYVAULT_ROLE_ID_SECRETS_USER, @@ -90,6 +92,13 @@ def mocked_resolve_oidc_issuer(mocker): ) +@pytest.fixture +def mocked_ga_runtime_context(mocker): + yield mocker.patch.object( + Instances, "get_runtime_context", return_value=Mock(identity=Mock(channel=RuntimeChannel.STABLE)), + ) + + def get_instance_endpoint( resource_group_name: Optional[str] = None, instance_name: Optional[str] = None, **kwargs: dict ) -> str: @@ -229,6 +238,24 @@ def get_mock_cl_record(name: str, resource_group_name: str) -> dict: return resource +@pytest.mark.parametrize("version", ["1.5.30", "1.6.0-preview.40"]) +def test_instance_write_read_does_not_discover_runtime(mocker, mocked_cmd, mocked_responses, version): + name, resource_group = "instance", "rg" + record = get_mock_instance_record(name, resource_group, version=version) + record["properties"]["retainedConfiguration"] = {"values": ["custom"]} + record["identity"] = {"type": "None"} + endpoint = get_instance_endpoint(resource_group, name, api_version="2026-10-01") + mocked_responses.add(responses.GET, endpoint, json=record) + discovery = mocker.patch.object( + Instances, "get_runtime_context", side_effect=HttpResponseError("AuthorizationFailed: discovery forbidden"), + ) + + assert Instances(mocked_cmd)._get_instance_for_write(name, resource_group) == record + discovery.assert_not_called() + assert len(mocked_responses.calls) == 1 + assert mocked_responses.calls[0].request.url == endpoint + + def test_instance_show(mocked_cmd, mocked_responses: responses): instance_name = generate_random_string() resource_group_name = generate_random_string() @@ -248,6 +275,288 @@ def test_instance_show(mocked_cmd, mocked_responses: responses): assert len(mocked_responses.calls) == 1 +@pytest.mark.parametrize("operation", ["show", "update"]) +@pytest.mark.parametrize("discovery_error", [ + ValidationError("Expected exactly one AIO extension on the associated connected cluster."), + ValidationError("The custom location is not associated with the AIO extension."), + ValidationError("Unable to determine installed AIO version: extension currentVersion is missing."), + ValidationError("AIO runtime '1.6.0-preview.8' on train 'integration' has no supported runtime profile mapping."), + HttpResponseError("AuthorizationFailed: cluster read forbidden"), + HttpResponseError("ResourceNotFound: connected cluster not found"), +]) +def test_instance_discovery_failure_does_not_block_ordinary_operations( + mocker, mocked_cmd, mocked_responses, caplog, operation, discovery_error, +): + name, resource_group = "instance", "rg" + record = get_mock_instance_record(name, resource_group) + mocked_responses.add(responses.GET, get_instance_endpoint(resource_group, name), json=record) + discovery = mocker.patch.object(Instances, "get_runtime_context", side_effect=discovery_error) + + if operation == "show": + with caplog.at_level("DEBUG"): + assert show_instance(mocked_cmd, name, resource_group) == record + assert not [record for record in caplog.records if record.levelname in {"WARNING", "ERROR", "CRITICAL"}] + else: + expected = deepcopy(record) + expected["tags"] = {"updated": "yes"} + expected["properties"]["description"] = "Updated description" + mocked_responses.add_callback( + responses.PUT, get_instance_endpoint(resource_group, name), callback=echo_callback, + ) + assert update_instance( + mocked_cmd, name, resource_group, tags=expected["tags"], + instance_description="Updated description", wait_sec=0, + ) == expected + assert mocked_responses.calls[-1].request.method == "PUT" + assert json.loads(mocked_responses.calls[-1].request.body) == expected + discovery.assert_not_called() + assert len(mocked_responses.calls) == (1 if operation == "show" else 2) + assert mocked_responses.calls[0].request.method == "GET" + + +@pytest.mark.parametrize("status", [403, 404, 500]) +def test_instance_show_does_not_hide_instance_read_errors(mocked_cmd, mocked_responses, caplog, status): + name, resource_group = "instance", "rg" + endpoint = get_instance_endpoint(resource_group, name) + mocked_responses.add( + responses.GET, endpoint, status=status, json={"error": {"code": "ReadFailed", "message": "Read failed"}}, + ) + + with pytest.raises(HttpResponseError) as caught: + show_instance(mocked_cmd, name, resource_group) + assert caught.value.status_code == status + assert "Returning the instance" not in caplog.text + assert all(call.request.method == "GET" for call in mocked_responses.calls) + + +@pytest.mark.parametrize("operation", ["show", "update"]) +@pytest.mark.parametrize("version", ["1.5.30", "1.6.0-preview.40"]) +def test_ga_instance_api_preserves_response_fields(mocker, mocked_cmd, mocked_responses, operation, version): + name, resource_group = "preview-instance", "preview-rg" + record = get_mock_instance_record(name, resource_group, version=version) + record["properties"]["retainedConfiguration"] = {"retained": ["value"]} + record["properties"]["previewOnlyProperty"] = {"enabled": True} + discovery = mocker.patch.object( + Instances, "get_runtime_context", side_effect=HttpResponseError("AuthorizationFailed: discovery forbidden"), + ) + if operation == "show": + record["properties"]["provisioningState"] = "Failed" + endpoint = get_instance_endpoint(resource_group, name, api_version="2026-10-01") + mocked_responses.add(responses.GET, endpoint, json=record) + expected = deepcopy(record) + if operation == "update": + expected["tags"] = {"updated": "yes"} + expected["properties"]["description"] = "Updated description" + mocked_responses.add_callback(responses.PUT, endpoint, callback=echo_callback) + result = update_instance( + mocked_cmd, name, resource_group, tags=expected["tags"], + instance_description="Updated description", wait_sec=0, + ) + writes = [call.request for call in mocked_responses.calls if call.request.method != "GET"] + assert len(writes) == 1 + assert json.loads(writes[0].body) == expected + assert writes[0].url == endpoint + else: + result = show_instance(mocked_cmd, name, resource_group) + assert all(call.request.method == "GET" for call in mocked_responses.calls) + assert result == expected + discovery.assert_not_called() + assert len(mocked_responses.calls) == (1 if operation == "show" else 2) + + +@pytest.mark.parametrize("operation", [ + "add_mi_user_assigned", "remove_mi_user_assigned", "enable_secretsync", "disable_secretsync", +]) +@pytest.mark.parametrize("train,version,api_version", [ + ("stable", "1.5.30", "2026-10-01"), + ("integration", "1.6.0-preview.40", "2026-10-01"), +]) +def test_instance_workflow_preserves_runtime_fields( + mocker, mocked_cmd, mocked_responses, mocked_get_tenant_id, operation, train, version, api_version, +): + name, resource_group = "runtime-instance", "runtime-rg" + identity_id = generate_resource_id( + resource_group_name=resource_group, resource_provider=UAMI_RP, resource_path="/userAssignedIdentities/identity", + ) + existing_id = identity_id if operation == "remove_mi_user_assigned" else f"{identity_id}-existing" + spc = get_mock_spc_record(name="spc", resource_group_name=resource_group) + initial = get_mock_instance_record( + name, resource_group, identity_map={existing_id: {}}, default_spc_resource_id=f"{spc['id']}-old", + ) + initial["properties"]["version"] = version + initial["properties"]["retainedConfiguration"] = {"values": ["custom"]} + if train == "integration": + initial["properties"]["previewOnlyProperty"] = {"retained": ["value"]} + initial_endpoint = get_instance_endpoint(resource_group, name) + mocked_responses.add(responses.GET, initial_endpoint, json=initial) + selected = deepcopy(initial) + selected_endpoint = get_instance_endpoint(resource_group, name, api_version=api_version) + mocked_responses.add_callback(responses.PUT, selected_endpoint, callback=echo_callback) + + instances = Instances(mocked_cmd) + discovery = mocker.patch.object( + instances, "get_runtime_context", side_effect=HttpResponseError("AuthorizationFailed: discovery forbidden"), + ) + mocker.patch.object(instances, "get_associated_cl", return_value=get_mock_cl_record("location", resource_group)) + resource_map = mocker.patch.object(instances, "get_resource_map").return_value + resource_map.connected_cluster.resource = {"name": "cluster", "location": "westus2"} + resource_map.connected_cluster.get_cl_resources_by_type.return_value = {} + mocker.patch.object(instances, "_ensure_oidc_issuer", return_value="https://issuer.example/cluster") + federate = mocker.patch.object(instances, "federate_msi") + unfederate = mocker.patch.object(instances, "unfederate_msi") + mocker.patch.object(instances, "_attempt_keyvault_role_assignments") + if operation in {"add_mi_user_assigned", "enable_secretsync"}: + mocked_responses.add( + responses.GET, get_uami_endpoint(resource_group, "identity"), + json={"properties": {"clientId": generate_uuid(), "principalId": generate_uuid()}}, + ) + keyvault_id = generate_resource_id( + resource_group_name=resource_group, resource_provider=KEYVAULT_RP, resource_path="/keyvaults/vault", + ) + if operation == "enable_secretsync": + mocked_responses.add(responses.GET, get_kv_endpoint(resource_group, "vault"), json={}) + mocked_responses.add(responses.PUT, get_spc_endpoint(resource_group, "spc"), json=spc) + operation_kwargs = { + "add_mi_user_assigned": {"mi_user_assigned": identity_id, "usage_type": IdentityUsageType.DATAFLOW.value}, + "remove_mi_user_assigned": {"mi_user_assigned": identity_id, "federated_credential_name": "credential"}, + "enable_secretsync": {"mi_user_assigned": identity_id, "keyvault_resource_id": keyvault_id, "spc_name": "spc"}, + "disable_secretsync": {"confirm_yes": True}, + } + result = getattr(instances, operation)( + name=name, resource_group_name=resource_group, wait_sec=0, **operation_kwargs[operation], + ) + expected = deepcopy(selected) + if operation == "add_mi_user_assigned": + expected["identity"]["userAssignedIdentities"][identity_id] = {} + elif operation == "remove_mi_user_assigned": + expected["identity"] = {"type": "None", "userAssignedIdentities": {}} + elif operation == "enable_secretsync": + expected["properties"]["defaultSecretProviderClassRef"] = {"resourceId": spc["id"]} + else: + del expected["properties"]["defaultSecretProviderClassRef"] + writes = [call.request for call in mocked_responses.calls + if call.request.method == "PUT" and "/instances/" in call.request.url] + assert len(writes) == 1 + assert writes[0].url == selected_endpoint + assert json.loads(writes[0].body) == expected + assert instances.iotops_api_version == api_version + if operation in {"add_mi_user_assigned", "remove_mi_user_assigned"}: + assert result == expected + elif operation == "enable_secretsync": + assert result == spc + else: + assert result is None + reads = [call.request.url for call in mocked_responses.calls + if call.request.method == "GET" and "/instances/" in call.request.url] + assert reads == [initial_endpoint] + discovery.assert_not_called() + assert not any(call.request.method == "PATCH" for call in mocked_responses.calls) + if operation in {"add_mi_user_assigned", "enable_secretsync"}: + federate.assert_called_once() + if operation == "remove_mi_user_assigned": + unfederate.assert_called_once() + + +@pytest.mark.parametrize("operation", [ + "add_mi_user_assigned", "remove_mi_user_assigned", "enable_secretsync", "disable_secretsync", +]) +@pytest.mark.parametrize("status", [403, 404, 500]) +def test_instance_workflow_api_failure_prevents_writes(mocker, mocked_cmd, mocked_responses, operation, status): + name, resource_group = "preview-instance", "preview-rg" + mocked_responses.add( + responses.GET, get_instance_endpoint(resource_group, name), status=status, + json={"error": {"code": "ReadFailed", "message": "Instance read failed"}}, + ) + instances = Instances(mocked_cmd) + side_effects = [mocker.patch.object(instances, method) for method in ( + "federate_msi", "unfederate_msi", "_attempt_keyvault_role_assignments", "_ensure_oidc_issuer", + )] + identity_id = generate_resource_id( + resource_group_name=resource_group, resource_provider=UAMI_RP, resource_path="/userAssignedIdentities/identity", + ) + operation_kwargs = { + "add_mi_user_assigned": {"mi_user_assigned": identity_id, "usage_type": IdentityUsageType.DATAFLOW.value}, + "remove_mi_user_assigned": {"mi_user_assigned": identity_id, "federated_credential_name": "credential"}, + "enable_secretsync": { + "mi_user_assigned": identity_id, + "keyvault_resource_id": generate_resource_id( + resource_group_name=resource_group, resource_provider=KEYVAULT_RP, resource_path="/vaults/vault", + ), + }, + "disable_secretsync": {"confirm_yes": True}, + } + with pytest.raises(HttpResponseError) as caught: + getattr(instances, operation)(name=name, resource_group_name=resource_group, **operation_kwargs[operation]) + assert caught.value.status_code == status + assert all(call.request.method == "GET" for call in mocked_responses.calls) + for side_effect in side_effects: + side_effect.assert_not_called() + + +def mock_runtime_discovery( + mocked_responses, instance, version="1.1.15", train="stable", connectivity="Connected", + extension_state="Succeeded", extension_statuses=None, +): + """Serve associated live ARM records; discovery/eligibility itself remains unmocked.""" + from ..test_upgrade2_unit import get_cluster_endpoint, get_cluster_extensions_endpoint + from azext_edge.edge.providers.orchestration.common import EXTENSION_TYPE_OPS + + resource_group = instance["id"].split("/")[4] + custom_location = get_mock_cl_record("location", resource_group) + custom_location["id"] = instance["extendedLocation"]["name"] + cluster_id = custom_location["properties"]["hostResourceId"] + extension_id = f"{cluster_id}/providers/Microsoft.KubernetesConfiguration/extensions/aio" + custom_location["properties"]["clusterExtensionIds"] = [extension_id] + mocked_responses.add( + responses.GET, f"{BASE_URL}{custom_location['id']}", json=custom_location, + ) + mocked_responses.add( + responses.GET, get_cluster_endpoint(resource_group), + json={"id": cluster_id, "properties": {"connectivityStatus": connectivity}}, + ) + mocked_responses.add( + responses.GET, get_cluster_extensions_endpoint(resource_group), + json={"value": [{"id": extension_id, "properties": { + "extensionType": EXTENSION_TYPE_OPS, "version": version, "currentVersion": version, + "releaseTrain": train, "provisioningState": extension_state, "statuses": extension_statuses or [], + }}]}, + ) + + +@pytest.mark.parametrize("unready", ["offline", "failed_instance", "updating_extension", "extension_error"]) +@pytest.mark.parametrize("enable_opcua", [False, True]) +def test_metadata_update_allows_unready_runtime_but_opcua_enable_does_not( + mocked_cmd, mocked_responses, unready, enable_opcua, +): + name, resource_group = "instance", "rg" + record = get_mock_instance_record(name, resource_group) + if unready == "failed_instance": + record["properties"]["provisioningState"] = "Failed" + if enable_opcua: + mock_runtime_discovery( + mocked_responses, record, + connectivity="Offline" if unready == "offline" else "Connected", + extension_state="Updating" if unready == "updating_extension" else "Succeeded", + extension_statuses=[{"level": "Error", "message": "Extension error"}] + if unready == "extension_error" else None, + ) + endpoint = get_instance_endpoint(resource_group, name) + mocked_responses.add(responses.GET, endpoint, json=record) + if enable_opcua: + with pytest.raises(ValidationError, match="runtime is not ready"): + update_instance(mocked_cmd, name, resource_group, instance_features=["opcua.mode=Stable"]) + assert all(call.request.method == "GET" for call in mocked_responses.calls) + else: + mocked_responses.add(responses.PUT, endpoint, json=record) + update_instance( + mocked_cmd, name, resource_group, tags={"updated": "yes"}, + instance_description="Updated description", wait_sec=0, + ) + writes = [call.request for call in mocked_responses.calls if call.request.method == "PUT"] + assert len(writes) == 1 + assert json.loads(writes[0].body)["tags"] == {"updated": "yes"} + + def test_instance_get_resource_map(mocker, mocked_cmd, mocked_responses: responses): cl_name = generate_random_string() instance_name = generate_random_string() @@ -344,6 +653,13 @@ def test_instance_list(mocked_cmd, mocked_responses: responses, resource_group_n }, "initialState": {"mqttBroker": {"settings": {"preview": "Enabled"}}}, }, + { + "inputs": ["connectors.settings.preview=Enabled"], + "initialState": {"connectors": {"mode": "Stable", "settings": {"other": "Enabled"}}}, + "expected": {"connectors": { + "mode": "Stable", "settings": {"other": "Enabled", "preview": "Enabled"}, + }}, + }, ], ) def test_instance_update( @@ -358,19 +674,21 @@ def test_instance_update( resource_group_name = generate_random_string() instance_endpoint = get_instance_endpoint(resource_group_name=resource_group_name, instance_name=instance_name) + tags_only = tags is not None and description is None and features_scenario.get("inputs") is None initial_feat_state = features_scenario.get("initialState") initial_record = get_mock_instance_record( name=instance_name, resource_group_name=resource_group_name, features=initial_feat_state, ) - mocked_responses.add( - method=responses.GET, - url=instance_endpoint, - json=initial_record, - status=200, - content_type="application/json", - ) + if not tags_only: + mocked_responses.add( + method=responses.GET, + url=instance_endpoint, + json=initial_record, + status=200, + content_type="application/json", + ) updated_record = get_mock_instance_record( name=instance_name, resource_group_name=resource_group_name, @@ -379,7 +697,7 @@ def test_instance_update( features=features_scenario.get("expected"), ) mocked_responses.add( - method=responses.PUT, + method=responses.PATCH if tags_only else responses.PUT, url=instance_endpoint, json=updated_record, status=200, @@ -395,9 +713,10 @@ def test_instance_update( instance_description=description, wait_sec=0, ) - assert len(mocked_responses.calls) == 2 + assert len(mocked_responses.calls) == (1 if tags_only else 2) - update_request = json.loads(mocked_responses.calls[1].request.body) + method = "PATCH" if tags_only else "PUT" + update_request = json.loads(next(c.request.body for c in mocked_responses.calls if c.request.method == method)) if description: assert update_request["properties"]["description"] == description @@ -407,10 +726,92 @@ def test_instance_update( if features_scenario: assert update_request["properties"]["features"] == features_scenario["expected"] - assert update_request == updated_record + assert update_request == ({"tags": tags} if tags_only else updated_record) assert result == updated_record +@pytest.mark.parametrize("property_changes", [ + {"description": "Updated description"}, + {"description": ""}, + {"features": ["opcua.mode=Disabled"]}, + {"features": []}, + {"adr_namespace_resource_id": "/namespace"}, + {"spc_resource_id": "/spc"}, +]) +@pytest.mark.parametrize("version", ["1.5.30", "1.6.0-preview.40"]) +def test_tags_with_properties_uses_put_without_discovery( + mocker, mocked_cmd, mocked_responses, property_changes, version, +): + instances = Instances(mocked_cmd) + record = get_mock_instance_record("instance", "rg", version=version) + record["properties"]["retainedConfiguration"] = {"values": ["custom"]} + endpoint = get_instance_endpoint("rg", "instance", api_version="2026-10-01") + mocked_responses.add(responses.GET, endpoint, json=record) + mocked_responses.add_callback(responses.PUT, endpoint, callback=echo_callback) + discovery = mocker.patch.object( + instances, "get_runtime_context", side_effect=HttpResponseError("AuthorizationFailed: discovery forbidden"), + ) + patch = mocker.patch.object(instances.iotops_mgmt_client.instance, "update") + + result = instances.update("instance", "rg", tags={}, wait_sec=0, **property_changes) + + expected = deepcopy(record) + expected["tags"] = {} + if property_changes.get("description"): + expected["properties"]["description"] = property_changes["description"] + if property_changes.get("features"): + expected["properties"]["features"] = {"opcua": {"mode": "Disabled"}} + if property_changes.get("adr_namespace_resource_id"): + expected["properties"]["adrNamespaceRef"] = {"resourceId": property_changes["adr_namespace_resource_id"]} + if property_changes.get("spc_resource_id"): + expected["properties"]["defaultSecretProviderClassRef"] = {"resourceId": property_changes["spc_resource_id"]} + assert result == expected + assert len(mocked_responses.calls) == 2 + assert mocked_responses.calls[-1].request.method == "PUT" + assert json.loads(mocked_responses.calls[-1].request.body) == expected + discovery.assert_not_called() + patch.assert_not_called() + + +@pytest.mark.parametrize("tags", [{"updated": "yes"}, {}]) +@pytest.mark.parametrize("version", ["1.4.112", "1.6.0-preview.22"]) +def test_tags_only_update_patches_without_discovery(mocker, mocked_cmd, mocked_responses, tags, version): + name, resource_group = "instance", "rg" + record = get_mock_instance_record(name, resource_group, tags=tags) + record["properties"]["version"] = version + record["properties"]["previewOnlyProperty"] = {"retained": ["value"]} + mocked_responses.add(responses.PATCH, get_instance_endpoint(resource_group, name), json=record) + show = mocker.patch.object(Instances, "show", side_effect=AssertionError("Unexpected instance read")) + discovery = mocker.patch.object( + Instances, "get_runtime_context", side_effect=AssertionError("Unexpected discovery"), + ) + put = mocker.patch.object(Instances, "_update", side_effect=AssertionError("Unexpected PUT")) + + result = update_instance(mocked_cmd, name, resource_group, tags=tags, no_status=True) + + assert result == record + assert len(mocked_responses.calls) == 1 + request = mocked_responses.calls[0].request + assert request.method == "PATCH" + assert json.loads(request.body) == {"tags": tags} + assert request.headers["CommandName"] == "iot ops update" + show.assert_not_called() + discovery.assert_not_called() + put.assert_not_called() + + +@pytest.mark.parametrize("status", [400, 403, 404, 409, 500]) +def test_tags_only_update_propagates_patch_errors(mocked_cmd, mocked_responses, status): + mocked_responses.add( + responses.PATCH, get_instance_endpoint("rg", "instance"), status=status, + json={"error": {"code": "PatchFailed", "message": "Tags PATCH failed"}}, + ) + with pytest.raises(HttpResponseError) as caught: + update_instance(mocked_cmd, "instance", "rg", tags={"updated": "yes"}, no_status=True) + assert caught.value.status_code == status + assert all(call.request.method == "PATCH" for call in mocked_responses.calls) + + @pytest.mark.parametrize( "features_scenario", [ @@ -637,6 +1038,7 @@ def test_instance_update_opcua_mode( new_opcua_mode = features_scenario["expected"].get("opcua", {}).get("mode") expects_backfill = new_opcua_mode not in (None, "Disabled") if expects_backfill: + mock_runtime_discovery(mocked_responses, initial_record) base_url = instance_endpoint.split("?")[0] list_re = re.compile(re.escape(base_url) + r"/akriConnectorTemplates(\?|$)") mocked_responses.add( @@ -666,7 +1068,7 @@ def test_instance_update_opcua_mode( wait_sec=0, ) - update_request = json.loads(mocked_responses.calls[1].request.body) + update_request = json.loads(next(c.request.body for c in mocked_responses.calls if c.request.method == "PUT")) assert update_request["properties"]["features"] == features_scenario["expected"] assert result == updated_record @@ -729,6 +1131,7 @@ def test_instance_update_opcua_backfill( initial_record = get_mock_instance_record( name=instance_name, resource_group_name=resource_group_name, features={"opcua": {"mode": "Disabled"}} ) + mock_runtime_discovery(mocked_responses, initial_record) mocked_responses.add(method=responses.GET, url=instance_endpoint, json=initial_record, status=200) updated_record = get_mock_instance_record( name=instance_name, resource_group_name=resource_group_name, features={"opcua": {"mode": "Stable"}} @@ -777,11 +1180,14 @@ def create_cb(request): assert created["name"].startswith("azureiotoperationsconnectorforopcua-") else: assert not template_puts, "connector template must not be created or overwritten" + requests = [call.request for call in mocked_responses.calls] + list_index = next(i for i, request in enumerate(requests) if list_re.match(request.url)) + first_put = next(i for i, request in enumerate(requests) if request.method == "PUT") + assert list_index < first_put def test_instance_update_opcua_backfill_surfaces_list_error(mocked_cmd, mocked_responses: responses): - """A transient template-list failure during re-enable surfaces instead of silently reporting - success with no connector template.""" + """A template-list failure rejects re-enable before the instance or its children are written.""" instance_name = generate_random_string() resource_group_name = generate_random_string() instance_endpoint = get_instance_endpoint(resource_group_name=resource_group_name, instance_name=instance_name) @@ -790,11 +1196,8 @@ def test_instance_update_opcua_backfill_surfaces_list_error(mocked_cmd, mocked_r initial_record = get_mock_instance_record( name=instance_name, resource_group_name=resource_group_name, features={"opcua": {"mode": "Disabled"}} ) + mock_runtime_discovery(mocked_responses, initial_record) mocked_responses.add(method=responses.GET, url=instance_endpoint, json=initial_record, status=200) - updated_record = get_mock_instance_record( - name=instance_name, resource_group_name=resource_group_name, features={"opcua": {"mode": "Stable"}} - ) - mocked_responses.add(method=responses.PUT, url=instance_endpoint, json=updated_record, status=200) list_re = re.compile(re.escape(base_url) + r"/akriConnectorTemplates(\?|$)") mocked_responses.add( @@ -809,6 +1212,7 @@ def test_instance_update_opcua_backfill_surfaces_list_error(mocked_cmd, mocked_r instance_features=["opcua.mode=Stable"], wait_sec=0, ) + assert all(call.request.method == "GET" for call in mocked_responses.calls) @pytest.mark.parametrize( @@ -837,6 +1241,7 @@ def test_instance_update_opcua_backfill_surfaces_list_error(mocked_cmd, mocked_r def test_secretsync_enable( mocked_cmd, mocked_responses: responses, + mocked_ga_runtime_context, spc_name: Optional[str], skip_role_assignments: Optional[bool], use_self_hosted_issuer: Optional[bool], @@ -1099,7 +1504,7 @@ def test_secretsync_enable_repairs_existing_fic( instances.resource_client = mocker.Mock() instances.msi_mgmt_client = mocker.Mock() instances.ssc_mgmt_client = mocker.Mock() - instances.show = mocker.Mock() + instances._get_instance_for_write = mocker.Mock() instances.get_resource_map = mocker.Mock() instances.get_associated_cl = mocker.Mock() instances._ensure_oidc_issuer = mocker.Mock() @@ -1124,7 +1529,7 @@ def test_secretsync_enable_repairs_existing_fic( "name": instance_name, "extendedLocation": {"name": generate_resource_id(resource_group_name=resource_group_name)}, } - instances.show.return_value = instance + instances._get_instance_for_write.return_value = instance instances.get_associated_cl.return_value = {"properties": {"namespace": namespace}} resource_map = instances.get_resource_map.return_value resource_map.connected_cluster.resource = {"name": generate_random_string()} @@ -1342,6 +1747,7 @@ def test_repair_federated_cred_issuer_surfaces_actionable_error(mocker, operatio def test_secretsync_enable_issuer_error( mocked_cmd, mocked_responses: responses, + mocked_ga_runtime_context, scenario: dict, ): resource_group_name = generate_random_string() @@ -1484,6 +1890,7 @@ def test_secretsync_enable_issuer_error( def test_secretsync_disable( mocked_cmd, mocked_responses: responses, + mocked_ga_runtime_context, default_spc_resource_id: Optional[str], existing_resources: Optional[list[dict]], ): @@ -1595,6 +2002,7 @@ def test_secretsync_disable( def test_add_mi_user_assigned( mocked_cmd, mocked_responses: responses, + mocked_ga_runtime_context, usage_type: Optional[str], use_self_hosted_issuer: Optional[bool], fc_name: Optional[str], diff --git a/azext_edge/tests/edge/orchestration/test_get_versions_unit.py b/azext_edge/tests/edge/orchestration/test_get_versions_unit.py index 90068a900..bdd6ebf92 100644 --- a/azext_edge/tests/edge/orchestration/test_get_versions_unit.py +++ b/azext_edge/tests/edge/orchestration/test_get_versions_unit.py @@ -4,6 +4,7 @@ # Licensed under the MIT License. See License file in the project root for license information. # ---------------------------------------------------------------------------------------------- +import json from typing import Tuple from unittest.mock import MagicMock @@ -90,3 +91,77 @@ def test_get_versions_target_link_constant(): from azext_edge.edge.common import GET_VERSIONS_URL assert GET_VERSIONS_URL == "https://aka.ms/aio-versions" + + +def test_inline_versions_preserve_legacy_fields_and_actual_train(mocker): + from azext_edge.constants import AIO_RELEASE, VERSION + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + from azext_edge.edge.providers.orchestration.targets import InitTargets + from azext_edge.edge.providers.orchestration.template import TEMPLATE_BLUEPRINT_INSTANCE + + browser = mocker.patch("webbrowser.open", side_effect=AssertionError("browser")) + mocker.patch("azure.cli.core.commands.client_factory.get_subscription_id", side_effect=AssertionError("auth")) + mocker.patch("azure.cli.core._profile.Profile.get_raw_token", side_effect=AssertionError("auth")) + mocker.patch("requests.sessions.Session.request", side_effect=AssertionError("network")) + legacy = InitTargets("", "") + result = get_versions(inline=True) + assert result["cliVersion"] == VERSION + assert result["iotOpsRelease"] == AIO_RELEASE + assert result["extensions"] == {**legacy.get_extension_versions(), **legacy.get_extension_versions(False)} + assert result["defaultRuntimeChannel"] == "stable" + assert set(result["runtimeProfiles"]) == {"stable", "preview"} + stable = result["runtimeProfiles"]["stable"] + assert stable["version"] == result["extensions"]["iotOperations"]["version"] + assert stable["train"] == TEMPLATE_BLUEPRINT_INSTANCE.content["variables"]["TRAINS"]["iotOperations"] + stable_profile = get_runtime_catalog().for_create() + assert stable["sourceRef"] == stable_profile.source_ref + assert stable["sourceCommit"] == stable_profile.source_commit + preview = result["runtimeProfiles"]["preview"] + preview_profile = get_runtime_catalog().for_create(use_preview=True) + assert preview == { + "release": preview_profile.release, + "version": preview_profile.identity.version, + "train": preview_profile.identity.train, + "sourceRef": preview_profile.source_ref, + "sourceCommit": preview_profile.source_commit, + "opcuaConnectorVersion": preview_profile.opcua_connector_version, + } + assert json.loads(json.dumps(result)) == result + browser.assert_not_called() + + +@pytest.mark.parametrize("train", [None, "integration"]) +def test_inline_reports_both_bundled_profiles_without_relabeling_trains(mocker, train): + from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel, RuntimeProfileCatalog + from azext_edge.edge.providers.orchestration.targets import InitTargets + from .test_runtime_profiles_unit import make_profile + + stable = make_profile(RuntimeChannel.STABLE, "1.5.7", train, opcua_connector_version="test-stable-tag") + preview = make_profile(RuntimeChannel.PREVIEW, "1.6.0-preview.4", train, + opcua_connector_version="test-preview-tag") + catalog = RuntimeProfileCatalog([preview, stable]) + mocker.patch("azext_edge.edge.providers.orchestration.runtime_catalog.get_runtime_catalog", return_value=catalog) + mocker.patch("requests.sessions.Session.request", side_effect=AssertionError("network")) + result = get_versions(inline=True) + assert result["extensions"]["iotOperations"] == {"version": "1.5.7", "train": train or "stable"} + shared = InitTargets("", "").get_extension_versions() + assert all(result["extensions"][name] == value for name, value in shared.items()) + assert list(result["runtimeProfiles"]) == ["stable", "preview"] + for profile in (stable, preview): + assert result["runtimeProfiles"][profile.channel.value] == { + "release": profile.release, + "version": profile.identity.version, + "train": train or profile.channel.value, + "sourceRef": "test-ref", + "sourceCommit": "test-commit", + "opcuaConnectorVersion": profile.opcua_connector_version, + } + result["runtimeProfiles"]["preview"]["train"] = "changed" + assert catalog.describe_profiles()["preview"]["train"] == (train or "preview") + + +def test_browser_versions_does_not_construct_runtime_catalog(mocker): + _setup_mocks(mocker) + mocker.patch("azext_edge.edge.providers.orchestration.runtime_catalog.get_runtime_catalog", + side_effect=AssertionError("runtime catalog")) + get_versions() diff --git a/azext_edge/tests/edge/orchestration/test_runtime_activation_unit.py b/azext_edge/tests/edge/orchestration/test_runtime_activation_unit.py new file mode 100644 index 000000000..9eeca5fbf --- /dev/null +++ b/azext_edge/tests/edge/orchestration/test_runtime_activation_unit.py @@ -0,0 +1,185 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Exercise lifecycle routing with the real bundled catalog and mocked services.""" + +import json +from copy import deepcopy + +import pytest +import responses + +from azext_edge.edge.commands_edge import create_instance, update_instance, upgrade_instance +from azext_edge.edge.providers.orchestration.common import EXTENSION_TYPE_OPS +from azext_edge.edge.providers.orchestration.resources.connector_templates import ConnectorTemplates +from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog +from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel +from azext_edge.edge.providers.orchestration.template_preview import TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW +from azext_edge.edge.providers.orchestration.upgrade2 import UpgradeManager + +from .resources.test_instances_unit import get_instance_endpoint, get_mock_instance_record, mock_runtime_discovery +from .test_runtime_commands_unit import create_args, isolated_work as isolated_work_fixture +from .test_upgrade2_unit import UpgradeScenario + + +isolated_work = isolated_work_fixture + + +@pytest.mark.parametrize("no_preflight", [False, True]) +def test_bundled_preview_create_reaches_work_without_consent(mocker, isolated_work, no_preflight, capsys): + manager, writer = isolated_work + mocker.patch("sys.stdin.isatty", return_value=False) + ask = mocker.patch("rich.prompt.Confirm.ask", side_effect=AssertionError("Unexpected confirmation prompt")) + profile = get_runtime_catalog().for_create(use_preview=True) + + create_instance(**create_args(), use_preview=True, no_preflight=no_preflight) + + writer.assert_called_once() + ask.assert_not_called() + assert manager._targets.runtime_profile is profile + assert manager._targets.get_extension_versions(False)["iotOperations"] == { + "version": profile.identity.version, "train": profile.identity.train, + } + template, _ = manager._targets.get_ops_instance_template() + assert "gds" not in json.dumps(template).lower() + assert template["variables"]["CONNECTORS_CHART_REGISTRY"] == ( + TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW.content["variables"]["CONNECTORS_CHART_REGISTRY"] + ) + output = capsys.readouterr() + assert "Supplemental Terms" not in output.out + output.err + + +def test_default_create_still_selects_ga_without_preview_consent(mocker, isolated_work): + manager, writer = isolated_work + ask = mocker.patch("rich.prompt.Confirm.ask") + create_instance(**create_args()) + writer.assert_called_once() + ask.assert_not_called() + assert manager._targets.runtime_profile.channel == RuntimeChannel.STABLE + + +def test_bundled_preview_update_preserves_payload_and_selects_connector_tag(mocker, mocked_cmd, mocked_responses): + profile = get_runtime_catalog().for_create(use_preview=True) + record = get_mock_instance_record("instance", "rg", features={ + "opcua": {"mode": "Disabled", "settings": {"retained": "Enabled"}}, + "otherComponent": {"mode": "Preview"}, + }) + record["properties"]["additionalProperty"] = {"preserved": True} + original = deepcopy(record) + endpoint = get_instance_endpoint(resource_group_name="rg", instance_name="instance", api_version="2026-10-01") + mocked_responses.add(responses.GET, endpoint, json=record) + mock_runtime_discovery(mocked_responses, record, profile.identity.version, profile.identity.train) + mocked_responses.add(responses.PUT, endpoint, json=record) + mocker.patch.object(ConnectorTemplates, "check_default_opcua_template_needed", return_value=(True, None)) + connector = mocker.patch.object(ConnectorTemplates, "create_default_opcua_template") + + update_instance( + mocked_cmd, "instance", "rg", instance_features=["opcua.mode=Preview"], + instance_description="updated description", wait_sec=0, + ) + + writes = [c for c in mocked_responses.calls if c.request.method == "PUT"] + assert len(writes) == 1 + assert writes[0].request.url == endpoint + body = json.loads(writes[0].request.body) + assert body["properties"]["features"] == { + "opcua": {"mode": "Preview", "settings": {"retained": "Enabled"}}, + "otherComponent": {"mode": "Preview"}, + } + assert body["properties"]["additionalProperty"] == {"preserved": True} + assert body["properties"]["description"] == "updated description" + assert record == original + assert connector.call_args.kwargs["connector_version"] == profile.require_opcua_connector_version() + assert connector.call_args.kwargs["runtime_profile"] is profile + + +@pytest.mark.parametrize("use_preview, endpoint_types", [ + (False, [{"endpointType": "Microsoft.OpcUa"}, {"endpointType": "Microsoft.OpcUa.WoT"}]), + (True, [{"endpointType": "Microsoft.OpcUa"}, {"endpointType": "Microsoft.OpcUa.WoT"}]), +]) +def test_connector_backfill_shape_matches_runtime_profile(mocker, mocked_cmd, use_preview, endpoint_types): + profile = get_runtime_catalog().for_create(use_preview=use_preview) + connectors = ConnectorTemplates(mocked_cmd) + extended_location = {"name": "/custom/location", "type": "CustomLocation"} + mocker.patch.object(connectors.instances, "get_ext_loc", return_value=extended_location) + writer = mocker.patch.object(connectors.ops, "begin_create_or_update") + mocker.patch( + "azext_edge.edge.providers.orchestration.resources.connector_templates.wait_for_terminal_state", + return_value={}, + ) + + connectors.create_default_opcua_template( + "rg", "instance", connector_version=profile.require_opcua_connector_version(), no_status=True, + runtime_profile=profile, + ) + + resource = profile.copy_instance_blueprint().get_resource_by_key("opcUaConnectorTemplate") + expected = deepcopy(resource["properties"]) + tag = profile.require_opcua_connector_version() + expected["connectorMetadataRef"] = f"mcr.microsoft.com/azureiotoperations/aio-connectors/opcua-metadata:{tag}" + expected["runtimeConfiguration"]["managedConfigurationSettings"]["imageConfigurationSettings"][ + "tagDigestSettings" + ]["tag"] = tag + assert writer.call_args.kwargs["resource"] == {"extendedLocation": extended_location, "properties": expected} + assert writer.call_args.kwargs["resource"]["properties"]["deviceInboundEndpointTypes"] == endpoint_types + writer.call_args.kwargs["resource"]["properties"]["deviceInboundEndpointTypes"].clear() + assert profile.copy_instance_blueprint().get_resource_by_key("opcUaConnectorTemplate")["properties"][ + "deviceInboundEndpointTypes" + ] == endpoint_types + + +@pytest.mark.parametrize("connector_exists", [False, True]) +def test_bundled_preview_upgrade_plan_uses_preview_target_and_shared_foundation( + mocked_cmd, mocked_responses, connector_exists, +): + profile = get_runtime_catalog().for_create(use_preview=True) + scenario = UpgradeScenario().set_extension( + EXTENSION_TYPE_OPS, ext_vers=profile.identity.version, ext_train=profile.identity.train, + ) + scenario.set_auxiliary_kwargs(opcua_connector_template_exists=connector_exists) + scenario.set_instance_mock(mocked_responses, "instance", "rg", iotops_api_version="2026-09-01-preview") + manager = UpgradeManager(mocked_cmd, "rg", "instance", no_progress=True) + state = manager.analyze_cluster() + assert manager.runtime_profile is profile + assert state.connector_template_needed is (not connector_exists) + if not connector_exists: + assert state.connector_template_version == profile.require_opcua_connector_version() + assert manager.targets.get_extension_versions() == scenario.targets.get_extension_versions() + assert not [c for c in mocked_responses.calls if c.request.method in {"PUT", "PATCH", "DELETE"}] + + +@pytest.mark.parametrize("connector_exists", [False, True]) +def test_bundled_preview_upgrade_repairs_same_runtime_without_qualification_block( + mocked_cmd, mocked_responses, connector_exists, +): + profile = get_runtime_catalog().for_create(use_preview=True) + scenario = UpgradeScenario().set_extension( + EXTENSION_TYPE_OPS, ext_vers=profile.identity.version, ext_train=profile.identity.train, + provisioning_state="Failed", + ) + scenario.set_auxiliary_kwargs(opcua_connector_template_exists=connector_exists, opcua_connector_version="1.5.18") + scenario.set_instance_mock(mocked_responses, "instance", "rg", iotops_api_version="2026-09-01-preview") + upgrade_instance(mocked_cmd, "rg", "instance", confirm_yes=True, no_progress=True) + patches = [c for c in mocked_responses.calls if c.request.method == "PATCH"] + assert len(patches) == 1 + properties = json.loads(patches[0].request.body)["properties"] + assert properties["version"] == profile.identity.version + # Same-train repair does not redundantly patch the unchanged release train. + assert "releaseTrain" not in properties + connector_writes = [ + request.request for request in mocked_responses.calls + if request.request.method == "PUT" and "/akriConnectorTemplates/" in request.request.url + ] + assert len(connector_writes) == int(not connector_exists) + if connector_writes: + assert "api-version=2026-09-01-preview" in connector_writes[0].url + connector = json.loads(connector_writes[0].body)["properties"] + assert connector["deviceInboundEndpointTypes"] == [ + {"endpointType": "Microsoft.OpcUa"}, {"endpointType": "Microsoft.OpcUa.WoT"}, + ] + assert connector["connectorMetadataRef"] == ( + "mcr.microsoft.com/azureiotoperations/aio-connectors/opcua-metadata:1.5.18" + ) diff --git a/azext_edge/tests/edge/orchestration/test_runtime_channels_int.py b/azext_edge/tests/edge/orchestration/test_runtime_channels_int.py new file mode 100644 index 000000000..4c5999452 --- /dev/null +++ b/azext_edge/tests/edge/orchestration/test_runtime_channels_int.py @@ -0,0 +1,64 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Shared runtime behavior and channel-boundary tests, run unchanged on GA and preview.""" + +import os +import subprocess + +import pytest + +from ...helpers import run +from ...runtime_checks import assert_runtime, read_runtime + + +pytestmark = pytest.mark.runtime_channel + + +@pytest.fixture +def channel_target(): + channel = os.environ.get("azext_edge_runtime_channel") + if not channel: + pytest.skip("Set azext_edge_runtime_channel for dual-channel qualification.") + return os.environ["azext_edge_instance"], os.environ["azext_edge_rg"], channel + + +def test_shared_instance_update_preserves_runtime_channel(channel_target): + name, group, channel = channel_target + before, instance, _ = read_runtime(name, group) + description = instance["properties"].get("description") or "" + try: + run(f"az iot ops update -n {name} -g {group} --description channel-integration-check") + after = assert_runtime(name, group, channel) + assert after.identity == before.identity + finally: + # Use argv rather than shell quoting for arbitrary existing descriptions. + subprocess.run( + ["az", "iot", "ops", "update", "-n", name, "-g", group, "--description", description], check=True, + ) + + +@pytest.mark.serial +def test_cross_channel_upgrade_rejected_without_mutation(channel_target): + name, group, channel = channel_target + assert_runtime(name, group, channel) + _, instance_before, extensions_before = read_runtime(name, group) + opposite = "preview" if channel == "stable" else "stable" + result = subprocess.run( + ["az", "iot", "ops", "upgrade", "-n", name, "-g", group, "--ops-train", opposite, + "--force", "--yes", "--no-progress"], capture_output=True, text=True, check=False, + ) + assert result.returncode != 0, "Cross-channel upgrade unexpectedly succeeded." + assert any(message in result.stderr.lower() for message in ( + "cross-train", "ga clusters must remain ga", "train overrides cannot change", + )), result.stderr + _, instance_after, extensions_after = read_runtime(name, group) + assert instance_after["properties"] == instance_before["properties"] + # Controller status timestamps can advance independently of this rejected command. + configuration = ("version", "releaseTrain", "configurationSettings", "autoUpgradeMinorVersion") + before = {ext["id"]: {key: ext["properties"].get(key) for key in configuration} for ext in extensions_before} + after = {ext["id"]: {key: ext["properties"].get(key) for key in configuration} for ext in extensions_after} + assert after == before, "Rejected upgrade changed extension state." diff --git a/azext_edge/tests/edge/orchestration/test_runtime_commands_unit.py b/azext_edge/tests/edge/orchestration/test_runtime_commands_unit.py new file mode 100644 index 000000000..8a5c571f0 --- /dev/null +++ b/azext_edge/tests/edge/orchestration/test_runtime_commands_unit.py @@ -0,0 +1,353 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +from copy import deepcopy +from unittest.mock import Mock + +import pytest +import responses +from azure.cli.core.azclierror import ValidationError + +from azext_edge.edge.commands_edge import create_instance, update_instance +from azext_edge.edge.providers.orchestration import runtime_requirements +from azext_edge.edge.providers.orchestration.common import EXTENSION_TYPE_OPS +from azext_edge.edge.providers.orchestration.resources.instances import Instances +from azext_edge.edge.providers.orchestration.runtime import OperationRequirements, ParameterRequirement +from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel, RuntimeProfileCatalog +from azext_edge.edge.providers.orchestration.work import WorkManager +from .resources.test_instances_unit import get_instance_endpoint, get_mock_instance_record, mock_runtime_discovery +from .test_runtime_profiles_unit import make_profile +from .test_upgrade2_unit import mocked_upgrade_manager # noqa: F401 +from .test_work_unit import CallKey, ServiceGenerator, build_target_scenario + + +@pytest.fixture +def preview_profile(): + return make_profile( + RuntimeChannel.PREVIEW, "1.6.0-preview.10", opcua_connector_version="test-preview-tag", + ) + + +@pytest.fixture +def isolated_work(mocker): + manager = WorkManager.__new__(WorkManager) + manager.cmd = Mock() + manager.subscription_id = "subscription" + mocker.patch.object(manager, "_bootstrap_ux") + mocker.patch.object(manager, "_build_display") + writer = mocker.patch.object(manager, "_do_work") + mocker.patch("azext_edge.edge.providers.orchestration.work.IoTOperationsResourceMap") + mocker.patch("azext_edge.edge.providers.orchestration.work.WorkManager", return_value=manager) + return manager, writer + + +def create_args(): + root = "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.DeviceRegistry" + return { + "cmd": Mock(), + "cluster_name": "cluster", + "resource_group_name": "rg", + "instance_name": "instance", + "schema_registry_resource_id": f"{root}/schemaRegistries/sr", + "adr_namespace_resource_id": f"{root}/namespaces/ns", + "no_progress": True, + } + + +@pytest.mark.parametrize("no_preflight", [True, False]) +@pytest.mark.parametrize("invalid", ["unbundled", "train", "version"]) +def test_create_runtime_gates_precede_all_work(mocker, isolated_work, preview_profile, no_preflight, invalid): + manager, writer = isolated_work + stable = make_profile(RuntimeChannel.STABLE, "1.5.7") + profiles = [stable] if invalid == "unbundled" else [stable, preview_profile] + mocker.patch( + "azext_edge.edge.providers.orchestration.work.get_runtime_catalog", return_value=RuntimeProfileCatalog(profiles) + ) + args = {**create_args(), "use_preview": True, "no_preflight": no_preflight} + if invalid == "train": + args["ops_train"] = "stable" + if invalid == "version": + args["ops_version"] = "1.6.0" + with pytest.raises(ValidationError): + create_instance(**args) + writer.assert_not_called() + manager._bootstrap_ux.assert_not_called() + + +@pytest.mark.parametrize("use_preview", [False, True]) +@pytest.mark.parametrize("no_preflight", [False, True]) +def test_create_selects_isolated_profile_without_consent( + mocker, isolated_work, preview_profile, use_preview, no_preflight, +): + manager, writer = isolated_work + stable = make_profile(RuntimeChannel.STABLE, "1.5.7") + mocker.patch( + "azext_edge.edge.providers.orchestration.work.get_runtime_catalog", + return_value=RuntimeProfileCatalog([stable, preview_profile]), + ) + mocker.patch("sys.stdin.isatty", return_value=False) + ask = mocker.patch("rich.prompt.Confirm.ask", side_effect=AssertionError("Unexpected confirmation prompt")) + create_instance(**create_args(), use_preview=use_preview, no_preflight=no_preflight) + assert manager._targets.runtime_profile is (preview_profile if use_preview else stable) + ask.assert_not_called() + writer.assert_called_once() + + +def test_init_does_not_select_runtime(mocker, isolated_work): + manager, writer = isolated_work + catalog = mocker.patch("azext_edge.edge.providers.orchestration.work.get_runtime_catalog") + manager.execute_ops_init(apply_foundation=True, cluster_name="cluster", resource_group_name="rg") + assert manager._targets.runtime_profile is None + catalog.assert_not_called() + writer.assert_called_once() + + +def test_create_requirements_use_effective_override(mocker, isolated_work): + _, writer = isolated_work + mocker.patch( + "azext_edge.edge.providers.orchestration.work.get_runtime_catalog", + return_value=RuntimeProfileCatalog([make_profile(RuntimeChannel.STABLE, "1.5.7")]), + ) + mocker.patch.dict(runtime_requirements.COMMAND_REQUIREMENTS, { + "iot ops create": OperationRequirements("create", minimum_version="1.5.0"), + }) + with pytest.raises(ValidationError, match="requires AIO version"): + create_instance(**create_args(), ops_version="1.4.0") + writer.assert_not_called() + + +@pytest.mark.parametrize("no_preflight", [False, True]) +def test_existing_partial_deployment_blocks_create_before_registration( + mocked_cmd, mocked_responses, mocked_sleep, no_preflight, +): + scenario = build_target_scenario(existing_ops_extension=True) + servgen = ServiceGenerator(scenario=scenario, mocked_responses=mocked_responses, action="create") + mocked_responses.assert_all_requests_are_fired = False + with pytest.raises(ValidationError, match="IoT Operations is detected"): + create_instance( + cmd=mocked_cmd, cluster_name=scenario["cluster"]["name"], + resource_group_name=scenario["resourceGroup"], instance_name=scenario["instance"]["name"], + schema_registry_resource_id=scenario["schemaRegistry"]["id"], + adr_namespace_resource_id=scenario["adrNamespace"]["id"], no_progress=True, no_preflight=no_preflight, + ) + assert not servgen.call_map[CallKey.GET_RESOURCE_PROVIDERS] + assert all(c.request.method in {"GET", "HEAD"} for c in mocked_responses.calls) + + +@pytest.mark.parametrize("channel", list(RuntimeChannel)) +def test_shared_update_with_restricted_parameter_value(mocker, mocked_cmd, mocked_responses, channel): + instance = get_mock_instance_record("instance", "rg") + endpoint = get_instance_endpoint(resource_group_name="rg", instance_name="instance") + mocked_responses.add(responses.GET, endpoint, json=instance) + version = "1.6.0-preview.4" if channel == RuntimeChannel.PREVIEW else "1.5.7" + mock_runtime_discovery(mocked_responses, instance, version, channel.value) + rule = ParameterRequirement( + "opcua.mode", OperationRequirements("Selected OPC UA mode", frozenset({RuntimeChannel.PREVIEW})), + frozenset({"Preview"}), + ) + mocker.patch.dict(runtime_requirements.PARAMETER_REQUIREMENTS, {"iot ops update": (rule,)}) + profile = make_profile(channel, version, opcua_connector_version="test-profile-tag") + mocker.patch( + "azext_edge.edge.providers.orchestration.resources.instances.get_runtime_catalog", + return_value=RuntimeProfileCatalog([profile]), + ) + # Isolate connector backfill writes while exercising real update ARM discovery and PUT. + mocker.patch( + "azext_edge.edge.providers.orchestration.resources.connector_templates.ConnectorTemplates" + ".check_default_opcua_template_needed", return_value=(False, None), + ) + if channel == RuntimeChannel.STABLE: + with pytest.raises(ValidationError, match="requires a preview runtime"): + update_instance(mocked_cmd, "instance", "rg", instance_features=["opcua.mode=Preview"]) + assert all(c.request.method == "GET" for c in mocked_responses.calls) + else: + mocked_responses.add(responses.PUT, endpoint, json=instance) + update_instance(mocked_cmd, "instance", "rg", instance_features=["opcua.mode=Preview"], wait_sec=0) + assert sum(c.request.method == "PUT" for c in mocked_responses.calls) == 1 + + +def test_update_does_not_mutate_supplied_record(mocker, mocked_cmd, mocked_responses): + instance = get_mock_instance_record("instance", "rg", features={"opcua": {"mode": "Stable"}}) + original = deepcopy(instance) + endpoint = get_instance_endpoint(resource_group_name="rg", instance_name="instance") + mocked_responses.add(responses.PUT, endpoint, json=instance) + Instances(mocked_cmd).update("instance", "rg", instance=instance, features=["opcua.mode=Disabled"], wait_sec=0) + assert instance == original + assert len(mocked_responses.calls) == 1 + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.usefixtures("mocked_upgrade_manager") +def test_upgrade_prevalidates_dependencies_before_delete(mocked_cmd, mocked_responses, mocked_sleep, force): + import json + + from azext_edge.edge.commands_edge import upgrade_instance + from azext_edge.edge.providers.orchestration.common import EXTENSION_TYPE_CM, EXTENSION_TYPE_PLATFORM + from .test_upgrade2_unit import UpgradeScenario + + scenario = UpgradeScenario().set_extension(EXTENSION_TYPE_PLATFORM, ext_vers="1.0.0") + scenario.set_extension(EXTENSION_TYPE_CM, remove=True) + scenario.set_extension(EXTENSION_TYPE_OPS, ext_vers="1.4.0") + scenario.set_instance_mock(mocked_responses, "instance", "rg") + if force: + upgrade_instance(mocked_cmd, "rg", "instance", ops_version="1.3.0", force=True, confirm_yes=True) + assert scenario.delete_record[EXTENSION_TYPE_PLATFORM] + assert EXTENSION_TYPE_CM in scenario.create_record + writes = [c.request for c in mocked_responses.calls if c.request.method == "PATCH"] + assert len(writes) == 1 + assert json.loads(writes[0].body)["properties"]["version"] == "1.3.0" + else: + with pytest.raises(ValidationError, match="downgrade"): + upgrade_instance(mocked_cmd, "rg", "instance", ops_version="1.3.0", confirm_yes=True) + assert not [c for c in mocked_responses.calls if c.request.method in {"PUT", "PATCH", "DELETE"}] + + +@pytest.mark.usefixtures("mocked_upgrade_manager") +def test_invalid_dependency_plan_blocks_platform_deletion(mocked_cmd, mocked_responses): + from azext_edge.edge.commands_edge import upgrade_instance + from azext_edge.edge.providers.orchestration.common import EXTENSION_TYPE_CM, EXTENSION_TYPE_PLATFORM + from .test_upgrade2_unit import UpgradeScenario + + scenario = UpgradeScenario().set_extension(EXTENSION_TYPE_PLATFORM, ext_vers="1.0.0") + scenario.set_extension(EXTENSION_TYPE_OPS, ext_vers="1.4.0") + scenario.set_extension(EXTENSION_TYPE_CM, ext_vers="2.0.0") + scenario.set_instance_mock(mocked_responses, "instance", "rg") + with pytest.raises(ValidationError, match="downgrade"): + upgrade_instance(mocked_cmd, "rg", "instance", cm_version="1.0.0", confirm_yes=True) + assert not [c for c in mocked_responses.calls if c.request.method in {"PUT", "PATCH", "DELETE"}] + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("change", ["pin", "missing_installed", "duplicate", "in_progress"]) +@pytest.mark.usefixtures("mocked_upgrade_manager") +def test_upgrade_unknown_or_inconsistent_runtime_never_writes(mocked_cmd, mocked_responses, force, change): + from azext_edge.edge.commands_edge import upgrade_instance + from .test_upgrade2_unit import UpgradeScenario + + scenario = UpgradeScenario().set_extension(EXTENSION_TYPE_OPS, ext_vers="1.4.0") + ext = scenario.extensions[EXTENSION_TYPE_OPS] + if change == "pin": + ext["properties"]["version"] = "1.5.0" + elif change == "missing_installed": + del ext["properties"]["currentVersion"] + elif change == "duplicate": + duplicate = deepcopy(ext) + duplicate["name"] = "other-aio" + scenario.extensions["duplicate"] = duplicate + else: + ext["properties"]["provisioningState"] = "Updating" + scenario.set_instance_mock(mocked_responses, "instance", "rg") + with pytest.raises(ValidationError): + upgrade_instance(mocked_cmd, "rg", "instance", confirm_yes=True, force=force) + assert not [c for c in mocked_responses.calls if c.request.method in {"PUT", "PATCH", "DELETE"}] + + +@pytest.mark.parametrize("source,target,train,match", [ + ("1.6.0-preview.10", "1.6.0-preview.4", "preview", "downgrade"), + ("1.5.7", "1.5.6", "stable", "downgrade"), + ("1.6.0-preview.10", "1.8.0-preview.11", "preview", "more than one minor version"), +]) +@pytest.mark.parametrize("force", [False, True]) +def test_runtime_boundaries_apply_even_with_force(source, target, train, match, force): + from .test_upgrade2_unit import build_ext_upgrade_state + + state = build_ext_upgrade_state( + ext_type=EXTENSION_TYPE_OPS, current_version=source, current_train=train, + built_in_version=target, built_in_train=train, version_override=target, force=force, + ) + if force and train == "stable": + state.validate_upgrade() + assert state.get_patch()["properties"]["version"] == target + return + with pytest.raises(ValidationError, match=match): + state.validate_upgrade() + with pytest.raises(ValidationError, match=match): + state.get_patch() + + +def test_integration_mapping_cannot_convert_ga_to_preview(): + from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeIdentity + from .test_upgrade2_unit import build_ext_upgrade_state + + state = build_ext_upgrade_state( + ext_type=EXTENSION_TYPE_OPS, current_version="1.5.7", current_train="integration", + built_in_version="1.6.0-preview.4", built_in_train="integration", force=True, + ) + state.qualification_identities = ( + RuntimeIdentity(RuntimeChannel.STABLE, "1.5.7", "integration"), + RuntimeIdentity(RuntimeChannel.PREVIEW, "1.6.0-preview.4", "integration"), + ) + with pytest.raises(ValidationError, match="GA clusters must remain GA"): + state.get_patch() + + +def test_extension_writes_use_cluster_subscription(mocker, mocked_cmd): + from azext_edge.edge.providers.orchestration.resources.clusters import ConnectedClusters + + cluster = mocker.patch("azext_edge.edge.providers.orchestration.resources.clusters.get_connectedk8s_mgmt_client") + extension = mocker.patch("azext_edge.edge.providers.orchestration.resources.clusters.get_clusterconfig_mgmt_client") + ConnectedClusters(mocked_cmd, subscription_id="host-subscription") + assert cluster.call_args.kwargs["subscription_id"] == "host-subscription" + assert extension.call_args.kwargs["subscription_id"] == "host-subscription" + + +def test_runtime_selector_is_normal_create_only_option_and_requires_no_authentication(mocker): + from inspect import signature + from azure.cli.core import AzCli + from knack.help_files import helps + from azext_edge.constants import PREVIEW_AGREEMENT_URL, PREVIEW_NOTICE + from azext_edge.edge import _help + from azext_edge import OpsExtensionCommandsLoader + + mocker.patch("azure.cli.core.commands.client_factory.get_subscription_id", side_effect=AssertionError("auth")) + mocker.patch("azure.cli.core._profile.Profile.get_raw_token", side_effect=AssertionError("auth")) + mocker.patch( + "azext_edge.edge.providers.orchestration.work.get_runtime_catalog", side_effect=AssertionError("runtime") + ) + cli = Mock(spec=AzCli) + cli.cloud = Mock(profile="latest") + cli.data = {"completer_active": False} + cli.enable_color = False + cli.config = Mock() + cli.invocation = Mock(data={}) + loader = OpsExtensionCommandsLoader(cli) + cli.invocation.commands_loader = loader + loader.load_command_table([]) + for name in ("iot ops create", "iot ops init", "iot ops update", "iot ops upgrade", "iot ops get-versions"): + cli.invocation.data["command_string"] = name + loader.command_name = name + loader.load_arguments(name) + option = loader.argument_registry.get_cli_argument(name, "use_preview").settings + if name == "iot ops create": + assert option["options_list"] == ["--use-preview"] + assert not option.get("is_preview") + assert PREVIEW_NOTICE in option["help"] + assert PREVIEW_AGREEMENT_URL in option["help"] + assert "constitutes acceptance" in option["help"] + assert "--yes" not in option["help"] + assert "confirm_yes" not in signature(create_instance).parameters + else: + assert not option + assert getattr(loader.command_table[name], "preview_info", None) is None + _help.load_iotops_help() + assert PREVIEW_NOTICE in helps["iot ops create"] + assert PREVIEW_AGREEMENT_URL in helps["iot ops create"] + + +def test_backfill_missing_version_is_rejected_before_instance_write(mocker, mocked_cmd): + from azext_edge.edge.providers.orchestration.resources.connector_templates import ConnectorTemplates + + record = get_mock_instance_record("instance", "rg", features={"opcua": {"mode": "Disabled"}}) + original = deepcopy(record) + instances = Instances(mocked_cmd) + writer = mocker.patch.object(instances.iotops_mgmt_client.instance, "begin_create_or_update") + mocker.patch.object(ConnectorTemplates, "check_default_opcua_template_needed", return_value=(True, None)) + connector_writer = mocker.patch.object(ConnectorTemplates, "create_default_opcua_template") + with pytest.raises(ValidationError, match="reviewed connector version"): + instances._update("instance", "rg", instance=record, features=["opcua.mode=Stable"]) + writer.assert_not_called() + connector_writer.assert_not_called() + assert record == original diff --git a/azext_edge/tests/edge/orchestration/test_runtime_dependencies_unit.py b/azext_edge/tests/edge/orchestration/test_runtime_dependencies_unit.py new file mode 100644 index 000000000..0680a6e9d --- /dev/null +++ b/azext_edge/tests/edge/orchestration/test_runtime_dependencies_unit.py @@ -0,0 +1,234 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +from copy import deepcopy +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from azure.cli.core.azclierror import ValidationError + +from azext_edge.edge.commands_edge import create_instance, upgrade_instance +from azext_edge.edge.providers.orchestration.common import ( + EXTENSION_TYPE_CM, EXTENSION_TYPE_OPS, EXTENSION_TYPE_PLATFORM, EXTENSION_TYPE_SSC, +) +from azext_edge.edge.providers.orchestration.runtime_dependencies import DependencyIdentity, DependencyRequirement +from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel, RuntimeProfileCatalog +from azext_edge.edge.providers.orchestration.targets import InitTargets +from azext_edge.edge.providers.orchestration.upgrade2 import ExtensionOperation, ExtensionUpgradeState, UpgradeManager +from azext_edge.edge.providers.orchestration.work import WorkManager +from .test_runtime_profiles_unit import make_profile +from .test_upgrade2_unit import UpgradeScenario +from .test_work_unit import CallKey, ServiceGenerator, build_target_scenario + + +def requirement(extension_type=EXTENSION_TYPE_SSC, version="1.5.3", train="stable", config=()): + # Test-only compatibility inputs, not a production qualification claim. + return DependencyRequirement(extension_type, (DependencyIdentity(version, train),), config) + + +def installed(extension_type=EXTENSION_TYPE_SSC, **overrides): + return {"properties": {"extensionType": extension_type, "version": "1.5.3", "currentVersion": "1.5.3", + "releaseTrain": "Stable", "provisioningState": "Succeeded", + "configurationSettings": {"retained": "yes"}, **overrides}} + + +def test_compatible_dependency_is_read_only(): + record = installed() + original = deepcopy(record) + requirement(config=(("retained", "yes"),)).validate_installed(record) + assert record == original + del record["properties"]["version"] + requirement().validate_installed(record) + + +@pytest.mark.parametrize("overrides,match", [ + ({"currentVersion": None}, "currentVersion missing"), + ({"currentVersion": "bad"}, "extension version"), + ({"currentVersion": "1.5.2", "version": "1.5.2"}, "compatibility policy"), + ({"currentVersion": "1.5.4", "version": "1.5.4"}, "compatibility policy"), + ({"version": "1.5.4"}, "requested and installed"), + ({"releaseTrain": "preview"}, "compatibility policy"), + ({"releaseTrain": None}, "explicit release train"), + ({"provisioningState": "Updating"}, "not ready"), + ({"provisioningState": "Failed"}, "not ready"), + ({"errorInfo": {"code": "Failure"}}, "failed status"), + ({"statuses": [{"level": "Error"}]}, "failed status"), + ({"statuses": ["invalid"]}, "failed status"), + ({"statuses": {"state": "unknown"}}, "failed status"), + ({"statuses": {}}, "failed status"), + ({"configurationSettings": {}}, "requires configuration"), + ({"configurationSettings": ["invalid"]}, "invalid configuration"), + ({"configurationSettings": []}, "invalid configuration"), + ({"extensionType": EXTENSION_TYPE_OPS}, "does not match"), +]) +def test_incompatible_or_unknown_installed_dependency_is_rejected(overrides, match): + with pytest.raises(ValidationError, match=match): + requirement(config=(("retained", "yes"),)).validate_installed(installed(**overrides)) + + +@pytest.mark.parametrize("state", ["Failed", "Canceled"]) +def test_known_failed_dependency_can_be_repaired(state): + record = installed(provisioningState=state, version="1.5.4", errorInfo={"code": "Failure"}) + requirement().validate_observed(record, allow_repair=True) + with pytest.raises(ValidationError, match="not ready"): + requirement().validate_observed(record) + record["properties"]["provisioningState"] = "Updating" + with pytest.raises(ValidationError, match="not ready"): + requirement().validate_observed(record, allow_repair=True) + + +def test_shared_policy_is_immutable_and_duplicate_rules_fail(): + identities = [DependencyIdentity("1.5.3", "stable")] + configuration = [["retained", "yes"]] + policy = DependencyRequirement(EXTENSION_TYPE_SSC, identities, configuration) + identities.clear() + configuration[0][1] = "changed" + policy.validate_installed(installed()) + with pytest.raises(ValidationError, match="Duplicate shared"): + RuntimeProfileCatalog([], dependency_requirements=(policy, policy)) + with pytest.raises(ValidationError, match="at least one reviewed"): + DependencyRequirement(EXTENSION_TYPE_SSC, ()) + + +@pytest.mark.parametrize("channel", list(RuntimeChannel)) +@pytest.mark.parametrize("no_preflight", [False, True]) +def test_dependency_gate_blocks_create_before_registration( + mocker, mocked_cmd, mocked_responses, mocked_sleep, channel, no_preflight, +): + version = "1.5.7" if channel == RuntimeChannel.STABLE else "1.6.0-preview.4" + profile = make_profile(channel, version) + mocker.patch("azext_edge.edge.providers.orchestration.work.get_runtime_catalog", return_value=( + RuntimeProfileCatalog([profile], dependency_requirements=(requirement(),)) + )) + scenario = build_target_scenario(extension_config_settings={EXTENSION_TYPE_SSC: installed(currentVersion=None)}) + service = ServiceGenerator(scenario=scenario, mocked_responses=mocked_responses, action="create") + mocked_responses.assert_all_requests_are_fired = False + with pytest.raises(ValidationError, match="currentVersion missing"): + create_instance( + cmd=mocked_cmd, cluster_name=scenario["cluster"]["name"], + resource_group_name=scenario["resourceGroup"], instance_name=scenario["instance"]["name"], + schema_registry_resource_id=scenario["schemaRegistry"]["id"], + adr_namespace_resource_id=scenario["adrNamespace"]["id"], + use_preview=channel == RuntimeChannel.PREVIEW, no_progress=True, no_preflight=no_preflight, + ) + assert not service.call_map[CallKey.GET_RESOURCE_PROVIDERS] + assert all(call.request.method in {"GET", "HEAD"} for call in mocked_responses.calls) + + +def test_customer_managed_trust_does_not_require_cert_manager(mocker): + manager = WorkManager.__new__(WorkManager) + manager._targets = SimpleNamespace(trust_settings={"issuerName": "custom"}) + manager._dependency_requirements = (requirement(EXTENSION_TYPE_CM), requirement()) + mocker.patch.object(WorkManager, "ops_extension_dependencies", new=property( + lambda _: {EXTENSION_TYPE_CM: None, EXTENSION_TYPE_SSC: installed()} + )) + manager._process_extension_dependencies() + + +@pytest.mark.parametrize("force", [False, True]) +def test_incompatible_dependency_target_blocks_all_upgrade_writes(mocker, mocked_cmd, mocked_responses, force): + scenario = UpgradeScenario().set_extension(EXTENSION_TYPE_PLATFORM, ext_vers="1.0.0") + scenario.set_extension(EXTENSION_TYPE_OPS, ext_vers="1.4.0") + scenario.set_extension(EXTENSION_TYPE_SSC, ext_vers="1.5.3") + scenario.set_instance_mock(mocked_responses, "instance", "rg") + profile = make_profile(RuntimeChannel.STABLE, "1.4.105", opcua_connector_version="test-tag") + # The override is valid semver but not a reviewed dependency identity. + mocker.patch("azext_edge.edge.providers.orchestration.upgrade2.get_runtime_catalog", return_value=( + RuntimeProfileCatalog([profile], dependency_requirements=(requirement(),)) + )) + with pytest.raises(ValidationError, match="compatibility policy"): + upgrade_instance(mocked_cmd, "rg", "instance", ssc_version="1.5.4", force=force, confirm_yes=True) + assert not [call for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + + +@pytest.mark.parametrize("operation", ["noop", "config", "version", "create", "repair", "user_trust"]) +def test_foundation_plan_validates_effective_target(operation): + manager = UpgradeManager.__new__(UpgradeManager) + manager.targets = InitTargets("cluster", "rg") + ext_type = EXTENSION_TYPE_CM if operation in {"create", "user_trust"} else EXTENSION_TYPE_SSC + manager.runtime_catalog = RuntimeProfileCatalog([], dependency_requirements=( + requirement(ext_type, config=(("retained", "yes"),)), + )) + record = installed(ext_type) + kwargs = {"extension": record, "desired_version_map": {"version": "1.5.3", "train": "stable"}} + if operation == "config": + record["properties"]["configurationSettings"] = {} + kwargs["desired_config"] = {"retained": "yes"} + if operation == "version": + record["properties"].update(version="1.5.2", currentVersion="1.5.2") + if operation == "create": + kwargs.update(extension=None, extension_type=ext_type, operation_type=ExtensionOperation.CREATE, + desired_config={"retained": "yes"}) + if operation == "repair": + record["properties"].update(provisioningState="Failed", version="1.5.4") + # Explicitly repair to a reviewed target, even if the requested pin differs. + kwargs["desired_version_map"]["version"] = "1.5.4" + manager.runtime_catalog = RuntimeProfileCatalog([], dependency_requirements=( + requirement(ext_type, version="1.5.4", config=(("retained", "yes"),)), + )) + extensions = [] if operation == "user_trust" else [ExtensionUpgradeState(**kwargs)] + manager._validate_foundation_plan(SimpleNamespace(extension_upgrades=extensions)) + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("required_key", ["AgentOperationTimeoutInMinutes", "reviewedSetting"]) +def test_cert_manager_creation_policy_validates_real_plan_not_invented_config( + mocker, mocked_cmd, mocked_responses, force, required_key, +): + scenario = UpgradeScenario().set_extension(EXTENSION_TYPE_PLATFORM, ext_vers="1.0.0") + scenario.set_extension(EXTENSION_TYPE_CM, remove=True) + scenario.set_extension(EXTENSION_TYPE_OPS, ext_vers="1.4.0") + scenario.set_instance_mock(mocked_responses, "instance", "rg") + cm = scenario.targets.get_extension_versions()["certManager"] + policy = requirement(EXTENSION_TYPE_CM, cm["version"], cm["train"], config=((required_key, "20"),)) + profile = make_profile(RuntimeChannel.STABLE, "1.4.105", opcua_connector_version="test-tag") + mocker.patch("azext_edge.edge.providers.orchestration.upgrade2.get_runtime_catalog", return_value=( + RuntimeProfileCatalog([profile], dependency_requirements=(policy,)) + )) + if required_key == "reviewedSetting": + # Eligibility requirements must not silently turn into configuration migrations. + with pytest.raises(ValidationError, match="requires configuration reviewedSetting"): + upgrade_instance(mocked_cmd, "rg", "instance", force=force, confirm_yes=True) + else: + manager = UpgradeManager(mocked_cmd, "rg", "instance", force=force, no_progress=True) + state = manager.analyze_cluster() + creates = [extension for extension in state.extension_upgrades + if extension.operation_type == ExtensionOperation.CREATE] + assert len(creates) == 1 + payload = manager._build_creation_payload(creates[0])["properties"] + policy.validate_identity(payload["version"], payload["releaseTrain"], payload["configurationSettings"]) + assert payload["configurationSettings"] == {"AgentOperationTimeoutInMinutes": "20"} + assert not [call for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + + +def test_apply_rechecks_dependency_policy_before_any_write(mocker): + manager = UpgradeManager.__new__(UpgradeManager) + manager.runtime_catalog = RuntimeProfileCatalog([], dependency_requirements=(requirement(),)) + extension = ExtensionUpgradeState(installed(), {"version": "1.5.4", "train": "stable"}) + state = SimpleNamespace(validate_plan=Mock(), extension_upgrades=[extension]) + writer = mocker.patch.object(manager, "_apply_single_operation") + with pytest.raises(ValidationError, match="compatibility policy"): + manager.apply_upgrades(state) + writer.assert_not_called() + + +@pytest.mark.parametrize("operation", [ExtensionOperation.CREATE, ExtensionOperation.UPDATE, ExtensionOperation.DELETE]) +def test_extension_mutations_target_cluster_resource_group(operation): + manager = UpgradeManager.__new__(UpgradeManager) + manager.resource_group_name = "instance-rg" + manager.targets = InitTargets("cluster", "host-rg") + clients = Mock() + manager.resource_map = SimpleNamespace(connected_cluster=SimpleNamespace( + cluster_name="host-cluster", resource_group_name="host-rg", clusters=SimpleNamespace(extensions=clients), + )) + record = installed(EXTENSION_TYPE_CM) + record["name"] = "cert-manager" + extension = ExtensionUpgradeState(record, {"version": "1.5.3", "train": "stable"}, operation_type=operation) + manager._apply_single_operation(extension, operation, {}) + assert len(clients.mock_calls) == 1 + assert clients.mock_calls[0].kwargs["resource_group_name"] == "host-rg" + assert clients.mock_calls[0].kwargs["cluster_name"] == "host-cluster" diff --git a/azext_edge/tests/edge/orchestration/test_runtime_profiles_unit.py b/azext_edge/tests/edge/orchestration/test_runtime_profiles_unit.py new file mode 100644 index 000000000..214efdf1f --- /dev/null +++ b/azext_edge/tests/edge/orchestration/test_runtime_profiles_unit.py @@ -0,0 +1,392 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +from copy import deepcopy +from dataclasses import FrozenInstanceError, replace + +import pytest +from azure.cli.core.azclierror import ValidationError + +from azext_edge.edge.providers.orchestration.common import AIO_INSECURE_LISTENER_NAME +from azext_edge.edge.providers.orchestration.runtime_profiles import ( + RuntimeChannel, + RuntimeIdentity, + RuntimeProfile, + RuntimeProfileCatalog, + resolve_runtime_identity, + validate_upgrade_boundary, +) +from azext_edge.edge.providers.orchestration.targets import InitTargets, merge_template_object +from azext_edge.edge.providers.orchestration.template import ( + TEMPLATE_BLUEPRINT_ENABLEMENT, + TEMPLATE_BLUEPRINT_INSTANCE, + get_insecure_listener, +) + + +def make_profile(channel, version, train=None, **kwargs): + # Synthetic fixtures, not generated release inputs or production defaults. + blueprint = TEMPLATE_BLUEPRINT_INSTANCE.copy() + blueprint.content["variables"]["VERSIONS"]["iotOperations"] = version + blueprint.content["variables"]["TRAINS"]["iotOperations"] = train or channel.value + return RuntimeProfile(channel, "test-release", "test-ref", "test-commit", blueprint, **kwargs) + + +@pytest.fixture +def profiles(): + return ( + make_profile(RuntimeChannel.STABLE, "1.5.7"), + make_profile(RuntimeChannel.PREVIEW, "1.6.0-preview.4"), + ) + + +def test_catalog_defaults_and_upgrade_selection(profiles): + stable, preview = profiles + catalog = RuntimeProfileCatalog(profiles) + assert catalog.for_create() is stable + assert catalog.for_create(use_preview=True) is preview + assert catalog.for_upgrade(RuntimeIdentity(RuntimeChannel.STABLE, "1.4.100", "stable")) is stable + assert catalog.for_upgrade(RuntimeIdentity(RuntimeChannel.PREVIEW, "1.6.0-preview.2", "preview")) is preview + + +def test_bundled_profiles_select_management_api_without_changing_preview_blueprint(): + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + + catalog = get_runtime_catalog() + stable = catalog.get(RuntimeChannel.STABLE) + assert stable.iotops_api_version == "2026-10-01" + preview = catalog.get(RuntimeChannel.PREVIEW) + assert preview.iotops_api_version == "2026-09-01-preview" + assert preview.copy_instance_blueprint().content["resources"]["aioInstance"]["apiVersion"] == "2026-09-01-preview" + + +def test_catalog_missing_and_duplicate_profiles_fail(profiles): + with pytest.raises(ValidationError, match="No reviewed preview"): + RuntimeProfileCatalog(profiles[:1]).for_create(use_preview=True) + with pytest.raises(ValidationError, match="Multiple target profiles"): + RuntimeProfileCatalog([profiles[0], profiles[0]]) + + +@pytest.mark.parametrize("train", ["integration", "canary", "", None]) +def test_unmapped_nonpublic_train_is_not_preview(train): + with pytest.raises(ValidationError): + resolve_runtime_identity("1.6.0-preview.4", train) + + +def test_integration_requires_exact_mapping(): + stable = RuntimeIdentity(RuntimeChannel.STABLE, "1.5.7", "integration") + preview = RuntimeIdentity(RuntimeChannel.PREVIEW, "1.6.0-preview.4", "integration") + identities = [stable, preview] + assert resolve_runtime_identity(stable.version, "Integration", identities) == stable + assert resolve_runtime_identity(preview.version, "integration", identities) == preview + with pytest.raises(ValidationError, match="no supported runtime profile"): + resolve_runtime_identity("1.6.0-preview.5", "integration", identities) + with pytest.raises(ValidationError, match="must remain"): + validate_upgrade_boundary(stable, preview) + + +@pytest.mark.parametrize( + "channel,version,train", + [ + (RuntimeChannel.STABLE, "1.6.0-preview.4", "stable"), + (RuntimeChannel.STABLE, "1.5.7", "preview"), + (RuntimeChannel.PREVIEW, "1.6.0", "preview"), + (RuntimeChannel.PREVIEW, "1.6.1-preview.4", "preview"), + (RuntimeChannel.PREVIEW, "1.6.0-preview.0", "preview"), + (RuntimeChannel.PREVIEW, "1.6.0-rc.4", "preview"), + (RuntimeChannel.STABLE, "invalid", "stable"), + (RuntimeChannel.STABLE, None, "stable"), + ], +) +def test_inconsistent_identity_rejected(channel, version, train): + with pytest.raises(ValidationError): + RuntimeIdentity(channel, version, train) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_cross_profile_upgrade_boundary(profiles, reverse): + identities = [profile.identity for profile in profiles] + if reverse: + identities.reverse() + with pytest.raises(ValidationError, match="must remain"): + validate_upgrade_boundary(*identities) + + +def test_numeric_preview_order_and_downgrade(): + older = RuntimeIdentity(RuntimeChannel.PREVIEW, "1.6.0-preview.2", "preview") + newer = RuntimeIdentity(RuntimeChannel.PREVIEW, "1.6.0-preview.10", "preview") + validate_upgrade_boundary(older, newer) + validate_upgrade_boundary(older, older) + with pytest.raises(ValidationError, match="downgrade"): + validate_upgrade_boundary(newer, older) + + +def test_profile_owns_blueprint_copies(): + blueprint = TEMPLATE_BLUEPRINT_INSTANCE.copy() + original = deepcopy(blueprint.content) + profile = RuntimeProfile(RuntimeChannel.STABLE, "test", "branch", "commit", blueprint) + blueprint.content["variables"].clear() + returned = profile.copy_instance_blueprint() + returned.content["resources"].clear() + assert profile.copy_instance_blueprint().content == original + with pytest.raises(FrozenInstanceError): + profile.release = "other" + + +def test_profile_requires_provenance(): + with pytest.raises(ValidationError, match="resolved source commit"): + RuntimeProfile(RuntimeChannel.STABLE, "test", "branch", "", TEMPLATE_BLUEPRINT_INSTANCE) + + +def test_profile_overrides_cannot_change_channel_or_train(profiles): + stable, preview = profiles + for profile, kwargs in [ + (stable, {"train": "preview"}), + (stable, {"version": "1.6.0-preview.4"}), + (preview, {"version": "1.6.0"}), + (preview, {"train": "integration"}), + ]: + with pytest.raises(ValidationError): + profile.validate_overrides(**kwargs) + stable.validate_overrides(version="1.5.8", train="Stable") + preview.validate_overrides(version="1.6.0-preview.5") + qualification = make_profile(RuntimeChannel.PREVIEW, "1.6.0-preview.4", "integration") + with pytest.raises(ValidationError, match="reviewed profile inputs"): + qualification.validate_overrides(version="1.6.0-preview.5") + + +def test_targets_use_selected_profile_without_changing_foundation(profiles): + original_instance = deepcopy(TEMPLATE_BLUEPRINT_INSTANCE.content) + original_enablement = deepcopy(TEMPLATE_BLUEPRINT_ENABLEMENT.content) + legacy = InitTargets("cluster", "rg") + stable_targets = InitTargets("cluster", "rg", runtime_profile=profiles[0], instance_name="custom") + preview_targets = InitTargets("cluster", "rg", runtime_profile=profiles[1], instance_name="custom") + assert stable_targets.get_extension_versions(False)["iotOperations"] == { + "version": "1.5.7", "train": "stable" + } + assert preview_targets.get_extension_versions(False)["iotOperations"] == { + "version": "1.6.0-preview.4", "train": "preview" + } + assert legacy.get_ops_enablement_template() == stable_targets.get_ops_enablement_template() + assert legacy.get_ops_enablement_template() == preview_targets.get_ops_enablement_template() + for targets in (stable_targets, preview_targets): + template, _ = targets.get_ops_instance_template() + assert template["resources"]["aioInstance"]["name"] == "custom" + assert template["resources"]["broker"]["name"] == "custom/default" + template["variables"]["VERSIONS"].clear() + assert targets.get_extension_versions(False)["iotOperations"]["version"] + assert TEMPLATE_BLUEPRINT_INSTANCE.content == original_instance + assert TEMPLATE_BLUEPRINT_ENABLEMENT.content == original_enablement + + +@pytest.mark.parametrize("channel", [None, RuntimeChannel.STABLE, RuntimeChannel.PREVIEW]) +@pytest.mark.parametrize("add_insecure_listener", [False, True]) +def test_optional_listener_uses_selected_blueprint_api(channel, add_insecure_listener): + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + + profile = get_runtime_catalog().get(channel) if channel is not None else None + blueprint = profile.copy_instance_blueprint() if profile is not None else TEMPLATE_BLUEPRINT_INSTANCE.copy() + original = deepcopy(blueprint.content) + targets = InitTargets( + "cluster", "rg", runtime_profile=profile, instance_name="instance", + add_insecure_listener=add_insecure_listener, + ) + template, _ = targets.get_ops_instance_template() + resources = template["resources"] + + if add_insecure_listener: + expected = get_insecure_listener("instance", "default") + if channel == RuntimeChannel.PREVIEW: + expected["apiVersion"] = original["resources"]["brokerListener"]["apiVersion"] + assert resources["brokerListenerInsecure"] == expected + assert expected["name"] == f"instance/default/{AIO_INSECURE_LISTENER_NAME}" + if channel != RuntimeChannel.PREVIEW: + assert expected["apiVersion"] == "2026-10-01" + else: + assert "brokerListenerInsecure" not in resources + for key, resource in original["resources"].items(): + if resource["type"].startswith("Microsoft.IoTOperations/"): + assert resources[key]["apiVersion"] == resource["apiVersion"] + if profile is not None: + assert profile.copy_instance_blueprint().content == original + else: + assert TEMPLATE_BLUEPRINT_INSTANCE.content == original + + +@pytest.mark.parametrize("listener_api", ["2026-09-01-preview", "2026-09-02-preview", "2026-09-03-preview"]) +def test_preview_optional_listener_follows_blueprint_api_changes(listener_api): + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + + original_profile = get_runtime_catalog().get(RuntimeChannel.PREVIEW) + blueprint = original_profile.copy_instance_blueprint() + blueprint.content["resources"]["brokerListener"]["apiVersion"] = listener_api + profile = replace(original_profile, instance_blueprint=blueprint) + targets = InitTargets("cluster", "rg", runtime_profile=profile, add_insecure_listener=True) + + template, _ = targets.get_ops_instance_template() + + assert template["resources"]["brokerListenerInsecure"]["apiVersion"] == listener_api + + +def test_targets_reject_conflicting_overrides(profiles): + with pytest.raises(ValidationError): + InitTargets("cluster", "rg", runtime_profile=profiles[0], ops_train="preview") + + +def test_targets_preserve_additional_instance_properties(profiles): + blueprint = profiles[1].copy_instance_blueprint() + blueprint.content["resources"]["aioInstance"]["properties"]["additionalProperty"] = {"mode": "keep"} + profile = RuntimeProfile(RuntimeChannel.PREVIEW, "test", "branch", "commit", blueprint) + targets = InitTargets("cluster", "rg", runtime_profile=profile, instance_description="description") + template, _ = targets.get_ops_instance_template() + properties = template["resources"]["aioInstance"]["properties"] + assert properties["additionalProperty"] == {"mode": "keep"} + assert properties["description"] == "description" + properties["additionalProperty"]["mode"] = "changed" + assert profile.copy_instance_blueprint().content == blueprint.content + + +@pytest.mark.parametrize("description", [None, "", "custom description"]) +def test_generated_instance_defaults_and_new_child_names_are_preserved(profiles, description): + blueprint = profiles[1].copy_instance_blueprint() + original_properties = deepcopy(blueprint.content["resources"]["aioInstance"]["properties"]) + child = {"type": "Microsoft.IoTOperations/instances/testResources", + "name": "[format('{0}/extra', parameters('aioInstanceName'))]"} + blueprint.content["resources"]["newChild"] = child + profile = RuntimeProfile(RuntimeChannel.PREVIEW, "test", "branch", "commit", blueprint) + targets = InitTargets( + "cluster", "rg", runtime_profile=profile, instance_name="My_Instance", instance_description=description, + ) + for phase in (None, 1, 2, 3): + template, parameters = targets.get_ops_instance_template(phase=phase) + assert parameters["aioInstanceName"] == {"value": "my-instance"} + assert "features" not in parameters + if phase in (None, 2): + assert template["resources"]["aioInstance"]["properties"] == { + **original_properties, "description": description, + } + if phase in (None, 3): + assert template["resources"]["newChild"] == child + assert profile.copy_instance_blueprint().content == blueprint.content + + +@pytest.mark.parametrize("channel", list(RuntimeChannel)) +@pytest.mark.parametrize("mode", ["Stable", "Preview", "Disabled"]) +@pytest.mark.parametrize("phase", [None, 1, 2, 3]) +def test_mode_only_features_satisfy_arm_parameter_contract(channel, mode, phase): + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + + profile = get_runtime_catalog().get(channel) + original = profile.copy_instance_blueprint().content + targets = InitTargets("cluster", "rg", runtime_profile=profile, instance_features=[f"opcua.mode={mode}"]) + template, parameters = targets.get_ops_instance_template(phase=phase) + + settings_schema = template["definitions"]["_1.InstanceFeature"]["properties"]["settings"] + assert settings_schema["type"] == "object" + assert settings_schema.get("nullable", False) is (channel == RuntimeChannel.PREVIEW) + assert parameters["features"] == {"value": {"opcua": {"mode": mode, "settings": {}}}} + assert targets.instance_features == {"opcua": {"mode": mode}} + assert profile.copy_instance_blueprint().content == original + parameters["features"]["value"]["opcua"]["settings"]["changed"] = "Enabled" + assert targets.get_ops_instance_template(phase=phase)[1]["features"]["value"]["opcua"]["settings"] == {} + + +@pytest.mark.parametrize("parameter_defaults", [True, False]) +def test_explicit_features_merge_nested_generated_defaults(profiles, parameter_defaults): + blueprint = profiles[1].copy_instance_blueprint() + defaults = {"opcua": {"mode": "Stable", "settings": {"retained": "value", "changed": "old"}}, + "otherComponent": {"mode": "Preview"}} + if parameter_defaults: + blueprint.content["parameters"]["features"]["defaultValue"] = defaults + else: + del blueprint.content["parameters"]["features"] + blueprint.content["resources"]["aioInstance"]["properties"]["features"] = defaults + profile = RuntimeProfile(RuntimeChannel.PREVIEW, "test", "branch", "commit", blueprint) + targets = InitTargets("cluster", "rg", runtime_profile=profile, + instance_features=["opcua.mode=Preview"], instance_description="") + template, parameters = targets.get_ops_instance_template() + properties = template["resources"]["aioInstance"]["properties"] + actual = parameters["features"]["value"] if parameter_defaults else properties["features"] + assert actual == {"opcua": {"mode": "Preview", "settings": {"retained": "value", "changed": "old"}}, + "otherComponent": {"mode": "Preview"}} + assert properties["description"] == "" + if parameter_defaults: + assert properties["features"] == "[parameters('features')]" + actual["opcua"]["settings"].clear() + assert profile.copy_instance_blueprint().content == blueprint.content + + +@pytest.mark.parametrize("parameter_defaults", [True, False]) +def test_feature_expression_merge_stays_in_template_scope(profiles, parameter_defaults): + blueprint = profiles[1].copy_instance_blueprint() + if parameter_defaults: + blueprint.content["parameters"]["featureDefaults"] = {"type": "object", "defaultValue": {}} + blueprint.content["parameters"]["features"]["defaultValue"] = "[parameters('featureDefaults')]" + else: + del blueprint.content["parameters"]["features"] + profile = RuntimeProfile(RuntimeChannel.PREVIEW, "test", "branch", "commit", blueprint) + targets = InitTargets("cluster", "rg", runtime_profile=profile, + instance_features=["opcua.mode=Preview"]) + template, parameters = targets.get_ops_instance_template() + assert "features" not in parameters + expression = (template["parameters"]["features"]["defaultValue"] if parameter_defaults + else template["resources"]["aioInstance"]["properties"]["features"]) + source = "parameters('featureDefaults')" if parameter_defaults else "parameters('features')" + override = ('{"opcua":{"mode":"Preview","settings":{}}}' if parameter_defaults + else '{"opcua":{"mode":"Preview"}}') + assert expression == ( + "[union(coalesce(" + source + ", createObject()), " + "json('" + override + "'))]" + ) + assert profile.copy_instance_blueprint().content == blueprint.content + + +def test_object_overlay_merges_nested_settings_and_escapes_arm_literals(): + defaults = {"component": {"mode": "Stable", "settings": {"a": "Enabled", "b": "Disabled"}}} + override = {"component": {"settings": {"b": "Enabled"}}} + assert merge_template_object(defaults, override) == { + "component": {"mode": "Stable", "settings": {"a": "Enabled", "b": "Enabled"}}, + } + assert defaults["component"]["settings"]["b"] == "Disabled" + assert merge_template_object("[variables('defaults')]", {"value": "it's"}) == ( + "[union(coalesce(variables('defaults'), createObject()), json('{\"value\":\"it''s\"}'))]" + ) + + +@pytest.mark.parametrize("bound,limit,value,valid", [ + ("minValue", 3, 3, True), ("minValue", 3, 2, False), + ("maxValue", 4, 4, True), ("maxValue", 4, 5, False), +]) +def test_selected_broker_schema_can_have_one_bound(profiles, bound, limit, value, valid): + from azure.cli.core.azclierror import InvalidArgumentValueError + + blueprint = profiles[1].copy_instance_blueprint() + definition = blueprint.content["definitions"]["_1.BrokerConfig"]["properties"]["frontendWorkers"] + definition.pop("minValue", None) + definition.pop("maxValue", None) + definition[bound] = limit + profile = RuntimeProfile(RuntimeChannel.PREVIEW, "test", "branch", "commit", blueprint) + if valid: + targets = InitTargets("cluster", "rg", runtime_profile=profile, broker_frontend_workers=value) + assert targets.broker_config["frontendWorkers"] == value + else: + with pytest.raises(InvalidArgumentValueError, match="frontendWorkers"): + InitTargets("cluster", "rg", runtime_profile=profile, broker_frontend_workers=value) + assert profile.copy_instance_blueprint().content == blueprint.content + + +def test_selected_broker_schema_is_used_and_not_mutated(profiles): + from azure.cli.core.azclierror import InvalidArgumentValueError + + blueprint = profiles[1].copy_instance_blueprint() + definition = blueprint.content["definitions"]["_1.BrokerConfig"]["properties"] + definition["frontendWorkers"]["maxValue"] = 3 + profile = RuntimeProfile(RuntimeChannel.PREVIEW, "test", "branch", "commit", blueprint) + with pytest.raises(InvalidArgumentValueError, match="frontendWorkers"): + InitTargets("cluster", "rg", runtime_profile=profile, broker_frontend_workers=4) + targets = InitTargets("cluster", "rg", runtime_profile=profile, broker_frontend_workers=3) + assert targets.get_broker_config_target_map()["frontendWorkers"] == 3 + assert profile.copy_instance_blueprint().content == blueprint.content diff --git a/azext_edge/tests/edge/orchestration/test_runtime_registration_unit.py b/azext_edge/tests/edge/orchestration/test_runtime_registration_unit.py new file mode 100644 index 000000000..32a140735 --- /dev/null +++ b/azext_edge/tests/edge/orchestration/test_runtime_registration_unit.py @@ -0,0 +1,462 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Real command registration, parser, expansion and validation; no account or ARM access.""" + +from copy import copy, deepcopy +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from azure.cli.core import AzCli +from azure.cli.core.azclierror import ValidationError +from azure.cli.core.commands import AzCliCommandInvoker, CliCommandType, _explode_list_args +from azure.cli.core.commands.parameters import get_three_state_flag +from azure.cli.core.commands.validators import IterateValue +from azure.cli.core.parser import AzCliCommandParser +from knack.arguments import CLIArgumentType, ignore_type +from knack.cli import CLI +from knack.events import EVENT_INVOKER_POST_PARSE_ARGS, EVENT_INVOKER_PRE_PARSE_ARGS +from knack.help_files import helps + +from azext_edge import OpsExtensionCommandsLoader +from azext_edge.edge.providers.orchestration import runtime_requirements +from azext_edge.edge.providers.orchestration.runtime import ( + OperationRequirements, ParameterRequirement, RuntimeContext, RuntimeIssue, RuntimeIssueCode, +) +from azext_edge.edge.providers.orchestration.runtime_commands import ( + PREVIEW_RUNTIME_NOTICE, RuntimeTarget, get_runtime_notice_targets, +) +from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel, RuntimeIdentity + + +PREVIEW_ONLY = OperationRequirements("Synthetic capability", channels={RuntimeChannel.PREVIEW}) + + +def synthetic_handler( + cmd, instance_name="instance", resource_group_name="rg", mode="Preview", enabled=False, count=0, + modes=None, instance_features=None, other_name=None, other_rg=None, target_subscription=None, + force=False, confirm_yes=False, no_preflight=False, use_preview=False, ensure_latest=False, +): + """Signature used by Azure CLI argument reflection, without validation boilerplate.""" + + +def runtime(channel=RuntimeChannel.STABLE, version=None, issues=()): + version = version or ("1.6.0-preview.4" if channel == RuntimeChannel.PREVIEW else "1.5.7") + return RuntimeContext( + instance_id="instance", custom_location_id="location", cluster_id="cluster", extension_id="extension", + identity=RuntimeIdentity(channel, version, channel.value), requested_version=version, + provisioning_state="Succeeded", readiness_issues=issues, + ) + + +@pytest.fixture +def invocation(mocker, tmp_path): + # Initialize Knack's real event/config plumbing but not AzCli's account caches. + cli = AzCli.__new__(AzCli) + CLI.__init__(cli, cli_name="az", config_dir=str(tmp_path)) + cli.cloud = Mock(profile="latest") + cli.local_context = SimpleNamespace(is_on=False) + cli.data["subscription_id"] = "default-subscription" + cli.data["completer_active"] = False + invoker = AzCliCommandInvoker.__new__(AzCliCommandInvoker) + invoker.cli_ctx = cli + invoker.data = {} + cli.invocation = invoker + loader = OpsExtensionCommandsLoader(cli) + invoker.commands_loader = loader + mocker.patch("azext_edge.edge.util.version_check.check_latest") + mocker.patch("azure.cli.core._profile.Profile.get_raw_token", side_effect=AssertionError("authentication")) + instances = mocker.patch("azext_edge.edge.providers.orchestration.resources.instances.Instances") + instances.return_value.get_runtime_context.return_value = runtime() + + def register(command_name="iot ops synthetic", validator=None, argument_validator=None, + registration_method="command", **metadata): + group_name, verb = command_name.rsplit(" ", 1) + command_type = CliCommandType(operations_tmpl=__name__ + "#{}") + with loader.command_group( + group_name, command_type=command_type, custom_command_type=command_type, + ) as group: + getattr(group, registration_method)(verb, "synthetic_handler", validator=validator, **metadata) + command = loader.command_table[command_name] + command.load_arguments() + # MainCommandsLoader normally supplies the global ignored cmd argument. + command.update_argument("cmd", ignore_type) + command.update_argument("mode", CLIArgumentType(options_list=["--mode", "-m"], validator=argument_validator)) + command.update_argument("enabled", get_three_state_flag()) + command.update_argument("count", CLIArgumentType(type=int)) + for dest in ("modes", "instance_features"): + command.update_argument(dest, CLIArgumentType(nargs="+")) + writer = Mock(return_value={}) + command.handler = writer + help_handler = Mock() + + def execute(arguments=(), expanded_targets=None, help_command=None): + selected_command = help_command or command_name + invoker.data["command_string"] = selected_command + cli.data["runtime_notice_targets"] = get_runtime_notice_targets(loader.command_table) + global_parser = AzCliCommandParser.create_global_parser(cli_ctx=cli) + invoker.parser = AzCliCommandParser( + cli_ctx=cli, cli_help=help_handler, prog="az", parents=[global_parser], + ) + invoker.parser.load_command_table(loader) + args = selected_command.split() + list(arguments) + cli.raise_event(EVENT_INVOKER_PRE_PARSE_ARGS, args=args) + namespace = invoker.parser.parse_args(args) + cli.raise_event(EVENT_INVOKER_POST_PARSE_ARGS, command=command_name, args=namespace) + if expanded_targets: + # Same values the ARM --ids parser supplies; use the actual CLI + # expansion and validator methods, including per-job subscriptions. + namespace.instance_name = IterateValue([item[0] for item in expanded_targets]) + namespace.resource_group_name = IterateValue([item[1] for item in expanded_targets]) + namespace._subscription = IterateValue([item[2] for item in expanded_targets]) + jobs = [] + for expanded in _explode_list_args(namespace): + job = copy(command) + job.cli_ctx = copy(cli) + job.cli_ctx.data = deepcopy(cli.data) + expanded.cmd = expanded._cmd = job + if hasattr(expanded, "_subscription"): + job.cli_ctx.data["subscription_id"] = expanded._subscription + invoker._validation(expanded) + jobs.append((job, expanded)) + for job, expanded in jobs: + job(invoker._filter_params(expanded)) + return jobs + + return SimpleNamespace( + execute=execute, command=command, writer=writer, instances=instances, cli=cli, + help_handler=help_handler, + ) + + return register + + +@pytest.mark.parametrize("registration", ["metadata", "registry"]) +@pytest.mark.parametrize("label", [False, True]) +@pytest.mark.parametrize("help_command", ["iot ops synthetic show", "iot ops synthetic"]) +def test_fixed_runtime_notice_for_offline_help(invocation, mocker, caplog, registration, label, help_command): + name = "iot ops synthetic show" + metadata = {"runtime_requirement": PREVIEW_ONLY} if registration == "metadata" else {} + if registration == "registry": + mocker.patch.dict(runtime_requirements.COMMAND_REQUIREMENTS, {name: PREVIEW_ONLY}) + call = invocation(command_name=name, is_preview=label, **metadata) + original_help = dict(helps) + preview_info = call.command.preview_info + with pytest.raises(SystemExit) as result: + call.execute(["--help"], help_command=help_command) + assert result.value.code == 0 + assert caplog.messages.count(PREVIEW_RUNTIME_NOTICE) == 1 + assert helps == original_help + assert call.command.preview_info is preview_info + call.instances.assert_not_called() + call.writer.assert_not_called() + call.help_handler.show_help.assert_called_once() + + +def test_runtime_notice_once_per_invocation_not_per_target(invocation, caplog): + call = invocation(runtime_requirement=PREVIEW_ONLY) + call.instances.return_value.get_runtime_context.return_value = runtime(RuntimeChannel.PREVIEW) + OpsExtensionCommandsLoader(call.cli) + for _ in range(2): + call.execute(expanded_targets=[("first", "rg", "sub"), ("second", "rg", "sub")]) + assert call.writer.call_count == 4 + assert caplog.messages.count(PREVIEW_RUNTIME_NOTICE) == 2 + + +@pytest.mark.parametrize("metadata", [ + {"is_preview": True}, + {"runtime_requirement": OperationRequirements("Stable", channels={RuntimeChannel.STABLE})}, + {"runtime_requirement": OperationRequirements("Shared", channels=set(RuntimeChannel))}, + {"runtime_parameters": (ParameterRequirement("enabled", PREVIEW_ONLY),)}, +]) +def test_shared_command_help_has_no_preview_runtime_notice(invocation, caplog, metadata): + call = invocation(**metadata) + with pytest.raises(SystemExit) as result: + call.execute(["--help"]) + assert result.value.code == 0 + assert PREVIEW_RUNTIME_NOTICE not in caplog.messages + call.instances.assert_not_called() + + +def test_mixed_group_help_has_no_preview_runtime_notice(invocation, caplog): + invocation(command_name="iot ops synthetic shared") + call = invocation(command_name="iot ops synthetic restricted", runtime_requirement=PREVIEW_ONLY) + with pytest.raises(SystemExit) as result: + call.execute(["--help"], help_command="iot ops synthetic") + assert result.value.code == 0 + assert PREVIEW_RUNTIME_NOTICE not in caplog.messages + call.instances.assert_not_called() + + +@pytest.mark.parametrize("channel", list(RuntimeChannel)) +@pytest.mark.parametrize("registration", ["metadata", "registry"]) +def test_command_requirement_precedes_handler(invocation, mocker, channel, registration, caplog): + metadata = {"runtime_requirement": PREVIEW_ONLY} if registration == "metadata" else {} + if registration == "registry": + mocker.patch.dict(runtime_requirements.COMMAND_REQUIREMENTS, {"iot ops synthetic": PREVIEW_ONLY}) + call = invocation(**metadata) + call.instances.return_value.get_runtime_context.return_value = runtime(channel) + if channel == RuntimeChannel.STABLE: + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute() + call.writer.assert_not_called() + else: + call.execute() + call.writer.assert_called_once() + assert call.command.preview_info is None + assert caplog.messages.count(PREVIEW_RUNTIME_NOTICE) == 1 + + +@pytest.mark.parametrize("registration_method", ["show_command", "custom_show_command"]) +@pytest.mark.parametrize("channel", list(RuntimeChannel)) +def test_show_registration_consumes_and_enforces_runtime_metadata(invocation, registration_method, channel): + call = invocation(registration_method=registration_method, runtime_requirement=PREVIEW_ONLY) + call.instances.return_value.get_runtime_context.return_value = runtime(channel) + if channel == RuntimeChannel.STABLE: + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute() + call.writer.assert_not_called() + else: + call.execute() + call.writer.assert_called_once() + + +@pytest.mark.parametrize("registration_method", ["show_command", "custom_show_command"]) +def test_show_parameter_metadata_and_custom_target(invocation, registration_method): + resolver = Mock(return_value=runtime()) + call = invocation(registration_method=registration_method, runtime_target=resolver, + runtime_parameters=(ParameterRequirement("enabled", PREVIEW_ONLY),)) + call.execute() + resolver.assert_not_called() + call.writer.reset_mock() + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute(["--enabled"]) + resolver.assert_called_once() + call.writer.assert_not_called() + + +@pytest.mark.parametrize("label", [False, True]) +def test_unmarked_command_and_cli_preview_label_do_not_discover(invocation, label, caplog): + call = invocation(is_preview=label) + call.execute() + call.instances.assert_not_called() + call.writer.assert_called_once() + assert PREVIEW_RUNTIME_NOTICE not in caplog.messages + + +@pytest.mark.parametrize("parameter", ["mode", "enabled", "count"]) +def test_omitted_option_default_does_not_restrict_shared_command(invocation, parameter): + call = invocation(runtime_parameters=(ParameterRequirement(parameter, PREVIEW_ONLY),)) + call.execute() + call.instances.assert_not_called() + call.writer.assert_called_once() + + +@pytest.mark.parametrize("parameter,arguments", [ + ("mode", ["-m", "Preview"]), ("mode", ["--mode=Preview"]), ("mode", ["--mode", ""]), + ("enabled", ["--enabled", "false"]), ("count", ["--count", "0"]), +]) +def test_explicit_default_false_zero_and_empty_are_requests(invocation, parameter, arguments): + call = invocation(runtime_parameters=(ParameterRequirement(parameter, PREVIEW_ONLY),)) + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute(arguments) + call.writer.assert_not_called() + + +@pytest.mark.parametrize("channel", list(RuntimeChannel)) +@pytest.mark.parametrize("arguments,restricted", [ + ([], False), (["--mode", "Stable"], False), (["--mode", "Preview"], True), + (["--modes", "Stable", "Preview"], True), + (["--instance-features", "component.mode=Stable"], False), + (["--instance-features", "component.mode=Preview"], True), +]) +def test_only_selected_restricted_values_require_runtime(invocation, channel, arguments, restricted): + rules = tuple(ParameterRequirement(name, PREVIEW_ONLY, {"Preview"}) for name in ( + "mode", "modes", "component.mode", + )) + call = invocation(runtime_parameters=rules) + call.instances.return_value.get_runtime_context.return_value = runtime(channel) + if restricted and channel == RuntimeChannel.STABLE: + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute(arguments) + call.writer.assert_not_called() + else: + call.execute(arguments) + call.writer.assert_called_once() + assert call.instances.call_count == int(restricted) + + +@pytest.mark.parametrize("version,allowed", [("1.5.0", False), ("1.5.7", True), ("1.6.0", False)]) +def test_shared_version_bounds(invocation, version, allowed): + requirement = OperationRequirements( + "Versioned operation", minimum_version="1.5.5", maximum_version_exclusive="1.6.0", + ) + call = invocation(runtime_requirement=requirement) + call.instances.return_value.get_runtime_context.return_value = runtime(version=version) + if allowed: + call.execute() + else: + with pytest.raises(ValidationError, match="requires AIO version"): + call.execute() + call.writer.assert_not_called() + + +@pytest.mark.parametrize("command_level", [False, True]) +def test_existing_validators_are_preserved_without_reclassifying_defaults(invocation, command_level): + visited = [] + + def existing(cmd, namespace): + visited.append(cmd.cli_ctx.data["subscription_id"]) + namespace.mode = "Preview" + + call = invocation( + validator=existing if command_level else None, + argument_validator=None if command_level else existing, + runtime_parameters=(ParameterRequirement("mode", PREVIEW_ONLY),), + ) + call.execute() + assert visited == ["default-subscription"] + call.instances.assert_not_called() + call.writer.assert_called_once() + + +def test_existing_validator_failure_stops_runtime_discovery(invocation): + def existing(namespace): + raise ValidationError("existing validation failed") + + call = invocation(validator=existing, runtime_requirement=PREVIEW_ONLY) + with pytest.raises(ValidationError, match="existing validation failed"): + call.execute() + call.instances.assert_not_called() + call.writer.assert_not_called() + + +def test_expanded_targets_use_job_subscription_and_all_validate_before_handlers(invocation): + call = invocation(runtime_requirement=PREVIEW_ONLY) + call.instances.return_value.get_runtime_context.side_effect = [runtime(RuntimeChannel.PREVIEW), runtime()] + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute(expanded_targets=[("one", "rg1", "sub1"), ("two", "rg2", "sub2")]) + assert [c.args[0].cli_ctx.data["subscription_id"] for c in call.instances.call_args_list] == ["sub1", "sub2"] + assert [c.kwargs for c in call.instances.return_value.show.call_args_list] == [ + {"name": "one", "resource_group_name": "rg1"}, {"name": "two", "resource_group_name": "rg2"}, + ] + assert call.cli.data["subscription_id"] == "default-subscription" + call.writer.assert_not_called() + + +def test_nonstandard_target_mapping(invocation): + call = invocation( + runtime_requirement=OperationRequirements("Shared"), + runtime_target=RuntimeTarget("other_name", "other_rg", "target_subscription"), + ) + call.execute(["--other-name", "mapped", "--other-rg", "mapped-rg", "--target-subscription", "mapped-sub"]) + assert call.instances.call_args.kwargs == {"subscription_id": "mapped-sub"} + call.instances.return_value.show.assert_called_once_with(name="mapped", resource_group_name="mapped-rg") + + +def test_missing_mapped_target_fails_closed(invocation): + call = invocation(runtime_requirement=PREVIEW_ONLY, runtime_target=RuntimeTarget("other_name", "other_rg")) + with pytest.raises(ValidationError, match="Cannot resolve the AIO runtime"): + call.execute() + call.instances.assert_not_called() + call.writer.assert_not_called() + + +def test_explicit_target_resolver(invocation): + resolver = Mock(return_value=runtime(RuntimeChannel.PREVIEW)) + call = invocation(runtime_requirement=PREVIEW_ONLY, runtime_target=resolver) + jobs = call.execute() + assert resolver.call_args.args[0] is jobs[0][0] + call.instances.assert_not_called() + call.writer.assert_called_once() + + +def test_not_ready_target_cannot_execute(invocation): + call = invocation(runtime_requirement=PREVIEW_ONLY) + call.instances.return_value.get_runtime_context.return_value = runtime(RuntimeChannel.PREVIEW, issues=( + RuntimeIssue(RuntimeIssueCode.PROVISIONING_STATE, "instance", "pending", "Updating"), + )) + with pytest.raises(ValidationError, match="not ready"): + call.execute() + call.writer.assert_not_called() + + +def test_restricted_help_is_offline(invocation): + call = invocation(runtime_requirement=PREVIEW_ONLY) + with pytest.raises(SystemExit) as error: + call.execute(["--help"]) + assert error.value.code == 0 + call.help_handler.show_help.assert_called_once() + call.instances.assert_not_called() + call.writer.assert_not_called() + + +@pytest.mark.parametrize("command_name", ["iot ops create", "iot ops update", "iot ops upgrade"]) +@pytest.mark.parametrize("explicit", [False, True]) +def test_lifecycle_reuses_explicit_selections_and_existing_discovery(invocation, command_name, explicit): + call = invocation(command_name, runtime_parameters=(ParameterRequirement("mode", PREVIEW_ONLY),)) + jobs = call.execute(["--mode", "Preview"] if explicit else []) + job = jobs[0][0] + call.instances.assert_not_called() + # Simulate the existing lifecycle's prewrite check after its own profile/runtime + # selection. Parser defaults cannot reappear as user selections on the second check. + defaults = {"mode": "Preview"} + if explicit: + with pytest.raises(ValidationError, match="requires a preview runtime"): + runtime_requirements.validate_runtime_requirements(command_name, runtime().identity, defaults, job) + else: + runtime_requirements.validate_runtime_requirements(command_name, runtime().identity, defaults, job) + assert "runtime_requested_arguments" not in vars(call.command) + + +def test_parameter_registry_is_enforced(invocation, mocker): + mocker.patch.dict(runtime_requirements.PARAMETER_REQUIREMENTS, { + "iot ops synthetic": (ParameterRequirement("enabled", PREVIEW_ONLY),), + }) + call = invocation() + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute(["--enabled", "false"]) + call.writer.assert_not_called() + + +@pytest.mark.parametrize("arguments", [["--force"], ["--confirm-yes"], ["--no-preflight"]]) +def test_flags_cannot_bypass_command_requirement(invocation, arguments): + call = invocation(runtime_requirement=PREVIEW_ONLY) + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute(arguments) + call.writer.assert_not_called() + + +def test_explicit_selection_does_not_leak_to_next_invocation(invocation): + call = invocation(runtime_parameters=(ParameterRequirement("enabled", PREVIEW_ONLY),)) + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute(["--enabled"]) + call.instances.reset_mock() + call.execute() + call.instances.assert_not_called() + call.writer.assert_called_once() + + +def test_validators_cannot_mutate_away_an_explicit_restricted_value(invocation): + def existing(namespace): + namespace.modes.clear() + + call = invocation( + validator=existing, runtime_parameters=(ParameterRequirement("modes", PREVIEW_ONLY, {"Preview"}),), + ) + with pytest.raises(ValidationError, match="requires a preview runtime"): + call.execute(["--modes", "Preview"]) + call.writer.assert_not_called() + + +def test_init_and_independent_adr_command_remain_unmarked(invocation): + for name in ("iot ops init", "iot ops ns asset show"): + call = invocation(name) + jobs = call.execute() + call.instances.assert_not_called() + assert "runtime_requested_arguments" not in vars(jobs[0][0]) + call.writer.assert_called_once() diff --git a/azext_edge/tests/edge/orchestration/test_runtime_unit.py b/azext_edge/tests/edge/orchestration/test_runtime_unit.py new file mode 100644 index 000000000..6645cbfd3 --- /dev/null +++ b/azext_edge/tests/edge/orchestration/test_runtime_unit.py @@ -0,0 +1,375 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +from copy import deepcopy +from dataclasses import replace +from unittest.mock import Mock + +import pytest +from azure.cli.core.azclierror import ValidationError +from azure.core.exceptions import HttpResponseError + +from azext_edge.edge.providers.orchestration.common import CUSTOM_LOCATIONS_API_VERSION +from azext_edge.edge.providers.orchestration.resources.instances import Instances +from azext_edge.edge.providers.orchestration.runtime import ( + OperationRequirements, + ParameterRequirement, + RuntimeIssue, + RuntimeIssueCode, + resolve_runtime, + validate_operation, +) +from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel, RuntimeIdentity + + +INSTANCE_ID = "/subscriptions/instance-sub/resourceGroups/instance-rg/providers/Microsoft.IoTOperations/instances/aio" +CL_ID = "/subscriptions/cluster-sub/resourceGroups/cluster-rg/providers/Microsoft.ExtendedLocation/customLocations/cl" +CLUSTER_ID = "/subscriptions/cluster-sub/resourceGroups/cluster-rg/providers/Microsoft.Kubernetes/connectedClusters/arc" +EXTENSION_ID = CLUSTER_ID + "/providers/Microsoft.KubernetesConfiguration/extensions/aio" + + +@pytest.fixture +def records(): + return { + "instance": { + "id": INSTANCE_ID, + "extendedLocation": {"name": CL_ID}, + "properties": {"provisioningState": "Succeeded", "version": "not-used-as-installed-version"}, + }, + "custom_location": { + "id": CL_ID, + "properties": { + "hostResourceId": CLUSTER_ID, + "clusterExtensionIds": [EXTENSION_ID], + "provisioningState": "Succeeded", + }, + }, + "cluster": {"id": CLUSTER_ID, "properties": {"connectivityStatus": "Connected"}}, + "extensions": [{ + "id": EXTENSION_ID, + "properties": { + "extensionType": "microsoft.iotoperations", + "currentVersion": "1.5.7", + "version": "1.5.7", + "releaseTrain": "Stable", + "provisioningState": "Succeeded", + }, + }], + } + + +def test_runtime_resolution_is_read_only_and_uses_installed_version(records): + original = deepcopy(records) + runtime = resolve_runtime(**records) + runtime.require_ready() + assert runtime.identity == RuntimeIdentity(RuntimeChannel.STABLE, "1.5.7", "stable") + assert runtime.cluster_id == CLUSTER_ID + assert runtime.instance_id == INSTANCE_ID + assert records == original + + +def test_requested_version_never_replaces_installed_version(records): + properties = records["extensions"][0]["properties"] + properties["version"] = "1.5.8" + runtime = resolve_runtime(**records) + assert runtime.identity.version == "1.5.7" + assert runtime.requested_version == "1.5.8" + with pytest.raises(ValidationError, match="differs from installed"): + runtime.require_ready() + del properties["currentVersion"] + with pytest.raises(ValidationError, match="currentVersion is missing"): + resolve_runtime(**records) + + +def test_requested_version_is_optional(records): + del records["extensions"][0]["properties"]["version"] + runtime = resolve_runtime(**records) + assert runtime.requested_version is None + runtime.require_ready() + + +@pytest.mark.parametrize("auto_upgrade", [False, True, None]) +def test_runtime_captures_upgrade_ownership(records, auto_upgrade): + if auto_upgrade is not None: + records["extensions"][0]["properties"]["autoUpgradeMinorVersion"] = auto_upgrade + runtime = resolve_runtime(**records) + assert runtime.auto_upgrade_minor_version is auto_upgrade + runtime.require_ready() + + +@pytest.mark.parametrize("state", ["Failed", "Canceled"]) +@pytest.mark.parametrize("error_source", ["statuses", "errorInfo"]) +def test_known_failed_runtime_can_be_reconciled(records, state, error_source): + properties = records["extensions"][0]["properties"] + properties.update(provisioningState=state, version="1.5.8") + properties[error_source] = [{"level": "Error"}] if error_source == "statuses" else {"message": "upgrade failed"} + runtime = resolve_runtime(**records) + assert runtime.readiness_issues == ( + RuntimeIssue( + RuntimeIssueCode.PROVISIONING_STATE, "extension", f"extension provisioning state is {state}", state, + ), + RuntimeIssue(RuntimeIssueCode.EXTENSION_ERROR, "extension", "extension reports an error", state), + RuntimeIssue( + RuntimeIssueCode.VERSION_MISMATCH, "extension", + "requested version 1.5.8 differs from installed version 1.5.7", state, + ), + ) + with pytest.raises(ValidationError): + runtime.require_ready() + runtime.require_upgradeable() + reworded = replace(runtime, readiness_issues=tuple( + replace(issue, message="Reworded diagnostic") for issue in runtime.readiness_issues + )) + reworded.require_upgradeable() + with pytest.raises(ValidationError, match="Reworded diagnostic"): + reworded.require_ready() + + +@pytest.mark.parametrize("state", ["Failed", "Canceled"]) +@pytest.mark.parametrize("record_name,field,value,code,resource", [ + ("instance", "provisioningState", "Updating", RuntimeIssueCode.PROVISIONING_STATE, "instance"), + ("instance", "provisioningState", None, RuntimeIssueCode.PROVISIONING_STATE, "instance"), + ("custom_location", "provisioningState", "Updating", RuntimeIssueCode.PROVISIONING_STATE, "custom location"), + ("custom_location", "provisioningState", None, RuntimeIssueCode.PROVISIONING_STATE, "custom location"), + ("cluster", "connectivityStatus", "Disconnected", RuntimeIssueCode.CLUSTER_CONNECTIVITY, "cluster"), + ("cluster", "connectivityStatus", None, RuntimeIssueCode.CLUSTER_CONNECTIVITY, "cluster"), + ("extension", "statuses", {}, RuntimeIssueCode.INVALID_EXTENSION_STATUS, "extension"), + ("extension", "statuses", [None], RuntimeIssueCode.INVALID_EXTENSION_STATUS, "extension"), +]) +def test_repair_does_not_ignore_other_readiness_failures( + records, state, record_name, field, value, code, resource, +): + records["extensions"][0]["properties"]["provisioningState"] = state + record = records["extensions"][0] if record_name == "extension" else records[record_name] + record["properties"][field] = value + runtime = resolve_runtime(**records) + assert (code, resource) in {(issue.code, issue.resource) for issue in runtime.readiness_issues} + with pytest.raises(ValidationError, match="cannot be upgraded"): + runtime.require_upgradeable() + reworded = replace(runtime, readiness_issues=tuple( + replace(issue, message="extension reports a differently worded issue") for issue in runtime.readiness_issues + )) + with pytest.raises(ValidationError, match="extension reports a differently worded issue"): + reworded.require_upgradeable() + + +@pytest.mark.parametrize("state", [ + None, "Unknown", "Failed", "Creating", "Updating", "Deleting", "Accepted", "Canceled", +]) +@pytest.mark.parametrize("record_name", ["instance", "custom_location", "extension"]) +def test_unready_runtime_rejected(records, state, record_name): + record = records["extensions"][0] if record_name == "extension" else records[record_name] + record["properties"]["provisioningState"] = state + runtime = resolve_runtime(**records) + resource = record_name.replace("_", " ") + assert runtime.readiness_issues == ( + RuntimeIssue(RuntimeIssueCode.PROVISIONING_STATE, resource, + f"{resource} provisioning state is {state or 'unknown'}", state), + ) + with pytest.raises(ValidationError, match="not ready"): + OperationRequirements("update").validate(runtime) + if state in {"Failed", "Canceled"}: + runtime.require_upgradeable() + else: + with pytest.raises(ValidationError): + runtime.require_upgradeable() + + +@pytest.mark.parametrize("preview", [False, True]) +@pytest.mark.parametrize("instance_state", ["Succeeded", "Failed", "Canceled"]) +@pytest.mark.parametrize("location_state", ["Succeeded", "Failed", "Canceled"]) +@pytest.mark.parametrize("extension_state", ["Succeeded", "Failed", "Canceled"]) +def test_terminal_resource_states_allow_recovery( + records, preview, instance_state, location_state, extension_state, +): + records["instance"]["properties"]["provisioningState"] = instance_state + records["custom_location"]["properties"]["provisioningState"] = location_state + properties = records["extensions"][0]["properties"] + properties["provisioningState"] = extension_state + if preview: + properties.update(currentVersion="1.6.0-preview.4", version="1.6.0-preview.4", releaseTrain="preview") + runtime = resolve_runtime(**records) + runtime.require_upgradeable() + reworded = replace(runtime, readiness_issues=tuple( + replace(issue, message="Independent diagnostic wording") for issue in runtime.readiness_issues + )) + reworded.require_upgradeable() + if runtime.readiness_issues: + with pytest.raises(ValidationError, match="not ready"): + runtime.require_ready() + + +@pytest.mark.parametrize("record_name", ["instance", "custom_location", "extension"]) +@pytest.mark.parametrize("state", [None, "Unknown", "Creating", "Updating", "Deleting", "Accepted"]) +def test_terminal_failures_do_not_hide_blocking_resource_states(records, record_name, state): + for record in (records["instance"], records["custom_location"], records["extensions"][0]): + record["properties"]["provisioningState"] = "Failed" + record = records["extensions"][0] if record_name == "extension" else records[record_name] + record["properties"]["provisioningState"] = state + runtime = resolve_runtime(**records) + with pytest.raises(ValidationError, match=f"{record_name.replace('_', ' ')} provisioning state"): + runtime.require_upgradeable() + + +@pytest.mark.parametrize("error", ["disconnected", "errorInfo", "statuses"]) +def test_runtime_reports_errors(records, error): + if error == "disconnected": + records["cluster"]["properties"]["connectivityStatus"] = "Disconnected" + elif error == "errorInfo": + records["extensions"][0]["properties"][error] = {"code": "failed"} + else: + records["extensions"][0]["properties"][error] = [{"level": "Error"}] + with pytest.raises(ValidationError, match="not ready"): + resolve_runtime(**records).require_ready() + + +@pytest.mark.parametrize("count", [0, 2]) +def test_missing_or_duplicate_aio_extension_rejected(records, count): + records["extensions"] *= count + with pytest.raises(ValidationError, match="exactly one"): + resolve_runtime(**records) + + +@pytest.mark.parametrize("mismatch", ["location", "host", "extension", "association", "invalid-id"]) +def test_relationship_mismatch_rejected(records, mismatch): + if mismatch == "location": + records["custom_location"]["id"] += "-other" + elif mismatch == "host": + records["cluster"]["id"] += "-other" + elif mismatch == "extension": + records["extensions"][0]["id"] = EXTENSION_ID.replace("connectedClusters/arc", "connectedClusters/other") + elif mismatch == "association": + records["custom_location"]["properties"]["clusterExtensionIds"] = [] + else: + records["instance"]["extendedLocation"]["name"] = "cl" + with pytest.raises(ValidationError): + resolve_runtime(**records) + + +def test_arm_id_matching_is_case_insensitive(records): + records["custom_location"]["id"] = CL_ID.upper() + records["cluster"]["id"] = CLUSTER_ID.upper() + records["custom_location"]["properties"]["clusterExtensionIds"] = [EXTENSION_ID.upper()] + resolve_runtime(**records).require_ready() + + +def test_integration_does_not_imply_preview(records): + records["extensions"][0]["properties"]["releaseTrain"] = "integration" + with pytest.raises(ValidationError, match="no supported runtime profile mapping"): + resolve_runtime(**records) + identity = RuntimeIdentity(RuntimeChannel.STABLE, "1.5.7", "integration") + assert resolve_runtime(**records, qualification_identities=[identity]).identity == identity + + +@pytest.mark.parametrize("preview", [False, True]) +def test_shared_command_and_preview_only_parameter(records, preview): + if preview: + records["extensions"][0]["properties"].update( + currentVersion="1.6.0-preview.4", version="1.6.0-preview.4", releaseTrain="preview" + ) + runtime = resolve_runtime(**records) + shared = OperationRequirements("GA command") + restricted = OperationRequirements("--test-option", frozenset({RuntimeChannel.PREVIEW})) + parameters = [ParameterRequirement("test_option", restricted)] + validate_operation(runtime, shared, {}, parameters) + validate_operation(runtime, shared, {"test_option": None}, parameters) + writer = Mock() + if preview: + validate_operation(runtime, shared, {"test_option": False}, parameters) + writer() + writer.assert_called_once() + else: + with pytest.raises(ValidationError, match="requires a preview runtime"): + validate_operation(runtime, shared, {"test_option": False}, parameters) + writer() + writer.assert_not_called() + + +def test_restrict_only_selected_values(records): + runtime = resolve_runtime(**records) + shared = OperationRequirements("GA command") + restriction = ParameterRequirement( + "mode", OperationRequirements("--mode experimental", {RuntimeChannel.PREVIEW}), {"experimental"} + ) + validate_operation(runtime, shared, {"mode": "ordinary"}, [restriction]) + with pytest.raises(ValidationError, match="requires a preview runtime"): + validate_operation(runtime, shared, {"mode": ["ordinary", "experimental"]}, [restriction]) + + +@pytest.mark.parametrize( + "arguments,values,expected", + [({}, None, False), ({"option": None}, None, False), ({"option": False}, None, True), + ({"option": 0}, None, True), ({"option": ""}, None, True), + ({"option": False}, {True}, False), ({"option": True}, {True}, True)], +) +def test_parameter_presence_not_truthiness(arguments, values, expected): + restriction = ParameterRequirement("option", OperationRequirements("test"), values) + assert restriction.applies(arguments) is expected + + +@pytest.mark.parametrize("minimum,maximum", [("1.5.8", None), (None, "1.5.7")]) +def test_shared_capability_still_requires_compatible_version(records, minimum, maximum): + with pytest.raises(ValidationError, match="requires AIO version"): + OperationRequirements("test", minimum_version=minimum, maximum_version_exclusive=maximum).validate( + resolve_runtime(**records) + ) + + +def test_discovery_uses_cluster_subscription_and_only_reads(records, mocker): + # Bypass constructor/authentication: exercise only the read-only discovery method. + provider = Instances.__new__(Instances) + provider.default_subscription_id = "instance-sub" + provider._arm_endpoint = "https://management.azure.com/" + provider.resource_client = Mock() + provider.resource_client.resources.get_by_id.return_value = records["custom_location"] + prefix = "azext_edge.edge.providers.orchestration.resources.instances." + clusters = mocker.patch(prefix + "get_connectedk8s_mgmt_client") + extensions = mocker.patch(prefix + "get_clusterconfig_mgmt_client") + clusters.return_value.connected_cluster.get.return_value = records["cluster"] + extensions.return_value.extensions.list.return_value = iter(records["extensions"]) + runtime = provider.get_runtime_context(records["instance"]) + runtime.require_ready() + provider.resource_client.resources.get_by_id.assert_called_once_with( + resource_id=CL_ID, api_version=CUSTOM_LOCATIONS_API_VERSION + ) + kwargs = {"subscription_id": "cluster-sub", "endpoint": "https://management.azure.com/"} + clusters.assert_called_once_with(**kwargs) + extensions.assert_called_once_with(**kwargs) + clusters.return_value.connected_cluster.get.assert_called_once_with( + resource_group_name="cluster-rg", cluster_name="arc" + ) + extensions.return_value.extensions.list.assert_called_once_with( + resource_group_name="cluster-rg", cluster_rp="Microsoft.Kubernetes", + cluster_resource_name="connectedClusters", cluster_name="arc" + ) + assert [call[0] for call in extensions.return_value.mock_calls] == ["extensions.list"] + assert [call[0] for call in clusters.return_value.mock_calls] == ["connected_cluster.get"] + + +def test_discovery_propagates_read_failure_without_fallback(records): + provider = Instances.__new__(Instances) + provider.resource_client = Mock() + provider.resource_client.resources.get_by_id.side_effect = HttpResponseError("Forbidden") + with pytest.raises(HttpResponseError, match="Forbidden"): + provider.get_runtime_context(records["instance"]) + + +def test_null_connectivity_is_unknown_not_ready(records): + records["cluster"]["properties"]["connectivityStatus"] = None + with pytest.raises(ValidationError, match="connectivity is unknown"): + resolve_runtime(**records).require_ready() + + +@pytest.mark.parametrize("statuses", ["unknown", {}, [None]]) +def test_invalid_status_information_is_not_ready(records, statuses): + records["extensions"][0]["properties"]["statuses"] = statuses + with pytest.raises(ValidationError, match="status information is invalid"): + resolve_runtime(**records).require_ready() + + +def test_invalid_requirement_version_range(): + with pytest.raises(ValidationError, match="Invalid runtime version range"): + OperationRequirements("test", minimum_version="1.5.7", maximum_version_exclusive="1.5.7") diff --git a/azext_edge/tests/edge/orchestration/test_targets_unit.py b/azext_edge/tests/edge/orchestration/test_targets_unit.py index cdd6c3664..ca90bc5e3 100644 --- a/azext_edge/tests/edge/orchestration/test_targets_unit.py +++ b/azext_edge/tests/edge/orchestration/test_targets_unit.py @@ -82,8 +82,11 @@ "clusterNamespace": "cluster_namespace", "clusterLocation": "location", "customLocationName": "custom_location_name", + "aioInstanceName": "instance_name", + "features": "instance_features", "schemaRegistryId": "schema_registry_resource_id", "adrNamespaceId": "adr_namespace_resource_id", + "sku": "sku", "defaultDataflowInstanceCount": "dataflow_profile_instances", "brokerConfig": "broker_config", "trustConfig": "trust_config", @@ -440,7 +443,11 @@ def test_init_targets(target_scenario: dict, mocked_feature_keys: Mock): # Verify instance properties aio_instance = instance_template["resources"]["aioInstance"] assert aio_instance["properties"]["description"] == targets.instance_description - assert aio_instance["properties"]["features"] == targets.instance_features + assert aio_instance["properties"]["features"] == "[parameters('features')]" + if targets.instance_features: + assert instance_parameters["features"]["value"] == targets.instance_features + else: + assert "features" not in instance_parameters if targets.tags: assert aio_instance["tags"] == targets.tags @@ -491,7 +498,38 @@ def test_init_targets_opcua_mode(target_scenario: dict): instance_template, _instance_parameters = targets.get_ops_instance_template(extension_ids) aio_instance = instance_template["resources"]["aioInstance"] - assert aio_instance["properties"]["features"] == expected_features + assert aio_instance["properties"]["features"] == "[parameters('features')]" + assert _instance_parameters["features"]["value"] == {"opcua": {"mode": "Stable", "settings": {}}} + + +@pytest.mark.parametrize( + "sku, expected_sku", + [ + (None, None), + ("Standard", "Standard"), + ("Essentials", "Essentials"), + ], +) +def test_init_targets_instance_sku(sku, expected_sku): + targets = InitTargets( + cluster_name=generate_random_string(), + resource_group_name=generate_random_string(), + schema_registry_resource_id=get_schema_registry_id(), + adr_namespace_resource_id=get_ns_resource_id(), + instance_name=generate_random_string(), + sku=sku, + ) + + instance_template, instance_parameters = targets.get_ops_instance_template([generate_random_string()]) + aio_instance = instance_template["resources"]["aioInstance"] + + assert aio_instance["sku"] == ( + "[if(not(equals(parameters('sku'), null())), createObject('name', parameters('sku')), null())]" + ) + if expected_sku is not None: + assert instance_parameters["sku"] == {"value": expected_sku} + else: + assert "sku" not in instance_parameters @pytest.mark.parametrize( diff --git a/azext_edge/tests/edge/orchestration/test_template_preview_unit.py b/azext_edge/tests/edge/orchestration/test_template_preview_unit.py new file mode 100644 index 000000000..ff107b154 --- /dev/null +++ b/azext_edge/tests/edge/orchestration/test_template_preview_unit.py @@ -0,0 +1,307 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Checks for the generated internal-qualification artifact, not live compatibility.""" + +import json +from copy import deepcopy + +import pytest +from azure.cli.core.azclierror import ValidationError + +from azext_edge.edge.providers.orchestration.runtime_catalog import ( + PREVIEW_PROFILE, + get_runtime_catalog, +) +from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel, RuntimeProfile +from azext_edge.edge.providers.orchestration.targets import InitTargets, InstancePhase +from azext_edge.edge.providers.orchestration.template import ( + TEMPLATE_BLUEPRINT_ENABLEMENT, + TEMPLATE_BLUEPRINT_INSTANCE, +) +from azext_edge.edge.providers.orchestration.template_preview import TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW + + +EXPECTED_PREVIEW_RESOURCE_KEYS = frozenset({ + "cluster", "aioExtension", "customLocation", "aioInstance", "broker", "brokerAuthn", "brokerListener", + "dataflowProfile", "dataflowEndpoint", "artifactRegistryEndpoint", "opcUaConnectorTemplate", "mcpDefaultPolicy", + "mcpAioConnection", +}) +EXTENSION_KEYS = {"cluster", "aioExtension"} +INSTANCE_KEYS = EXTENSION_KEYS | {"customLocation", "aioInstance"} + + +@pytest.fixture +def qualification_profile(): + # Exercise the actual generated artifact without registering it for CLI operations. + return RuntimeProfile( + channel=RuntimeChannel.PREVIEW, + release="prev2610", + source_ref="preview/v1.6.x/2610", + source_commit="test-commit", + instance_blueprint=TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW, + ) + + +def test_preview_source_identity_and_contract(qualification_profile): + blueprint = TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW + content = blueprint.content + assert blueprint.commit_id == "f91dee8af923eceea4b8c68ab9a863aee53630c8" + assert content + assert content["variables"]["VERSIONS"] == {"iotOperations": "1.6.0-preview.43", "connectors": "1.5.18"} + assert content["variables"]["TRAINS"] == {"iotOperations": "integration"} + assert qualification_profile.identity.train == "integration" + assert qualification_profile.identity.channel == RuntimeChannel.PREVIEW + assert set(content["resources"]) == EXPECTED_PREVIEW_RESOURCE_KEYS + aio_resources = [r for r in content["resources"].values() if r["type"].startswith("Microsoft.IoTOperations/")] + assert len(aio_resources) == 10 + assert {r["apiVersion"] for r in aio_resources} == {"2026-09-01-preview", "2026-09-02-preview"} + assert content["resources"]["aioExtension"]["properties"]["autoUpgradeMinorVersion"] is False + assert "disableOpcUaFeature" not in content["parameters"] + assert content["definitions"]["_1.InstanceFeature"]["properties"]["settings"]["nullable"] is True + + +def test_preview_keeps_approved_scalars_without_loaded_source_blob(): + content = TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW.content + serialized = json.dumps(content) + assert "$fxv" not in serialized + assert "AIO_EXTENSION_VALUES" not in serialized + assert content["variables"]["OPCUA_CONNECTOR_VERSION"] == ( + "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'version'), " + "variables('VERSIONS').connectors)]" + ) + assert content["definitions"]["_1.AdvancedConfig"]["properties"]["connectors"]["properties"]["version"] == { + "type": "string", "nullable": True, + } + for variable, key, value in ( + ("CONNECTORS_CHART_REGISTRY", "registry", "aioconnectorsdev.azurecr.io"), + ("CONNECTORS_CHART_REPOSITORY", "repository", "aio-connectors/helmchart/microsoft-aio-connectors"), + ("CONNECTORS_IMAGE_REGISTRY", "imageRegistry", "aioconnectorsdev.azurecr.io"), + ): + assert content["variables"][variable] == ( + "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), '" + + key + "'), '" + value + "')]" + ) + assert content["definitions"]["_1.AdvancedConfig"]["properties"]["connectors"]["properties"][key] == { + "type": "string", "nullable": True, + } + config = content["variables"]["defaultAioConfigurationSettings"] + assert config["connectors.image.tag"] == "[variables('OPCUA_CONNECTOR_VERSION')]" + assert config["connectors.image.registry"] == "[variables('CONNECTORS_CHART_REGISTRY')]" + assert config["connectors.image.repository"] == "[variables('CONNECTORS_CHART_REPOSITORY')]" + assert config["connectors.values.image.registry"] == "[variables('CONNECTORS_IMAGE_REGISTRY')]" + assert "enableGdsManager" not in content["parameters"] + assert "connectors.values.gdsManager.enabled" not in config + + +@pytest.mark.parametrize("blueprint", [TEMPLATE_BLUEPRINT_INSTANCE, TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW]) +def test_instance_blueprints_exclude_gds(blueprint): + assert "gds" not in json.dumps(blueprint.content).lower() + + +def test_preview_persistence_sku_and_endpoint_defaults(): + content = TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW.content + assert content["parameters"]["enablePersistence"] == {"type": "bool", "defaultValue": True} + helper = content["functions"][0]["members"]["buildBrokerPersistence"] + assert helper["parameters"] == [ + {"type": "bool", "name": "enablePersistence"}, + {"$ref": "#/definitions/_1.BrokerPersistence", "nullable": True, "name": "persistence"}, + ] + assert helper["output"]["value"] == ( + "[if(not(parameters('enablePersistence')), null(), coalesce(parameters('persistence'), " + "createObject('maxSize', '3Gi', 'retain', createObject('mode', 'None'), " + "'subscriberQueue', createObject('mode', 'None'), 'stateStore', createObject('mode', 'Custom', " + "'stateStoreSettings', createObject('dynamic', createObject('mode', 'Enabled'))))))]" + ) + assert content["variables"]["BROKER_CONFIG"]["persistence"] == ( + "[_2.buildBrokerPersistence(parameters('enablePersistence'), " + "tryGet(parameters('brokerConfig'), 'persistence'))]" + ) + assert content["parameters"]["sku"] == { + "type": "string", "allowedValues": ["Essentials", "Standard"], "nullable": True, + } + assert content["resources"]["aioInstance"]["sku"] == ( + "[if(not(equals(parameters('sku'), null())), createObject('name', parameters('sku')), null())]" + ) + assert content["resources"]["opcUaConnectorTemplate"]["properties"]["deviceInboundEndpointTypes"] == [ + {"endpointType": "Microsoft.OpcUa"}, {"endpointType": "Microsoft.OpcUa.WoT"}, + ] + + +@pytest.mark.parametrize("phase", [None, InstancePhase.EXT, InstancePhase.INSTANCE, InstancePhase.RESOURCES]) +def test_preview_phase_resources_and_defaults(qualification_profile, phase): + source = TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW.content + targets = InitTargets("cluster", "rg", runtime_profile=qualification_profile, instance_name="My_Instance") + template, parameters = targets.get_ops_instance_template(phase=phase) + resources = template["resources"] + expected = (EXTENSION_KEYS if phase == InstancePhase.EXT else + INSTANCE_KEYS if phase == InstancePhase.INSTANCE else EXPECTED_PREVIEW_RESOURCE_KEYS) + assert set(resources) == expected + assert parameters["aioInstanceName"] == {"value": "my-instance"} + assert "enableGdsManager" not in parameters + assert "enablePersistence" not in parameters + assert "sku" not in parameters + assert "features" not in parameters + assert "enableGdsManager" not in template["parameters"] + assert template["parameters"]["enablePersistence"]["defaultValue"] is True + assert template["variables"]["defaultAioConfigurationSettings"] == source["variables"][ + "defaultAioConfigurationSettings" + ] + if phase in (InstancePhase.INSTANCE, InstancePhase.RESOURCES): + existing = EXTENSION_KEYS | {"customLocation"} + if phase == InstancePhase.RESOURCES: + existing.add("aioInstance") + assert all(resources[key]["existing"] for key in existing) + if phase in (None, InstancePhase.INSTANCE): + assert resources["aioInstance"]["properties"] == { + **source["resources"]["aioInstance"]["properties"], "description": None, + } + if phase in (None, InstancePhase.RESOURCES): + for resource in resources.values(): + if resource["type"].startswith("Microsoft.IoTOperations/instances/"): + assert resource["name"].startswith("my-instance/") + assert "condition" not in resources["opcUaConnectorTemplate"] + assert resources["mcpDefaultPolicy"]["name"] == "my-instance/aio-mcp-policy-v1" + assert resources["mcpAioConnection"]["name"] == "my-instance/aio" + assert resources["mcpAioConnection"]["properties"]["service"]["name"] == "my-instance-mcp" + + +@pytest.mark.parametrize("instance_name", [None, "My_Instance"]) +def test_preview_mcp_policy_preserves_source_contract(qualification_profile, instance_name): + source = deepcopy(TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW.content["resources"]["mcpDefaultPolicy"]) + template, parameters = InitTargets( + "cluster", "rg", runtime_profile=qualification_profile, instance_name=instance_name, + ).get_ops_instance_template() + policy = template["resources"]["mcpDefaultPolicy"] + assert "features" not in parameters + assert policy["type"] == "Microsoft.IoTOperations/instances/mcpAuthorizationPolicies" + assert policy["apiVersion"] == "2026-09-02-preview" + assert "condition" not in policy + assert policy["properties"]["policies"] == "[_2.mcpDefaultPolicies()]" + if instance_name: + source["name"] = "my-instance/aio-mcp-policy-v1" + assert policy == source + + +@pytest.mark.parametrize("instance_name", [None, "My_Instance", "a" * 32, "a" * 33]) +def test_preview_mcp_connection_preserves_source_contract(qualification_profile, instance_name): + source = deepcopy(TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW.content["resources"]["mcpAioConnection"]) + targets = InitTargets("cluster", "rg", runtime_profile=qualification_profile, instance_name=instance_name) + template, parameters = targets.get_ops_instance_template() + connection = template["resources"]["mcpAioConnection"] + assert "features" not in parameters + assert "condition" not in connection + assert connection["apiVersion"] == "2026-09-02-preview" + assert connection["type"] == "Microsoft.IoTOperations/instances/mcpServerConnections" + assert connection["dependsOn"] == ["aioInstance", "customLocation", "mcpDefaultPolicy"] + assert connection["properties"]["authorization"] == { + "mode": "PolicyBased", "policyRef": {"name": "aio-mcp-policy-v1"}, + } + assert "trustBundle" not in connection["properties"] + assert connection["properties"]["service"]["path"] == "/mcp" + if instance_name: + assert parameters["aioInstanceName"] == {"value": targets.instance_name} + source["name"] = f"{targets.instance_name}/aio" + source["properties"]["service"]["name"] = f"{targets.instance_name}-mcp" + assert connection == source + + +def test_preview_extension_versions_exclude_connector_metadata(qualification_profile): + targets = InitTargets("cluster", "rg", runtime_profile=qualification_profile) + assert targets.get_extension_versions(False) == { + "iotOperations": {"version": "1.6.0-preview.43", "train": "integration"}, + } + + +@pytest.mark.parametrize("mode", ["Preview", "Disabled"]) +def test_preview_mcp_feature_is_not_exposed_by_cli(qualification_profile, mode): + from azure.cli.core.azclierror import InvalidArgumentValueError + + with pytest.raises(InvalidArgumentValueError, match="Supported feature keys: opcua.mode"): + InitTargets("cluster", "rg", runtime_profile=qualification_profile, instance_features=[f"mcp.mode={mode}"]) + + +@pytest.mark.parametrize("mode", ["Stable", "Preview", "Disabled"]) +def test_preview_features_use_source_contract(qualification_profile, mode): + template, parameters = InitTargets( + "cluster", "rg", runtime_profile=qualification_profile, instance_features=[f"opcua.mode={mode}"] + ).get_ops_instance_template() + assert parameters["features"] == {"value": {"opcua": {"mode": mode, "settings": {}}}} + assert template["resources"]["aioInstance"]["properties"]["features"] == "[parameters('features')]" + assert ("opcUaConnectorTemplate" in template["resources"]) == (mode != "Disabled") + assert "effectiveFeatures" not in template["variables"] + assert template["variables"]["TRAINS"]["iotOperations"] == "integration" + + +def test_preview_explicit_configuration_overlays_preserve_other_defaults(qualification_profile): + source = TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW.content + template, _ = InitTargets( + "cluster", "rg", runtime_profile=qualification_profile, + ops_config=[ + "connectors.values.mqttBroker.serviceAccountTokenAudience=qualification", + "connectors.image.registry=qualification.example", + ], + ).get_ops_instance_template() + expected = deepcopy(source["variables"]["defaultAioConfigurationSettings"]) + expected.update({"connectors.values.mqttBroker.serviceAccountTokenAudience": "qualification", + "connectors.image.registry": "qualification.example"}) + assert template["variables"]["defaultAioConfigurationSettings"] == expected + assert source["variables"]["defaultAioConfigurationSettings"]["connectors.image.registry"] == ( + "[variables('CONNECTORS_CHART_REGISTRY')]" + ) + + +@pytest.mark.parametrize("preview_first", [False, True]) +def test_preview_preparation_isolated_from_ga_and_shared_foundation(qualification_profile, preview_first): + blueprints = (TEMPLATE_BLUEPRINT_INSTANCE, TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW, TEMPLATE_BLUEPRINT_ENABLEMENT) + snapshots = [deepcopy(blueprint.content) for blueprint in blueprints] + stable = get_runtime_catalog().for_create() + order = [stable, qualification_profile] + if preview_first: + order.reverse() + foundation = InitTargets("cluster", "rg").get_ops_enablement_template() + for profile in order: + targets = InitTargets("cluster", "rg", runtime_profile=profile) + assert targets.get_ops_enablement_template() == foundation + assert targets.get_extension_versions(False)["iotOperations"] == { + "version": profile.identity.version, "train": profile.identity.train, + } + template, _ = targets.get_ops_instance_template() + assert template["variables"]["defaultAioConfigurationSettings"] == profile.copy_instance_blueprint().content[ + "variables" + ]["defaultAioConfigurationSettings"] + template["variables"].clear() + template["resources"].clear() + profile.copy_instance_blueprint().content["definitions"].clear() + assert [blueprint.content for blueprint in blueprints] == snapshots + + +def test_bundled_preview_registered_without_changing_default_or_train(): + from azext_edge.edge.providers.orchestration.runtime_profiles import resolve_runtime_identity + + catalog = get_runtime_catalog() + assert catalog.for_create().channel == RuntimeChannel.STABLE + profile = catalog.for_create(use_preview=True) + assert profile is PREVIEW_PROFILE + assert profile.copy_instance_blueprint() == TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW + assert resolve_runtime_identity( + profile.identity.version, profile.identity.train, catalog.qualification_identities + ) == profile.identity + assert catalog.for_upgrade(profile.identity) is profile + assert profile.require_opcua_connector_version() == "1.5.18" + assert profile.require_opcua_connector_version() == ( + TEMPLATE_BLUEPRINT_INSTANCE_PREVIEW.content["variables"]["VERSIONS"]["connectors"] + ) + + +@pytest.mark.parametrize("version", [ + "1.6.0-preview.9", "1.6.0-preview.18", "1.6.0-preview.19", "1.6.0-preview.20", "1.6.0-preview.21", +]) +def test_unqualified_preview_integration_versions_are_not_retained(version): + from azext_edge.edge.providers.orchestration.runtime_profiles import resolve_runtime_identity + + with pytest.raises(ValidationError): + resolve_runtime_identity(version, "integration", get_runtime_catalog().qualification_identities) diff --git a/azext_edge/tests/edge/orchestration/test_template_unit.py b/azext_edge/tests/edge/orchestration/test_template_unit.py index fd5adf999..6ada04f96 100644 --- a/azext_edge/tests/edge/orchestration/test_template_unit.py +++ b/azext_edge/tests/edge/orchestration/test_template_unit.py @@ -151,11 +151,11 @@ def test_template_blueprint(content: dict): EXTENSION_CONFIGS = { "enablement": [ - (EXTENSION_TYPE_CM, "1.1.2", "stable"), - (EXTENSION_TYPE_SSC, "1.5.3", "stable"), + (EXTENSION_TYPE_CM, "1.2.0", "stable"), + (EXTENSION_TYPE_SSC, "1.5.5", "stable"), ], "instance": [ - (EXTENSION_TYPE_OPS, "1.4.112", "stable"), + (EXTENSION_TYPE_OPS, "1.5.33", "integration"), ], } diff --git a/azext_edge/tests/edge/orchestration/test_upgrade2_unit.py b/azext_edge/tests/edge/orchestration/test_upgrade2_unit.py index 4a1c2df5e..71c2dd385 100644 --- a/azext_edge/tests/edge/orchestration/test_upgrade2_unit.py +++ b/azext_edge/tests/edge/orchestration/test_upgrade2_unit.py @@ -51,6 +51,7 @@ CLUSTER_EXTENSIONS_API_VERSION, CLUSTER_EXTENSIONS_URL_MATCH_RE, CONNECTED_CLUSTER_API_VERSION, + INSTANCES_API_VERSION, get_base_endpoint, get_mock_resource, ) @@ -232,17 +233,29 @@ def spy_upgrade_displays(mocker): @pytest.fixture def mocked_upgrade_manager(): - # Patch InitTargets.get_extension_versions to return stable trains for testing + from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel, RuntimeProfileCatalog + from .test_runtime_profiles_unit import make_profile + + version = InitTargets(cluster_name="cluster", resource_group_name="rg").get_extension_versions(False)[ + EXTENSION_MONIKER_OPS + ]["version"] + catalog = RuntimeProfileCatalog([ + make_profile(RuntimeChannel.STABLE, version, opcua_connector_version=OPCUA_CONNECTOR_VERSION), + make_profile(RuntimeChannel.PREVIEW, "1.6.0-preview.10", opcua_connector_version=OPCUA_CONNECTOR_VERSION), + ]) + # Use explicit synthetic release inputs rather than relabel production templates. original_get_extension_versions = InitTargets.get_extension_versions def patched_get_extension_versions(self, for_enablement=True): versions = original_get_extension_versions(self, for_enablement) # Override non-stable trains for IoT Operations only - if EXTENSION_MONIKER_OPS in versions and versions[EXTENSION_MONIKER_OPS].get("train", "").lower() != "stable": + if EXTENSION_MONIKER_OPS in versions and versions[EXTENSION_MONIKER_OPS].get("train") == "integration": versions[EXTENSION_MONIKER_OPS]["train"] = "stable" return versions - with patch.object(InitTargets, "get_extension_versions", patched_get_extension_versions) as mock: + with patch.object(InitTargets, "get_extension_versions", patched_get_extension_versions) as mock, patch( + "azext_edge.edge.providers.orchestration.upgrade2.get_runtime_catalog", return_value=catalog + ): yield mock @@ -293,7 +306,9 @@ def _build_defaults(self): "properties": { "extensionType": ext_type, "version": vers, + "currentVersion": vers, "releaseTrain": train, + "autoUpgradeMinorVersion": False, "configurationSettings": {}, "provisioningState": PROVISIONING_STATE_SUCCESS, }, @@ -358,6 +373,7 @@ def set_extension( "extensionType": ext_type, "version": actual_vers, "releaseTrain": actual_train, + "autoUpgradeMinorVersion": False, "configurationSettings": config_settings or {}, "provisioningState": provisioning_state or PROVISIONING_STATE_SUCCESS, }, @@ -371,6 +387,7 @@ def set_extension( self.extensions[ext_type]["properties"]["provisioningState"] = provisioning_state if config_settings is not None: self.extensions[ext_type]["properties"]["configurationSettings"] = config_settings + self.extensions[ext_type]["properties"]["currentVersion"] = actual_vers return self def set_response_on_patch( @@ -396,8 +413,12 @@ def set_auxiliary_kwargs(self: T, **kwargs): return self - def set_instance_mock(self: T, mocked_responses: responses, instance_name: str, resource_group_name: str) -> T: + def set_instance_mock( + self: T, mocked_responses: responses, instance_name: str, resource_group_name: str, + iotops_api_version: str = INSTANCES_API_VERSION, + ) -> T: mocked_responses.assert_all_requests_are_fired = False + self.iotops_api_version = iotops_api_version # Always use version 1.2.0+ (which includes ADR namespace) # unless explicitly testing scenario without ADR @@ -416,6 +437,10 @@ def set_instance_mock(self: T, mocked_responses: responses, instance_name: str, adr_namespace_name="default-adr", ) + mock_instance_record["properties"]["provisioningState"] = self.aux_kwargs.get( + "instance_provisioning_state", PROVISIONING_STATE_SUCCESS + ) + # Add existing SPC reference if specified in aux_kwargs if self.aux_kwargs.get("has_existing_spc_ref"): mock_instance_record["properties"]["defaultSecretProviderClassRef"] = { @@ -432,6 +457,12 @@ def set_instance_mock(self: T, mocked_responses: responses, instance_name: str, # Track if instance update was called self.instance_update_called = False + if iotops_api_version != INSTANCES_API_VERSION: + mocked_responses.add( + responses.GET, + get_instance_endpoint(resource_group_name, instance_name, api_version=iotops_api_version), + json=mock_instance_record, + ) # Add instance update mock if expected if self.expect_instance_update: @@ -469,12 +500,25 @@ def instance_update_callback(request): mocked_responses.add_callback( method=responses.PUT, - url=get_instance_endpoint(resource_group_name=resource_group_name, instance_name=instance_name), + url=get_instance_endpoint( + resource_group_name=resource_group_name, instance_name=instance_name, + api_version=iotops_api_version, + ), callback=instance_update_callback, ) cl_name = generate_random_string() mock_cl_record = get_mock_cl_record(name=cl_name, resource_group_name=resource_group_name) + mock_cl_record["id"] = mock_instance_record["extendedLocation"]["name"] + mock_cl_record["properties"]["provisioningState"] = self.aux_kwargs.get( + "custom_location_provisioning_state", PROVISIONING_STATE_SUCCESS + ) + cluster_id = mock_cl_record["properties"]["hostResourceId"] + for extension in self.extensions.values(): + extension["id"] = ( + f"{cluster_id}/providers/Microsoft.KubernetesConfiguration/extensions/{extension['name']}" + ) + mock_cl_record["properties"]["clusterExtensionIds"] = [ext["id"] for ext in self.extensions.values()] mocked_responses.add( method=responses.GET, url=f"{BASE_URL}{mock_instance_record['extendedLocation']['name']}", @@ -486,6 +530,7 @@ def instance_update_callback(request): mock_cluster_record = get_mock_cluster_record( resource_group_name=resource_group_name, connected_status=self.cluster_connected_status ) + mock_cluster_record["id"] = cluster_id mocked_responses.add( method=responses.GET, url=get_cluster_endpoint(resource_group_name=resource_group_name), @@ -731,7 +776,7 @@ def delete_spc_callback(request): def _setup_registry_endpoint_mocks(self, mocked_responses: responses, instance_name: str, resource_group_name: str): """Set up registry endpoint mocks for tests.""" list_endpoint = get_registry_endpoint_endpoint( - instance_name=instance_name, resource_group_name=resource_group_name + instance_name=instance_name, resource_group_name=resource_group_name, api_version=self.iotops_api_version, ) # Determine test configuration @@ -763,7 +808,8 @@ def _setup_registry_endpoint_mocks(self, mocked_responses: responses, instance_n # Always add PUT mock for creation attempts create_endpoint = get_registry_endpoint_endpoint( - instance_name=instance_name, resource_group_name=resource_group_name, registry_endpoint_name="default" + instance_name=instance_name, resource_group_name=resource_group_name, registry_endpoint_name="default", + api_version=self.iotops_api_version, ) def registry_create_callback(request): @@ -790,7 +836,7 @@ def _setup_connector_template_mocks( ): """Set up default OPC UA connector template mocks for tests.""" list_endpoint = get_connector_template_endpoint( - instance_name=instance_name, resource_group_name=resource_group_name + instance_name=instance_name, resource_group_name=resource_group_name, api_version=self.iotops_api_version, ) base_list_url = list_endpoint.split("?")[0] @@ -825,7 +871,10 @@ def _setup_connector_template_mocks( ) # Always add PUT mock for creation attempts (name is instance-derived). - create_endpoint = re.compile(re.escape(base_list_url) + r"/azureiotoperationsconnectorforopcua-[a-z0-9]+") + create_endpoint = re.compile( + re.escape(base_list_url) + r"/azureiotoperationsconnectorforopcua-[a-z0-9]+" + + re.escape("?" + list_endpoint.split("?", 1)[1]) + "$" + ) def connector_template_create_callback(request): assert_upgrade_headers(request.headers) @@ -839,7 +888,9 @@ def connector_template_create_callback(request): "imageConfigurationSettings" ] assert image_settings["imageName"] == "azureiotoperations/aio-connectors/supervisor" - assert image_settings["tagDigestSettings"]["tag"] == OPCUA_CONNECTOR_VERSION + assert image_settings["tagDigestSettings"]["tag"] == self.aux_kwargs.get( + "opcua_connector_version", OPCUA_CONNECTOR_VERSION, + ) template_name = request.path_url.split("?")[0].split("/")[-1] response_body = deepcopy(body) @@ -1093,25 +1144,27 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis ( UpgradeScenario("No-op: Version ahead with different train").set_extension( ext_type=EXTENSION_TYPE_OPS, ext_vers="9.9.9", ext_train="custom-train" - ), + ).expecting_validation_error("no supported runtime profile mapping"), {}, ), # ========== Train-only updates (version unchanged) ========== ( UpgradeScenario("Train update: Auto-increment when version matches desired").set_extension( ext_type=EXTENSION_TYPE_OPS, ext_train="old-train" - ), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, train=BUILT_IN_VALUE)}, + ).expecting_validation_error("no supported runtime profile mapping"), + {}, ), ( - UpgradeScenario("Train update: Explicit train override").set_user_kwargs(ops_train="custom-train"), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, train="custom-train")}, + UpgradeScenario("Train update: Explicit train override").set_user_kwargs(ops_train="custom-train") + .expecting_validation_error("Cross-train upgrades"), + {}, ), ( UpgradeScenario("Train update: No auto-increment with explicit version") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_train="old-train") - .set_user_kwargs(ops_version="9.9.9", force=True), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="9.9.9")}, + .set_user_kwargs(ops_version="9.9.9", force=True) + .expecting_validation_error("no supported runtime profile mapping"), + {}, ), # ========== Standard version upgrades ========== ( @@ -1129,8 +1182,9 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis ( UpgradeScenario("Version upgrade: Dev version string") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.1.0-main.20250425.8") - .set_user_kwargs(ops_version="1.1.0-main.20250425.9"), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.1.0-main.20250425.9")}, + .set_user_kwargs(ops_version="1.1.0-main.20250425.9") + .expecting_validation_error("conflicts with the stable runtime profile"), + {}, ), # ========== Downgrade validation ========== ( @@ -1219,53 +1273,57 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis .set_user_kwargs(ops_version="1.2.36", force=True), {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.2.36")}, ), - # ========== Preview train validation (blocks all changes except identical version+train) ========== + # ========== Preview runtime progression and non-bypassable channel boundary ========== ( UpgradeScenario("Preview train blocked: From preview to stable with version change") - .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.0.0", ext_train="preview") - .set_user_kwargs(ops_version="1.1.0") - .expecting_validation_error(r"Upgrades to or from non-stable release trains are not supported"), + .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.6.0-preview.4", ext_train="preview") + .set_user_kwargs(ops_version="1.6.0", ops_train="stable") + .expecting_validation_error("Cross-train upgrades"), {}, ), ( UpgradeScenario("Preview train blocked: From stable to preview with version change") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.0.0", ext_train="stable") .set_user_kwargs(ops_version="1.1.0", ops_train="preview") - .expecting_validation_error(r"Upgrades to or from non-stable release trains are not supported"), + .expecting_validation_error("Cross-train upgrades"), {}, ), ( UpgradeScenario("Preview train blocked: Same version different train") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.1.0", ext_train="stable") .set_user_kwargs(ops_version="1.1.0", ops_train="preview") - .expecting_validation_error(r"Upgrades to or from non-stable release trains are not supported"), + .expecting_validation_error("Cross-train upgrades"), {}, ), ( UpgradeScenario("Preview train blocked: Preview to different preview with same version") - .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.1.0", ext_train="preview") - .set_user_kwargs(ops_version="1.1.0", ops_train="canary") - .expecting_validation_error(r"Upgrades to or from non-stable release trains are not supported"), + .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.6.0-preview.4", ext_train="preview") + .set_user_kwargs(ops_version="1.6.0-preview.4", ops_train="canary") + .expecting_validation_error("Cross-train upgrades"), {}, ), ( - UpgradeScenario("Preview train blocked: Preview to preview with version change") - .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.0.0", ext_train="preview") - .set_user_kwargs(ops_version="1.1.0", ops_train="preview") - .expecting_validation_error(r"Upgrades to or from non-stable release trains are not supported"), - {}, + UpgradeScenario("Preview upgrades numerically from RC 4 to RC 10") + .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.6.0-preview.4", ext_train="preview") + .set_user_kwargs(ops_version="1.6.0-preview.10", ops_train="preview"), + {EXTENSION_TYPE_OPS: build_extension_props( + EXTENSION_TYPE_OPS, version="1.6.0-preview.10", train="preview" + )}, ), ( UpgradeScenario("Preview train allowed: Identical version and train") - .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.1.0", ext_train="preview") - .set_user_kwargs(ops_version="1.1.0", ops_train="preview"), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.1.0", train="preview")}, + .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.6.0-preview.4", ext_train="preview") + .set_user_kwargs(ops_version="1.6.0-preview.4", ops_train="preview"), + {EXTENSION_TYPE_OPS: build_extension_props( + EXTENSION_TYPE_OPS, version="1.6.0-preview.4", train="preview" + )}, ), ( - UpgradeScenario("Preview train allowed with force") - .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.0.0", ext_train="preview") - .set_user_kwargs(ops_version="1.1.0", ops_train="stable", force=True), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.1.0", train="stable")}, + UpgradeScenario("Preview conversion blocked with force") + .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.6.0-preview.4", ext_train="preview") + .set_user_kwargs(ops_version="1.6.0", ops_train="stable", force=True) + .expecting_validation_error("Cross-train upgrades"), + {}, ), # ========== Configuration updates ========== ( @@ -1298,27 +1356,26 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.1.0")}, ), ( - UpgradeScenario("Failed state: With preview train and version change") + UpgradeScenario("Failed state: Preview upgrade to the next minor version") .set_extension( ext_type=EXTENSION_TYPE_OPS, - ext_vers="1.0.0", + ext_vers="1.6.0-preview.4", ext_train="preview", provisioning_state=PROVISIONING_STATE_FAILED, ) - .set_user_kwargs(ops_version="1.1.0") - .expecting_validation_error(r"Upgrades to or from non-stable release trains are not supported"), - {}, + .set_user_kwargs(ops_version="1.7.0-preview.1"), + {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.7.0-preview.1")}, ), ( UpgradeScenario("Failed state: Preview train allowed with force") .set_extension( ext_type=EXTENSION_TYPE_OPS, - ext_vers="1.0.0", + ext_vers="1.6.0-preview.9", ext_train="preview", provisioning_state=PROVISIONING_STATE_FAILED, ) - .set_user_kwargs(ops_version="1.1.0", force=True), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.1.0")}, + .set_user_kwargs(ops_version="1.6.0-preview.10", force=True), + {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.6.0-preview.10")}, ), # ========== Multi-extension scenarios ========== ( @@ -1336,7 +1393,7 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis ( UpgradeScenario("Multi-extension: Mixed overrides") .set_extension(ext_type=EXTENSION_TYPE_SSC, ext_vers="1.0.0") - .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="0.1.0") + .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.2.0") .set_user_kwargs( ssc_config=["c=d"], ssc_version="1.1.1", @@ -1371,24 +1428,25 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis ), # ========== Combined validation scenarios ========== ( - UpgradeScenario("Combined: Min version takes precedence over preview train") + UpgradeScenario("Combined: Inconsistent runtime identity fails before path policy") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.0.0", ext_train="preview") .set_user_kwargs(ops_version="1.2.36") - .expecting_validation_error(r"min compatible upgrade version.*1\.1\.59"), + .expecting_validation_error("conflicts with the preview runtime profile"), {}, ), ( UpgradeScenario("Combined: Preview train validation after min version passes") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.1.59", ext_train="preview") # Meets min version .set_user_kwargs(ops_version="1.2.36") - .expecting_validation_error(r"Upgrades to or from non-stable release trains are not supported"), + .expecting_validation_error("conflicts with the preview runtime profile"), {}, ), ( - UpgradeScenario("Combined: All validations bypassed with force") + UpgradeScenario("Combined: Invalid runtime is blocked with force") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.0.0", ext_train="preview") - .set_user_kwargs(ops_version="1.2.36", ops_train="stable", force=True), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.2.36", train="stable")}, + .set_user_kwargs(ops_version="1.2.36", ops_train="stable", force=True) + .expecting_validation_error("conflicts with the preview runtime profile"), + {}, ), # ========== User confirmation test ========== ( @@ -1414,10 +1472,11 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis ( UpgradeScenario("Migration: Platform already deleted, create CertManager") .set_extension(ext_type=EXTENSION_TYPE_CM, remove=True) - .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE), + .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE) + .set_user_kwargs(ops_version="1.4.112"), { EXTENSION_TYPE_CM: build_extension_props(EXTENSION_TYPE_CM, version=BUILT_IN_VALUE), - EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version=BUILT_IN_VALUE), + EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.4.112"), }, ), ( @@ -1772,18 +1831,14 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis }, ), ( - UpgradeScenario("Registry: Handle error gracefully when checking endpoints") + UpgradeScenario("Registry: Failed discovery blocks migration before writes") .set_extension(ext_type=EXTENSION_TYPE_PLATFORM, ext_vers="1.0.0") .set_extension(ext_type=EXTENSION_TYPE_CM, remove=True) .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.2.0") .set_user_kwargs(ops_version=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE) - .set_auxiliary_kwargs(registry_list_error=True), - { - EXTENSION_TYPE_CM: build_extension_props(EXTENSION_TYPE_CM, version=BUILT_IN_VALUE), - EXTENSION_TYPE_OPS: build_extension_props( - EXTENSION_TYPE_OPS, version=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE - ), - }, + .set_auxiliary_kwargs(registry_list_error=True) + .expecting_validation_error("Unable to validate the default registry endpoint"), + {}, ), # ========== OPC UA Connector Template Backfill (>= MIN_INSTANCE_VERSION_FOR_OPCUA_CONNECTOR_TEMPLATE) ==== ( @@ -1871,15 +1926,12 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis }, ), ( - UpgradeScenario("OPC UA Template: Handle error gracefully when checking template") + UpgradeScenario("OPC UA Template: Failed discovery blocks upgrade before writes") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.4.0") .set_user_kwargs(ops_version=MIN_INSTANCE_VERSION_FOR_OPCUA_CONNECTOR_TEMPLATE) - .set_auxiliary_kwargs(connector_template_list_error=True), - { - EXTENSION_TYPE_OPS: build_extension_props( - EXTENSION_TYPE_OPS, version=MIN_INSTANCE_VERSION_FOR_OPCUA_CONNECTOR_TEMPLATE - ), - }, + .set_auxiliary_kwargs(connector_template_list_error=True) + .expecting_validation_error("Unable to validate the OPC UA connector template"), + {}, ), ( UpgradeScenario("Combined: registry + OPC UA template + secretsync ordering") @@ -2064,7 +2116,7 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis UpgradeScenario("Missing data: Current version is None blocks upgrade") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers=None) .set_user_kwargs(ops_version="1.2.0") - .expecting_validation_error(r"Unable to determine installed version for.*Cannot validate upgrade path"), + .expecting_validation_error("currentVersion is missing"), {}, ), ( @@ -2072,21 +2124,23 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.1.0", ext_train=None) .set_user_kwargs(ops_version="1.2.0") .expecting_validation_error( - r"Unable to determine release train for installed.*Cannot validate upgrade path" + "Unable to determine AIO release train" ), {}, ), ( - UpgradeScenario("Missing data: Force bypasses missing version check") + UpgradeScenario("Missing data: Force cannot bypass missing version check") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers=None) - .set_user_kwargs(ops_version="1.2.0", force=True), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.2.0")}, + .set_user_kwargs(ops_version="1.2.0", force=True) + .expecting_validation_error("currentVersion is missing"), + {}, ), ( - UpgradeScenario("Missing data: Force bypasses missing train check") + UpgradeScenario("Missing data: Force cannot bypass missing train check") .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.1.0", ext_train=None) - .set_user_kwargs(ops_version="1.2.0", force=True), - {EXTENSION_TYPE_OPS: build_extension_props(EXTENSION_TYPE_OPS, version="1.2.0")}, + .set_user_kwargs(ops_version="1.2.0", force=True) + .expecting_validation_error("Unable to determine AIO release train"), + {}, ), ( UpgradeScenario("Early Validation: IoT Ops downgrade blocks platform migration") @@ -2094,7 +2148,7 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis .set_extension(ext_type=EXTENSION_TYPE_CM, remove=True) .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE) .set_user_kwargs(ops_version="1.2.0") # Downgrade from MIN_INSTANCE_VERSION_FOR_CM_MIGRATE - .expecting_validation_error(r"is a downgrade which is not supported"), + .expecting_validation_error("downgrade.*not supported"), {}, ), ( @@ -2112,7 +2166,7 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis .set_extension(ext_type=EXTENSION_TYPE_CM, remove=True) .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.2.0", ext_train="preview") .set_user_kwargs(ops_version=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE) - .expecting_validation_error(r"Upgrades to or from non-stable release trains are not supported"), + .expecting_validation_error("conflicts with the preview runtime profile"), {}, ), ( @@ -2125,18 +2179,14 @@ def assert_operation_order(target_scenario: UpgradeScenario, upgrade_result: Lis {}, ), ( - UpgradeScenario("Early Validation: Force bypasses validation and allows migration") + UpgradeScenario("Early Validation: Force allows stable migration") .set_extension(ext_type=EXTENSION_TYPE_PLATFORM, ext_vers="1.0.0") .set_extension(ext_type=EXTENSION_TYPE_CM, remove=True) .set_extension(ext_type=EXTENSION_TYPE_OPS, ext_vers="1.0.0") .set_user_kwargs(ops_version=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE, force=True), - { - # Platform deleted, CM created, IoT Ops upgraded - all operations proceed with force - EXTENSION_TYPE_CM: build_extension_props(EXTENSION_TYPE_CM, version=BUILT_IN_VALUE), - EXTENSION_TYPE_OPS: build_extension_props( - EXTENSION_TYPE_OPS, version=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE - ), - }, + {EXTENSION_TYPE_OPS: build_extension_props( + EXTENSION_TYPE_OPS, version=MIN_INSTANCE_VERSION_FOR_CM_MIGRATE, + )}, ), ], ) @@ -2192,7 +2242,12 @@ def test_ops_upgrade( mocked_logger.error.assert_called_once_with( f"Correlation Id for failed update operation: {target_scenario.last_correlation_id}" ) - assert_displays(spy_upgrade_displays, no_progress, error_context=err) + if isinstance(err.value, ValidationError): + assert not [call for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + assert_displays( + spy_upgrade_displays, no_progress, error_context=err, + progress_count=0 if target_scenario.cluster_connected_status != "Connected" else None, + ) return upgrade_result = upgrade_instance(**call_kwargs) @@ -2269,6 +2324,7 @@ def test_ops_upgrade_retry_assertion( mocked_logger: Mock, mocked_sleep: Mock, spy_upgrade_displays: Dict[str, Mock], + mocked_upgrade_manager: Mock, ): from azext_edge.edge.commands_edge import upgrade_instance @@ -2613,33 +2669,13 @@ def assert_displays( if isinstance(error_value, ValidationError): error_msg = str(error_value) - - # These errors occur early (before table render), only 1 progress init - early_errors = [ - "is not connected", - "IoT Operations extension not detected", - "requires an ADR namespace", - ] - - ops_validation_patterns = [ - "is a downgrade", - "incompatible", - "min compatible upgrade version", - "non-stable release trains", - "Unable to determine", - ] - is_ops_validation_error = EXTENSION_MONIKER_OPS in error_msg and any( - pattern in error_msg for pattern in ops_validation_patterns + discovery_errors = ( + "no supported runtime profile mapping", "conflicts with the", + "currentVersion is missing", "Unable to determine AIO release train", + "Expected exactly one AIO extension", "AIO runtime is not ready", ) - - if any(phrase in error_msg for phrase in early_errors): - progress_count = 1 - elif is_ops_validation_error: - # IoT Operations validation errors are early (gatekeeper for entire upgrade) - progress_count = 1 - else: - # Other validation errors (e.g., certManager downgrade) are late - progress_count = 2 + # All other plan validation also precedes rendering and mutations. + progress_count = 0 if any(text in error_msg for text in discovery_errors) else 1 elif isinstance(error_value, HttpResponseError): # HTTP errors occur during apply_upgrades, after table render progress_count = 2 @@ -2794,13 +2830,13 @@ def test_opcua_connector_version_matches_template_tag(): constant. Nothing else couples them, so this fails if a template regeneration bumps one without the other (which would otherwise stamp two different tags on the same release). """ - import re - opcua_var = TEMPLATE_BLUEPRINT_INSTANCE.content["variables"].get("OPCUA_CONNECTOR_VERSION") assert opcua_var, "OPCUA_CONNECTOR_VERSION variable missing from the instance template." - # The connectors tag is the coalesce fallback literal, i.e. the last single-quoted token. - quoted_literals = re.findall(r"'([^']*)'", opcua_var) - template_tag = quoted_literals[-1] if quoted_literals else None + assert opcua_var == ( + "[coalesce(tryGet(tryGet(parameters('advancedConfig'), 'connectors'), 'version'), " + "variables('VERSIONS').connectors)]" + ) + template_tag = TEMPLATE_BLUEPRINT_INSTANCE.content["variables"]["VERSIONS"]["connectors"] assert template_tag == OPCUA_CONNECTOR_VERSION, ( f"OPCUA_CONNECTOR_VERSION constant ({OPCUA_CONNECTOR_VERSION}) does not match the connectors " f"tag stamped by the instance template ({template_tag}); update the constant during the " @@ -2841,13 +2877,17 @@ def build_ext_upgrade_state( desired_config: Optional[Dict[str, str]] = None, force: bool = False, operation_type=None, + qualification_identities=(), ): """Build an ExtensionUpgradeState directly, bypassing cluster discovery.""" from azext_edge.edge.providers.orchestration.upgrade2 import ConfigOverride, ExtensionUpgradeState - props: Dict[str, str] = {"extensionType": ext_type, "provisioningState": provisioning_state} + props = { + "extensionType": ext_type, "provisioningState": provisioning_state, "autoUpgradeMinorVersion": False, + } if current_version: props["version"] = current_version + props["currentVersion"] = current_version if current_train: props["releaseTrain"] = current_train @@ -2858,7 +2898,202 @@ def build_ext_upgrade_state( override=ConfigOverride(version=version_override, train=train_override), force=force, operation_type=operation_type, + qualification_identities=qualification_identities, + ) + + +def test_preview_upgrade_routes_instance_and_backfills_to_profile_api(mocked_cmd, mocked_responses, mocked_sleep): + from azext_edge.edge.providers.orchestration.upgrade2 import UpgradeManager + from azext_edge.edge.providers.orchestration.resources.connector_templates import ConnectorTemplates + + name, resource_group = "preview-instance", "preview-rg" + scenario = UpgradeScenario().set_extension( + EXTENSION_TYPE_OPS, ext_vers="1.6.0-preview.43", ext_train="integration", + ) + scenario.set_instance_mock(mocked_responses, name, resource_group) + preview_record = deepcopy(scenario.instance_record) + preview_record["properties"]["previewOnlyProperty"] = {"retained": ["value"]} + api_version = "2026-09-01-preview" + endpoint = get_instance_endpoint(resource_group, name, api_version=api_version) + base_url = endpoint.split("?")[0] + mocked_responses.add(responses.GET, endpoint, json=preview_record) + for resource_type in ("registryEndpoints", "akriConnectorTemplates"): + mocked_responses.add( + responses.GET, f"{base_url}/{resource_type}?api-version={api_version}", json={"value": []}, + ) + + def echo_resource(request): + record = json.loads(request.body) + record["properties"]["provisioningState"] = "Succeeded" + return 200, STANDARD_HEADERS, json.dumps(record) + + template_name = ConnectorTemplates.default_opcua_template_name(name) + write_urls = [ + endpoint, + f"{base_url}/registryEndpoints/default?api-version={api_version}", + f"{base_url}/akriConnectorTemplates/{template_name}?api-version={api_version}", + ] + for url in write_urls: + mocked_responses.add_callback(responses.PUT, url, callback=echo_resource) + adr_id = preview_record["properties"]["adrNamespaceRef"]["resourceId"] + "-updated" + manager = UpgradeManager(mocked_cmd, resource_group, name, adr_namespace_resource_id=adr_id, no_progress=True) + plan = manager.analyze_cluster() + assert plan.instance_upgrade and plan.registry_endpoint_needed and plan.connector_template_needed + assert plan.connector_template_endpoint_types == [ + {"endpointType": "Microsoft.OpcUa"}, {"endpointType": "Microsoft.OpcUa.WoT"}, + ] + manager.apply_upgrades(plan) + + aio_requests = [call.request for call in mocked_responses.calls if "/Microsoft.IoTOperations/" in call.request.url] + assert aio_requests[0].url == get_instance_endpoint(resource_group, name) + assert all(request.url.endswith(f"api-version={api_version}") for request in aio_requests[1:]) + writes = [request for request in aio_requests if request.method != "GET"] + assert [request.url for request in writes] == write_urls + assert all(request.method == "PUT" for request in writes) + payload = json.loads(writes[0].body) + assert payload["properties"]["previewOnlyProperty"] == {"retained": ["value"]} + assert payload["properties"]["adrNamespaceRef"]["resourceId"] == adr_id + assert "previewOnlyProperty" not in scenario.instance_record["properties"] + assert ( + json.loads(writes[2].body)["properties"]["deviceInboundEndpointTypes"] == plan.connector_template_endpoint_types + ) + + +@pytest.mark.parametrize("channel", ["stable", "preview"]) +@pytest.mark.parametrize("repair", [False, True]) +def test_connector_confirmation_renders_profile_endpoint_types(mocker, channel, repair): + from io import StringIO + from rich.console import Console + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + from azext_edge.edge.providers.orchestration.resources.connector_templates import ConnectorTemplates + from azext_edge.edge.providers.orchestration.upgrade2 import render_upgrade_table + + profile = get_runtime_catalog().get(channel) + state = Mock( + no_cm_install=False, extension_upgrades=[], instance_upgrade=None, registry_endpoint_needed=False, + connector_template_needed=True, connector_template_repair_name="failed-template" if repair else None, + connector_template_version=profile.require_opcua_connector_version(), secretsync_migration_needed=False, + connector_template_endpoint_types=ConnectorTemplates.default_opcua_endpoint_types(profile), + ) + output = StringIO() + mocker.patch( + "azext_edge.edge.providers.orchestration.upgrade2.console", Console(file=output, width=240, color_system=None), + ) + render_upgrade_table(state) + rendered = output.getvalue() + assert "Microsoft.OpcUa" in rendered + assert "Microsoft.OpcUa.WoT" in rendered + assert ("Replace failed template" if repair else "Create default OPC UA") in rendered + assert profile.require_opcua_connector_version() in rendered + + +@pytest.mark.parametrize("resource", ["instance", "custom_location"]) +@pytest.mark.parametrize("state", ["Failed", "Canceled"]) +@pytest.mark.parametrize("repair", [False, True]) +def test_terminal_resource_state_allows_upgrade_writes( + mocked_cmd, mocked_responses, mocked_sleep, mocked_upgrade_manager, resource, state, repair, +): + from azext_edge.edge.commands_edge import upgrade_instance + + scenario = UpgradeScenario().set_extension( + EXTENSION_TYPE_OPS, ext_vers="1.4.0" if not repair else BUILT_IN_VALUE, + provisioning_state=PROVISIONING_STATE_FAILED if repair else PROVISIONING_STATE_SUCCESS, + ) + scenario.aux_kwargs[f"{resource}_provisioning_state"] = state + scenario.set_instance_mock(mocked_responses, "instance", "rg") + + upgrade_instance(mocked_cmd, "rg", "instance", confirm_yes=True, no_progress=True) + + ops_name = scenario.extensions[EXTENSION_TYPE_OPS]["name"] + writes = [ + call.request for call in mocked_responses.calls + if call.request.method == "PATCH" and f"/extensions/{ops_name}?" in call.request.url + ] + assert len(writes) == 1 + expected_version = scenario.init_version_map[EXTENSION_MONIKER_OPS]["version"] + assert json.loads(writes[0].body)["properties"]["version"] == expected_version + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("auto_upgrade", [True, None]) +def test_upgrade_ownership_failure_prevents_all_writes( + mocked_cmd, mocked_responses, mocked_upgrade_manager, force, auto_upgrade, +): + from azext_edge.edge.commands_edge import upgrade_instance + + scenario = UpgradeScenario().set_extension(EXTENSION_TYPE_OPS, ext_vers="1.4.0") + properties = scenario.extensions[EXTENSION_TYPE_OPS]["properties"] + if auto_upgrade is None: + properties.pop("autoUpgradeMinorVersion") + else: + properties["autoUpgradeMinorVersion"] = auto_upgrade + scenario.set_instance_mock(mocked_responses, "instance", "rg") + with pytest.raises(ValidationError, match="autoUpgradeMinorVersion is explicitly false"): + upgrade_instance(mocked_cmd, "rg", "instance", confirm_yes=True, force=force) + assert not [call for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("auto_upgrade", [False, True, None, "false"]) +@pytest.mark.parametrize("operation", ["upgrade", "repair", "config", "noop"]) +def test_ops_version_pinning_requires_manual_ownership(force, auto_upgrade, operation): + ext = build_ext_upgrade_state( + ext_type=EXTENSION_TYPE_OPS, + current_version="1.6.0-preview.9", current_train="preview", + built_in_version="1.6.0-preview.10" if operation == "upgrade" else "1.6.0-preview.9", + built_in_train="preview", force=force, + provisioning_state=PROVISIONING_STATE_FAILED if operation == "repair" else PROVISIONING_STATE_SUCCESS, + desired_config={"setting": "value"} if operation == "config" else None, ) + properties = ext.extension["properties"] + if auto_upgrade is None: + properties.pop("autoUpgradeMinorVersion") + else: + properties["autoUpgradeMinorVersion"] = auto_upgrade + if operation in {"upgrade", "repair"} and auto_upgrade is not False: + for validate in (ext.validate_upgrade, ext.get_patch): + with pytest.raises(ValidationError, match="autoUpgradeMinorVersion is explicitly false") as caught: + validate() + assert ( + "az k8s-extension update --subscription " + "--resource-group --cluster-name " + "--cluster-type connectedClusters --name --auto-upgrade false" + ) in str(caught.value) + assert "--force" not in str(caught.value) + else: + ext.validate_upgrade() + patch_properties = ext.get_patch().get("properties", {}) + if operation in {"upgrade", "repair"}: + assert patch_properties["version"] == ext.desired_version[0] + else: + assert "version" not in patch_properties + assert "autoUpgradeMinorVersion" not in patch_properties + + +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("target_version,error_match", [ + ("1.6.0-preview.9", None), + ("1.6.0-preview.10", None), + ("1.6.0-preview.11", None), + ("1.6.0-preview.20", None), + ("1.7.0-preview.1", None), + ("1.7.0-preview.11", None), + ("1.8.0-preview.1", "more than one minor version"), + ("2.0.0-preview.1", "across major versions"), + ("1.6.0-preview.8", "downgrade"), +]) +def test_preview_upgrades_allow_one_minor_version_and_suffix_jumps(force, target_version, error_match): + ext = build_ext_upgrade_state( + ext_type=EXTENSION_TYPE_OPS, current_version="1.6.0-preview.9", current_train="preview", + built_in_version=target_version, built_in_train="preview", version_override=target_version, force=force, + ) + if error_match is None: + ext.validate_upgrade() + assert ext.get_patch()["properties"]["version"] == target_version + else: + for validate in (ext.validate_upgrade, ext.get_patch): + with pytest.raises(ValidationError, match=error_match): + validate() def test_desired_state_ignores_built_in_target_when_no_version_is_sent(): @@ -3002,7 +3237,8 @@ def test_validate_upgrade_skips_non_update_operations(op_type_name): pytest.param("stable", "preview", id="train delta from --ops-train override"), ], ) -def test_reconcile_cannot_bypass_release_train_guard(built_in_train, train_override): +@pytest.mark.parametrize("force", [False, True]) +def test_reconcile_cannot_bypass_release_train_guard(built_in_train, train_override, force): """Both routes are covered: an --ops-train override short circuits before the version compare.""" ext = build_ext_upgrade_state( ext_type=EXTENSION_TYPE_OPS, @@ -3010,14 +3246,248 @@ def test_reconcile_cannot_bypass_release_train_guard(built_in_train, train_overr built_in_train=built_in_train, train_override=train_override, provisioning_state=PROVISIONING_STATE_FAILED, + force=force, ) - with pytest.raises(ValidationError, match="non-stable release trains are not supported"): + with pytest.raises(ValidationError, match="Cross-train upgrades"): ext.validate_upgrade() - with pytest.raises(ValidationError, match="non-stable release trains are not supported"): + with pytest.raises(ValidationError, match="Cross-train upgrades"): ext.get_patch() +@pytest.mark.parametrize("explicit_target", [False, True]) +@pytest.mark.parametrize("force", [False, True]) +def test_ga_catalog_train_change_preserves_force_behavior(explicit_target, force): + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel + + catalog = get_runtime_catalog() + target = catalog.get(RuntimeChannel.STABLE).identity + ext = build_ext_upgrade_state( + ext_type=EXTENSION_TYPE_OPS, + current_version="1.4.112", + current_train="stable", + built_in_version=target.version, + built_in_train=target.train, + version_override=target.version if explicit_target else None, + train_override=target.train if explicit_target else None, + force=force, + qualification_identities=catalog.qualification_identities, + ) + + if force: + ext.validate_upgrade() + properties = ext.get_patch()["properties"] + assert properties["version"] == target.version + assert properties["releaseTrain"] == target.train + else: + for validate in (ext.validate_upgrade, ext.get_patch): + with pytest.raises(ValidationError, match="Cross-train upgrades"): + validate() + + +@pytest.mark.parametrize("current_version,current_train", [("1.4.112", "stable"), ("1.5.40", "integration")]) +@pytest.mark.parametrize("force", [False, True]) +def test_explicit_unbundled_ga_integration_force_upgrade(current_version, current_train, force): + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel + + catalog = get_runtime_catalog() + target = catalog.get(RuntimeChannel.STABLE).identity + ext = build_ext_upgrade_state( + ext_type=EXTENSION_TYPE_OPS, + current_version=current_version, + current_train=current_train, + built_in_version=target.version, + built_in_train=target.train, + version_override="1.5.40", + train_override="integration", + force=force, + qualification_identities=catalog.qualification_identities, + ) + + if force: + ext.validate_upgrade() + properties = ext.get_patch()["properties"] + assert properties["version"] == "1.5.40" + assert properties["releaseTrain"] == "integration" + else: + for validate in (ext.validate_upgrade, ext.get_patch): + with pytest.raises(ValidationError): + validate() + + +@pytest.mark.parametrize( + "current_version,current_train,force,ops_version,ops_train,error", + [ + ("1.4.112", "stable", True, "1.5.40", "integration", None), + ("1.5.40", "integration", True, "1.5.40", "integration", None), + ("1.4.112", "stable", False, "1.5.40", "integration", "Cross-train upgrades"), + ("1.5.40", "integration", False, "1.5.40", "integration", "supported runtime profile mapping"), + ("1.5.40", "integration", True, None, "integration", None), + ("1.5.40", "integration", True, "1.5.40", None, None), + ("1.4.112", "stable", True, "1.5.40", None, None), + ("preview", "preview", True, "1.6.0-preview.50", None, None), + ("preview", "integration", True, "1.6.0-preview.50", "integration", None), + ("1.6.0-preview.50", "integration", True, "1.6.0-preview.50", "integration", None), + ("1.6.0-preview.50", "integration", True, "1.6.0-preview.50", None, None), + ("1.5.40", "integration", False, None, None, None), + ("1.5.40", "integration", True, None, None, None), + ("1.6.0-preview.50", "integration", False, None, None, None), + ("1.6.0-preview.50", "integration", True, None, None, None), + ("preview", "integration", True, "1.5.40", "integration", "GA clusters must remain GA"), + ("1.4.112", "stable", True, "1.6.0-preview.999", "integration", "Cross-train upgrades"), + ("1.5.40", "integration", True, "1.6.0-preview.50", None, "GA clusters must remain GA"), + ("1.6.0-preview.50", "integration", True, "1.5.40", None, "GA clusters must remain GA"), + ("preview", "integration", False, "1.6.0-preview.50", "integration", "supported runtime profile mapping"), + ("preview", "integration", True, "1.8.0-preview.50", "integration", "more than one minor version"), + ("preview", "integration", True, "2.0.0-preview.50", "integration", "across major versions"), + ("1.6.0-preview.50", "integration", True, "preview", "integration", "downgrade"), + ("preview", "integration", True, "1.6.1-preview.50", "integration", "conflicts with"), + ("preview", "integration", True, "1.6.0-alpha.50", "integration", "conflicts with"), + ("1.5.40", "integration", True, "1.5.40", "stable", None), + ], +) +def test_unbundled_integration_force_command_preserves_boundaries( + mocked_cmd, mocked_responses, mocked_sleep, + current_version, current_train, force, ops_version, ops_train, error, +): + from azext_edge.edge.commands_edge import upgrade_instance + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel + + profile = get_runtime_catalog().get( + RuntimeChannel.PREVIEW if current_version == "preview" or "preview" in current_version + else RuntimeChannel.STABLE + ) + if current_version == "preview": + current_version = profile.identity.version + if ops_version == "preview": + ops_version = profile.identity.version + scenario = UpgradeScenario().set_extension( + EXTENSION_TYPE_OPS, ext_vers=current_version, ext_train=current_train, + ) + scenario.set_instance_mock(mocked_responses, "instance", "rg", iotops_api_version=profile.iotops_api_version) + kwargs = {"force": force, "ops_version": ops_version, "ops_train": ops_train, + "confirm_yes": True, "no_progress": True} + + if error: + with pytest.raises(ValidationError, match=error): + upgrade_instance(mocked_cmd, "rg", "instance", **kwargs) + assert not [call for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + else: + upgrade_instance(mocked_cmd, "rg", "instance", **kwargs) + ops_name = scenario.extensions[EXTENSION_TYPE_OPS]["name"] + patches = [ + json.loads(call.request.body)["properties"] for call in mocked_responses.calls + if call.request.method == "PATCH" and f"/extensions/{ops_name}?" in call.request.url + ] + if ops_version is None and ops_train is None: + assert not patches + assert not [call for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + else: + assert len(patches) == 1 + if ops_version is None: + assert "version" not in patches[0] + else: + assert patches[0]["version"] == ops_version + if ops_train is None: + assert "releaseTrain" not in patches[0] + else: + assert patches[0]["releaseTrain"] == ops_train + + +@pytest.mark.parametrize("current_version", ["1.5.40", "1.6.0-preview.50"]) +@pytest.mark.parametrize("force", [False, True]) +@pytest.mark.parametrize("provisioning_state", ["Succeeded", "Failed", "Canceled"]) +@pytest.mark.parametrize("ops_train", [None, "integration"]) +@pytest.mark.parametrize("auto_upgrade", [False, True, None]) +def test_unbundled_installed_runtime_force_and_repair_contract( + mocked_cmd, mocked_responses, mocked_sleep, + current_version, force, provisioning_state, ops_train, auto_upgrade, +): + from azext_edge.edge.commands_edge import upgrade_instance + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel + + scenario = UpgradeScenario().set_extension( + EXTENSION_TYPE_OPS, ext_vers=current_version, ext_train="integration", provisioning_state=provisioning_state, + ) + properties = scenario.extensions[EXTENSION_TYPE_OPS]["properties"] + if auto_upgrade is None: + properties.pop("autoUpgradeMinorVersion") + else: + properties["autoUpgradeMinorVersion"] = auto_upgrade + profile = get_runtime_catalog().get( + RuntimeChannel.PREVIEW if "preview" in current_version else RuntimeChannel.STABLE + ) + scenario.set_instance_mock(mocked_responses, "instance", "rg", iotops_api_version=profile.iotops_api_version) + kwargs = {"force": force, "ops_train": ops_train, "confirm_yes": True, "no_progress": True} + + if provisioning_state != "Succeeded" and auto_upgrade is not False: + with pytest.raises(ValidationError, match="autoUpgradeMinorVersion is explicitly false"): + upgrade_instance(mocked_cmd, "rg", "instance", **kwargs) + assert not [call for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + return + + upgrade_instance(mocked_cmd, "rg", "instance", **kwargs) + writes = [call.request for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + if provisioning_state == "Succeeded" and ops_train is None: + assert not writes + return + + assert len(writes) == 1 + assert writes[0].method == "PATCH" + assert f"/extensions/{scenario.extensions[EXTENSION_TYPE_OPS]['name']}?" in writes[0].url + expected = {} + if provisioning_state != "Succeeded": + expected["version"] = current_version + if ops_train is not None: + expected["releaseTrain"] = ops_train + assert json.loads(writes[0].body)["properties"] == expected + + +@pytest.mark.parametrize("current_version", ["1.5.40", "1.6.0-preview.50"]) +@pytest.mark.parametrize("ops_train", [None, "integration"]) +@pytest.mark.parametrize( + "auto_upgrade,connectivity,instance_state,error", + [ + (True, "Connected", "Succeeded", "autoUpgradeMinorVersion is explicitly false"), + (None, "Connected", "Succeeded", "autoUpgradeMinorVersion is explicitly false"), + (False, "Disconnected", "Succeeded", "disconnected"), + (False, "Connected", "Deleting", "instance provisioning state"), + ], +) +def test_unbundled_integration_force_preserves_readiness_and_ownership( + mocked_cmd, mocked_responses, auto_upgrade, connectivity, instance_state, error, current_version, ops_train, +): + from azext_edge.edge.commands_edge import upgrade_instance + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel + + scenario = UpgradeScenario().set_extension( + EXTENSION_TYPE_OPS, ext_vers=current_version, ext_train="integration", + ) + properties = scenario.extensions[EXTENSION_TYPE_OPS]["properties"] + if auto_upgrade is None: + properties.pop("autoUpgradeMinorVersion") + else: + properties["autoUpgradeMinorVersion"] = auto_upgrade + scenario.set_cluster_connected_status(connectivity) + scenario.aux_kwargs["instance_provisioning_state"] = instance_state + profile = get_runtime_catalog().get( + RuntimeChannel.PREVIEW if "preview" in current_version else RuntimeChannel.STABLE + ) + scenario.set_instance_mock(mocked_responses, "instance", "rg", iotops_api_version=profile.iotops_api_version) + + with pytest.raises(ValidationError, match=error): + upgrade_instance( + mocked_cmd, "rg", "instance", force=True, ops_version=current_version, ops_train=ops_train, + confirm_yes=True, no_progress=True, + ) + assert not [call for call in mocked_responses.calls if call.request.method in {"PUT", "PATCH", "DELETE"}] + + def test_reconcile_train_delta_validates_the_version_the_patch_sends(): # A train override re-runs the guard, it must compare the reconcile version, not the stale built-in. ext = build_ext_upgrade_state( @@ -3036,16 +3506,16 @@ def test_reconcile_train_delta_validates_the_version_the_patch_sends(): def test_reconcile_repairs_a_failed_extension_on_a_preview_train(): ext = build_ext_upgrade_state( ext_type=EXTENSION_TYPE_OPS, - current_version="1.4.73", + current_version="1.6.0-preview.10", current_train="preview", - built_in_version="1.3.105", + built_in_version="1.6.0-preview.4", built_in_train="preview", provisioning_state=PROVISIONING_STATE_FAILED, ) ext.validate_upgrade() - assert ext.get_patch()["properties"]["version"] == "1.4.73" + assert ext.get_patch()["properties"]["version"] == "1.6.0-preview.10" @pytest.mark.parametrize("provisioning_state", [PROVISIONING_STATE_SUCCESS, PROVISIONING_STATE_FAILED]) @@ -3059,8 +3529,11 @@ def test_explicit_downgrade_still_blocked(provisioning_state): @pytest.mark.parametrize("provisioning_state", [PROVISIONING_STATE_SUCCESS, PROVISIONING_STATE_FAILED]) -def test_explicit_downgrade_allowed_with_force(provisioning_state): - ext = build_ext_upgrade_state(version_override="1.0.0", provisioning_state=provisioning_state, force=True) +@pytest.mark.parametrize("ext_type", [EXTENSION_TYPE_CM, EXTENSION_TYPE_OPS]) +def test_explicit_downgrade_allowed_with_force(provisioning_state, ext_type): + ext = build_ext_upgrade_state( + ext_type=ext_type, version_override="1.0.0", provisioning_state=provisioning_state, force=True, + ) ext.validate_upgrade() assert ext.get_patch()["properties"]["version"] == "1.0.0" diff --git a/azext_edge/tests/edge/orchestration/test_upgrade_int.py b/azext_edge/tests/edge/orchestration/test_upgrade_int.py index f7401fd61..8f3a400e0 100644 --- a/azext_edge/tests/edge/orchestration/test_upgrade_int.py +++ b/azext_edge/tests/edge/orchestration/test_upgrade_int.py @@ -5,6 +5,7 @@ # ---------------------------------------------------------------------------------------------- import random +import os from copy import deepcopy from typing import Any, Dict, List @@ -19,6 +20,7 @@ from ...generators import generate_random_string from ...helpers import process_additional_args, run, strip_quotes +from ...runtime_checks import assert_runtime, configured_baseline EXTENSION_TYPE_TO_ALIAS_MAP = { val: key @@ -53,6 +55,12 @@ def test_upgrade(upgrade_int_setup): additional_args = upgrade_int_setup["additionalUpgradeArgs"] or "" resource_group = upgrade_int_setup["resourceGroup"] instance_name = upgrade_int_setup["instanceName"] + channel = os.environ.get("azext_edge_runtime_channel") + baseline = configured_baseline() + if channel: + source = assert_runtime(instance_name, resource_group, channel, baseline) + # Qualification always targets the bundled profile, not arbitrary overrides. + assert not additional_args, "Do not override the target in a channel qualification job." # make tree get us the cluster instance_tree = run(f"az iot ops show -n {instance_name} -g {resource_group} --tree") @@ -75,6 +83,18 @@ def test_upgrade(upgrade_int_setup): # run first command with only additional args from input run(f"{command} {additional_args}") + if channel: + target = assert_runtime(instance_name, resource_group, channel) + assert source.instance_id == target.instance_id + assert source.identity.channel == target.identity.channel + assert source.identity.train == target.identity.train + if baseline: + from azext_edge.edge.providers.orchestration.runtime_profiles import parse_runtime_version + + assert parse_runtime_version(source.identity.version) < parse_runtime_version(target.identity.version) + # This is an actual version upgrade, not the random configuration/repair + # scenario below. Migration-specific assertions belong to the supplied path. + return assert_extensions(cluster_id=cluster_id, original_ext_map=original_ext_map, additional_args=additional_args) # if additional args present, only run once if additional_args: @@ -90,6 +110,8 @@ def test_upgrade(upgrade_int_setup): run(f"{command} {additional_args}") assert_extensions(cluster_id=cluster_id, original_ext_map=original_ext_map, additional_args=additional_args) + if channel: + assert_runtime(instance_name, resource_group, channel) def assert_extensions(cluster_id: str, original_ext_map: Dict[str, Any], additional_args: str = ""): diff --git a/azext_edge/tests/edge/orchestration/test_work_unit.py b/azext_edge/tests/edge/orchestration/test_work_unit.py index 546bc5919..1c7404108 100644 --- a/azext_edge/tests/edge/orchestration/test_work_unit.py +++ b/azext_edge/tests/edge/orchestration/test_work_unit.py @@ -201,6 +201,15 @@ def _bootstrap(self, **kwargs): omit_methods = frozenset([]) omit_methods = omit_methods.union(override_omit_http_method) + # Resource Graph POST is a read. Negative create tests still allow discovery + # while rejecting every deployment/provider-registration write. + if responses.POST in omit_methods: + self.mocked_responses.assert_all_requests_are_fired = False + self.mocked_responses.add_callback( + method=responses.POST, + url=re.compile(r"https://management\.azure\.com/providers/Microsoft\.ResourceGraph/resources.*"), + callback=self._handle_requests, + ) for method in [ responses.GET, responses.HEAD, @@ -366,7 +375,13 @@ def _handle_create(self, request_kpis: RequestKPIs): ): assert request_kpis.params["api-version"] == ExpectedAPIVersion.CLUSTER_EXTENSION.value self.call_map[CallKey.GET_CLUSTER_EXTENSIONS].append(request_kpis) - return (200, STANDARD_HEADERS, json.dumps(self.scenario["cluster"]["extensions"])) + extensions = self.scenario["cluster"]["extensions"] + if not self.call_map[CallKey.DEPLOY_CREATE_EXT] and not self.scenario.get("existing_ops_extension"): + extensions = {"value": [ + ext for ext in extensions["value"] + if ext["properties"]["extensionType"].lower() != EXTENSION_TYPE_OPS + ]} + return (200, STANDARD_HEADERS, json.dumps(extensions)) if request_kpis.method == responses.PUT: self._assert_correlation_headers(request_kpis) @@ -1520,6 +1535,29 @@ def test_iot_ops_create_block_feature_config( assert "opcua.mode" in exc_msg +@pytest.mark.parametrize( + "instance_features, expected_features, conflict_mode", + [ + (None, ["opcua.mode=Disabled"], None), + (["opcua.mode=Disabled"], ["opcua.mode=Disabled"], None), + (["opcua.mode=Stable"], ["opcua.mode=Disabled"], "Stable"), + (["opcua.mode=Preview"], ["opcua.mode=Disabled"], "Preview"), + ], +) +def test_apply_essentials_sku(mocker, instance_features, expected_features, conflict_mode): + from azext_edge.edge.commands_edge import _apply_essentials_sku + + mocked_logger = mocker.patch("azext_edge.edge.commands_edge.logger", autospec=True) + assert _apply_essentials_sku(instance_features) == expected_features + warnings = [call.args for call in mocked_logger.warning.call_args_list] + + if conflict_mode: + assert warnings[0][1] == conflict_mode + assert "will use opcua.mode=Disabled" in warnings[0][0] + else: + assert not warnings + + def assert_logger(mocked_logger: Mock, target_scenario: dict): expected_warnings: List[Tuple[int, str]] = target_scenario.get("warnings", []) warning_calls: List[Mock] = mocked_logger.warning.mock_calls @@ -1581,6 +1619,7 @@ def assert_instance_deployment_body(body_str: str, target_scenario: dict, phase: assert set(parameters["clExtensionIds"]["value"]) == cl_extension_ids assert parameters["schemaRegistryId"]["value"] == target_scenario["schemaRegistry"]["id"] assert parameters["adrNamespaceId"]["value"] == target_scenario["adrNamespace"]["id"] + assert parameters["aioInstanceName"]["value"] == target_scenario["instance"]["name"].lower() # TODO - eventually delete. assert "deployResourceSyncRules" not in parameters @@ -1643,11 +1682,11 @@ def assert_instance_deployment_body(body_str: str, target_scenario: dict, phase: if target_scenario["instance"]["tags"]: assert resources["aioInstance"]["tags"] == target_scenario["instance"]["tags"] instance_features = target_scenario.get("instance_features") + assert resources["aioInstance"]["properties"]["features"] == "[parameters('features')]" if instance_features: - assert resources["aioInstance"]["properties"]["features"] + assert parameters["features"]["value"] else: - # TODO: think about general 'not in' or 'not' pattern - assert not resources["aioInstance"]["properties"]["features"] + assert "features" not in parameters if phase in [InstancePhase.RESOURCES]: assert resources["broker"]["name"] == f"{instance_name_lowered}/{DEFAULT_BROKER}" diff --git a/azext_edge/tests/edge/support/create_bundle_int/helpers.py b/azext_edge/tests/edge/support/create_bundle_int/helpers.py index 3dd4e5b3f..51b3345b1 100644 --- a/azext_edge/tests/edge/support/create_bundle_int/helpers.py +++ b/azext_edge/tests/edge/support/create_bundle_int/helpers.py @@ -775,7 +775,7 @@ def split_name(name: str) -> List[str]: """ Splits a name by the .'s. - If a number is present (ex: versioning like 1.0.0-preview), do not split that portion. + If a number is present (ex: versioning like 1.0.0-preview.2-hash), do not split that portion. Make sure the extension is split out (last . for the extension). """ first_pass = name.split(".") @@ -783,7 +783,10 @@ def split_name(name: str) -> List[str]: for i in range(len(first_pass)): # we should not need to worry about trying to access too early # since the first part should be the workload type (ex: pod) - if all([i != (len(first_pass) - 1), first_pass[i].isnumeric() or first_pass[i - 1].isnumeric()]): + if all([ + i != (len(first_pass) - 1), + first_pass[i].partition("-")[0].isnumeric() or first_pass[i - 1].isnumeric(), + ]): second_pass[-1] = f"{second_pass[-1]}.{first_pass[i]}" else: second_pass.append(first_pass[i]) diff --git a/azext_edge/tests/edge/support/create_bundle_int/test_auto_int.py b/azext_edge/tests/edge/support/create_bundle_int/test_auto_int.py index 394fbfb82..1660b4552 100644 --- a/azext_edge/tests/edge/support/create_bundle_int/test_auto_int.py +++ b/azext_edge/tests/edge/support/create_bundle_int/test_auto_int.py @@ -15,7 +15,7 @@ run_bundle_command, BASE_ZIP_PATH, ) -from ....helpers import assert_extra_or_missing_names +from ....helpers import assert_extra_or_missing_names, get_multi_kubectl_workload_items logger = get_logger(__name__) @@ -175,6 +175,18 @@ def _get_expected_services(walk_result: Dict[str, Dict[str, List[str]]], ops_ser if not exists and service in expected_services: expected_services.remove(service) + for service, prefixes in [ + (OpsServiceType.schemaregistry.value, ["adr-schema-registry"]), + (OpsServiceType.edgeregistry.value, ["aio-edge-registry"]), + ]: + if service in expected_services: + resources = get_multi_kubectl_workload_items( + expected_workload_types=["configmap", "pod", "service", "statefulset", "pvc"], + prefixes=prefixes, + ) + if not any(resources.values()): + expected_services.remove(service) + # Add meta and meso if they're not already present # These are always included as common services for service in ["meta", "meso"]: diff --git a/azext_edge/tests/edge/support/create_bundle_int/test_helpers_unit.py b/azext_edge/tests/edge/support/create_bundle_int/test_helpers_unit.py index 36b5707ed..eb79cc9c1 100644 --- a/azext_edge/tests/edge/support/create_bundle_int/test_helpers_unit.py +++ b/azext_edge/tests/edge/support/create_bundle_int/test_helpers_unit.py @@ -6,7 +6,7 @@ import pytest from typing import List -from .helpers import split_name +from .helpers import assert_file_names, convert_file_names, split_name @pytest.mark.parametrize("input, expected", [ @@ -30,3 +30,29 @@ def test_split_name(input: str, expected: List[str]): result = split_name(input) assert result == expected + + +@pytest.mark.parametrize("pod_name", [ + "aio-akri-upgrade-status-job-1.4.0-preview.2-lktm8", + "aio-akri-upgrade-status-job-1.4.14-lktm8", + "aio-akri-operator-0", +]) +@pytest.mark.parametrize("suffix, descriptors, extension", [ + ("yaml", [], "yaml"), + ("aio-akri-upgrade-status-job.log", ["aio-akri-upgrade-status-job"], "log"), + ("aio-akri-upgrade-status-job.previous.log", ["aio-akri-upgrade-status-job", "previous"], "log"), + ("aio-akri-upgrade-status-job.init.log", ["aio-akri-upgrade-status-job", "init"], "log"), + ("metric.yaml", ["metric"], "yaml"), +]) +def test_pod_filenames_preserve_versioned_names(pod_name, suffix, descriptors, extension): + filename = f"pod.{pod_name}.{suffix}" + assert split_name(filename) == ["pod", pod_name, *descriptors, extension] + assert_file_names([filename]) + + file_type = "podmetric" if suffix == "metric.yaml" else "pod" + expected = {"name": pod_name, "full_name": filename, "extension": extension} + if descriptors: + expected["descriptor"] = descriptors[0] + if len(descriptors) == 2: + expected["sub_descriptor"] = descriptors[1] + assert convert_file_names([filename]) == {file_type: [expected]} diff --git a/azext_edge/tests/edge/support/create_bundle_int/test_schemaregistry_int.py b/azext_edge/tests/edge/support/create_bundle_int/test_schemaregistry_int.py index be90d57db..25d24a005 100644 --- a/azext_edge/tests/edge/support/create_bundle_int/test_schemaregistry_int.py +++ b/azext_edge/tests/edge/support/create_bundle_int/test_schemaregistry_int.py @@ -5,6 +5,7 @@ # ---------------------------------------------------------------------------------------------- import pytest +from os.path import basename from knack.log import get_logger from azext_edge.edge.common import OpsServiceType from ....helpers import get_multi_kubectl_workload_items @@ -16,31 +17,36 @@ SCHEMA_PREFIXES = ["adr-schema-registry"] SCHEMA_WORKLOAD_TYPES = ["configmap", "pod", "service", "statefulset", "pvc"] SCHEMA_LABEL = ("app.kubernetes.io/name", "microsoft-iotoperations-schemas") +EDGE_REGISTRY_PREFIXES = ["aio-edge-registry"] +EDGE_REGISTRY_LABEL = ("app.kubernetes.io/name", "microsoft-iotoperations-registry") -def test_create_bundle_schemas(cluster_connection, tracked_files): - """Test for ensuring file names and content. ONLY CHECKS arcagents.""" - ops_service = OpsServiceType.schemaregistry.value +@pytest.mark.parametrize("ops_service, prefixes, expected_label", [ + (OpsServiceType.schemaregistry.value, SCHEMA_PREFIXES, SCHEMA_LABEL), + (OpsServiceType.edgeregistry.value, EDGE_REGISTRY_PREFIXES, EDGE_REGISTRY_LABEL), +]) +def test_create_bundle_registries(cluster_connection, tracked_files, ops_service, prefixes, expected_label): pre_bundle_workload_items = get_multi_kubectl_workload_items( expected_workload_types=SCHEMA_WORKLOAD_TYPES, - prefixes=SCHEMA_PREFIXES, - expected_label=SCHEMA_LABEL + prefixes=prefixes, + ) + check_cluster_label_coverage( + prefixes=prefixes, + expected_label=expected_label, + workload_types=SCHEMA_WORKLOAD_TYPES, ) command = f"az iot ops support create-bundle --ops-service {ops_service}" walk_result, bundle_path = run_bundle_command(command=command, tracked_files=tracked_files) - file_map = get_file_map(walk_result, "schemaregistry")["aio"] + if not any(pre_bundle_workload_items.values()): + assert not any(basename(directory) == ops_service for directory in walk_result) + return + file_map = get_file_map(walk_result, ops_service)["aio"] assert set(file_map.keys()).issubset(set(SCHEMA_WORKLOAD_TYPES)) check_workload_resource_files( file_objs=file_map, pre_bundle_items=pre_bundle_workload_items, - prefixes=SCHEMA_PREFIXES, + prefixes=prefixes, bundle_path=bundle_path, - expected_label=SCHEMA_LABEL, - ) - check_cluster_label_coverage( - prefixes=SCHEMA_PREFIXES, - expected_label=SCHEMA_LABEL, - workload_types=SCHEMA_WORKLOAD_TYPES, ) diff --git a/azext_edge/tests/edge/support/test_support_unit.py b/azext_edge/tests/edge/support/test_support_unit.py index 214c40c3c..5da3c17dc 100644 --- a/azext_edge/tests/edge/support/test_support_unit.py +++ b/azext_edge/tests/edge/support/test_support_unit.py @@ -57,7 +57,7 @@ from azext_edge.edge.providers.support.dataflow import DATAFLOW_NAME_LABEL from azext_edge.edge.providers.support.meta import META_DIRECTORY_PATH, META_NAME_LABEL, META_PREFIX_NAMES from azext_edge.edge.providers.support.mq import MQ_DIRECTORY_PATH, MQ_NAME_LABEL -from azext_edge.edge.providers.support.schemaregistry import SCHEMAS_DIRECTORY_PATH, SCHEMAS_NAME_LABEL +from azext_edge.edge.providers.support.schemaregistry import SCHEMAS_NAME_LABEL from azext_edge.edge.providers.support_bundle import ( COMPAT_META_APIS, COMPAT_CLUSTER_CONFIG_APIS, @@ -1080,7 +1080,15 @@ def test_create_bundle_arc_agents( ) -def test_create_bundle_schemas( +@pytest.mark.parametrize("ops_service, label_selector, directory_path", [ + (OpsServiceType.schemaregistry.value, + "app.kubernetes.io/name in (microsoft-iotoperations-schemas)", "schemaregistry"), + (OpsServiceType.edgeregistry.value, "app.kubernetes.io/name in (microsoft-iotoperations-registry)", "edgeregistry"), +]) +def test_create_bundle_registries( + ops_service, + label_selector, + directory_path, mocked_client, mocked_config, mocked_os_makedirs, @@ -1096,10 +1104,11 @@ def test_create_bundle_schemas( mocked_root_logger, mocked_get_config_map, ): + assert SCHEMAS_NAME_LABEL == "app.kubernetes.io/name in (microsoft-iotoperations-schemas)" since_seconds = random.randint(86400, 172800) result = support_bundle( None, - ops_services=[OpsServiceType.schemaregistry.value], + ops_services=[ops_service], bundle_dir=a_bundle_dir, log_age_seconds=since_seconds, ) @@ -1111,36 +1120,139 @@ def test_create_bundle_schemas( mocked_client, mocked_zipfile, mocked_list_pods, - label_selector=SCHEMAS_NAME_LABEL, - directory_path=SCHEMAS_DIRECTORY_PATH, + label_selector=label_selector, + directory_path=directory_path, since_seconds=since_seconds, ) assert_list_config_maps( mocked_client, mocked_zipfile, - label_selector=SCHEMAS_NAME_LABEL, - directory_path=SCHEMAS_DIRECTORY_PATH, + label_selector=label_selector, + directory_path=directory_path, ) assert_list_stateful_sets( mocked_client, mocked_zipfile, - label_selector=SCHEMAS_NAME_LABEL, - directory_path=SCHEMAS_DIRECTORY_PATH, + label_selector=label_selector, + directory_path=directory_path, ) assert_list_services( mocked_client, mocked_zipfile, - label_selector=SCHEMAS_NAME_LABEL, - directory_path=SCHEMAS_DIRECTORY_PATH, + label_selector=label_selector, + directory_path=directory_path, ) assert_list_persistent_volume_claims( mocked_client, mocked_zipfile, - directory_path=SCHEMAS_DIRECTORY_PATH, - label_selector=SCHEMAS_NAME_LABEL, + directory_path=directory_path, + label_selector=label_selector, ) +@pytest.mark.parametrize("ops_service, prefixes, expected_label", [ + (OpsServiceType.schemaregistry.value, ["adr-schema-registry"], + ("app.kubernetes.io/name", "microsoft-iotoperations-schemas")), + (OpsServiceType.edgeregistry.value, ["aio-edge-registry"], + ("app.kubernetes.io/name", "microsoft-iotoperations-registry")), +]) +@pytest.mark.parametrize("deployed", [False, True]) +def test_registry_bundle_integration_checks(mocker, ops_service, prefixes, expected_label, deployed): + from .create_bundle_int import test_schemaregistry_int as schema_tests + + pre_bundle_items = {"statefulset": {"registry": {}} if deployed else {}} + workloads = mocker.patch.object(schema_tests, "get_multi_kubectl_workload_items", return_value=pre_bundle_items) + bundle_command = mocker.patch.object(schema_tests, "run_bundle_command", return_value=({}, "bundle.zip")) + file_map = {"statefulset": []} + map_files = mocker.patch.object(schema_tests, "get_file_map", return_value={"aio": file_map}) + check_files = mocker.patch.object(schema_tests, "check_workload_resource_files") + check_labels = mocker.patch.object(schema_tests, "check_cluster_label_coverage") + + schema_tests.test_create_bundle_registries(None, [], ops_service, prefixes, expected_label) + + workload_types = ["configmap", "pod", "service", "statefulset", "pvc"] + workloads.assert_called_once_with(expected_workload_types=workload_types, prefixes=prefixes) + bundle_command.assert_called_once_with( + command=f"az iot ops support create-bundle --ops-service {ops_service}", tracked_files=[], + ) + if deployed: + map_files.assert_called_once_with({}, ops_service) + check_files.assert_called_once_with( + file_objs=file_map, pre_bundle_items=pre_bundle_items, prefixes=prefixes, bundle_path="bundle.zip", + ) + else: + map_files.assert_not_called() + check_files.assert_not_called() + check_labels.assert_called_once_with( + prefixes=prefixes, expected_label=expected_label, workload_types=workload_types, + ) + + +@pytest.mark.parametrize("ops_service", [None, "schemaregistry", "edgeregistry"]) +@pytest.mark.parametrize("deployed", ["schemaregistry", "edgeregistry", "both", "neither"]) +def test_auto_bundle_expected_registries(mocker, ops_service, deployed): + from .create_bundle_int import test_auto_int as auto_tests + + def resources(expected_workload_types, prefixes): + service = "schemaregistry" if prefixes == ["adr-schema-registry"] else "edgeregistry" + return {"pod": {"registry": {}} if deployed in (service, "both") else {}} + + mocker.patch.object(auto_tests, "get_multi_kubectl_workload_items", side_effect=resources) + result = auto_tests._get_expected_services({}, ops_service, "azure-iot-operations") + for service in ("schemaregistry", "edgeregistry"): + assert (service in result) == (ops_service in (None, service) and deployed in (service, "both")) + + +@pytest.mark.parametrize("prefix, expected_label", [ + ("adr-schema-registry", "microsoft-iotoperations-schemas"), + ("aio-edge-registry", "microsoft-iotoperations-registry"), +]) +@pytest.mark.parametrize("label_case", ["correct", "other_component", "obsolete", "missing"]) +def test_registry_label_coverage_rejects_wrong_labels(mocker, prefix, expected_label, label_case): + from .create_bundle_int import helpers as bundle_helpers + + actual_label = { + "correct": expected_label, + "other_component": ( + "microsoft-iotoperations-registry" if prefix == "adr-schema-registry" + else "microsoft-iotoperations-schemas" + ), + "obsolete": "aio-edge-registry", + "missing": None, + }[label_case] + mocker.patch.object(bundle_helpers, "run", return_value={"items": [{"metadata": { + "name": prefix + "-0", "namespace": "azure-iot-operations", + "labels": {"app.kubernetes.io/name": actual_label} if actual_label else {}, + }}]}) + arguments = { + "prefixes": [prefix], "expected_label": ("app.kubernetes.io/name", expected_label), + "workload_types": ["pod"], + } + if label_case == "correct": + bundle_helpers.check_cluster_label_coverage(**arguments) + else: + with pytest.raises(AssertionError, match="missing/wrong label"): + bundle_helpers.check_cluster_label_coverage(**arguments) + + +@pytest.mark.parametrize("ops_service, prefixes, label", [ + ("schemaregistry", ["adr-schema-registry"], "microsoft-iotoperations-schemas"), + ("edgeregistry", ["aio-edge-registry"], "microsoft-iotoperations-registry"), +]) +def test_absent_registry_rejects_unexpected_bundle_section(mocker, ops_service, prefixes, label): + from .create_bundle_int import test_schemaregistry_int as registry_tests + + mocker.patch.object(registry_tests, "get_multi_kubectl_workload_items", return_value={"pod": {}}) + mocker.patch.object(registry_tests, "check_cluster_label_coverage") + mocker.patch.object(registry_tests, "run_bundle_command", return_value=( + {join("support_bundle", "azure-iot-operations", ops_service): {}}, "bundle.zip", + )) + with pytest.raises(AssertionError): + registry_tests.test_create_bundle_registries( + None, [], ops_service, prefixes, ("app.kubernetes.io/name", label), + ) + + def test_kind_to_dir_override_functionality(): from unittest.mock import Mock, patch diff --git a/azext_edge/tests/runtime_checks.py b/azext_edge/tests/runtime_checks.py new file mode 100644 index 000000000..b6108083b --- /dev/null +++ b/azext_edge/tests/runtime_checks.py @@ -0,0 +1,56 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Live integration assertions; expected channel is never inferred from a version suffix.""" + +import json +import os +import shlex + +from azext_edge.edge.providers.orchestration.runtime import resolve_runtime +from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog +from azext_edge.edge.providers.orchestration.runtime_profiles import RuntimeChannel, resolve_runtime_identity +from .helpers import run + + +def expected_runtime(channel, baseline=None): + catalog = get_runtime_catalog() + channel = RuntimeChannel(channel) + if baseline: + identity = resolve_runtime_identity( + baseline["version"], baseline["train"], catalog.qualification_identities, + ) + assert identity.channel == channel, "Upgrade baseline is for the wrong runtime channel." + return identity + return catalog.get(channel).identity + + +def read_runtime(instance_name, resource_group): + instance = run(f"az iot ops show -n {shlex.quote(instance_name)} -g {shlex.quote(resource_group)}") + location_id = instance["extendedLocation"]["name"] + location = run(f"az rest --method get --url {shlex.quote(location_id + '?api-version=2021-08-15')}") + cluster_id = location["properties"]["hostResourceId"] + cluster = run(f"az rest --method get --url {shlex.quote(cluster_id + '?api-version=2024-01-01')}") + url = cluster_id + "/providers/Microsoft.KubernetesConfiguration/extensions?api-version=2023-05-01" + extensions = [] + while url: + page = run(f"az rest --method get --url {shlex.quote(url)}") + extensions.extend(page["value"]) + url = page.get("nextLink") + runtime = resolve_runtime(instance, location, cluster, extensions, get_runtime_catalog().qualification_identities) + return runtime, instance, extensions + + +def assert_runtime(instance_name, resource_group, channel, baseline=None): + runtime, _, _ = read_runtime(instance_name, resource_group) + runtime.require_ready() + expected = expected_runtime(channel, baseline) + assert runtime.identity == expected, f"Expected {expected}, observed installed runtime {runtime.identity}." + return runtime + + +def configured_baseline(): + return json.loads(os.environ.get("azext_edge_upgrade_baseline", "null")) diff --git a/azext_edge/tests/settings.py b/azext_edge/tests/settings.py index 38cbf1652..d14a785d8 100644 --- a/azext_edge/tests/settings.py +++ b/azext_edge/tests/settings.py @@ -22,6 +22,10 @@ class EnvironmentVariables(Enum): sp_secret = "azext_edge_sp_secret" init_args = "azext_edge_init_args" create_args = "azext_edge_create_args" + runtime_channel = "azext_edge_runtime_channel" + wheel = "azext_edge_wheel" + wheel_sha256 = "azext_edge_wheel_sha256" + upgrade_baseline = "azext_edge_upgrade_baseline" skip_cluster_check = "azext_edge_skip_cluster_check" aio_cleanup = "azext_edge_aio_cleanup" init_continue_on_error = "azext_edge_init_continue_on_error" diff --git a/azext_edge/tests/utility/test_az_client_unit.py b/azext_edge/tests/utility/test_az_client_unit.py index 09c3a6ea9..2fda73b64 100644 --- a/azext_edge/tests/utility/test_az_client_unit.py +++ b/azext_edge/tests/utility/test_az_client_unit.py @@ -12,6 +12,22 @@ AZ_CLIENT_PATH = "azext_edge.edge.util.az_client" +@pytest.mark.parametrize("api_version,expected", [ + (None, "2026-10-01"), + ("2026-07-01", "2026-07-01"), + ("2026-10-01", "2026-10-01"), + ("2026-09-01-preview", "2026-09-01-preview"), +]) +def test_iotops_management_api_default_and_overrides(api_version, expected): + from azext_edge.edge.util.az_client import DEFAULT_IOTOPS_MGMT_API_VERSION, get_iotops_mgmt_client + + kwargs = {"api_version": api_version} if api_version else {} + client = get_iotops_mgmt_client(subscription_id="00000000-0000-0000-0000-000000000000", **kwargs) + + assert DEFAULT_IOTOPS_MGMT_API_VERSION.value == "2026-10-01" + assert client._config.api_version == expected + + @pytest.mark.parametrize("done", [True, False]) def test_wait_for_terminal_state(mocker, done): # could be fixture with param diff --git a/azext_edge/tests/utility/test_integration_support_unit.py b/azext_edge/tests/utility/test_integration_support_unit.py new file mode 100644 index 000000000..c9559c49a --- /dev/null +++ b/azext_edge/tests/utility/test_integration_support_unit.py @@ -0,0 +1,1051 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Offline coverage for channel matrix, pinned wheel isolation and live assertions.""" + +from copy import deepcopy +from email.message import Message +import hashlib +import importlib.util +from io import BytesIO +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import subprocess +from types import SimpleNamespace +from urllib.request import HTTPHandler, HTTPSHandler, build_opener +from urllib.response import addinfourl +from zipfile import ZipFile + +import pytest +import yaml +from azure.cli.core.azclierror import ValidationError + +from azext_edge.edge.providers.orchestration.runtime_profiles import ( + RuntimeChannel, RuntimeIdentity, RuntimeProfileCatalog, +) +from azext_edge.tests import conftest, runtime_checks +from azext_edge.tests.edge.orchestration.test_runtime_profiles_unit import make_profile +from azext_edge.tests.edge.orchestration.test_runtime_unit import records as runtime_records + + +records = runtime_records +ROOT = Path(__file__).resolve().parents[3] + + +def load_tool(name, path): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +matrix = load_tool("integration_matrix", ".github/actions/build-int-test-matrix/build_matrix.py") +runner = load_tool("integration_runner", "tools/integration_runner.py") + + +def baseline(): + return { + "wheel_url": "https://example.invalid/azure_iot_ops-1.0.0-py3-none-any.whl", + "sha256": "a" * 64, "version": "1.6.0-preview.2", "train": "preview", "create_args": "--yes", + } + + +@pytest.mark.parametrize("use_preview", [False, True]) +@pytest.mark.parametrize("description,include_description", [ + pytest.param(None, False, id="omitted"), + pytest.param(None, True, id="null"), + pytest.param("", True, id="empty"), + pytest.param("custom description", True, id="custom"), +]) +@pytest.mark.parametrize("matches", [False, True]) +def test_init_description_assertion_uses_requested_value( + mocker, use_preview, description, include_description, matches, +): + from azext_edge.tests.edge.init.int import test_init_int as init_tests + from azext_edge.tests.helpers import process_additional_args + + actual = description if matches else "An AIO instance." + properties = { + "description": actual, "schemaRegistryRef": {"resourceId": "/registry"}, + "adrNamespaceRef": {"resourceId": "/namespace"}, + } + if matches and not include_description: + properties.pop("description") + command = mocker.patch.object(init_tests, "run", side_effect=[ + {"id": "/cluster"}, + {"value": [{"properties": {"extensionType": init_tests.EXTENSION_TYPE_OPS}, + "identity": {"principalId": "principal"}}]}, + {"extendedLocation": {"name": "/locations/location"}, "properties": properties}, + "location cert-manager", + ]) + roles = mocker.patch.object(init_tests, "assert_role_assignment") + arguments = process_additional_args("--use-preview" if use_preview else "") + if description is not None: + arguments["description"] = description + arguments.update(instance_name="instance", cluster_name="cluster", resource_group="rg", + schema_registry_id="/registry", adr_namespace_id="/namespace") + if matches: + init_tests.assert_aio_instance(**arguments) + assert command.call_count == 4 + roles.assert_called_once() + else: + with pytest.raises(AssertionError, match="Unexpected instance description"): + init_tests.assert_aio_instance(**arguments) + roles.assert_not_called() + + +@pytest.fixture +def init_broker_assertion(mocker): + from azext_edge.tests.edge.init.int import test_init_int as init_tests + + properties = { + "memoryProfile": "Medium", + "cardinality": { + "backendChain": {"partitions": 2, "redundancyFactor": 2, "workers": 2}, + "frontend": {"replicas": 2, "workers": 2}, + }, + } + mocker.patch.object(init_tests, "run", side_effect=[ + [{"name": "default", "properties": properties}], + [{}], + [{"name": "default", "properties": {"serviceType": "ClusterIp"}}], + ]) + return init_tests.assert_broker_args, properties + + +@pytest.fixture +def preview_broker_persistence(): + return { + "maxSize": "3Gi", + "encryption": {"mode": "Enabled"}, + "persistentVolumeClaimSpec": {"accessModes": ["ReadWriteOncePod"]}, + "retain": {"mode": "None"}, + "stateStore": {"mode": "Custom", "stateStoreSettings": {"dynamic": {"mode": "Enabled"}}}, + "subscriberQueue": {"mode": "None"}, + } + + +@pytest.mark.parametrize("use_preview, has_persistence, matches", [ + (False, False, True), (True, True, True), (False, True, False), (True, False, False), +]) +def test_init_broker_defaults_by_channel( + init_broker_assertion, preview_broker_persistence, use_preview, has_persistence, matches, +): + from azext_edge.tests.helpers import process_additional_args + + assert_broker, properties = init_broker_assertion + if has_persistence: + properties["persistence"] = preview_broker_persistence + arguments = process_additional_args("--use-preview" if use_preview else "") + if matches: + assert_broker("instance", "rg", **arguments) + else: + with pytest.raises(AssertionError): + assert_broker("instance", "rg", **arguments) + + +@pytest.mark.parametrize("setting, value", [ + ("maxSize", "10Gi"), + ("retain", {"mode": "All"}), + ("subscriberQueue", {"mode": "All"}), + ("stateStore", {"mode": "Custom", "stateStoreSettings": {"dynamic": {"mode": "Disabled"}}}), + ("encryption", {"mode": "Disabled"}), + ("persistentVolumeClaimSpec", {"accessModes": ["ReadWriteOnce"]}), +]) +def test_init_broker_preview_rejects_wrong_defaults( + init_broker_assertion, preview_broker_persistence, setting, value, +): + assert_broker, properties = init_broker_assertion + properties["persistence"] = {**preview_broker_persistence, setting: value} + with pytest.raises(AssertionError, match=f"Unexpected broker persistence {setting}"): + assert_broker("instance", "rg", use_preview=True) + + +@pytest.mark.parametrize("use_preview", [False, True]) +@pytest.mark.parametrize("matches", [False, True]) +def test_init_broker_explicit_size_overrides_channel_defaults( + init_broker_assertion, preview_broker_persistence, use_preview, matches, +): + assert_broker, properties = init_broker_assertion + properties["persistence"] = preview_broker_persistence + preview_broker_persistence["maxSize"] = "10Gi" if matches else "3Gi" + for policy in ("retain", "subscriberQueue"): + preview_broker_persistence[policy] = {"mode": "Custom", f"{policy}Settings": {"dynamic": {"mode": "Enabled"}}} + if matches: + assert_broker("instance", "rg", use_preview=use_preview, persist_max_size="10Gi") + else: + with pytest.raises(AssertionError, match="Unexpected broker persistence maxSize"): + assert_broker("instance", "rg", use_preview=use_preview, persist_max_size="10Gi") + + +@pytest.mark.parametrize("use_preview", [False, True]) +@pytest.mark.parametrize("wrapped", [False, True]) +@pytest.mark.parametrize("persistence_mode", ["omitted", "null", "configured"]) +def test_init_broker_custom_file_overrides_channel_defaults( + init_broker_assertion, tmp_path, use_preview, wrapped, persistence_mode, +): + assert_broker, properties = init_broker_assertion + config = deepcopy(properties) + if persistence_mode == "null": + config["persistence"] = None + elif persistence_mode == "configured": + config["persistence"] = { + "maxSize": "7Gi", "retain": {"mode": "All"}, + "persistentVolumeClaimSpec": {"storageClassName": "custom-storage"}, + } + properties["persistence"] = deepcopy(config["persistence"]) + properties["persistence"]["encryption"] = {"mode": "Enabled"} + properties["persistence"]["persistentVolumeClaimSpec"]["accessModes"] = ["ReadWriteOncePod"] + config_file = tmp_path / "broker.json" + config_file.write_text(json.dumps({"properties": config} if wrapped else config), encoding="utf-8") + assert_broker("instance", "rg", use_preview=use_preview, broker_config_file=str(config_file)) + + +@pytest.mark.parametrize("use_preview", [False, True]) +def test_init_broker_rejects_custom_persistence_mismatch(init_broker_assertion, tmp_path, use_preview): + assert_broker, properties = init_broker_assertion + config = {**properties, "persistence": {"maxSize": "7Gi"}} + config_file = tmp_path / "broker.json" + config_file.write_text(json.dumps(config), encoding="utf-8") + properties["persistence"] = {"maxSize": "3Gi"} + with pytest.raises(AssertionError, match="Unexpected broker persistence maxSize"): + assert_broker("instance", "rg", use_preview=use_preview, broker_config_file=str(config_file)) + + +def test_workload_identity_jobs_serialize_through_cleanup(): + config = yaml.safe_load((ROOT / ".github/workflows/int_test.yml").read_text()) + assert "concurrency" not in config + job = config["jobs"]["int-test"] + assert "max-parallel" not in job["strategy"] + assert job["concurrency"] == { + "group": ( + "${{ matrix.scenario.tox_env == 'python-wlif-int' && " + "format('iot-ops-wlif-{0}', inputs.resource-group || 'ops-cli-int-test-rg') || " + "format('iot-ops-int-{0}-{1}-{2}', github.run_id, github.run_attempt, matrix.scenario.name) }}" + ), + "cancel-in-progress": False, + "queue": "max", + } + steps = {step.get("name"): step for step in job["steps"]} + names = list(steps) + assert ( + names.index("Tox INIT Integration Tests") + < names.index("${{ matrix.scenario.description }}") + < names.index("Delete AIO resources") + < names.index("Delete connected cluster and resources") + ) + for name in ("Delete AIO resources", "Delete connected cluster and resources"): + assert steps[name]["if"] == "${{ always() }}" + + +def test_default_matrix_covers_both_channels_without_invented_baselines(): + scenarios = yaml.safe_load((ROOT / ".github/test-scenarios.yml").read_text())["scenarios"] + rows = matrix.expand_channels(matrix.process_scenarios(scenarios, "")) + assert len(rows) == 2 * sum(not scenario.get("requires_baseline") for scenario in scenarios) + assert len({row["name"] for row in rows}) == len(rows) + for row in rows: + assert "--use-preview" not in row["init_args"] + assert ("--use-preview" in row["create_args"]) == (row["channel"] == "preview") + assert row["baseline"] is None + assert not row["requires_baseline"] + runtime_rows = [row for row in rows if row["tox_env"] == "python-runtime-int"] + assert len(runtime_rows) == 2 + assert all(not row["parallel"] for row in runtime_rows) + + +@pytest.mark.parametrize("channels", ["stable", "preview", "stable,preview", "preview,stable", "stable,stable"]) +def test_matrix_channel_selection_preserves_scenario_inputs(channels): + scenarios = matrix.process_scenarios([{ + "name": "trust", "init_args": "--user-trust", "create_args": "--feature opcua.mode=Disabled", + "parallel": False, "env": [{"SETTING": "value"}], + }], "trust") + original = deepcopy(scenarios) + rows = matrix.expand_channels(scenarios, channels, init_args="--no-progress", create_args="--description testing") + assert scenarios == original + assert len(rows) == len(set(channels.split(","))) + for row in rows: + assert row["init_args"] == "--user-trust --no-progress" + assert "--feature opcua.mode=Disabled" in row["create_args"] + assert "--description testing" in row["create_args"] + assert not row["parallel"] + assert row["env"] == [{"name": "SETTING", "value": "value"}] + + +@pytest.mark.parametrize("channels", ["", "ga", "canary", "stable,other"]) +def test_unknown_channels_fail(channels): + with pytest.raises(ValueError, match="runtime-channels"): + matrix.expand_channels([], channels) + + +@pytest.mark.parametrize("arg", ["--use-preview", "--use-preview=false", "--ops-version 1.0.0", "--ops-train stable"]) +@pytest.mark.parametrize("position", ["init_args", "create_args"]) +def test_matrix_disallows_runtime_override_collisions(arg, position): + scenarios = matrix.process_scenarios([{"name": "edge"}], "") + with pytest.raises(ValueError, match="Runtime selection belongs"): + matrix.expand_channels(scenarios, **{position: arg}) + scenarios[0][position] = arg + with pytest.raises(ValueError, match="Runtime selection belongs"): + matrix.expand_channels(scenarios) + + +def test_upgrade_path_requires_explicit_per_channel_baseline(): + scenarios = matrix.process_scenarios([{"name": "upgrade-path", "requires_baseline": True}], "upgrade-path") + with pytest.raises(ValueError, match="explicit stable upgrade baseline"): + matrix.expand_channels(scenarios) + rows = matrix.expand_channels(scenarios, "preview", {"preview": baseline()}) + assert rows[0]["baseline"] == baseline() + assert rows[0]["requires_baseline"] + + +@pytest.mark.parametrize("field,value", [ + ("wheel_url", "http://example.invalid/test.whl"), + ("wheel_url", "https://user:password@example.invalid/test.whl"), + ("wheel_url", "https://example.invalid/test.whl?token=redacted"), + ("wheel_url", "https://example.invalid/source.tar.gz"), + ("sha256", "not-a-hash"), ("train", "canary"), ("version", ""), ("create_args", []), +]) +def test_invalid_baseline_artifacts_fail_before_jobs(field, value): + definition = {**baseline(), field: value} + with pytest.raises(ValueError): + matrix.validate_baseline(definition) + + +def test_matrix_action_main_serializes_channels(monkeypatch, tmp_path): + config = tmp_path / "scenarios.yml" + config.write_text("scenarios:\n - name: edge\n", encoding="utf-8") + output = tmp_path / "output" + for key, value in { + "TEST_SCENARIO_FILE": str(config), "GITHUB_OUTPUT": str(output), "TEST_SCENARIOS": "edge", + "RUNTIME_CHANNELS": "stable,preview", "UPGRADE_BASELINES": "{}", + "RUNTIME_INIT_ARGS": "", "RUNTIME_CREATE_ARGS": "", + }.items(): + monkeypatch.setenv(key, value) + matrix.main() + rows = json.loads(output.read_text().partition("=")[2]) + assert [row["name"] for row in rows] == ["edge-stable", "edge-preview"] + + +@pytest.fixture +def wheel(tmp_path): + archive = tmp_path / "azure_iot_ops-1.0.0-py3-none-any.whl" + target = tmp_path / "extensions" / "azure-iot-ops" + with ZipFile(archive, "w") as content: + content.writestr("azext_edge/__init__.py", "VERSION = 'test'\n") + content.writestr("azext_edge/edge/runtime.py", "IDENTITY = 'candidate'\n") + content.extractall(target) + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + return archive, digest, target + + +def test_wheel_and_installed_bytes_are_verified(wheel): + archive, digest, target = wheel + assert runner.verify_wheel(archive, digest) == archive + runner.verify_installed(archive, target) + with pytest.raises(ValueError, match="SHA256 mismatch"): + runner.verify_wheel(archive, "0" * 64) + (target / "azext_edge/edge/runtime.py").write_text("IDENTITY = 'source'\n", encoding="utf-8") + with pytest.raises(ValueError, match="differs from wheel"): + runner.verify_installed(archive, target) + + +def test_missing_installed_file_fails(wheel): + archive, _, target = wheel + (target / "azext_edge/edge/runtime.py").unlink() + with pytest.raises(ValueError, match="file missing"): + runner.verify_installed(archive, target) + + +UNSAFE_BASELINE_URLS = [ + "http://example.invalid/test.whl", + "https:///test.whl", + "https://user:password@example.invalid/test.whl", + "https://user@example.invalid/test.whl", + "https://:password@example.invalid/test.whl", + "https://@example.invalid/test.whl", + "https://example.invalid/test.whl?token=redacted", + "https://example.invalid/test.whl#fragment", +] + + +@pytest.mark.parametrize("url", UNSAFE_BASELINE_URLS) +@pytest.mark.parametrize("stage", ["initial", "final"]) +def test_baseline_rejects_unsafe_urls_before_copy_or_install(mocker, monkeypatch, tmp_path, url, stage): + definition = baseline() + if stage == "initial": + definition["wheel_url"] = url + mocker.patch.object(runner, "validate_baseline_identity") + opener = mocker.patch.object(runner, "build_opener").return_value + response = opener.open.return_value.__enter__.return_value + response.url = url + install = mocker.patch.object(runner, "install_wheel") + monkeypatch.setenv("azext_edge_baseline_extension_dir", "unchanged") + with pytest.raises(ValueError, match="credential-free HTTPS"): + runner.prepare_baseline(definition, "preview", tmp_path) + if stage == "initial": + opener.open.assert_not_called() + response.read.assert_not_called() + install.assert_not_called() + assert not list(tmp_path.iterdir()) + assert os.environ["azext_edge_baseline_extension_dir"] == "unchanged" + + +@pytest.fixture +def baseline_http(mocker): + """Exercise urllib's real redirect chain with an entirely in-memory transport.""" + def configure(locations, payload=b"", code=302): + requests = [] + + class Transport(HTTPSHandler, HTTPHandler): + def https_open(self, req): + index = len(requests) + requests.append(req.full_url) + headers = Message() + status = 200 + body = payload + if index < len(locations): + headers["Location"] = locations[index] + status, body = code, b"" + response = addinfourl(BytesIO(body), headers, req.full_url, status) + response.msg = "Redirect" if status != 200 else "OK" + return response + + http_open = https_open + + mocker.patch.object(runner, "build_opener", side_effect=lambda handler: build_opener(handler, Transport())) + mocker.patch.object(runner, "validate_baseline_identity") + return requests + + return configure + + +# Python 3.10 refuses 308 outright; exercise it on interpreters that follow it. +@pytest.mark.parametrize("code", [ + code for code in (301, 302, 303, 307, 308) if hasattr(runner.HTTPRedirectHandler, f"http_error_{code}") +]) +@pytest.mark.parametrize("url", [value for value in UNSAFE_BASELINE_URLS if value != "https:///test.whl"]) +def test_baseline_blocks_unsafe_redirect_before_request(baseline_http, mocker, tmp_path, url, code): + safe_hop = "https://cdn.example.invalid/mirror.whl" + requests = baseline_http([safe_hop, url], code=code) + install = mocker.patch.object(runner, "install_wheel") + with pytest.raises(ValueError, match="credential-free HTTPS"): + runner.prepare_baseline(baseline(), "preview", tmp_path) + assert requests == [baseline()["wheel_url"], safe_hop] + assert not list(tmp_path.iterdir()) + install.assert_not_called() + + +@pytest.mark.parametrize("locations", [[], ["/mirror.whl"], [ + "https://cdn.example.invalid/mirror.whl", "//other.example.invalid/wheel.whl", +]]) +@pytest.mark.parametrize("valid_digest", [True, False]) +def test_baseline_safe_download_verifies_digest_before_install( + baseline_http, wheel, mocker, monkeypatch, tmp_path, locations, valid_digest): + archive, digest, _ = wheel + requests = baseline_http(locations, archive.read_bytes()) + definition = {**baseline(), "sha256": digest if valid_digest else "0" * 64} + work = tmp_path / "download" + work.mkdir() + install = mocker.patch.object(runner, "install_wheel") + monkeypatch.setenv("azext_edge_baseline_extension_dir", "unchanged") + if valid_digest: + target = runner.prepare_baseline(definition, "preview", work) + assert target == work / "baseline-extensions/azure-iot-ops" + install.assert_called_once_with(work / archive.name, target) + assert os.environ["azext_edge_baseline_extension_dir"] == str(target.parent) + else: + with pytest.raises(ValueError, match="SHA256 mismatch"): + runner.prepare_baseline(definition, "preview", work) + install.assert_not_called() + assert os.environ["azext_edge_baseline_extension_dir"] == "unchanged" + assert len(requests) == len(locations) + 1 + assert (work / archive.name).read_bytes() == archive.read_bytes() + + +def test_verification_only_does_not_install_or_authenticate(wheel, mocker, monkeypatch): + archive, digest, target = wheel + monkeypatch.setenv("azext_edge_wheel", str(archive)) + monkeypatch.setenv("azext_edge_wheel_sha256", digest) + execute = mocker.patch.object(runner.subprocess, "run", side_effect=AssertionError("unexpected execution")) + assert runner.main(["--verify-installed", "--extension-dir", str(target)]) == 0 + execute.assert_not_called() + + +@pytest.mark.parametrize("target_exists", [False, True]) +def test_runner_imports_newly_installed_candidate_in_fresh_process(wheel, tmp_path, target_exists): + """Exercise real imports after install, including Tox's nonexistent startup PYTHONPATH.""" + archive, digest, _ = wheel + target = tmp_path / "fresh extensions" / "azure-iot-ops" + if target_exists: + target.mkdir(parents=True) + tests = tmp_path / "test-sources" + tests.mkdir() + (tests / "test_example_int.py").write_text("def test_example(): pass\n", encoding="utf-8") + script = ''' +import importlib.util +import json +import os +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace +from zipfile import ZipFile + +runner_path, target_path, tests_path, work_path = sys.argv[1:] +target = Path(target_path) +assert "azext_edge" not in sys.modules +assert importlib.util.find_spec("azext_edge") is None +assert str(target) in sys.path_importer_cache +if not target.exists(): + assert sys.path_importer_cache[str(target)] is None +spec = importlib.util.spec_from_file_location("isolated_runner", runner_path) +runner = importlib.util.module_from_spec(spec) +spec.loader.exec_module(runner) +calls = [] + +def execute(command, check): + assert check + calls.append(command) + if command[:4] == [sys.executable, "-m", "pip", "install"]: + assert command[command.index("--target") + 1] == str(target) + # Replace only the installer transport; run the real install/verification/import path. + with ZipFile(command[-1]) as wheel: + wheel.extractall(target) + else: + assert command == ["az", "version"] + +def extension_info(command, text): + assert text and command == ["az", "extension", "show", "--name", "azure-iot-ops", "-o", "json"] + return json.dumps({"path": str(target)}) + +runner.subprocess = SimpleNamespace(run=execute, check_output=extension_info) +pytest = ModuleType("pytest") + +def collect(arguments): + package = sys.modules["azext_edge"] + assert Path(package.__file__).resolve() == target / "azext_edge" / "__init__.py" + assert package.VERSION == "test" + assert str(target / "azext_edge/tests/test_example_int.py") in arguments + assert os.environ["PYTHONPATH"] == str(target) + assert os.environ["AZURE_EXTENSION_DIR"] == str(target.parent) + assert Path.cwd() == Path(work_path) + print("candidate-import-verified") + return 17 + +pytest.main = collect +sys.modules["pytest"] = pytest +result = runner.main([ + "--extension-dir", str(target), "--tests", tests_path, "--work-dir", work_path, + "--junit", str(Path(work_path) / "init.xml"), + "--coverage-config", str(Path(work_path) / ".coveragerc"), "--scenario", "init_scenario_test", +]) +assert result == 17 +assert len(calls) == 2 +''' + environment = dict(os.environ, PYTHONPATH=str(target), azext_edge_wheel=str(archive), + azext_edge_wheel_sha256=digest, azext_edge_upgrade_baseline="null") + result = subprocess.run( + [runner.sys.executable, "-S", "-c", script, str(ROOT / "tools/integration_runner.py"), + str(target), str(tests), str(tmp_path / "work")], + cwd=tmp_path, env=environment, capture_output=True, text=True, check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "candidate-import-verified" in result.stdout + + +def test_runner_isolates_cli_imports_tests_config_and_working_directory(wheel, mocker, monkeypatch, tmp_path): + archive, digest, target = wheel + tests = tmp_path / "test-sources" + tests.mkdir() + (tests / "test_example_int.py").write_text("def test_example(): pass\n", encoding="utf-8") + (tests / "test_example_unit.py").write_text("raise AssertionError('not collected')\n", encoding="utf-8") + monkeypatch.setenv("azext_edge_wheel", str(archive)) + monkeypatch.setenv("azext_edge_wheel_sha256", digest) + monkeypatch.setenv("azext_edge_upgrade_baseline", "null") + monkeypatch.setenv("PYTHONPATH", str(ROOT)) + # Register cleanup before the runner mutates cwd/sys.path/environment. + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(runner.sys, "path", list(runner.sys.path)) + monkeypatch.setenv("AZURE_EXTENSION_DIR", "unused") + monkeypatch.setenv("AZURE_EXTENSION_USE_DYNAMIC_INSTALL", "unused") + install = mocker.patch.object(runner, "install_wheel") + package_import = mocker.Mock(return_value=SimpleNamespace( + __file__=str(target / "azext_edge" / "__init__.py"), + )) + # Keep the fake package import local; mock target resolution also uses importlib. + mocker.patch.object(runner, "importlib", SimpleNamespace( + import_module=package_import, invalidate_caches=importlib.invalidate_caches, + )) + assert importlib.import_module("pytest") is pytest + mocker.patch.object(runner.subprocess, "run") + mocker.patch.object(runner.subprocess, "check_output", return_value=json.dumps({"path": str(target)})) + execute = mocker.patch("pytest.main", return_value=0) + work = tmp_path / "isolated" + assert runner.main([ + "--extension-dir", str(target), "--tests", str(tests), "--work-dir", str(work), + "--junit", str(tmp_path / "junit/results.xml"), "--coverage-config", str(ROOT / ".coveragerc"), + "--scenario", "edge", "--collect-only", + ]) == 0 + install.assert_called_once_with(archive, target) + package_import.assert_called_once_with("azext_edge") + + assert os.getcwd() == str(work) + assert os.environ["PYTHONPATH"] == str(target) + assert os.environ["AZURE_EXTENSION_DIR"] == str(target.parent) + arguments = execute.call_args.args[0] + assert arguments[arguments.index("-c") + 1] == str(work / "pytest.ini") + assert str(target / "azext_edge/tests/test_example_int.py") in arguments + assert not any(arg.endswith("_unit.py") for arg in arguments) + assert "--collect-only" in arguments + assert (work / "pytest.ini").read_text() == "[pytest]\njunit_family = xunit1\n" + + +@pytest.mark.parametrize("version,train,channel,allowed", [ + ("1.6.0-preview.2", "preview", "preview", True), + ("1.5.6", "stable", "stable", True), + ("1.5.7", "stable", "stable", False), + ("1.5.8", "stable", "stable", False), + ("1.6.0-preview.2", "integration", "preview", False), + ("1.6.0-preview.2", "preview", "stable", False), +]) +def test_baseline_must_be_known_older_and_same_channel(mocker, version, train, channel, allowed): + catalog = RuntimeProfileCatalog([ + make_profile(RuntimeChannel.STABLE, "1.5.7"), make_profile(RuntimeChannel.PREVIEW, "1.6.0-preview.4"), + ]) + mocker.patch("azext_edge.edge.providers.orchestration.runtime_catalog.get_runtime_catalog", return_value=catalog) + definition = {**baseline(), "version": version, "train": train} + if allowed: + assert runner.validate_baseline_identity(definition, channel).version == version + else: + with pytest.raises((ValueError, ValidationError)): + runner.validate_baseline_identity(definition, channel) + + +@pytest.mark.parametrize("channel", list(RuntimeChannel)) +def test_live_assertions_use_actual_installed_identity(mocker, records, channel): + version = "1.5.7" if channel == RuntimeChannel.STABLE else "1.6.0-preview.4" + profile = make_profile(channel, version, train="integration") + mocker.patch.object(runtime_checks, "get_runtime_catalog", return_value=RuntimeProfileCatalog([profile])) + records["extensions"][0]["properties"].update(currentVersion=version, version=version, releaseTrain="integration") + run = mocker.patch.object(runtime_checks, "run", side_effect=[ + records["instance"], records["custom_location"], records["cluster"], {"value": records["extensions"]}, + ]) + result = runtime_checks.assert_runtime("instance", "rg", channel) + assert result.identity == RuntimeIdentity(channel, version, "integration") + assert run.call_count == 4 + assert all("az rest --method get" in call.args[0] for call in run.call_args_list[1:]) + + +@pytest.mark.parametrize("failure", ["wrong-channel", "missing-current-version", "pending", "requested-only"]) +def test_live_identity_failures_are_hard_errors(mocker, records, failure): + profile = make_profile(RuntimeChannel.STABLE, "1.5.7") + mocker.patch.object(runtime_checks, "get_runtime_catalog", return_value=RuntimeProfileCatalog([profile])) + props = records["extensions"][0]["properties"] + if failure == "wrong-channel": + props.update(currentVersion="1.6.0-preview.4", version="1.6.0-preview.4", releaseTrain="preview") + elif failure == "missing-current-version": + del props["currentVersion"] + elif failure == "pending": + props["provisioningState"] = "Updating" + else: + props["version"] = "1.5.8" + mocker.patch.object(runtime_checks, "run", side_effect=[ + records["instance"], records["custom_location"], records["cluster"], {"value": records["extensions"]}, + ]) + with pytest.raises((ValidationError, AssertionError)): + runtime_checks.assert_runtime("instance", "rg", "stable") + + +@pytest.mark.parametrize("workflow,input_name", [ + ("int_test.yml", "resource-group"), + ("container_int_test.yml", "resource-group"), + ("cluster_cleanup.yml", "resource_group"), +]) +def test_workflows_default_to_existing_test_resource_group(workflow, input_name): + source = (ROOT / ".github/workflows" / workflow).read_text() + config = yaml.load(source, Loader=yaml.BaseLoader) + for event in ("workflow_call", "workflow_dispatch"): + input_config = config["on"][event]["inputs"][input_name] + if event == "workflow_dispatch" or workflow != "cluster_cleanup.yml": + assert input_config["default"] == "ops-cli-int-test-rg" + if workflow != "container_int_test.yml": + assert config["env"]["RESOURCE_GROUP"] == ( + "${{ inputs." + input_name + " || 'ops-cli-int-test-rg' }}" + ) + assert "centralus" not in source.lower() + + +def test_workflow_builds_once_and_tox_never_installs_checkout(): + config = yaml.safe_load((ROOT / ".github/workflows/int_test.yml").read_text()) + jobs = config["jobs"] + assert jobs["int-test"]["needs"] == ["setup", "build-matrix", "build-candidate"] + assert jobs["build-candidate"]["needs"] == "build-matrix" + assert jobs["unit-test"]["needs"] == "build-matrix" + assert jobs["unit-test"]["name"] == "Run linter and unit tests" + assert any(step.get("run") == "tox r --skip-pkg-install" for step in jobs["unit-test"]["steps"]) + builds = [ + step for job in jobs.values() for step in job.get("steps", []) if "python -m build" in step.get("run", "") + ] + assert len(builds) == 1 + assert "matrix.scenario.channel" in jobs["int-test"]["env"]["azext_edge_runtime_channel"] + tox = (ROOT / "tox.ini").read_text().split("# integration tests", 1)[1].split("# code coverage", 1)[0] + assert "pip install" not in tox + assert "integration_runner.py" in tox + assert "changedir = {envtmpdir}" in tox + + +def test_container_workflow_builds_once_for_both_channels_and_includes_runner(): + config = yaml.safe_load((ROOT / ".github/workflows/container_int_test.yml").read_text()) + jobs = config["jobs"] + assert jobs["test"]["needs"] == "build-candidate" + assert "needs.build-candidate.outputs.matrix" in jobs["test"]["strategy"]["matrix"]["scenario"] + builds = [(job_name, step) for job_name, job in jobs.items() for step in job.get("steps", []) + if "python -m build" in step.get("run", "")] + assert len(builds) == 1 and builds[0][0] == "build-candidate" + steps = {step.get("name"): step for step in jobs["test"]["steps"]} + verify = steps["Verify and install the candidate for workflow CLI commands"]["run"] + assert 'sha256sum --check SHA256SUMS' in verify + assert '"$azext_edge_wheel_sha256"' in verify + assert '--verify-installed' in verify + assert jobs["test"]["env"]["azext_edge_wheel_sha256"] == "${{ needs.build-candidate.outputs.sha256 }}" + assert jobs["test"]["env"]["azext_edge_runtime_channel"] == "${{ matrix.scenario.channel }}" + for variable in ("CLUSTER_NAME", "INSTANCE_NAME"): + assert "matrix.scenario.name" in jobs["test"]["env"][variable] + scenarios = yaml.safe_load((ROOT / ".github/test-container-scenarios.yml").read_text())["scenarios"] + rows = matrix.expand_channels(matrix.process_scenarios(scenarios, "")) + assert [row["name"] for row in rows] == ["container-e2e-stable", "container-e2e-preview"] + assert [row["create_args"] for row in rows] == ["", "--use-preview"] + assert all(row["tox_env"] == "python-e2e-int" and not row["init_args"] for row in rows) + assert "--use-preview" not in steps["Run az iot ops init"]["run"] + assert steps["Containerized tests"]["env"]["azext_edge_instance"] == "${{ env.INSTANCE_NAME }}" + assert steps["Setup Cluster Resources"]["with"]["suffix"] == "${{ steps.resources.outputs.suffix }}" + upload = steps["Upload container results and candidate fingerprint"] + assert upload["if"] == "${{ always() }}" and upload["with"]["include-hidden-files"] + assert "SHA256SUMS" in upload["with"]["path"] + ignored = (ROOT / ".dockerignore").read_text().splitlines() + assert "tools/" not in ignored + assert "!tools/integration_runner.py" in ignored and "!.coveragerc" in ignored + assert ".artifacts/" in ignored and "pytest.ini" in ignored and ".venv/" in ignored + assert '"python-e2e-int"' in (ROOT / "Dockerfile").read_text() + + +def _find_workflow_bash(platform_name): + if platform_name == "nt": + # PATH's bash.exe may be the WSL launcher, not Git for Windows' shell. + git = shutil.which("git") + if git: + for directory in Path(git).resolve().parents: + for relative in ("usr/bin/bash.exe", "bin/bash.exe"): + candidate = directory / relative + if candidate.is_file(): + return str(candidate) + raise RuntimeError("Workflow shell tests require Git for Windows with Bash; WSL is not used.") + bash = shutil.which("bash") + if not bash: + raise RuntimeError("Workflow shell tests require Bash on PATH.") + return bash + + +@pytest.fixture(scope="module") +def workflow_shell(): + bash = _find_workflow_bash(os.name) + + def run(script, cwd, env): + environment = dict(env) + # Make Git's POSIX utilities available without loading interactive profiles. + bin_dir = Path(bash).parent + environment["PATH"] = os.pathsep.join([ + str(bin_dir), str(bin_dir.parent / "usr/bin"), environment.get("PATH", ""), + ]) + environment.pop("BASH_ENV", None) + environment.pop("ENV", None) + # stdin avoids Windows command-line quoting and CRLF script-file differences. + # Binary input also prevents Python's Windows text pipes from restoring CRLF. + result = subprocess.run( + [bash, "--noprofile", "--norc", "-e", "-o", "pipefail", "-s"], + input=script.replace("\r\n", "\n").encode("utf-8"), cwd=cwd, env=environment, + capture_output=True, check=False, + ) + return subprocess.CompletedProcess( + result.args, result.returncode, result.stdout.decode("utf-8"), result.stderr.decode("utf-8"), + ) + + def directory(path): + result = run("pwd -P\n", path, os.environ) + assert result.returncode == 0, result.stdout + result.stderr + return PurePosixPath(result.stdout.strip()) + + return SimpleNamespace(run=run, directory=directory) + + +@pytest.mark.parametrize("git_relative", ["cmd/git.exe", "mingw64/bin/git.exe"]) +@pytest.mark.parametrize("bash_relative", ["usr/bin/bash.exe", "bin/bash.exe"]) +def test_workflow_bash_selects_git_for_windows_not_wsl(mocker, tmp_path, git_relative, bash_relative): + installation = tmp_path / "Program Files/Git" + git = installation / git_relative + bash = installation / bash_relative + for executable in (git, bash): + executable.parent.mkdir(parents=True, exist_ok=True) + executable.touch() + which = mocker.patch.object(shutil, "which", side_effect=lambda name: { + "git": str(git), "bash": "C:/Windows/System32/bash.exe", + }.get(name)) + assert _find_workflow_bash("nt") == str(bash.resolve()) + which.assert_called_once_with("git") + + +@pytest.mark.parametrize("platform_name", ["nt", "posix"]) +def test_workflow_bash_missing_dependency_fails_instead_of_skipping(mocker, platform_name): + mocker.patch.object(shutil, "which", return_value=None) + with pytest.raises(RuntimeError, match="require.*Bash"): + _find_workflow_bash(platform_name) + + +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_workflow_shell_handles_spaces_newlines_and_ignores_profiles(workflow_shell, tmp_path, newline): + work = tmp_path / "working directory with spaces" + work.mkdir() + profile = work / "unexpected-profile.sh" + profile.write_text("exit 99\n", encoding="utf-8") + shell_dir = workflow_shell.directory(work) + environment = dict(os.environ, BASH_ENV=str(profile), ENV=str(profile), + OUTPUT=str(shell_dir / "file with spaces"), VALUE="value with spaces") + script = newline.join(['printf "%s" "$VALUE" > "$OUTPUT"', 'cat "$OUTPUT"', 'exit 17', '']) + result = workflow_shell.run(script, work, environment) + assert result.returncode == 17, result.stdout + result.stderr + assert result.stdout == "value with spaces" + assert (work / "file with spaces").read_text() == result.stdout + + +def test_workflow_shell_uses_binary_lf_input(workflow_shell, mocker, tmp_path): + execute = mocker.patch.object(subprocess, "run", return_value=subprocess.CompletedProcess( + [], 17, b"output", b"diagnostic", + )) + result = workflow_shell.run("exit 17\r\n", tmp_path, os.environ) + assert execute.call_args.kwargs["input"] == b"exit 17\n" + assert not execute.call_args.kwargs.get("text") + assert "encoding" not in execute.call_args.kwargs + assert (result.returncode, result.stdout, result.stderr) == (17, "output", "diagnostic") + + +@pytest.mark.parametrize("channel", ["stable", "preview"]) +@pytest.mark.parametrize("build_exit,run_exit", [(0, 0), (0, 17), (23, 0)]) +def test_container_shell_passes_candidate_targets_and_preserves_failures( + workflow_shell, tmp_path, channel, build_exit, run_exit): + """Execute the actual workflow shell with a fake Docker; never build images or contact Azure.""" + config = yaml.safe_load((ROOT / ".github/workflows/container_int_test.yml").read_text()) + script = next(step["run"] for step in config["jobs"]["test"]["steps"] + if step.get("name") == "Containerized tests") + home = tmp_path / "home" + (home / ".azure").mkdir(parents=True) + (home / ".kube").mkdir() + (home / ".kube/config").touch() + candidate = tmp_path / "candidate with spaces" / "candidate.whl" + candidate.parent.mkdir() + candidate.touch() + arguments = tmp_path / "docker-args" + shell_dir = workflow_shell.directory(tmp_path) + shell_candidate = shell_dir / candidate.relative_to(tmp_path).as_posix() + environment = dict(os.environ, HOME=str(shell_dir / "home"), DOCKER_ARGS=str(shell_dir / "docker-args"), + BUILD_EXIT=str(build_exit), RUN_EXIT=str(run_exit), + azext_edge_wheel=str(shell_candidate), azext_edge_wheel_sha256="a" * 64, + azext_edge_runtime_channel=channel, azext_edge_skip_init="true", + azext_edge_init_redeployment="false", azext_edge_rg="test-rg", + azext_edge_cluster="test-cluster", azext_edge_instance="test-instance") + docker_stub = ''' +docker() { + printf '%s\\0' "$@" >> "$DOCKER_ARGS" + if [[ "$1" == build ]]; then + printf 'test-image\\n' + return "$BUILD_EXIT" + fi + return "$RUN_EXIT" +} +''' + result = workflow_shell.run(docker_stub + script, tmp_path, environment) + assert result.returncode == (build_exit or run_exit), result.stdout + result.stderr + args = arguments.read_bytes().decode().split("\0") + if build_exit: + assert "run" not in args + return + assert "run" in args and "test-image" in args + assert f"{shell_candidate.parent}:/opt/aio-candidate:ro" in args + assert f"{shell_dir / 'home/.azure'}:/root/.azure" in args + assert f"{shell_dir / 'home/.kube/config'}:/root/.kube/config:ro" in args + for value in ( + "azext_edge_wheel=/opt/aio-candidate/candidate.whl", f"azext_edge_wheel_sha256={'a' * 64}", + f"azext_edge_runtime_channel={channel}", "azext_edge_instance=test-instance", + "azext_edge_rg=test-rg", "azext_edge_cluster=test-cluster", + "azext_edge_junit_path=/integration-results/e2e.xml", + "azext_edge_coverage_file=/integration-results/.coverage", + ): + assert value in args + assert f"{shell_dir / '.artifacts/container-results'}:/integration-results" in args + + +@pytest.mark.parametrize("channel", ["stable", "preview"]) +def test_container_init_is_shared_and_create_uses_selected_channel(workflow_shell, tmp_path, channel): + config = yaml.safe_load((ROOT / ".github/workflows/container_int_test.yml").read_text()) + steps = {step.get("name"): step for step in config["jobs"]["test"]["steps"]} + scenarios = yaml.safe_load((ROOT / ".github/test-container-scenarios.yml").read_text())["scenarios"] + row = matrix.expand_channels(matrix.process_scenarios(scenarios, ""), channel)[0] + environment = dict(os.environ, CLUSTER_NAME="test-cluster", RESOURCE_GROUP="test-rg", + INSTANCE_NAME="test-instance", SCHEMA_REGISTRY_ID="/test/registry", + ADR_NAMESPACE_ID="/test/namespace", CREATE_ARGS=row["create_args"]) + # A shell function captures argv; no real az command can be invoked by these steps. + stub = "az() { printf '%s\\0' \"$@\"; }\n" + for step, verb in (("Run az iot ops init", "init"), ("Run az iot ops create", "create")): + result = workflow_shell.run(stub + steps[step]["run"], tmp_path, environment) + assert result.returncode == 0, result.stdout + result.stderr + args = result.stdout.split("\0") + assert args[:3] == ["iot", "ops", verb] + assert ("--use-preview" in args) == (verb == "create" and channel == "preview") + assert "--yes" not in args + + +@pytest.mark.parametrize("missing", ["", "SCHEMA_REGISTRY_ID", "ADR_NAMESPACE_ID", "STORAGE_ID", "all"]) +@pytest.mark.parametrize("failed", ["", "SCHEMA_REGISTRY_ID", "ADR_NAMESPACE_ID", "STORAGE_ID"]) +def test_container_cleanup_pins_only_adr_and_preserves_failures(workflow_shell, tmp_path, missing, failed): + config = yaml.safe_load((ROOT / ".github/workflows/container_int_test.yml").read_text()) + steps = {step.get("name"): step for step in config["jobs"]["test"]["steps"]} + cleanup = steps["Delete schema registry, ADR namespace and storage"] + provider_root = "/subscriptions/test/resourceGroups/test rg/providers" + resources = { + "SCHEMA_REGISTRY_ID": f"{provider_root}/Microsoft.DeviceRegistry/schemaRegistries/sr", + "ADR_NAMESPACE_ID": f"{provider_root}/Microsoft.DeviceRegistry/namespaces/ns", + "STORAGE_ID": f"{provider_root}/Microsoft.Storage/storageAccounts/storage", + } + resources = {name: "" if missing in (name, "all") else value for name, value in resources.items()} + environment = dict(os.environ, **resources, FAILED_ID=resources.get(failed, "")) + stub = ''' +az() { + printf '%s\\0' "$@" + printf '\\n' + if [[ "$4" == "$FAILED_ID" ]]; then return 17; fi + return 0 +} +''' + result = workflow_shell.run(stub + cleanup["run"], tmp_path, environment) + assert result.returncode == int(bool(resources.get(failed))), result.stdout + result.stderr + calls = [line.removesuffix("\0").split("\0") for line in result.stdout.splitlines()] + expected = [] + for name, resource_id in resources.items(): + if resource_id: + api_args = [] if name == "STORAGE_ID" else ["--api-version", "2026-04-01"] + expected.append(["resource", "delete", "--id", resource_id, *api_args, "--verbose", "--no-wait"]) + assert calls == expected + assert cleanup["if"] == "${{ always() }}" + upload = steps["Upload container results and candidate fingerprint"] + assert upload["if"] == "${{ always() }}" and upload["with"]["include-hidden-files"] + assert ".artifacts/container-results/" in upload["with"]["path"] + assert ".artifacts/candidate/SHA256SUMS" in upload["with"]["path"] + + +def test_redeployment_reports_are_distinct_and_uploaded_after_all_stages(): + config = yaml.safe_load((ROOT / ".github/workflows/int_test.yml").read_text()) + steps = config["jobs"]["int-test"]["steps"] + names = [step.get("name") for step in steps] + init = steps[names.index("Tox INIT Integration Tests")] + redeploy = steps[names.index("Redeploy cluster via tox")] + assert init["env"]["azext_edge_junit_path"] == "${{ github.workspace }}/junit/init.xml" + assert redeploy["env"]["azext_edge_junit_path"] == "${{ github.workspace }}/junit/redeploy.xml" + for name in ("Upload coverage artifacts", "Upload channel-specific test results and candidate fingerprint"): + assert names.index(name) > names.index("Delete connected cluster and resources") + assert names.index(name) > names.index("Redeploy cluster via tox") + assert steps[names.index(name)]["if"] == "${{ always() }}" + upload = steps[names.index("Upload channel-specific test results and candidate fingerprint")]["with"] + assert upload["include-hidden-files"] and "junit/" in upload["path"] and "SHA256SUMS" in upload["path"] + tox = (ROOT / "tox.ini").read_text() + assert "--junit {env:azext_edge_junit_path:{toxinidir}/junit/{envname}.xml}" in tox + assert "COVERAGE_FILE={env:azext_edge_coverage_file:{toxinidir}/.coverage}" in tox + + +def test_runner_retains_init_report_when_redeployment_fails(wheel, mocker, monkeypatch, tmp_path): + archive, digest, target = wheel + tests = tmp_path / "test-sources" + tests.mkdir() + (tests / "test_example_int.py").write_text("def test_example(): pass\n", encoding="utf-8") + for key, value in {"azext_edge_wheel": str(archive), "azext_edge_wheel_sha256": digest, + "azext_edge_upgrade_baseline": "null", "PYTHONPATH": "", + "AZURE_EXTENSION_DIR": "", "AZURE_EXTENSION_USE_DYNAMIC_INSTALL": "no"}.items(): + monkeypatch.setenv(key, value) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(runner.sys, "path", list(runner.sys.path)) + # Simulate installation clearing staged tests on each invocation, without pip/network. + mocker.patch.object(runner, "install_wheel", side_effect=lambda *_: shutil.rmtree( + target / "azext_edge/tests", ignore_errors=True, + )) + package_import = mocker.Mock(return_value=SimpleNamespace( + __file__=str(target / "azext_edge/__init__.py"), + )) + # Keep the fake package import local; mock target resolution also uses importlib. + mocker.patch.object(runner, "importlib", SimpleNamespace( + import_module=package_import, invalidate_caches=importlib.invalidate_caches, + )) + assert importlib.import_module("pytest") is pytest + mocker.patch.object(runner.subprocess, "run") + mocker.patch.object(runner.subprocess, "check_output", return_value=json.dumps({"path": str(target)})) + + def emit_report(arguments): + path = Path(next(arg.partition("=")[2] for arg in arguments if arg.startswith("--junitxml="))) + failures = int(path.stem == "redeploy") + path.write_text(f'', encoding="utf-8") + return failures + + mocker.patch("pytest.main", side_effect=emit_report) + for phase, code in (("init", 0), ("redeploy", 1)): + assert runner.main([ + "--extension-dir", str(target), "--tests", str(tests), "--work-dir", str(tmp_path / "work"), + "--junit", str(tmp_path / "junit" / f"{phase}.xml"), "--coverage-config", str(ROOT / ".coveragerc"), + "--scenario", "init_scenario_test", + ]) == code + assert package_import.call_args_list == [mocker.call("azext_edge"), mocker.call("azext_edge")] + assert (tmp_path / "junit/init.xml").read_text() == '' + assert (tmp_path / "junit/redeploy.xml").read_text() == '' + + +@pytest.mark.parametrize("selection,expected_tests", [ + ("init_scenario_test", ["test_init_scenario"]), + ("(init_scenario_test)", ["test_init_scenario"]), + ("init_scenario_test and not long_running", ["test_init_scenario"]), + ("not init_scenario_test", ["test_runtime"]), + ("init_scenario_test or long_running", ["test_init_scenario", "test_runtime"]), + ("", ["test_init_scenario", "test_runtime"]), +]) +def test_runtime_precheck_uses_selected_items(mocker, monkeypatch, tmp_path, selection, expected_tests): + monkeypatch.setenv("azext_edge_runtime_channel", "preview") + monkeypatch.setenv("azext_edge_instance", "instance") + monkeypatch.setenv("azext_edge_rg", "rg") + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + config = tmp_path / "pytest.ini" + config.write_text("[pytest]\n", encoding="utf-8") + (tmp_path / f"test_{tmp_path.name}_int.py").write_text( + "import pytest\n" + "@pytest.mark.init_scenario_test\n" + "def test_init_scenario(): pass\n" + "@pytest.mark.long_running\n" + "def test_runtime(): pass\n", + encoding="utf-8", + ) + assertion = mocker.patch.object(runtime_checks, "assert_runtime") + mocker.patch.object(runtime_checks, "configured_baseline", return_value=None) + selected_tests = [] + + class SelectionPlugin: + def pytest_collection_finish(self, session): + selected_tests.extend(item.name for item in session.items) + + result = pytest.main([ + str(tmp_path), "-c", str(config), "--confcutdir", str(tmp_path), + "-m", selection, "-q", "-p", "no:cacheprovider", + ], plugins=[conftest, SelectionPlugin()]) + + assert result == pytest.ExitCode.OK + assert selected_tests == expected_tests + if expected_tests == ["test_init_scenario"]: + assertion.assert_not_called() + else: + assertion.assert_called_once_with("instance", "rg", "preview", None) diff --git a/docs/integration-tests.md b/docs/integration-tests.md index e09277090..30233b63a 100644 --- a/docs/integration-tests.md +++ b/docs/integration-tests.md @@ -39,6 +39,8 @@ There are, however, some prerequisites and caveats that users should be made awa - **insecure-listener**: Tests with insecure listener deployment - **rpsaas**: Cloud-side (RPSaaS) tests - **upgrade**: Azure IoT Operations upgrade tests (runs serially) + - **runtime-channel**: Shared update and cross-channel refusal tests (runs serially) + - **upgrade-path**: Actual older-to-newer upgrade tests; explicitly selected with pinned baselines - **mgmtactions**: Azure IoT Operations management actions tests (runs serially) - **redeploy**: Tests cluster redeployment functionality - **trustbundle**: Workload identity federation tests @@ -62,7 +64,114 @@ There are, however, some prerequisites and caveats that users should be made awa test-scenarios: "rpsaas,upgrade" ``` - If not specified, all scenarios will run. + If not specified, all standard scenarios run on both channels. The `upgrade-path` + scenario requires explicit selection and baseline definitions. + +### GA and preview qualification + +The workflow expands each selected scenario into independent **stable** and **preview** +jobs by default. These labels mean the intended runtime channel; the actual deployment +train can still be `integration` for an internal candidate. Each job provisions its own +temporary k3s/Arc cluster and separately named instance, storage, schema registry and +ADR namespace. No existing cluster is converted between channels. + +- `runtime-channels: "stable,preview"` runs both channels; select one explicitly for troubleshooting. +- `init` is identical on both channels. Only preview **create** receives `--use-preview --yes`. +- Extra init/create arguments must not contain `--use-preview`, `--ops-version`, or `--ops-train`; + the matrix owns runtime selection. Existing feature-mode arguments remain independent. +- Shared scenario tests run unchanged on both deployments. The serial `runtime-channel` + scenario checks shared update behavior and rejects cross-channel upgrades, including with force. + Future preview-only features should add preview success and GA rejection/no-mutation tests; + there are no production feature restrictions to classify automatically today. +- Create and each subsequent suite verify the **installed** `currentVersion`, release train, + runtime channel, associations and readiness. Qualification assertions are hard failures, + even when the legacy `init-continue-on-error` option is set. +- Results, coverage and cleanup are isolated by scenario and channel. Running both channels + roughly doubles runtime-dependent jobs; jobs can be scheduled sequentially to reduce peak cost. + +#### Workload identity scheduling + +Workload-identity jobs (`python-wlif-int`, the `trustbundle` scenario) share a +resource-group-specific concurrency group. Only one runs at a time, from setup and +init through tests and cleanup, because OPC UA certificate commands create fixed-name +SecretSync resources. Other scenarios remain parallel. `parallel: false` controls +pytest workers within a job; it does not provide this cross-job protection. + +The group spans stable/preview channels, branches and workflow runs in the same +repository that use this configuration. `cancel-in-progress: false` preserves the +running job and `queue: max` allows up to 100 pending jobs instead of replacing the +previous pending job. Either channel may run first. Runs in other repositories or +older workflows without the group are not coordinated. Cleanup failures still need +investigation: the lock lasts through cleanup attempts, not until successful deletion +is independently guaranteed. + +#### The same candidate wheel everywhere + +One `build-candidate` job builds the wheel and publishes its SHA256. Every integration job +downloads that exact artifact and verifies its digest and installed package bytes. Both +workflow CLI commands and tox environments use it; tox never rebuilds the checkout. +The test runner stages only test sources alongside the installed package, changes to an +isolated working directory and checks the CLI extension path to prevent source-tree shadowing. +The candidate fingerprint is included with each job's JUnit artifact. +Init and redeployment write separate JUnit reports. Final coverage and result uploads run +after all test stages and cleanup, even on failure, so redeployment evidence is retained. + +For local integration runs, first build a candidate wheel, then set `azext_edge_wheel` +to its absolute path and `azext_edge_wheel_sha256` to its SHA256 digest. Set +`azext_edge_runtime_channel` to `stable` or `preview`, along with the normal cluster, +resource-group and instance settings. The existing tox environments remain available; +`python-runtime-int` selects the isolated channel tests. Local test runs still create +and modify real Azure resources—collection and unit tests do not qualify a live runtime. + +#### Containerized end-to-end tests + +The [container workflow](../.github/workflows/container_int_test.yml) also accepts +`runtime-channels` (default `stable,preview`). It builds one candidate wheel per workflow +invocation, then provisions independent clusters/resources for its two channel jobs. +The host uses that verified wheel for setup; each test container receives the same wheel +through a read-only mount, together with its digest, runtime channel and target instance. +The image contains the test harness, not a separately rebuilt extension under test. +Shared `init` is unchanged; only preview `create` receives the selector and consent flags. + +Container exit failures fail the job. Channel-labelled artifacts retain JUnit, coverage +data and the candidate fingerprint even when tests fail. Direct use of the test image +likewise requires mounting a candidate wheel and supplying `azext_edge_wheel` (the path +inside the container), `azext_edge_wheel_sha256`, `azext_edge_runtime_channel`, and the +normal target/authentication/kubeconfig inputs. Mount a results directory and set +`azext_edge_junit_path` and `azext_edge_coverage_file` to paths inside it to retain reports. + +#### Actual version upgrades versus reconciliation + +The default `upgrade` scenario tests same-version reconciliation/configuration updates. +It is **not** evidence of an older-to-newer upgrade. + +Select `test-scenarios: "upgrade-path"` explicitly for actual upgrades and provide +`upgrade-baselines`, a JSON object keyed by each selected channel. Each definition requires: + +| Field | Meaning | +|---|---| +| `wheel_url` | Credential-free HTTPS URL of the pinned older CLI wheel; no signed query strings | +| `sha256` | Exact baseline wheel digest | +| `version` | Expected installed source runtime version | +| `train` | Actual source deployment train | +| `create_args` | Explicit create arguments understood by that older CLI, including any required consent | + +The candidate initializes the shared foundation. The isolated baseline CLI provisions the +older instance; candidate assertions then verify that the expected source runtime is really +installed. Only the **candidate wheel** executes upgrade and verifies that the installed +version advanced to its bundled target without changing channel/train or instance identity. + +Baseline versions must be older, same-channel and same-train. Historical `integration` +identities must already have an explicit reviewed mapping in the candidate catalog; inputs +cannot inject mappings or bypass production upgrade protections. No baseline versions are +guessed, and omitted definitions fail an explicitly requested upgrade-path job rather than +silently substituting a current-version deployment. Supply migration-specific assertions +when a release handoff requires them. Baseline downloads/installation are separate from +candidate qualification; no baseline artifact replaces the candidate in other tests. + +The default scenario selection excludes `upgrade-path` until baseline inputs are supplied +and that scenario is explicitly selected. Release qualification must include both ordinary +channel jobs and the approved upgrade paths; a default green run alone does not prove upgrades. ### Inputs @@ -84,7 +193,9 @@ There are, however, some prerequisites and caveats that users should be made awa | Input | Description | |---|---| **resource-group** | *The resource group to run tests in* -**test-scenarios** | *Comma-separated list of scenarios to run (e.g., "rpsaas,upgrade"). If empty, all scenarios run.* +**test-scenarios** | *Comma-separated scenarios (e.g., "rpsaas,upgrade"). Empty selects standard scenarios, excluding opt-in upgrade-path.* +**runtime-channels** | *Comma-separated channels; defaults to stable,preview with independent clusters.* +**upgrade-baselines** | *Pinned baseline definitions keyed by channel; required when selecting upgrade-path.* **custom-locations-oid** | *Custom Locations Object ID - used to enable cluster-connect feature.* **runtime-init-args** | *Additional init arguments (beyond cluster name, resource group, schema registry)* **runtime-create-args** | *Additional create arguments (beyond cluster name, resource group, instance name)* @@ -141,7 +252,9 @@ explicitly versioned preview calls are unchanged. Changing regions is not a substitute for keeping the API version consistent across the resource lifecycle. #### CLI Extension Builds -Currently our pipeline uses the most recent dev branch of the IoT Operations extension to build our extension. The extension repo is cloned from the `dev` branch, the wheel is built from that source, and then added to the agent's CLI extension path. +The workflow builds one candidate wheel from its checked-out source revision and shares the +hash-verified artifact across all runtime-channel jobs. Integration tox environments require +that artifact instead of installing from the source tree. ## Using github actions to independently connect a cluster to ARC and/or deploy AIO diff --git a/docs/tox-testing.md b/docs/tox-testing.md index 2dbec1f24..7c5378902 100644 --- a/docs/tox-testing.md +++ b/docs/tox-testing.md @@ -12,18 +12,32 @@ Our [current tox config](../tox.ini) contains the following environments, and ca - **python, py3\***: Runs unit tests using a specific python version. `python` will run the default version from your `PATH` - Command: `pytest -k _unit ./azext_edge/tests` -- **python-{init,e2e,rpsaas,upgrade,mgmtactions,long,wlif,edge,all}-int**: Runs integration tests for specific scenarios. +- **python-{init,e2e,rpsaas,upgrade,runtime,mgmtactions,long,wlif,edge,all}-int**: Runs integration tests for specific scenarios. - Scenarios: - `python-init-int`: Tests for `ops init`/create. - `python-e2e-int`: End-to-end pipeline tests. - `python-rpsaas-int`: RPSaaS (cloud side) only. - `python-upgrade-int`: Azure IoT Operations upgrade tests. + - `python-runtime-int`: Isolated, serial runtime-channel update/boundary checks. - `python-mgmtactions-int`: Azure IoT Operations management actions tests. - `python-long-int`: Long-running tests. - `python-wlif-int`: Workload identity setup required. - `python-edge-int`: All non-RPSaaS tests (edge). - `python-all-int`: All non-init related tests. - - Command: `pytest -k "_int.py" -m {SCENARIO}` (uses pytest markers to determine tests) + - Uses the wheel-only integration runner and pytest scenario markers. + - Requires `azext_edge_wheel` (absolute candidate wheel path) and `azext_edge_wheel_sha256`. + The candidate is verified and installed into each isolated tox environment; the checkout + is never installed as the extension under test. Unit-test environments still test source. + - Set `azext_edge_runtime_channel=stable` or `preview` and the normal `azext_edge_*` target + environment variables. The runner does not inherit a parent pytest configuration's + environment overrides, so explicitly export the intended cluster/resource group/instance. + - Tests are staged alongside the candidate package; reports are written back to the checkout. + See [dual-channel qualification](integration-tests.md#ga-and-preview-qualification) for baseline upgrade inputs. + - Optional `azext_edge_junit_path` sets an absolute JUnit report path; otherwise each tox + environment writes its own report under the checkout's JUnit directory. CI uses separate + init and redeployment paths so a later failure cannot overwrite the first phase's report. + Optional `azext_edge_coverage_file` sets the coverage data path for a mounted container + results directory; by default coverage continues accumulating in the checkout. Note: any test without an explicit mark will be grouped into `edge` tests. diff --git a/setup.cfg b/setup.cfg index 26ab3e82f..6586a220c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,7 @@ per-file-ignores = azext_edge/edge/providers/check/*.py:E501 # ignore line length for template content azext_edge/edge/providers/orchestration/template.py:E501 + azext_edge/edge/providers/orchestration/template_preview.py:E501 # ignore module level import not at top of file azext_edge/edge/util/az_client.py:E402 exclude = diff --git a/tools/integration_runner.py b/tools/integration_runner.py new file mode 100644 index 000000000..fc611e068 --- /dev/null +++ b/tools/integration_runner.py @@ -0,0 +1,184 @@ +# coding=utf-8 +# ---------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License file in the project root for license information. +# ---------------------------------------------------------------------------------------------- + +"""Run integration tests against a hash-verified wheel, never the checkout package. + +Only test sources are staged into the installed package (tests are excluded from the +wheel). Both pytest imports and its az subprocesses use this isolated extension root. +The baseline wheel, when supplied, is used exclusively to provision an older runtime. +""" + +import argparse +import hashlib +import importlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, build_opener +from zipfile import ZipFile + + +def verify_wheel(path, digest): + path = Path(path).resolve() + if not path.is_file() or path.suffix != ".whl" or not re.fullmatch(r"[a-fA-F0-9]{64}", digest): + raise ValueError("Supply azext_edge_wheel and its azext_edge_wheel_sha256 SHA256 digest.") + if hashlib.sha256(path.read_bytes()).hexdigest() != digest.lower(): + raise ValueError(f"Wheel SHA256 mismatch: {path.name}") + return path + + +def verify_installed(wheel, target): + """Check package bytes as well as the artifact hash; a version string is not proof.""" + target = Path(target).resolve() + with ZipFile(wheel) as archive: + entries = [name for name in archive.namelist() if name.startswith("azext_edge/") and not name.endswith("/")] + if "azext_edge/__init__.py" not in entries: + raise ValueError("Candidate is not an IoT Operations extension wheel.") + for name in entries: + destination = (target / name).resolve() + if not destination.is_relative_to(target) or not destination.is_file(): + raise ValueError(f"Installed candidate file missing: {name}") + if destination.read_bytes() != archive.read(name): + raise ValueError(f"Installed candidate differs from wheel: {name}") + + +def install_wheel(wheel, target): + target = Path(target).resolve() + if target.name != "azure-iot-ops": + raise ValueError("Isolated extension destination must end in azure-iot-ops.") + if target.exists(): + shutil.rmtree(target) + subprocess.run( + [sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "--target", str(target), str(wheel)], + check=True, + ) + verify_installed(wheel, target) + + +def validate_baseline_identity(baseline, channel): + from azext_edge.edge.providers.orchestration.runtime_catalog import get_runtime_catalog + from azext_edge.edge.providers.orchestration.runtime_profiles import ( + RuntimeChannel, parse_runtime_version, resolve_runtime_identity, validate_upgrade_boundary, + ) + + catalog = get_runtime_catalog() + target = catalog.get(RuntimeChannel(channel)).identity + source = resolve_runtime_identity(baseline["version"], baseline["train"], catalog.qualification_identities) + if source.channel != target.channel: + raise ValueError("Baseline runtime channel does not match this integration job.") + validate_upgrade_boundary(source, target) + if parse_runtime_version(source.version) >= parse_runtime_version(target.version): + raise ValueError("An upgrade-path baseline must be older than the bundled target, not a same-version repair.") + return source + + +def validate_baseline_url(value): + """Apply the same credential-free HTTPS policy to initial URLs and every redirect.""" + url = urlsplit(value) + if (url.scheme != "https" or not url.hostname or url.username is not None + or url.password is not None or url.query or url.fragment): + raise ValueError("Baseline wheel must use credential-free HTTPS.") + return url + + +class BaselineRedirectHandler(HTTPRedirectHandler): + """Reject unsafe redirect targets before urllib sends the redirected request.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + validate_baseline_url(newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def prepare_baseline(baseline, channel, work_dir): + # This validates reviewed runtime mappings BEFORE downloading or provisioning. + validate_baseline_identity(baseline, channel) + url = validate_baseline_url(baseline["wheel_url"]) + wheel = Path(work_dir) / Path(url.path).name + opener = build_opener(BaselineRedirectHandler()) + with opener.open(baseline["wheel_url"], timeout=60) as response: + validate_baseline_url(response.url) + with wheel.open("wb") as output: + shutil.copyfileobj(response, output) + verify_wheel(wheel, baseline["sha256"]) + target = Path(work_dir) / "baseline-extensions" / "azure-iot-ops" + install_wheel(wheel, target) + os.environ["azext_edge_baseline_extension_dir"] = str(target.parent) + return target + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--extension-dir", required=True) + parser.add_argument("--verify-installed", action="store_true") + parser.add_argument("--tests") + parser.add_argument("--work-dir") + parser.add_argument("--junit") + parser.add_argument("--coverage-config") + parser.add_argument("--scenario") + args, pytest_args = parser.parse_known_args(argv) + wheel = verify_wheel(os.environ.get("azext_edge_wheel", ""), os.environ.get("azext_edge_wheel_sha256", "")) + target = Path(args.extension_dir).resolve() + if args.verify_installed: + verify_installed(wheel, target) + return 0 + if not all((args.tests, args.work_dir, args.junit, args.coverage_config, args.scenario)): + parser.error("Test execution requires --tests, --work-dir, --junit, --coverage-config and --scenario.") + tests = Path(args.tests).resolve() + work_dir = Path(args.work_dir).resolve() + work_dir.mkdir(parents=True, exist_ok=True) + install_wheel(wheel, target) + shutil.copytree(tests, target / "azext_edge" / "tests", ignore=shutil.ignore_patterns("__pycache__", "*.pyc")) + # The script path is tools/, not the checkout root; cwd and PYTHONPATH cannot + # point at the source package when pytest imports or az subprocesses start. + os.chdir(work_dir) + sys.path.insert(0, str(target)) + os.environ["PYTHONPATH"] = str(target) + os.environ["AZURE_EXTENSION_DIR"] = str(target.parent) + os.environ["AZURE_EXTENSION_USE_DYNAMIC_INSTALL"] = "no" + # Tox puts target on PYTHONPATH before installation creates it. Python may + # cache that missing directory; refresh finders before importing the new wheel. + importlib.invalidate_caches() + package = importlib.import_module("azext_edge") + if Path(package.__file__).resolve() != target / "azext_edge" / "__init__.py": + raise ValueError("Source checkout is shadowing the candidate wheel.") + baseline = json.loads(os.environ.get("azext_edge_upgrade_baseline", "null")) + if baseline: + validate_baseline_identity(baseline, os.environ["azext_edge_runtime_channel"]) + if args.scenario.strip('"') == "init_scenario_test": + prepare_baseline(baseline, os.environ["azext_edge_runtime_channel"], work_dir) + subprocess.run(["az", "version"], check=True) + extension = subprocess.check_output(["az", "extension", "show", "--name", "azure-iot-ops", "-o", "json"], text=True) + if Path(json.loads(extension)["path"]).resolve() != target: + raise ValueError("az is not using the candidate extension directory.") + Path(args.junit).parent.mkdir(parents=True, exist_ok=True) + print(f"Candidate wheel: {wheel.name} SHA256={os.environ['azext_edge_wheel_sha256']}", flush=True) + # A parent checkout pytest.ini may contain a developer's live cluster env values. + # Never let automatic root/config discovery override the job's target resources. + pytest_config = work_dir / "pytest.ini" + pytest_config.write_text("[pytest]\njunit_family = xunit1\n", encoding="utf-8") + import pytest + + integration_files = sorted(str(path) for path in (target / "azext_edge" / "tests").rglob("*_int.py")) + if not integration_files: + raise ValueError("No integration test files were staged.") + result = pytest.main([ + "-c", str(pytest_config), "--rootdir", str(work_dir), + "--randomly-dont-reorganize", "-vv", "-k", "_int.py", "-m", args.scenario.strip('"'), + "--import-mode=importlib", *integration_files, + f"--cov={target / 'azext_edge' / 'edge'}", f"--cov-config={args.coverage_config}", "--cov-append", + "--cov-report=term:skip-covered", "--durations=0", f"--junitxml={args.junit}", *pytest_args, + ]) + verify_installed(wheel, target) + return result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tox.ini b/tox.ini index 2498990de..15bbac498 100644 --- a/tox.ini +++ b/tox.ini @@ -61,8 +61,9 @@ commands = --junitxml=junit/test-aziotops-ext-unit.xml {posargs} # integration tests -[testenv:python-{init,e2e,rpsaas,upgrade,mgmtactions,long,wlif,edge,all}-int] +[testenv:python-{init,e2e,rpsaas,upgrade,runtime,mgmtactions,long,wlif,edge,all}-int] skip_install = True +changedir = {envtmpdir} description = {[base]description} init: ops init / create @@ -70,6 +71,7 @@ description = e2e: marked e2e pipeline rpsaas: marked cloud upgrade: marked upgrade + runtime: isolated runtime-channel behavior mgmtactions: marked mgmt-actions long: marked long-running edge: non-marked (default) @@ -86,6 +88,7 @@ setenv = # You can temporarily add variables here to modify your tests # azext_edge_skip_init=true PYTHONPATH={envsitepackagesdir}/azure-cli-extensions/azure-iot-ops + COVERAGE_FILE={env:azext_edge_coverage_file:{toxinidir}/.coverage} init: SCENARIO="init_scenario_test" e2e: SCENARIO="e2e" rpsaas: SCENARIO="rpsaas and not long_running" @@ -93,18 +96,17 @@ setenv = wlif: SCENARIO="require_wlif_setup" edge: SCENARIO="edge" upgrade: SCENARIO="upgrade" + runtime: SCENARIO="runtime_channel" mgmtactions: SCENARIO="mgmtactions" all: SCENARIO="not init_scenario_test and not require_wlif_setup" commands = python --version - # install to tox extension dir - pip install -U --target {envsitepackagesdir}/azure-cli-extensions/azure-iot-ops . - # validate az and extension version - az -v - # run tests (do not reorder execution with randomly) - pytest --randomly-dont-reorganize -vv -k "_int.py" -m {env:SCENARIO} ./azext_edge/tests \ - --cov=azext_edge/edge --cov-config .coveragerc --cov-append --cov-report term:skip-covered \ - --durations 0 --junitxml=junit/JUnit.xml {posargs} + # Require the same hash-verified wheel as CI; never rebuild/install the source checkout. + python {toxinidir}/tools/integration_runner.py \ + --extension-dir {envsitepackagesdir}/azure-cli-extensions/azure-iot-ops \ + --tests {toxinidir}/azext_edge/tests --work-dir {envtmpdir}/integration \ + --junit {env:azext_edge_junit_path:{toxinidir}/junit/{envname}.xml} --coverage-config {toxinidir}/.coveragerc \ + --scenario {env:SCENARIO} {posargs} # code coverage [testenv:report]