From 31521dc7c1f36029b672c61aeab4e6c276a0fd29 Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Tue, 13 Oct 2026 19:54:50 -0700 Subject: [PATCH 1/2] Default to AIO 2609 and skip the OPC UA template when disabled Add AIO release 2609 (AIO 1.4.112, connectors 1.4.14, cert-manager 1.1.2 and secret store 1.5.3 on instance API 2026-07-01) and make it the default for Sites, guided installation, the Secret Sync profile and E2E runs. Sites can still pin 2607 or 2608, and the migration guide shows how. Install and upgrade create the OPC UA connector template only when the instance's OPC UA feature is not Disabled. The legacy connectors feature name applies when opcua is absent, and opcua takes precedence. This matches the AIO 1.4.112 deployment templates, which skip the template because no supervisor reconciles it while OPC UA is disabled. Release acceptance deploys its second fleet Site on 2609, so the fleet combines the v1.0.0b6 default with the new one. E2E runs gain an opcua-modes input for Disabled Sites, Stable Sites keep omitting features, and the E2E guide documents the runs that qualify 2609. --- .github/workflows/e2e-test.yaml | 59 ++++++-- docs/aio-releases.md | 67 +++++++-- docs/e2e-testing.md | 56 ++++++-- docs/guided-inputs.md | 11 +- docs/migrating.md | 18 ++- docs/secret-sync.md | 2 +- docs/site-configuration.md | 2 +- scripts/aggregate-fleet-results.py | 2 +- scripts/deploy-release-fleet.py | 2 +- scripts/probe-fleet-plan.py | 2 +- scripts/render-e2e-site.py | 12 +- tests/e2e/sites/e2e-test.yaml.tmpl | 2 + tests/integration/helpers/kube.py | 13 ++ .../integration/test_aio_install_manifest.py | 22 ++- .../integration/test_aio_upgrade_manifest.py | 44 ++---- tests/test_browse.py | 2 +- tests/test_fleet_outcomes.py | 12 +- tests/test_fleet_plan_probe.py | 14 +- tests/test_guided_cli.py | 8 +- tests/test_installation_guide.py | 12 ++ tests/test_kubectl_base.py | 27 ++++ tests/test_packaging.py | 2 +- tests/test_published_e2e_workflow.py | 136 +++++++++++++++++- tests/test_render_e2e_site.py | 29 ++++ tests/workspace/test_aio_2608_contracts.py | 31 ++-- .../test_aio_release_behavior_contracts.py | 127 +++++++++++++++- tests/workspace/test_parameter_chaining.py | 2 +- tests/workspace/test_site_configuration.py | 8 +- .../manifests/aio-install/entry.yaml | 2 +- .../manifests/aio-install/inputs.yaml | 2 +- .../manifests/secretsync/README.md | 4 +- .../manifests/secretsync/entry.yaml | 2 +- .../manifests/secretsync/inputs.yaml | 4 +- .../parameters/aio-releases/2609.yaml | 21 +++ .../aio-upgrade-deploy-release-resources.yaml | 1 + .../samples/aio-with-opc-ua/README.md | 2 +- .../samples/opc-ua-solution/README.md | 2 +- .../resource-set-composition/README.md | 2 +- .../iot-operations/siteops-index.inputs.json | 14 +- workspaces/iot-operations/siteops-index.json | 4 +- .../iot-operations/sites/base-site.yaml | 2 +- .../aio/modules/instance-2026-07-01.bicep | 5 +- .../upgrade/deploy-release-resources.bicep | 6 + .../deploy-release-resources-2025-10-01.bicep | 4 + .../deploy-release-resources-2026-03-01.bicep | 4 + .../deploy-release-resources-2026-07-01.bicep | 8 +- 46 files changed, 666 insertions(+), 147 deletions(-) create mode 100644 workspaces/iot-operations/parameters/aio-releases/2609.yaml diff --git a/.github/workflows/e2e-test.yaml b/.github/workflows/e2e-test.yaml index 2586da24..3b570449 100644 --- a/.github/workflows/e2e-test.yaml +++ b/.github/workflows/e2e-test.yaml @@ -37,9 +37,9 @@ on: options: [aio, release-acceptance, fleet, fleet-cleanup, site-cleanup, windows-installer-preflight] default: aio aio-releases: - description: "AIO scenario only: comma-separated releases (e.g. 2607,2608). Fleet uses fixed 2607 and 2608 targets." + description: "AIO scenario only: comma-separated releases (e.g. 2608,2609). Fleet uses fixed 2607 and 2609 targets." type: string - default: "2608" + default: "2609" environment: description: GitHub Environment whose secrets/approvers apply. type: string @@ -76,6 +76,10 @@ on: description: "AIO scenario only: enabled,disabled. Disabled omits Secret Sync, OIDC issuer and workload identity. Fleet has Secret Sync disabled." type: string default: "enabled" + opcua-modes: + description: "Source AIO scenario only: Stable,Disabled. Disabled requires explicit tests limited to aio-install,enable-secretsync,sync-secrets,aio-upgrade." + type: string + default: "Stable" tests: description: "Subset of integration test files to run, comma-separated. Each value maps to one tests/integration/test__manifest.py file and narrows which manifest gets deployed onto the cluster. Empty = run all. Valid values: aio-install, enable-secretsync, sync-secrets, opc-ua-solution, dataflow-sample, aio-resources, resource-set-samples, aio-upgrade. Useful for demos and focused debugging when paired with keep-cluster-alive-minutes." type: string @@ -291,6 +295,7 @@ jobs: pytest-paths: ${{ steps.v.outputs.pytest_paths }} isolate-dataflow-sample: ${{ steps.v.outputs.isolate_dataflow_sample }} secret-sync-modes: ${{ steps.v.outputs.secret_sync_modes }} + opcua-modes: ${{ steps.v.outputs.opcua_modes }} max-parallel: ${{ steps.v.outputs.max_parallel }} published-mode: ${{ steps.v.outputs.published_mode }} published-release: ${{ steps.v.outputs.published_release }} @@ -319,6 +324,7 @@ jobs: INPUT_RELEASES: "${{ inputs.aio-releases }}" INPUT_UPGRADE_TO: "${{ inputs.upgrade-to }}" INPUT_SECRET_SYNC_MODES: "${{ inputs.secret-sync-modes }}" + INPUT_OPCUA_MODES: "${{ inputs.opcua-modes }}" INPUT_TESTS: "${{ inputs.tests }}" INPUT_PUBLISHED_RELEASE: "${{ inputs.published-release }}" INPUT_PUBLISHED_SOURCE_SHA: "${{ inputs.published-source-sha }}" @@ -417,11 +423,16 @@ jobs: "secret-sync-modes entries not recognized: " f"{invalid_secret_sync_modes}. Valid: ['disabled', 'enabled']." ) + opcua_modes = list(dict.fromkeys( + mode.strip() for mode in os.environ["INPUT_OPCUA_MODES"].split(",") if mode.strip() + )) + if not opcua_modes or set(opcua_modes) - {"Stable", "Disabled"}: + sys.exit("opcua-modes must select Stable, Disabled, or both.") persistent = bool(rg_in) - if cl_in and (len(rs) > 1 or len(secret_sync_modes) > 1): + if cl_in and (len(rs) > 1 or len(secret_sync_modes) > 1 or len(opcua_modes) > 1): sys.exit( "cluster-name cannot be combined with a matrix that has " - "multiple AIO releases or Secret Sync modes. Each matrix " + "multiple AIO releases or Secret Sync modes or OPC UA modes. Each matrix " "cell registers its own cluster." ) # Validate upgrade-to against shipped versions. Same-version cells @@ -521,8 +532,10 @@ jobs: if published_mode: if len(rs) != 1: sys.exit("Published E2E requires exactly one aio-releases entry.") - if published_journey == "guided" and rs != ["2608"]: - sys.exit("Published guided E2E currently requires aio-releases=2608.") + if published_journey == "guided" and rs != ["2609"]: + sys.exit("Published guided E2E currently requires aio-releases=2609.") + if opcua_modes != ["Stable"]: + sys.exit("Published E2E requires opcua-modes=Stable.") if not persistent or cl_in: sys.exit( "Published E2E requires an existing resource group and an " @@ -544,7 +557,8 @@ jobs: if candidate_mode: oid = target_inputs.get("custom-locations-oid", "") if ( - rs != ["2608"] or secret_sync_modes != ["enabled"] or tests_in + rs != ["2609"] or secret_sync_modes != ["enabled"] or tests_in + or opcua_modes != ["Stable"] or published_mode or published_source_sha or published_journey != "configured" or persistent or cl_in or upgrade_to or not isinstance(oid, str) or oid.strip() or os.environ["INPUT_SKIP_TEARDOWN"].lower() == "true" @@ -555,8 +569,17 @@ jobs: "Leave the single Site options at their defaults." ) secret_sync_modes = ["disabled", "enabled", "existing"] + if "Disabled" in opcua_modes and ( + not tests_in + or set(tests_in) - {"aio-install", "enable-secretsync", "sync-secrets", "aio-upgrade"} + ): + sys.exit( + "Disabled OPC UA requires explicit tests limited to " + "aio-install,enable-secretsync,sync-secrets,aio-upgrade." + ) print(f"versions={json.dumps(rs)}") print(f"secret_sync_modes={json.dumps(secret_sync_modes)}") + print(f"opcua_modes={json.dumps(opcua_modes)}") print(f"max_parallel={3 if candidate_mode else 1 if persistent or published_mode else 20}") print(f"upgrade_to={upgrade_to}") print(f"persistent={'true' if persistent else 'false'}") @@ -622,7 +645,7 @@ jobs: print(f"Site cases use {mode} resource groups.") e2e: - name: ${{ needs.prep.outputs.candidate-mode == 'true' && format('Site case ({0})', fromJSON('{"disabled":"site-aio","enabled":"site-combined","existing":"site-existing-secretsync"}')[matrix.secret-sync-mode]) || format('e2e ({0}, {1})', matrix.aio-release, matrix.secret-sync-mode) }} + name: ${{ needs.prep.outputs.candidate-mode == 'true' && format('Site case ({0})', fromJSON('{"disabled":"site-aio","enabled":"site-combined","existing":"site-existing-secretsync"}')[matrix.secret-sync-mode]) || format('e2e ({0}, {1}{2})', matrix.aio-release, matrix.secret-sync-mode, matrix.opcua-mode == 'Disabled' && ', OPC UA Disabled' || '') }} needs: [prep, site-groups] if: ${{ !cancelled() && needs.prep.result == 'success' && (needs.prep.outputs.candidate-mode != 'true' || needs.site-groups.result == 'success') }} runs-on: ubuntu-latest @@ -642,7 +665,7 @@ jobs: # Ephemeral: rg_key is per-run and the release suffix distinguishes # cells, so matrix fan-out runs in parallel. # Candidate cases take both values from site-groups. - group: e2e-${{ needs.site-groups.outputs.rg-key || needs.prep.outputs.rg-key }}${{ (needs.prep.outputs.persistent == 'true' || needs.site-groups.outputs.groups == 'persistent') && '-persistent' || format('-{0}-secretsync-{1}', matrix.aio-release, matrix.secret-sync-mode) }} + group: e2e-${{ needs.site-groups.outputs.rg-key || needs.prep.outputs.rg-key }}${{ (needs.prep.outputs.persistent == 'true' || needs.site-groups.outputs.groups == 'persistent') && '-persistent' || format('-{0}-secretsync-{1}-opcua-{2}', matrix.aio-release, matrix.secret-sync-mode, matrix.opcua-mode) }} cancel-in-progress: false strategy: fail-fast: false @@ -650,10 +673,12 @@ jobs: matrix: aio-release: ${{ fromJSON(needs.prep.outputs.versions) }} secret-sync-mode: ${{ fromJSON(needs.prep.outputs.secret-sync-modes) }} + opcua-mode: ${{ fromJSON(needs.prep.outputs.opcua-modes) }} env: PERSISTENT_RG: ${{ needs.prep.outputs.persistent }} PUBLISHED_JOURNEY: ${{ needs.prep.outputs.published-journey }} CANDIDATE_MODE: ${{ needs.prep.outputs.candidate-mode }} + E2E_OPCUA_MODE: ${{ matrix.opcua-mode }} DEBUG_USER_OID: ${{ secrets.DEBUG_USER_OID }} FLEET_CANDIDATE: ${{ inputs.candidate }} FLEET_RUN_ID: ${{ github.run_id }} @@ -822,6 +847,10 @@ jobs: LONG_SUFFIX="${RUN_ID}-${RUN_ATTEMPT}-${SAFE_VER}-secretsync-${SECRET_SYNC_MODE}" SHORT_RUN="${RUN_ID: -8}-${RUN_ATTEMPT}" RESOURCE_SUFFIX="${SHORT_RUN}-${SAFE_VER}-${SYNC_SUFFIX}" + if [[ "${E2E_OPCUA_MODE:-Stable}" == "Disabled" ]]; then + LONG_SUFFIX="${LONG_SUFFIX}-opcua-off" + RESOURCE_SUFFIX="${RESOURCE_SUFFIX}-opcua-off" + fi # Candidate cases use the masked names owned by their run attempt. RG="${RG_IN:-${FLEET_RESOURCE_GROUP:-rg-e2e-$LONG_SUFFIX}}" CL="${CL_IN:-${FLEET_CLUSTER_NAME:-e2e-$RESOURCE_SUFFIX}}" @@ -1743,7 +1772,7 @@ jobs: run: | set -euo pipefail pytest $PYTEST_PATHS -v -m integration --tb=long \ - --junitxml="e2e-results-${AIO_RELEASE}-secretsync-${SECRET_SYNC_MODE}.xml" + --junitxml="e2e-results-${AIO_RELEASE}-secretsync-${SECRET_SYNC_MODE}-opcua-${E2E_OPCUA_MODE}.xml" - name: Deploy AIO through the published engine and package id: deploy @@ -2258,8 +2287,8 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() && needs.prep.outputs.published-mode != 'true' with: - name: e2e-results-${{ matrix.aio-release }}-secretsync-${{ matrix.secret-sync-mode }} - path: e2e-results-${{ matrix.aio-release }}-secretsync-${{ matrix.secret-sync-mode }}.xml + name: e2e-results-${{ matrix.aio-release }}-secretsync-${{ matrix.secret-sync-mode }}-opcua-${{ matrix.opcua-mode }} + path: e2e-results-${{ matrix.aio-release }}-secretsync-${{ matrix.secret-sync-mode }}-opcua-${{ matrix.opcua-mode }}.xml - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() && needs.prep.outputs.published-mode == 'true' && env.CANDIDATE_MODE != 'true' @@ -2316,13 +2345,13 @@ jobs: run: | set -euo pipefail pytest $PYTEST_PATHS -v -m integration --tb=long \ - --junitxml="e2e-results-${AIO_RELEASE_FROM}-to-${AIO_RELEASE_TO}-secretsync-${SECRET_SYNC_MODE}.xml" + --junitxml="e2e-results-${AIO_RELEASE_FROM}-to-${AIO_RELEASE_TO}-secretsync-${SECRET_SYNC_MODE}-opcua-${E2E_OPCUA_MODE}.xml" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() && needs.prep.outputs.upgrade-to != '' && needs.prep.outputs.upgrade-to != matrix.aio-release with: - name: e2e-results-${{ matrix.aio-release }}-to-${{ needs.prep.outputs.upgrade-to }}-secretsync-${{ matrix.secret-sync-mode }} - path: e2e-results-${{ matrix.aio-release }}-to-${{ needs.prep.outputs.upgrade-to }}-secretsync-${{ matrix.secret-sync-mode }}.xml + name: e2e-results-${{ matrix.aio-release }}-to-${{ needs.prep.outputs.upgrade-to }}-secretsync-${{ matrix.secret-sync-mode }}-opcua-${{ matrix.opcua-mode }} + path: e2e-results-${{ matrix.aio-release }}-to-${{ needs.prep.outputs.upgrade-to }}-secretsync-${{ matrix.secret-sync-mode }}-opcua-${{ matrix.opcua-mode }}.xml - name: Keep cluster alive (pre-teardown hold) if: always() && inputs.keep-cluster-alive-minutes > 0 diff --git a/docs/aio-releases.md b/docs/aio-releases.md index 3ee1efab..96ebcf35 100644 --- a/docs/aio-releases.md +++ b/docs/aio-releases.md @@ -5,10 +5,10 @@ Azure IoT Operations (AIO) ships on a release cadence. Each AIO release pins spe ## How release selection works ``` -site.properties.aioRelease: "2608" +site.properties.aioRelease: "2609" │ ▼ -workspaces/iot-operations/parameters/aio-releases/2608.yaml +workspaces/iot-operations/parameters/aio-releases/2609.yaml │ ▼ (siteops auto-forwards matching params to Bicep) templates/aio/enablement.bicep ──► cert-manager, secret store extensions @@ -22,8 +22,8 @@ Each AIO release YAML has a stable schema at the top level. differ even when two AIO releases use the same ARM API generation: ```yaml -# parameters/aio-releases/2608.yaml -aioVersion: "1.4.73" # AIO extension version pinned in Arc +# parameters/aio-releases/2609.yaml +aioVersion: "1.4.112" # AIO extension version pinned in Arc aioTrain: stable # Extension release train aioApiVersion: "2026-07-01" # Microsoft.IoTOperations/instances API version adrApiVersion: "2026-04-01" # Microsoft.DeviceRegistry/namespaces API version @@ -32,20 +32,20 @@ aioReleaseConfiguration: wasmGraphControllerMqttTrust: true resources: opcUaConnector: - version: "1.4.10" -certManagerVersion: "1.0.0" + version: "1.4.14" +certManagerVersion: "1.1.2" certManagerTrain: stable certManagerConfigurationOverrides: trust-manager.secretTargets.enabled: "false" trust-manager.secretTargets.authorizedSecretsAll: "false" -secretStoreVersion: "1.5.2" +secretStoreVersion: "1.5.3" secretStoreTrain: stable ``` The `aioApiVersion` and `adrApiVersion` values route CREATE and UPDATE operations through their matching versioned modules (for example `templates/aio/modules/instance-2026-07-01.bicep` and `templates/deps/modules/adr-ns-2026-04-01.bicep`). Configuration override objects specific to an AIO release are forwarded automatically to templates that declare them. Bicep cannot parameterize API version strings, so the dispatchers use `@allowed` + conditional modules. See [Adding a new AIO release](#adding-a-new-aio-release) below. -The AIO release 2608 configuration also deploys the OPC UA connector template -and supplies the MQTT trust settings required by its WebAssembly graph +The AIO release 2608 and 2609 configurations also deploy the OPC UA connector template +and supply the MQTT trust settings required by the WebAssembly graph controller. Install and upgrade use the same typed connector template module. Settings under `aioReleaseConfiguration.extension` belong to the AIO Arc extension. Settings under `aioReleaseConfiguration.resources` describe child @@ -60,6 +60,13 @@ AIO releases where it is not yet a default of the API generation. `extension.wasmGraphControllerMqttTrust` enables the derived MQTT trust settings required by the WebAssembly graph controller. +The connector template is deployed only when its version is configured and +the instance's effective OPC UA feature mode is not `Disabled`. +`features.opcua` takes precedence over the legacy `features.connectors` alias. +When `opcua` is absent, `connectors` supplies the feature. An omitted mode +uses `Stable`. Upgrade reads the existing instance's resolved features +rather than the Site's desired features. + For a Site override, keep the map under `parameters`: ```yaml @@ -85,10 +92,10 @@ name: munich-prod inherits: base-site.yaml properties: - aioRelease: "2608" # must match parameters/aio-releases/2608.yaml + aioRelease: "2609" # must match parameters/aio-releases/2609.yaml ``` -If not specified, the Site inherits the value from its parent template. Every committed Site in this workspace inherits `base-site.yaml`, directly or through a shared template, and `base-site.yaml` declares `"2608"`. A manifest that loads the release file fails preparation for a Site that carries no `aioRelease` at all, reporting that the Site does not carry the property the path selects on. +If not specified, the Site inherits the value from its parent template. Every committed Site in this workspace inherits `base-site.yaml`, directly or through a shared template, and `base-site.yaml` declares `"2609"`. A manifest that loads the release file fails preparation for a Site that carries no `aioRelease` at all, reporting that the Site does not carry the property the path selects on. ## Available AIO releases @@ -103,17 +110,47 @@ Every file in `workspaces/iot-operations/parameters/aio-releases/` is a shipped | `2605` | `2026-03-01` | `2026-04-01` | | | `2606` | `2026-03-01` | `2026-04-01` | | | `2607` | `2026-07-01` | `2026-04-01` | OPC connector template affected by [Microsoft known issue 1330](https://learn.microsoft.com/azure/iot-operations/troubleshoot/known-issues#opc-connector-template-missing) | -| `2608` | `2026-07-01` | `2026-04-01` | Default in `base-site.yaml` | +| `2608` | `2026-07-01` | `2026-04-01` | Selectable by an explicit pin | +| `2609` | `2026-07-01` | `2026-04-01` | Default in `base-site.yaml`. Pins from AIO CLI v2.10.0 | Source of truth for every pinned version number is the YAML itself. Check it against the [IoT Operations release matrix](https://github.com/Azure/azure-iot-ops-cli-extension/wiki/IoT-Operations-versions) before shipping a new one. +### Select 2609 or 2608 + +2609 is the default for configured Sites and guided installation. +2608 remains selectable through a Site pin or the guided `aioRelease` input. +2609 uses the same AIO and ADR API versions as 2608. +The [AIO CLI v2.10.0 release notes](https://github.com/Azure/azure-iot-ops-cli-extension/releases/tag/v2.10.0) +record the fix that skips the OPC UA connector template when OPC UA is disabled. +Scale Kit applies that guard to both install and upgrade. + +For a configured Site, set: + +```yaml +properties: + aioRelease: "2609" +``` + +For guided installation on an existing Arc cluster, select 2609 with an input: + +```text +siteops deploy aio-install --source "official@" --input "cluster=" --input aioRelease=2609 +``` + +Use a Scale Kit source release that includes `2609.yaml`. +To select 2608, set `properties.aioRelease: "2608"` on the Site or pass +`--input aioRelease=2608` to guided installation. +For an existing AIO installation, pin the Site to 2609 and follow the +[upgrade operation guide](../workspaces/iot-operations/manifests/aio-upgrade/README.md) +instead of running installation again. + ## Upgrading an existing Site Follow the [upgrade operation guide](../workspaces/iot-operations/manifests/aio-upgrade/README.md) to prepare and deploy an AIO release change in place. The operation updates extensions and the resources that the AIO release requires rather than reapplying the -greenfield instance configuration. An upgrade to 2608 adds the OPC UA -connector template. +greenfield instance configuration. Upgrades to 2608 or 2609 deploy the OPC UA +connector template when OPC UA is enabled. ### Catalog families and upgrade order @@ -132,7 +169,7 @@ This applies to every resource area a Site selects through `properties.resourceS Azure IoT Operations supports upgrade to any patch of the same minor version, or to the next minor version. Other transitions (downgrades, jumps across several minor versions, and moves between preview and GA) require uninstall and reinstall. See [Upgrade Azure IoT Operations](https://learn.microsoft.com/azure/iot-operations/deploy-iot-ops/howto-upgrade) for the authoritative rules. The optional E2E workflow can exercise selected upgrades between adjacent AIO releases, -such as `2607` to `2608`. A passing dispatch establishes its selected +such as `2608` to `2609`. A passing dispatch establishes its selected integration assertions. It is not a general readiness or support certification for another cluster or upgrade path. diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index 74e40a75..4f366552 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -141,7 +141,7 @@ slots use those persistent groups instead, as described in [Choose ephemeral or persistent resource groups](#choose-ephemeral-or-persistent-resource-groups). Each host then connects its cluster in its group's region. -The two configured Sites select AIO `2607` and `2608`, Secret Sync disabled +The two configured Sites select AIO `2607` and `2609`, Secret Sync disabled and the existing E2E Low broker memory profile. Acceptance checks exact Site identities, operation identities and dispositions, effective AIO release parameters, the installed engine version, actual deployed extension versions, Kubernetes @@ -357,7 +357,7 @@ From the **Actions** tab, dispatch **E2E Tests** with the defaults to run one AI | Input | Typical value | Notes | |-------|--------------|-------| | `scenario` | `aio` | `aio` runs the AIO matrix described on this page. `release-acceptance` and `site-cleanup` run [release acceptance](#accept-one-release-candidate). `fleet` and `fleet-cleanup` run [fleet qualification](#qualify-one-exact-candidate-across-two-sites). `windows-installer-preflight` runs the [Windows runner check](#check-a-windows-runner-before-installer-qualification). | -| `aio-releases` | `2608` or `2607,2608` | Separated by commas. Ephemeral mode runs them in parallel. Persistent mode serializes cells in the same RG. See [aio-releases.md](aio-releases.md) for how AIO releases are defined and pinned. | +| `aio-releases` | `2609` or `2608,2609` | Separated by commas. Ephemeral mode runs them in parallel. Persistent mode serializes cells in the same RG. See [aio-releases.md](aio-releases.md) for how AIO releases are defined and pinned. | | `environment` | `dev` | GitHub Environment whose secrets/approvers apply. | | `location` | `eastus2` | ephemeral mode only. Persistent derives from the RG. | | `resource-group` | empty (ephemeral) or existing RG (persistent) | | @@ -366,8 +366,9 @@ From the **Actions** tab, dispatch **E2E Tests** with the defaults to run one AI | `skip-teardown` | false | Preserve the deployment for inspection. Scope depends on mode (see below). | | `keep-cluster-alive-minutes` | `0` | Hold the runner for N min before teardown for debugging. Clamped to what is left of the job budget so teardown still runs. Nothing should be added to the persistent RG during the hold (it'll be deleted by teardown). | | `tests` | empty (run all) or `aio-install,enable-secretsync` | Allowlist of test phases to deploy and run, separated by commas. Valid values: `aio-install`, `enable-secretsync`, `sync-secrets`, `opc-ua-solution`, `dataflow-sample`, `aio-resources`, `resource-set-samples`, `aio-upgrade`. Useful for demos and focused debugging when paired with `keep-cluster-alive-minutes`. | -| `upgrade-to` | empty or `2608` | Optional AIO release to upgrade to after the install phase tests pass. Empty skips the upgrade phase. Each cell skips it when it equals the cell's `aio-releases` value. Requires `aio-upgrade` to be in the `tests` allowlist (or `tests` empty). | +| `upgrade-to` | empty or `2609` | Optional AIO release to upgrade to after the install phase tests pass. Empty skips the upgrade phase. Each cell skips it when it equals the cell's `aio-releases` value. Requires `aio-upgrade` to be in the `tests` allowlist (or `tests` empty). | | `secret-sync-modes` | `enabled` or `enabled,disabled` | Matrix modes for Secret Sync and workload identity. Use `enabled,disabled` with `tests=aio-upgrade` to qualify upgrade behavior with and without the OIDC profile. | +| `opcua-modes` | `Stable` or `Stable,Disabled` | Source AIO runs only. `Disabled` requires explicit tests limited to `aio-install`, `enable-secretsync`, `sync-secrets` and `aio-upgrade`. Published and candidate acceptance runs require `Stable`. | | `published-release` | empty or an exact tag | Empty keeps the integration suite that runs from the checkout source. A tag selects the bounded, verified published package mode below. | | `published-source-sha` | empty or a full commit | Required with `published-release`. Must be the exact commit targeted by the published tag. | | `published-journey` | `configured` | Applies only with `published-release`. `configured` deploys a configured Site, and `guided` deploys one guided Site. See below. | @@ -378,12 +379,47 @@ Qualify both AIO upgrade optionality paths in one dispatch: ```bash gh workflow run e2e-test.yaml \ - -f aio-releases=2607 \ - -f upgrade-to=2608 \ + -f aio-releases=2608 \ + -f upgrade-to=2609 \ -f tests=aio-upgrade \ -f secret-sync-modes=enabled,disabled ``` +To cover fresh 2609 installation, 2608 to 2609 upgrade, Secret Sync and both +OPC UA feature modes in one source run, use: + +```bash +gh workflow run e2e-test.yaml \ + -f aio-releases=2608,2609 \ + -f upgrade-to=2609 \ + -f opcua-modes=Stable,Disabled \ + -f secret-sync-modes=enabled \ + -f tests=aio-install,enable-secretsync,sync-secrets,aio-upgrade +``` + +This creates four cells. Both 2608 cells upgrade to 2609 after installation +tests pass. Both 2609 cells skip the extra upgrade phase. +The rendered Site sets `parameters.features.opcua.mode` to `Disabled` only +for the Disabled cells. Stable cells omit features and exercise the default mode. +Install and upgrade tests require an OPC UA connector template for `Stable` +and no template for `Disabled`. This checks fresh test Sites, not removal of +a template that an earlier deployment already created. +The selected phases do not test OPC UA data delivery. +Resource names and result artifacts distinguish the OPC UA modes. + +For 2609 qualification, retain evidence from a second dispatch with the full +default test selection: + +```bash +gh workflow run e2e-test.yaml \ + -f aio-releases=2609 \ + -f opcua-modes=Stable +``` + +Leave `tests` empty. This runs the full integration suite, including +`opc-ua-solution`, on 2609. The restricted Disabled matrix above does not run +that phase. Both dispatches form part of the 2609 qualification evidence. + ### Qualify a published engine and workspace Published package mode proves a different boundary from the ordinary source @@ -424,7 +460,7 @@ Published qualification is deliberately bounded: |---|---| | `published-release` | Exact approved published tag | | `published-source-sha` | Exact full source commit | -| `aio-releases` | One AIO release. Guided mode uses `2608`. | +| `aio-releases` | One AIO release. Guided mode uses `2609`. | | `tests` | `aio-install` | | `published-journey` | `configured` or `guided` | | `secret-sync-modes` | `disabled` for configured Sites. Guided supports `disabled`, `enabled` or both. | @@ -468,7 +504,7 @@ gh workflow run e2e-test.yaml \ --ref main \ -f published-release= \ -f published-source-sha= \ - -f aio-releases=2608 \ + -f aio-releases=2609 \ -f resource-group= \ -f published-journey=guided \ -f tests=aio-install \ @@ -535,7 +571,7 @@ Set the required variables. The renderer fills in defaults for the optional ones ```powershell $env:E2E_RESOURCE_GROUP = "rg-e2e-dev" $env:E2E_CLUSTER_NAME = "arc-e2e-dev" -$env:E2E_AIO_RELEASE = "2608" +$env:E2E_AIO_RELEASE = "2609" $env:E2E_SITE_NAME = "e2e-local-$([DateTimeOffset]::Now.ToUnixTimeSeconds())" $sitesDir = Join-Path $env:TEMP "e2e-sites" @@ -552,7 +588,7 @@ pytest tests/integration/ -v -m integration ```bash export E2E_RESOURCE_GROUP=rg-e2e-dev export E2E_CLUSTER_NAME=arc-e2e-dev -export E2E_AIO_RELEASE=2608 +export E2E_AIO_RELEASE=2609 export E2E_SITE_NAME="e2e-local-$(date +%s)" SITES_DIR="${TMPDIR:-/tmp}/e2e-sites" @@ -574,7 +610,7 @@ To exercise an upgrade between AIO releases locally, install at one AIO release ```bash # Re-render with the upgrade target. Same E2E_SITE_NAME so the file overwrites in place. -export E2E_AIO_RELEASE=2608 +export E2E_AIO_RELEASE=2609 python scripts/render-e2e-site.py --output-dir "$SITES_DIR" export SITEOPS_E2E_UPGRADE_PHASE=1 diff --git a/docs/guided-inputs.md b/docs/guided-inputs.md index 2f990671..216138c9 100644 --- a/docs/guided-inputs.md +++ b/docs/guided-inputs.md @@ -76,7 +76,7 @@ maximum of 59 characters. Existing saved Sites retain their names. Environment and country labels are optional. Add `--input environment=dev` or `--input country=US` when you want those labels and resource tags. Omitted values create neither labels nor tags. The default AIO release is -2608, cert-manager is enabled, and Secret Sync is disabled. To list every +2609, cert-manager is enabled, and Secret Sync is disabled. To list every optional input, such as `enableCertManager` and `brokerMemoryProfile`, run `siteops inputs aio-install` with the same `--source`. @@ -179,7 +179,7 @@ siteops deploy aio-install --source "official@" --input siteName=plant- ## Enable Secret Sync on an existing instance -For an existing AIO 2607 or 2608 instance, supply its resource ID instead +For an existing AIO 2607, 2608 or 2609 instance, supply its resource ID instead of running installation again: ```text @@ -195,7 +195,7 @@ The reads do not enable cluster features or grant roles. The plan contains only instance resolution and Secret Sync enablement. It uses the actual instance name and does not reinstall or upgrade AIO. -The shared typed contract supports existing AIO 2607 and 2608 instances. +The shared typed contract supports existing AIO 2607, 2608 and 2609 instances. Configured Sites retain their release selection for other API generations. The default Site name comes from the associated cluster, matching the @@ -241,8 +241,9 @@ deleting the existing cluster or resources owned by others. `aioRelease` is a typed answer mapped to the selected Site's `properties.aioRelease`. The packaged IoT Operations workspace supplies -AIO release configurations for `2607` and `2608`, with `2608` as the current -default. To use different AIO releases without saving Site files, prepare and +the [supported AIO release configurations](aio-releases.md#available-aio-releases), +including `2608`. `2609` is the default. +To use different AIO releases without saving Site files, prepare and deploy each existing Arc cluster in a separate invocation: ```text diff --git a/docs/migrating.md b/docs/migrating.md index d83c10c0..e9e21423 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -61,7 +61,7 @@ Start with the changes that affect your workflow: | Repeat an option such as `-w`, or shorten option names | Review [option spelling](#option-spelling). | | Reference shipped manifests, partials or dataflow sets by path | Update [workspace content paths](#workspace-content-paths). | | Pass a manifest filename without a directory | Review [manifest names and paths](#manifest-names-and-paths). | -| Rely on the workspace default AIO release | Review the [2608 default](#default-aio-release). | +| Rely on the workspace default AIO release | Review the [2609 default](#default-aio-release). | | Select dataflow sets or read catalog step outputs | Update [resource sets](#resource-sets) and [catalog step outputs](#catalog-step-outputs). | | Author sites, manifests or parameter files | Review [preparation checks](#preparation-checks), [empty site mappings](#empty-site-mappings) and [parameter file selection](#parameter-file-selection). | | Write `when: ""` on a step or include | Omit `when:` or set it to `null`. See [empty conditions](#empty-conditions). | @@ -325,11 +325,17 @@ workspace. ### Default AIO release -`sites/base-site.yaml` now sets `aioRelease: "2608"`, where v1.0.0b6 set -`"2607"`. A site that inherits it without its own `aioRelease` selects 2608 on -its next deployment, and 2608 adds the OPC UA connector template. Pin -`properties.aioRelease: "2607"` on a site that must stay on 2607, or move it -with the [upgrade operation](../workspaces/iot-operations/manifests/aio-upgrade/README.md). +`sites/base-site.yaml` now sets `aioRelease: "2609"`, where v1.0.0b6 set +`"2607"`. Sites that do not pin `properties.aioRelease` target 2609 on their +next deploy. Pin `properties.aioRelease: "2607"` to stay on the v1.0.0b6 release, +or `properties.aioRelease: "2608"` to select that release. +The guided installation and Secret Sync API profile also default to 2609. +Use the [upgrade operation](../workspaces/iot-operations/manifests/aio-upgrade/README.md) +for in-place upgrades of existing instances instead of rerunning installation. +Moving off 2607 adds the OPC UA connector template unless OPC UA is disabled. +Install and upgrade skip the template when the feature mode is `Disabled`. +This includes the legacy `connectors` alias when `opcua` is absent. +The canonical `opcua` feature takes precedence over `connectors`. See [AIO releases](aio-releases.md). ### Resource sets diff --git a/docs/secret-sync.md b/docs/secret-sync.md index 585b4521..9264a177 100644 --- a/docs/secret-sync.md +++ b/docs/secret-sync.md @@ -106,7 +106,7 @@ Both steps are gated by a `when` condition on their includes in `aio-install` an ### Option 2: Standalone enablement on existing instances -For AIO 2607 or 2608, the [guided route with an instance ID](guided-inputs.md#enable-secret-sync-on-an-existing-instance) +For AIO 2607, 2608 or 2609, the [guided route with an instance ID](guided-inputs.md#enable-secret-sync-on-an-existing-instance) needs only the existing instance resource ID and explicit read permission. It resolves the related cluster and checks identity prerequisites before deployment. Optional answers select an existing vault or override Site labels. diff --git a/docs/site-configuration.md b/docs/site-configuration.md index 7e51d36e..4fecfc4c 100644 --- a/docs/site-configuration.md +++ b/docs/site-configuration.md @@ -222,7 +222,7 @@ properties: # `parameters/aio-releases/.yaml` gets loaded. This must be a # property, not a parameter: parameter-file path interpolation reads # `site.properties` and `site.labels`, never `site.parameters`. - aioRelease: "2608" + aioRelease: "2609" # Ordered resource sets. Each list item names a YAML file in the matching # `resource-sets//` directory. Omit an area for no selection, or use [] to diff --git a/scripts/aggregate-fleet-results.py b/scripts/aggregate-fleet-results.py index d2659269..1b87fac7 100644 --- a/scripts/aggregate-fleet-results.py +++ b/scripts/aggregate-fleet-results.py @@ -78,7 +78,7 @@ def aggregate(selected: FleetCandidate, root: Path, *, run: int, attempt: int) - or re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}(?:-preview)?", row["apiVersion"]) is None for row in deployment["slots"].values()) or any(deployment["slots"][slot].get("release") != release - for slot, release in (("one", "2607"), ("two", "2608"))) + for slot, release in (("one", "2607"), ("two", "2609"))) ): raise ValueError("The installed controller did not establish the required fleet outcome.") for slot, document in zip(("one", "two"), readiness, strict=True): diff --git a/scripts/deploy-release-fleet.py b/scripts/deploy-release-fleet.py index e3caae44..53b24298 100644 --- a/scripts/deploy-release-fleet.py +++ b/scripts/deploy-release-fleet.py @@ -23,7 +23,7 @@ from siteops.arm_resources_azure_cli import _run_az # noqa: E402 from siteops.artifacts import load_artifact_json, open_regular_file # noqa: E402 -RELEASES = {"one": "2607", "two": "2608"} +RELEASES = {"one": "2607", "two": "2609"} SELECTOR = "name=fleet-one,name=fleet-two" EXPECTED_OPERATIONS = { "global-edge-site": "skipped", "edge-site": "skipped", "schema-registry": "succeeded", diff --git a/scripts/probe-fleet-plan.py b/scripts/probe-fleet-plan.py index 159ea9be..2627299c 100644 --- a/scripts/probe-fleet-plan.py +++ b/scripts/probe-fleet-plan.py @@ -40,7 +40,7 @@ def inspect(spec: dict) -> dict: } for row in rows) or {row["slot"] for row in rows} != {"one", "two"} or any(row["name"] != f"fleet-{row['slot']}" - or row["release"] != {"one": "2607", "two": "2608"}[row["slot"]] for row in rows)): + or row["release"] != {"one": "2607", "two": "2609"}[row["slot"]] for row in rows)): raise ValueError("The fleet target specification is incomplete.") wanted = {row["name"]: row for row in rows} observed = [] diff --git a/scripts/render-e2e-site.py b/scripts/render-e2e-site.py index 96900879..9c5a407e 100644 --- a/scripts/render-e2e-site.py +++ b/scripts/render-e2e-site.py @@ -17,6 +17,7 @@ E2E_LOCATION Defaults to `az group show -n $E2E_RESOURCE_GROUP --query location -o tsv` E2E_ENABLE_SECRET_SYNC Defaults to `true` + E2E_OPCUA_MODE Defaults to `Stable`, which omits features. `Disabled` adds the feature override. Usage: python scripts/render-e2e-site.py \ @@ -44,6 +45,7 @@ "E2E_SUBSCRIPTION", "E2E_LOCATION", "E2E_ENABLE_SECRET_SYNC", + "E2E_OPCUA_MODE", ) ALL_VARS = REQUIRED_VARS + OPTIONAL_VARS @@ -93,6 +95,10 @@ def compute_defaults(values: dict[str, str]) -> dict[str, str]: out["E2E_ENABLE_SECRET_SYNC"] = "true" if out["E2E_ENABLE_SECRET_SYNC"] not in {"true", "false"}: raise RuntimeError("E2E_ENABLE_SECRET_SYNC must be `true` or `false`.") + if not out.get("E2E_OPCUA_MODE"): + out["E2E_OPCUA_MODE"] = "Stable" + if out["E2E_OPCUA_MODE"] not in {"Stable", "Disabled"}: + raise RuntimeError("E2E_OPCUA_MODE must be `Stable` or `Disabled`.") return out @@ -117,11 +123,15 @@ def collect_values() -> dict[str, str]: def render(template_path: Path, values: dict[str, str]) -> str: template = string.Template(template_path.read_text(encoding="utf-8")) + opcua_features = ( + " features:\n opcua:\n mode: Disabled\n settings: {}" + if values.get("E2E_OPCUA_MODE") == "Disabled" else "" + ) # `safe_substitute` lets bare `$` characters (e.g. shell-style `$VAR` in # comments, or `$$` literals) pass through unchanged. The # `UNRESOLVED_PATTERN` check below still catches genuine missing # `${...}` placeholders, which is the failure mode that matters. - rendered = template.safe_substitute(values) + rendered = template.safe_substitute(values, E2E_OPCUA_FEATURES=opcua_features) leftovers = UNRESOLVED_PATTERN.findall(rendered) if leftovers: diff --git a/tests/e2e/sites/e2e-test.yaml.tmpl b/tests/e2e/sites/e2e-test.yaml.tmpl index 51ffbc87..06dc8bed 100644 --- a/tests/e2e/sites/e2e-test.yaml.tmpl +++ b/tests/e2e/sites/e2e-test.yaml.tmpl @@ -30,6 +30,7 @@ # E2E_AIO_RELEASE AIO release selector (drives versioned-templates path) # E2E_ENABLE_SECRET_SYNC # Whether the site enables Secret Sync (`true` or `false`) +# E2E_OPCUA_MODE `Disabled` adds a feature override. `Stable` omits features. apiVersion: siteops/v1 kind: Site @@ -59,6 +60,7 @@ properties: parameters: clusterName: "${E2E_CLUSTER_NAME}" +${E2E_OPCUA_FEATURES} brokerConfig: memoryProfile: Low defaultDataflowInstanceCount: 1 diff --git a/tests/integration/helpers/kube.py b/tests/integration/helpers/kube.py index 0f8f5503..d7696ba9 100644 --- a/tests/integration/helpers/kube.py +++ b/tests/integration/helpers/kube.py @@ -128,6 +128,19 @@ def kubectl_text(args: list[str], *, timeout: int = 30) -> str: return proc.stdout +def list_opcua_connector_templates(namespace: str) -> list[str]: + """Return OPC UA template names, including when the feature installs no CRD.""" + resource = "connectortemplates.akri.iotoperations.azure.com" + crds = kubectl_json(["get", "customresourcedefinitions"]) + if not any(item["metadata"]["name"] == resource for item in crds["items"]): + return [] + templates = kubectl_json(["get", resource, "-n", namespace]) + return [ + item["metadata"]["name"] for item in templates["items"] + if item["metadata"]["name"].startswith("azureiotoperationsconnectorforopcua-") + ] + + def _is_not_found(error: KubectlError) -> bool: """True when the error is the standard kubectl NotFound diagnostic.""" text = error.stderr.lower() diff --git a/tests/integration/test_aio_install_manifest.py b/tests/integration/test_aio_install_manifest.py index 98965cfe..7fea7de1 100644 --- a/tests/integration/test_aio_install_manifest.py +++ b/tests/integration/test_aio_install_manifest.py @@ -17,7 +17,7 @@ site_results, ) from tests.integration.helpers.azure import run_az -from tests.integration.helpers.kube import is_pod_ready, list_pods +from tests.integration.helpers.kube import is_pod_ready, list_opcua_connector_templates, list_pods from tests.integration.helpers.releases import load_aio_release pytestmark = [pytest.mark.integration] @@ -171,6 +171,26 @@ def test_aio_extension_version_matches_version_config( f"selected the wrong API version or the version YAML is stale." ) + def test_opcua_connector_template_matches_feature_mode( + self, aio_install_result, orchestrator, aio_namespace, kubectl_available, + ): + for name in site_names(aio_install_result): + _, release = load_aio_release(orchestrator, name, WORKSPACE_PATH) + connector = release["aioReleaseConfiguration"].get("resources", {}).get("opcUaConnector", {}) + if not connector.get("version"): + continue + assert_step_succeeded(aio_install_result, name, "aio-instance") + site = orchestrator.load_site(name) + features = site.parameters.get("features") or {} + opcua_feature = features.get("opcua") + if opcua_feature is None: + opcua_feature = features.get("connectors") or {} + disabled = opcua_feature.get("mode") == "Disabled" + templates = list_opcua_connector_templates(aio_namespace) + assert bool(templates) is not disabled, ( + f"Site '{name}': OPC UA template presence does not match its feature mode" + ) + def test_security_pki_settings_match_release_contract( self, aio_install_result, orchestrator ): diff --git a/tests/integration/test_aio_upgrade_manifest.py b/tests/integration/test_aio_upgrade_manifest.py index 534ae909..92f7e0e6 100644 --- a/tests/integration/test_aio_upgrade_manifest.py +++ b/tests/integration/test_aio_upgrade_manifest.py @@ -30,7 +30,7 @@ site_names, site_results, ) -from tests.integration.helpers.kube import KubectlError, kubectl_json +from tests.integration.helpers.kube import list_opcua_connector_templates from tests.integration.helpers.releases import load_aio_release pytestmark = [pytest.mark.integration] @@ -51,7 +51,6 @@ "trust-manager.secretTargets.enabled", "trust-manager.secretTargets.authorizedSecretsAll", } -OPC_UA_CONNECTOR_TEMPLATE_PREFIX = "azureiotoperationsconnectorforopcua-" def _release_configuration(orchestrator, site_name: str) -> dict: @@ -555,7 +554,7 @@ def test_aio_configuration_settings_preserved( def test_aio_release_train_preserved(self, aio_upgrade_result): """releaseTrain change is allowed only when the release config bumps - the train. Shipped release configs (2603 through 2608) all use `stable`, + the train. Shipped release configs (2603 through 2609) all use `stable`, so a mismatch here means the RP defaulted the train unexpectedly OR the release config was bumped (intentional, update the test). """ @@ -573,7 +572,7 @@ def test_aio_release_train_preserved(self, aio_upgrade_result): class TestOpcUaConnectorTemplateUpgrade: """Release-required connector templates are added during upgrade.""" - def test_connector_template_present_after_upgrade( + def test_connector_template_matches_resolved_features_after_upgrade( self, aio_upgrade_result, orchestrator, @@ -592,33 +591,16 @@ def test_connector_template_present_after_upgrade( assert_step_succeeded( aio_upgrade_result, name, "deploy-release-resources" ) - try: - templates = kubectl_json( - [ - "get", - "connectortemplates.akri.iotoperations.azure.com", - "-n", - aio_namespace, - ] - ) - except KubectlError as e: - pytest.fail( - f"Site '{name}': connector templates not retrievable after " - f"upgrade in `{aio_namespace}`: {e}" - ) - names = [ - item.get("metadata", {}).get("name", "") - for item in templates.get("items", []) - ] - matching = [ - value - for value in names - if value.startswith(OPC_UA_CONNECTOR_TEMPLATE_PREFIX) - ] - assert matching, ( - f"Site '{name}': no OPC UA connector template is present after " - f"upgrade. The cluster reports {len(names)} connector " - "template(s), none for OPC UA." + resolve = assert_step_succeeded(aio_upgrade_result, name, "resolve-aio") + features = assert_output_exists(resolve, "features") + opcua_feature = features.get("opcua") + if opcua_feature is None: + opcua_feature = features.get("connectors") or {} + disabled = opcua_feature.get("mode") == "Disabled" + matching = list_opcua_connector_templates(aio_namespace) + assert bool(matching) is not disabled, ( + f"Site '{name}': OPC UA template presence after upgrade " + "does not match the resolved instance feature mode." ) checked += 1 diff --git a/tests/test_browse.py b/tests/test_browse.py index 347ec919..b73905bd 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -322,7 +322,7 @@ def test_aio_guidance_distinguishes_typed_defaults_and_packaged_tools(monkeypatc monkeypatch.setattr(browse_output, "_command_shell", lambda: "PowerShell") card = render_browse_plain(inspect_content(workspace, "aio-install")) assert "environment and country" in card - assert "typed AIO route defaults to 2608" in card + assert "typed AIO route defaults to 2609" in card assert "compiled ARM JSON" in card assert "Authored Site input guidance" in card assert card.count("descriptive") == 1 diff --git a/tests/test_fleet_outcomes.py b/tests/test_fleet_outcomes.py index 9a5428f5..663586e9 100644 --- a/tests/test_fleet_outcomes.py +++ b/tests/test_fleet_outcomes.py @@ -76,7 +76,7 @@ def test_extension_observation_checks_azure_state_not_the_echoed_site_property(t helper = module("deploy-release-fleet") scope = FleetScope("example/repository", "a" * 40, "b" * 64, "c" * 64, 42, 1, "00000000-0000-0000-0000-000000000001") - slots = {"one": {"version": "1.4.41"}, "two": {"version": "1.4.73"}} + slots = {"one": {"version": "1.4.41"}, "two": {"version": "1.4.112"}} calls = [] def runner(args): @@ -136,7 +136,7 @@ def receipts(tmp_path): "resolvedParametersVerified": True, "extensionVersionsVerified": True, "podReadiness": "pending-host-observation", "workloadFunctionality": "not-checked", "slots": {"one": {"release": "2607", "version": "1.4.41", "apiVersion": "2026-07-01"}, - "two": {"release": "2608", "version": "1.4.73", "apiVersion": "2026-07-01"}}, + "two": {"release": "2609", "version": "1.4.112", "apiVersion": "2026-07-01"}}, }, "cleanup/cleanup.json": { **copy.deepcopy(common), "kind": "FleetCleanup", "groups": "ephemeral", "status": "complete", @@ -156,7 +156,7 @@ def receipts(tmp_path): @pytest.mark.parametrize("fault", [None, "persistent", "missing", "duplicate", "candidate", "scope", "cleanup", - "invocations", "private-field", "groups"]) + "invocations", "private-field", "groups", "old-slot-two-release"]) def test_aggregator_rejects_missing_ambiguous_or_unbound_evidence(receipts, fault): selected, root, data = receipts helper = module("aggregate-fleet-results") @@ -176,6 +176,8 @@ def test_aggregator_rejects_missing_ambiguous_or_unbound_evidence(receipts, faul data[name]["deployInvocations"] = 2 elif fault == "private-field": data[name]["resourceGroup"] = "private-marker" + elif fault == "old-slot-two-release": + data[name]["slots"]["two"] = {"release": "2608", "version": "1.4.73", "apiVersion": "2026-07-01"} elif fault in {"persistent", "groups"}: name = "cleanup/cleanup.json" data[name]["groups"] = "persistent" if fault == "persistent" else "private-marker" @@ -297,7 +299,7 @@ def run(arguments, *, cwd, logs, name, timeout, environment): if name.startswith("save-"): answers = json.loads(Path(arguments[arguments.index("--input-file") + 1]).read_text())["values"] assert answers["enableSecretSync"] is False and "existingVault" not in answers - assert answers["aioRelease"] == ("2607" if name == "save-one" else "2608") + assert answers["aioRelease"] == ("2607" if name == "save-one" else "2609") Path(arguments[arguments.index("--save-site") + 1]).write_text(json.dumps(answers)) elif name == "inspect-plan": spec = json.loads(Path(arguments[arguments.index("--spec") + 1]).read_text()) @@ -307,7 +309,7 @@ def run(arguments, *, cwd, logs, name, timeout, environment): (logs / "inspect-plan.out").write_text(json.dumps({ "kind": "FleetPlanInspection", "operationIdentitiesVerified": True, "parametersVerified": True, "slots": {"one": {"release": "2607", "version": "1.4.41", "apiVersion": "2026-07-01"}, - "two": {"release": "2608", "version": "1.4.73", "apiVersion": "2026-07-01"}}, + "two": {"release": "2609", "version": "1.4.112", "apiVersion": "2026-07-01"}}, })) else: assert name == "deploy" and arguments.count("deploy") == 1 diff --git a/tests/test_fleet_plan_probe.py b/tests/test_fleet_plan_probe.py index 52a3ce94..c90bd69a 100644 --- a/tests/test_fleet_plan_probe.py +++ b/tests/test_fleet_plan_probe.py @@ -32,12 +32,12 @@ def fleet_app(tmp_path_factory): (source / "parameters" / "common.yaml").write_text( 'clusterName: "{{ site.parameters.clusterName }}"\nbrokerConfig: "{{ site.parameters.brokerConfig }}"\n', ) - for release, version in (("2607", "1.4.41"), ("2608", "1.4.73")): + for release, version in (("2607", "1.4.41"), ("2609", "1.4.112")): (source / "parameters" / "aio-releases" / f"{release}.yaml").write_text( yaml.safe_dump({"aioVersion": version, "aioApiVersion": "2026-07-01", "aioReleaseConfiguration": {} if release == "2607" else {"extension": {"wasmGraphControllerMqttTrust": True}}, - "certManagerVersion": "0.14.0" if release == "2607" else "1.0.0", - "secretStoreVersion": "1.5.1" if release == "2607" else "1.5.2"}), + "certManagerVersion": "0.14.0" if release == "2607" else "1.1.2", + "secretStoreVersion": "1.5.1" if release == "2607" else "1.5.3"}), ) steps = [] for name in ( @@ -65,7 +65,7 @@ def fleet_app(tmp_path_factory): assert result.returncode == 0, result.stdout + result.stderr (app.root / "project" / "sites" / "one.yaml").unlink() targets = [] - for slot, release in (("one", "2607"), ("two", "2608")): + for slot, release in (("one", "2607"), ("two", "2609")): target = {"slot": slot, "release": release, "name": f"fleet-{slot}", "subscription": "00000000-0000-0000-0000-000000000001", "resourceGroup": f"private-group-{slot}", "cluster": f"private-cluster-{slot}"} @@ -85,7 +85,7 @@ def fleet_app(tmp_path_factory): return app, targets -@pytest.mark.parametrize("fault", [None, "engine", "target", "release", "cluster"]) +@pytest.mark.parametrize("fault", [None, "engine", "target", "release", "old-slot-two-release", "cluster"]) def test_installed_probe_observes_the_real_planner_and_keeps_values_private(fleet_app, fault): app, original = fleet_app targets = json.loads(json.dumps(original)) @@ -93,6 +93,8 @@ def test_installed_probe_observes_the_real_planner_and_keeps_values_private(flee targets[0]["resourceGroup"] = "other" elif fault == "release": targets[0]["release"] = "2608" + elif fault == "old-slot-two-release": + targets[1]["release"] = "2608" elif fault == "cluster": targets[0]["cluster"] = "other" name = fault or "valid" @@ -116,4 +118,4 @@ def test_installed_probe_observes_the_real_planner_and_keeps_values_private(flee report = json.loads(result.stdout) assert report["targetCount"] == 2 and report["parallel"] == 2 assert report["slots"]["one"]["version"] == "1.4.41" - assert report["slots"]["two"]["version"] == "1.4.73" + assert report["slots"]["two"]["version"] == "1.4.112" diff --git a/tests/test_guided_cli.py b/tests/test_guided_cli.py index 0de0f0d4..bede1862 100644 --- a/tests/test_guided_cli.py +++ b/tests/test_guided_cli.py @@ -1367,7 +1367,7 @@ def test_aio_input_inspection_distinguishes_required_and_defaulted(capsys): assert fields["subscription"]["derivableFrom"] == ["cluster"] assert fields["enableSecretSync"]["default"] is False assert fields["aioRelease"]["status"] == "defaulted" - assert fields["aioRelease"]["default"] == "2608" + assert fields["aioRelease"]["default"] == "2609" assert fields["enableCertManager"]["default"] is True @@ -1585,7 +1585,7 @@ def read(self, ref, *, facts=frozenset()): @pytest.mark.parametrize( ("cluster", "release"), - [("arc-2608", "2608"), ("arc-2607", "2607")], + [("arc-2608", "2608"), ("arc-2607", "2607"), ("arc-2609", "2609")], ) def test_inline_aio_release_selects_one_cluster_for_each_deploy( cluster, release, capsys, tmp_path, @@ -1836,7 +1836,7 @@ def test_aio_preparation_selects_each_site_release_configuration(tmp_path): "environment=dev", "country=US", f"aioRelease={release}", ]) - for release in ("2608", "2607") + for release in ("2608", "2607", "2609") ] session = _aio_template_session(tmp_path, include_aio_version=True) with patch("siteops.orchestrator.TemplateCompilationSession", return_value=session): @@ -1858,7 +1858,7 @@ def test_aio_preparation_selects_each_site_release_configuration(tmp_path): if isinstance(entry.key, LiteralValue) and entry.key.value == "aioVersion" ) selected[target.name] = resolve_plan_value(value, {}) - assert selected == {"plant-2608": "1.4.73", "plant-2607": "1.4.41"} + assert selected == {"plant-2608": "1.4.73", "plant-2607": "1.4.41", "plant-2609": "1.4.112"} def test_aio_executable_preparation_resolves_country_and_environment_tags( diff --git a/tests/test_installation_guide.py b/tests/test_installation_guide.py index 99eebd43..018336b7 100644 --- a/tests/test_installation_guide.py +++ b/tests/test_installation_guide.py @@ -333,6 +333,18 @@ def test_short_guide_downloads_enforce_the_same_https_policy_as_release_notes(): assert "With an explicit commit, repeat installation" in " ".join(content.split()) +def test_aio_migration_distinguishes_the_b6_pin_from_the_optional_2608_release(): + migration = (GUIDE.parent / "migrating.md").read_text(encoding="utf-8") + section = migration.split("### Default AIO release\n", 1)[1].split("\n### ", 1)[0] + prose = " ".join(section.split()) + assert re.findall(r'properties\.aioRelease: "(\d+)"', section) == ["2607", "2608"] + assert 'Pin `properties.aioRelease: "2607"` to stay on the v1.0.0b6 release' in prose + assert '`properties.aioRelease: "2608"` to select that release' in prose + assert "Moving off 2607 adds the OPC UA connector template unless OPC UA is disabled" in prose + assert "`Disabled`" in section and "`connectors`" in section + assert "for in-place upgrades of existing instances instead of rerunning installation" in prose + + def test_preview_migration_describes_rejections_not_aliases(): migration = (GUIDE.parent / "migrating.md").read_text(encoding="utf-8") for old, replacement in ( diff --git a/tests/test_kubectl_base.py b/tests/test_kubectl_base.py index d3c1e6fe..54f446da 100644 --- a/tests/test_kubectl_base.py +++ b/tests/test_kubectl_base.py @@ -9,6 +9,11 @@ from __future__ import annotations +from unittest.mock import Mock + +import pytest + +from tests.integration.helpers import kube from tests.integration.helpers.kube import _kubectl_base @@ -40,3 +45,25 @@ def test_base_read_at_every_call_not_cached(monkeypatch): second = _kubectl_base() assert first == ["kubectl", "--kubeconfig", "/path/one"] assert second == ["kubectl", "--kubeconfig", "/path/two"] + + +@pytest.mark.parametrize("crd_present", [False, True]) +def test_opcua_template_discovery_handles_a_disabled_feature(monkeypatch, crd_present): + resource = "connectortemplates.akri.iotoperations.azure.com" + read = Mock(side_effect=[ + {"items": [{"metadata": {"name": resource}}] if crd_present else []}, + {"items": [{"metadata": {"name": "azureiotoperationsconnectorforopcua-one"}}, + {"metadata": {"name": "unrelated"}}]}, + ]) + monkeypatch.setattr(kube, "kubectl_json", read) + assert kube.list_opcua_connector_templates("aio") == ( + ["azureiotoperationsconnectorforopcua-one"] if crd_present else [] + ) + assert read.call_count == (2 if crd_present else 1) + + +def test_opcua_template_discovery_does_not_hide_cluster_read_errors(monkeypatch): + read = Mock(side_effect=kube.KubectlError("denied", 1, "Forbidden")) + monkeypatch.setattr(kube, "kubectl_json", read) + with pytest.raises(kube.KubectlError, match="denied"): + kube.list_opcua_connector_templates("aio") diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 655ee257..e28cf653 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -295,7 +295,7 @@ def test_installed_acquired_aio_manual_inputs_and_configured_site(installed_acqu *options, "inputs", "aio-install", *inline_older, "--save-site", str(saved_older), "--offline-content", ) - assert yaml.safe_load(saved.read_text(encoding="utf-8"))["properties"]["aioRelease"] == "2608" + assert yaml.safe_load(saved.read_text(encoding="utf-8"))["properties"]["aioRelease"] == "2609" assert yaml.safe_load(saved_older.read_text(encoding="utf-8"))["properties"]["aioRelease"] == "2607" fleet = json.loads(app.run( *options, "plan", "aio-install", "--describe", diff --git a/tests/test_published_e2e_workflow.py b/tests/test_published_e2e_workflow.py index de8e19fe..0b8eb90e 100644 --- a/tests/test_published_e2e_workflow.py +++ b/tests/test_published_e2e_workflow.py @@ -60,11 +60,12 @@ def _parse_inputs(**changes: str) -> subprocess.CompletedProcess[str]: environment = { "INPUT_SCENARIO": "aio", "INPUT_CANDIDATE_SET": "false", - "INPUT_RELEASES": "2608", + "INPUT_RELEASES": "2609", "INPUT_RG": "", "INPUT_CLUSTER": "", "INPUT_UPGRADE_TO": "", "INPUT_SECRET_SYNC_MODES": "enabled", + "INPUT_OPCUA_MODES": "Stable", "INPUT_TESTS": "", "INPUT_PUBLISHED_RELEASE": "", "INPUT_PUBLISHED_SOURCE_SHA": "", @@ -640,8 +641,9 @@ def test_target_input_mask_rejects_unsafe_values_without_echo(tmp_path, rg, clus ("published", "cluster_in"), [("true", ""), ("false", "arc-private-marker")], ) +@pytest.mark.parametrize("opcua_mode", ["Stable", "Disabled"]) def test_computed_site_mask_preserves_published_and_source_targets( - tmp_path, published, cluster_in, + tmp_path, published, cluster_in, opcua_mode, ): output = tmp_path / "names.txt" result = subprocess.run( @@ -652,6 +654,7 @@ def test_computed_site_mask_preserves_published_and_source_targets( "CL_IN": cluster_in, "RELEASE": "2608", "SECRET_SYNC_MODE": "disabled", + "E2E_OPCUA_MODE": opcua_mode, "RUN_ID": "1234567890", "RUN_ATTEMPT": "1", "GITHUB_OUTPUT": _bash_path(output), @@ -670,6 +673,8 @@ def test_computed_site_mask_preserves_published_and_source_targets( else: assert values["cluster"].startswith("e2e-") assert values["site_name"].startswith("e2e-") + assert values["site_name"].endswith("-opcua-off") is (opcua_mode == "Disabled") + assert len(values["site_name"]) <= 59 if published == "true": assert values["cluster"] == values["site_name"] assert result.stdout.splitlines() == [f"::add-mask::{values['site_name']}"] @@ -780,7 +785,7 @@ def test_guided_published_journey_pins_qualified_aio_version(): INPUT_RG="rg-example", ) assert result.returncode != 0 - assert "Published guided E2E currently requires aio-releases=2608" in result.stderr + assert "Published guided E2E currently requires aio-releases=2609" in result.stderr @pytest.mark.parametrize( @@ -863,6 +868,110 @@ def test_source_input_contract_keeps_existing_parallel_default(): assert "max_parallel=20" in result.stdout +@pytest.mark.parametrize(("secret_sync_modes", "tests"), [ + ("enabled", ""), + ("disabled,enabled", "aio-upgrade"), +]) +def test_source_e2e_accepts_2609_install_and_upgrade(secret_sync_modes, tests): + result = _parse_inputs( + INPUT_RELEASES="2608,2609", INPUT_UPGRADE_TO="2609", + INPUT_SECRET_SYNC_MODES=secret_sync_modes, INPUT_TESTS=tests, + ) + assert result.returncode == 0, result.stderr + outputs = _outputs(result) + assert json.loads(outputs["versions"]) == ["2608", "2609"] + assert json.loads(outputs["secret_sync_modes"]) == secret_sync_modes.split(",") + assert outputs["upgrade_to"] == "2609" + assert outputs["published_mode"] == outputs["candidate_mode"] == "false" + assert outputs["pytest_paths"] == ( + "tests/integration/test_aio_upgrade_manifest.py" if tests else "tests/integration/" + ) + for release, upgrades in (("2608", True), ("2609", False)): + context = { + "needs": {"prep": {"result": "success", "outputs": {"upgrade-to": outputs["upgrade_to"]}}}, + "matrix": {"aio-release": release}, + } + for name in ("Re-render E2E site at upgrade-to", "Run upgrade-phase integration tests"): + assert truthy(evaluate(_e2e_step(name)["if"], context)) is upgrades + + +def test_default_aio_release_is_consistent_across_site_guided_and_acceptance_routes(): + workspace = ROOT / "workspaces" / "iot-operations" + base = yaml.safe_load((workspace / "sites" / "base-site.yaml").read_text(encoding="utf-8")) + guided = yaml.safe_load((workspace / "manifests" / "aio-install" / "inputs.yaml").read_text(encoding="utf-8")) + secretsync = yaml.safe_load((workspace / "manifests" / "secretsync" / "inputs.yaml").read_text(encoding="utf-8")) + workflow = yaml.safe_load(_workflow()) + defaults = workflow[True]["workflow_dispatch"]["inputs"] + selected = [ + base["properties"]["aioRelease"], + next(item["default"] for item in guided["inputs"] if item["name"] == "aioRelease"), + secretsync["siteDefaults"]["properties"]["aioRelease"], + defaults["aio-releases"]["default"], + ] + assert selected == ["2609"] * len(selected) + assert json.loads(_outputs(_parse_inputs())["versions"]) == ["2609"] + accepted = _parse_inputs(INPUT_SCENARIO="release-acceptance", INPUT_CANDIDATE_SET="true") + assert accepted.returncode == 0, accepted.stderr + assert json.loads(_outputs(accepted)["versions"]) == ["2609"] + guided_run = _parse_inputs( + INPUT_PUBLISHED_RELEASE="v1.0.0b7", INPUT_PUBLISHED_SOURCE_SHA="a" * 40, + INPUT_PUBLISHED_JOURNEY="guided", INPUT_RG="rg-example", INPUT_TESTS="aio-install", + ) + assert guided_run.returncode == 0, guided_run.stderr + assert json.loads(_outputs(guided_run)["versions"]) == ["2609"] + + +def test_source_e2e_can_cover_both_opcua_modes_with_secretsync_and_upgrade(): + result = _parse_inputs( + INPUT_RELEASES="2608,2609", INPUT_UPGRADE_TO="2609", INPUT_OPCUA_MODES="Stable,Disabled", + INPUT_TESTS="aio-install,enable-secretsync,sync-secrets,aio-upgrade", + ) + assert result.returncode == 0, result.stderr + outputs = _outputs(result) + assert json.loads(outputs["opcua_modes"]) == ["Stable", "Disabled"] + assert json.loads(outputs["versions"]) == ["2608", "2609"] + assert outputs["upgrade_to"] == "2609" + assert json.loads(outputs["secret_sync_modes"]) == ["enabled"] + workflow = yaml.safe_load(_workflow()) + assert workflow["jobs"]["e2e"]["strategy"]["matrix"]["opcua-mode"] == ( + "${{ fromJSON(needs.prep.outputs.opcua-modes) }}" + ) + assert workflow["jobs"]["e2e"]["env"]["E2E_OPCUA_MODE"] == "${{ matrix.opcua-mode }}" + job = workflow["jobs"]["e2e"] + for mode in ("Stable", "Disabled"): + context = { + "needs": {"prep": {"outputs": {"candidate-mode": "false", "upgrade-to": "2609"}}}, + "matrix": {"aio-release": "2608", "secret-sync-mode": "enabled", "opcua-mode": mode}, + } + name = evaluate(job["name"], context) + assert name == f"e2e (2608, enabled{', OPC UA Disabled' if mode == 'Disabled' else ''})" + artifacts = [ + step["with"]["name"] for step in job["steps"] + if step.get("with", {}).get("name", "").startswith("e2e-results-") + ] + assert len(artifacts) == 2 + rendered = [ + re.sub(r"\$\{\{(.*?)\}\}", lambda match: str(evaluate(match[1], context)), name) + for name in artifacts + ] + assert all(name.endswith(f"-opcua-{mode}") for name in rendered) + + +@pytest.mark.parametrize(("changes", "message"), [ + ({"INPUT_OPCUA_MODES": "Preview"}, "opcua-modes must select"), + ({"INPUT_OPCUA_MODES": "Disabled"}, "Disabled OPC UA requires explicit tests"), + ({"INPUT_OPCUA_MODES": "Disabled", "INPUT_TESTS": "opc-ua-solution"}, "Disabled OPC UA requires explicit tests"), + ({"INPUT_OPCUA_MODES": "Stable,Disabled", "INPUT_TESTS": "aio-install", + "INPUT_CLUSTER": "arc-fixed"}, "cluster-name cannot be combined"), + ({"INPUT_OPCUA_MODES": "Disabled", "INPUT_CANDIDATE_SET": "true"}, + "Candidate acceptance selects its own Site cases"), +]) +def test_opcua_matrix_rejects_invalid_or_incompatible_source_scope(changes, message): + result = _parse_inputs(**changes) + assert result.returncode != 0 + assert message in result.stderr + + def test_published_engine_is_authenticated_and_cannot_come_from_checkout(): action = _action() @@ -1724,6 +1833,25 @@ def test_published_readiness_is_bounded_and_existing_teardown_is_retained(): assert value in workflow +def test_2609_qualification_documents_a_second_full_stable_dispatch(): + guide = (ROOT / "docs" / "e2e-testing.md").read_text(encoding="utf-8") + dispatches = [] + for block in re.findall(r"```bash\n(.*?)```", guide, re.DOTALL): + tokens = shlex.split(block.replace("\\\n", " ")) + if tokens[:4] != ["gh", "workflow", "run", "e2e-test.yaml"]: + continue + fields = dict(tokens[index + 1].split("=", 1) for index, token in enumerate(tokens) if token == "-f") + if fields.get("aio-releases") == "2609" and fields.get("opcua-modes") == "Stable": + dispatches.append(fields) + assert len(dispatches) == 1 + fields = dispatches[0] + assert fields.get("tests", "") == "" + assert not fields.get("published-release") and not fields.get("candidate") + result = _parse_inputs(INPUT_RELEASES=fields["aio-releases"], INPUT_OPCUA_MODES=fields["opcua-modes"]) + assert result.returncode == 0, result.stderr + assert _outputs(result)["pytest_paths"] == "tests/integration/" + + def test_published_guide_explains_private_persistent_cleanup_diagnostics(): guide = " ".join( (ROOT / "docs" / "e2e-testing.md").read_text(encoding="utf-8").lower().split() @@ -2275,7 +2403,7 @@ def test_candidate_request_selects_three_parallel_guided_cases(scenario): )} == { "candidate_mode": "true", "secret_sync_modes": '["disabled", "enabled", "existing"]', "max_parallel": "3", "published_mode": "true", "published_journey": "guided", "persistent": "false", - "versions": '["2608"]', "published_release": "", "rg_key": "ephemeral-42", + "versions": '["2609"]', "published_release": "", "rg_key": "ephemeral-42", } assert _outputs(_parse_inputs())["candidate_mode"] == "false" refused = _parse_inputs(INPUT_SCENARIO="release-acceptance") diff --git a/tests/test_render_e2e_site.py b/tests/test_render_e2e_site.py index da2da460..78684139 100644 --- a/tests/test_render_e2e_site.py +++ b/tests/test_render_e2e_site.py @@ -46,6 +46,7 @@ def _load_render_module(): "E2E_SUBSCRIPTION": "00000000-0000-0000-0000-000000000000", "E2E_LOCATION": "eastus2", "E2E_ENABLE_SECRET_SYNC": "true", + "E2E_OPCUA_MODE": "Stable", } @@ -148,6 +149,11 @@ def test_secret_sync_mode_rejects_invalid_value(self): with pytest.raises(RuntimeError, match="must be `true` or `false`"): render_e2e_site.compute_defaults(values) + def test_opcua_mode_defaults_to_stable_and_rejects_invalid_values(self): + assert render_e2e_site.compute_defaults(FULL_ENV)["E2E_OPCUA_MODE"] == "Stable" + with pytest.raises(RuntimeError, match="E2E_OPCUA_MODE"): + render_e2e_site.compute_defaults({**FULL_ENV, "E2E_OPCUA_MODE": "enabled"}) + class TestRender: def _make_template(self, tmp_path: Path, body: str) -> Path: @@ -228,6 +234,28 @@ def test_secret_sync_disabled_renders_boolean_false(self): doc = yaml.safe_load(out) assert doc["properties"]["deployOptions"]["enableSecretSync"] is False + @pytest.mark.parametrize("mode", [None, "Stable", "Disabled"]) + def test_opcua_mode_reaches_the_resolved_site(self, tmp_path, mode): + root = Path(__file__).parent.parent + template = root / "tests" / "e2e" / "sites" / "e2e-test.yaml.tmpl" + values = dict(FULL_ENV) + values.pop("E2E_OPCUA_MODE") + if mode is not None: + values["E2E_OPCUA_MODE"] = mode + rendered = render_e2e_site.render(template, values) + document = yaml.safe_load(rendered) + (tmp_path / "site.yaml").write_text(rendered, encoding="utf-8") + site = Orchestrator(root / "workspaces" / "iot-operations", extra_trusted_sites_dirs=[tmp_path]).load_site( + FULL_ENV["E2E_SITE_NAME"], + ) + if mode == "Disabled": + expected = {"opcua": {"mode": "Disabled", "settings": {}}} + assert document["parameters"]["features"] == expected + assert site.parameters["features"] == expected + else: + assert "features" not in document["parameters"] + assert "features" not in site.parameters + def test_published_template_is_a_self_contained_operator_site(self, tmp_path): template = ( Path(__file__).parent.parent @@ -241,6 +269,7 @@ def test_published_template_is_a_self_contained_operator_site(self, tmp_path): document = yaml.safe_load(rendered) assert "inherits" not in document + assert "features" not in document["parameters"] assert document["properties"]["deployOptions"] == { "enableGlobalSite": False, "enableEdgeSite": False, diff --git a/tests/workspace/test_aio_2608_contracts.py b/tests/workspace/test_aio_2608_contracts.py index ba5133ed..e1f79239 100644 --- a/tests/workspace/test_aio_2608_contracts.py +++ b/tests/workspace/test_aio_2608_contracts.py @@ -1,7 +1,8 @@ -"""Workspace contracts for the AIO 2608 release.""" +"""Workspace contracts for the AIO 2608 and 2609 releases.""" from pathlib import Path +import pytest import yaml @@ -14,17 +15,25 @@ def _release(workspace: Path, release: str) -> dict: return yaml.safe_load(path.read_text(encoding="utf-8")) -def test_2608_release_metadata_matches_the_public_matrix(workspace): - release = _release(workspace, "2608") +@pytest.mark.parametrize(("release_name", "aio", "connector", "cert_manager", "secret_store"), [ + ("2608", "1.4.73", "1.4.10", "1.0.0", "1.5.2"), + ("2609", "1.4.112", "1.4.14", "1.1.2", "1.5.3"), +]) +def test_release_metadata_matches_the_public_release(workspace, release_name, aio, connector, cert_manager, secret_store): + release = _release(workspace, release_name) - assert release["aioVersion"] == "1.4.73" + assert release["aioVersion"] == aio assert release["aioTrain"] == "stable" assert release["aioApiVersion"] == "2026-07-01" assert release["adrApiVersion"] == "2026-04-01" - assert release["certManagerVersion"] == "1.0.0" + assert release["certManagerVersion"] == cert_manager assert release["certManagerTrain"] == "stable" - assert release["secretStoreVersion"] == "1.5.2" + assert release["secretStoreVersion"] == secret_store assert release["secretStoreTrain"] == "stable" + assert release["aioReleaseConfiguration"] == { + "extension": {"wasmGraphControllerMqttTrust": True}, + "resources": {"opcUaConnector": {"version": connector}}, + } def test_2608_enables_only_its_release_specific_aio_settings(workspace): @@ -70,8 +79,9 @@ def test_connector_template_module_is_shared_by_install_and_upgrade(workspace): assert module_path in create_module assert module_path in upgrade_module assert module_path not in update_extensions - assert "if (!empty(opcuaConnectorVersion))" in create_module - assert "if (!empty(opcuaConnectorVersion))" in upgrade_module + condition = "if (!empty(opcuaConnectorVersion) && opcUaFeatureMode != 'Disabled')" + assert condition in create_module + assert condition in upgrade_module assert "uniqueString(aioInstance.name)" in create_module assert "uniqueString(aioInstanceName)" in upgrade_module assert "aioApiVersion == '2026-07-01'" in upgrade_dispatcher @@ -242,8 +252,9 @@ def test_2608_wasm_mqtt_trust_flows_through_install_and_upgrade(workspace): assert "wasmGraphControllerMqttTrust" in update_extensions -def test_2608_cert_manager_defaults_match_the_release(workspace): - release = _release(workspace, "2608") +@pytest.mark.parametrize("release_name", ["2608", "2609"]) +def test_cert_manager_defaults_match_the_release(workspace, release_name): + release = _release(workspace, release_name) assert release["certManagerConfigurationOverrides"] == { "trust-manager.secretTargets.enabled": "false", diff --git a/tests/workspace/test_aio_release_behavior_contracts.py b/tests/workspace/test_aio_release_behavior_contracts.py index 69a5b359..5f77b466 100644 --- a/tests/workspace/test_aio_release_behavior_contracts.py +++ b/tests/workspace/test_aio_release_behavior_contracts.py @@ -6,6 +6,7 @@ from pathlib import Path from unittest.mock import MagicMock +import pytest import yaml from siteops.compilation import TemplateCompilationSession @@ -18,7 +19,9 @@ PlanIntent, resolve_plan_value, ) +from tests.actions_expressions import parse from tests.test_orchestrator_planning import _arm_template, _RecordingToolRunner +from tests.workspace.conftest import az_path def _read(workspace: Path, relative_path: str) -> str: @@ -30,6 +33,128 @@ def _release(workspace: Path, release: str) -> dict: return yaml.safe_load(path.read_text(encoding="utf-8")) +def _arm_value(value, template, parameters): + """Evaluate only the ARM functions used by the connector deployment guard.""" + if not isinstance(value, str) or not value.startswith("["): + return value + + def try_get(value, *keys): + for key in keys: + value = value.get(key) if isinstance(value, dict) else None + return value + + def parameter(name): + if name in parameters: + return parameters[name] + declaration = template["parameters"][name] + if "defaultValue" in declaration: + return declaration["defaultValue"] + assert declaration.get("nullable") is True, f"Missing required parameter: {name}" + return None + + def evaluate(node): + if node.kind == "literal": + return node.value + if node.kind == "index": + container, key = (evaluate(child) for child in node.children) + return container[key] + assert node.kind == "call", node + args = [evaluate(child) for child in node.children] + functions = { + "parameters": parameter, + "variables": lambda name: _arm_value(template["variables"][name], template, parameters), + "tryget": try_get, + "coalesce": lambda *values: next((item for item in values if item is not None), None), + "createobject": dict, + "string": str, + "empty": lambda item: not item, + "not": lambda item: not item, + "and": lambda *items: all(items), + "equals": lambda left, right: left == right, + } + assert node.value in functions, f"Unsupported ARM guard function: {node.value}" + return functions[node.value](*args) + + return evaluate(parse(value[1:-1])) + + +def _resources(template): + resources = template.get("resources", {}) + return list(resources.values()) if isinstance(resources, dict) else resources + + +@pytest.fixture(scope="module", params=[ + "templates/aio/instance.bicep", + "templates/aio/upgrade/deploy-release-resources.bicep", +], ids=["install", "upgrade"]) +def connector_arm(request, workspace): + result = subprocess.run( + [az_path(), "bicep", "build", "--file", str(workspace / request.param), "--stdout"], + capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@pytest.mark.parametrize(("features", "configured", "expected"), [ + pytest.param(None, True, True, id="default"), + pytest.param({}, True, True, id="empty-features"), + pytest.param({"opcua": {"settings": {}}}, True, True, id="default-mode"), + pytest.param({"opcua": {"mode": "Stable", "settings": {}}}, True, True, id="stable"), + pytest.param({"opcua": {"mode": "Preview", "settings": {}}}, True, True, id="preview"), + pytest.param({"opcua": {"mode": "Disabled", "settings": {}}}, True, False, id="disabled"), + pytest.param({"connectors": {"mode": "Disabled", "settings": {}}}, True, False, id="legacy-disabled"), + pytest.param({ + "opcua": {"mode": "Stable", "settings": {}}, + "connectors": {"mode": "Disabled", "settings": {}}, + }, True, True, id="canonical-stable-wins"), + pytest.param({ + "opcua": {"mode": "Disabled", "settings": {}}, + "connectors": {"mode": "Stable", "settings": {}}, + }, True, False, id="canonical-disabled-wins"), + pytest.param({ + "opcua": {"settings": {}}, + "connectors": {"mode": "Disabled", "settings": {}}, + }, True, True, id="canonical-default-wins"), + pytest.param({"mqtt": {"mode": "Disabled", "settings": {}}}, True, True, id="other-disabled"), + pytest.param(None, False, False, id="no-version"), + pytest.param({"opcua": {"mode": "Stable", "settings": {}}}, False, False, id="stable-no-version"), +]) +def test_opcua_template_respects_effective_feature_mode(connector_arm, features, configured, expected): + parameters = { + "aioApiVersion": "2026-07-01", + "aioReleaseConfiguration": {"resources": {"opcUaConnector": {"version": "1.4.14"}}} + if configured else {}, + } + if features is not None: + parameters["features"] = features + connectors = [] + for module in _resources(connector_arm): + template = module.get("properties", {}).get("template", {}) + for child in _resources(template): + nested = child.get("properties", {}).get("template", {}) + if any(resource["type"] == "Microsoft.IoTOperations/instances/akriConnectorTemplates" + for resource in _resources(nested)): + connectors.append((module, template, child)) + assert len(connectors) == 1, "Expected one typed OPC UA connector deployment" + module, template, connector = connectors[0] + assert _arm_value(module["condition"], connector_arm, parameters) is True + forwarded = module["properties"]["parameters"] + child_parameters = { + name: _arm_value(forwarded[name]["value"], connector_arm, parameters) + for name in ("features", "aioReleaseConfiguration") if name in forwarded + } + assert _arm_value(connector.get("condition", True), template, child_parameters) is expected + + +def test_upgrade_release_resources_use_resolved_instance_features(workspace): + manifest = yaml.safe_load(_read(workspace, "manifests/aio-upgrade/manifest.yaml")) + step = next(step for step in manifest["steps"] if step.get("name") == "deploy-release-resources") + assert "parameters/inputs/aio-upgrade-deploy-release-resources.yaml" in step["parameters"] + inputs = yaml.safe_load(_read(workspace, "parameters/inputs/aio-upgrade-deploy-release-resources.yaml")) + assert inputs["features"] == "{{ steps.resolve-aio.outputs.features }}" + + def test_aio_install_forwards_site_extension_settings_over_release_values(workspace, tmp_path, monkeypatch): overrides = { "aioConfigurationOverrides": {"example.setting": "site-aio"}, @@ -141,7 +266,7 @@ def test_security_pki_defaults_follow_released_aio_behavior(workspace): for release in ("2605", "2606"): extension = _release(workspace, release)["aioReleaseConfiguration"]["extension"] assert extension["securityPkiSubjectName"] is True - for release in ("2607", "2608"): + for release in ("2607", "2608", "2609"): extension = ( _release(workspace, release)["aioReleaseConfiguration"].get("extension", {}) ) diff --git a/tests/workspace/test_parameter_chaining.py b/tests/workspace/test_parameter_chaining.py index 432e9f5a..4f45a738 100644 --- a/tests/workspace/test_parameter_chaining.py +++ b/tests/workspace/test_parameter_chaining.py @@ -810,7 +810,7 @@ def test_aio_extension_name_deriver_parity(self, workspace): } AIO_RELEASE_EXTENSION_SCOPED_FIELDS = { "securityPkiSubjectName": ({2605, 2606}, bool), - "wasmGraphControllerMqttTrust": ({2608}, bool), + "wasmGraphControllerMqttTrust": ({2608, 2609}, bool), } AIO_RELEASE_RESOURCE_SCOPED_FIELDS = { "opcUaConnector": (2608, dict), diff --git a/tests/workspace/test_site_configuration.py b/tests/workspace/test_site_configuration.py index 2e7fb291..23c70d8f 100644 --- a/tests/workspace/test_site_configuration.py +++ b/tests/workspace/test_site_configuration.py @@ -5,6 +5,7 @@ from collections import defaultdict from pathlib import Path +import pytest import yaml from siteops.orchestrator import Orchestrator @@ -157,7 +158,8 @@ def test_subscription_scoped_sites_carry_scope_label(self, workspace, orchestrat f"`selector: scope=subscription`." ) - def test_e2e_fallback_inherits_resolves(self, tmp_path, workspace): + @pytest.mark.parametrize("release", [None, "2608", "2609"]) + def test_e2e_fallback_inherits_resolves(self, tmp_path, workspace, release): """A site file in an extras dir with `inherits: base-site.yaml` should resolve via the workspace fallback (this is what the e2e workflow relies on when the rendered site lives in a tmp dir).""" @@ -171,14 +173,14 @@ def test_e2e_fallback_inherits_resolves(self, tmp_path, workspace): "subscription: '00000000-0000-0000-0000-000000000000'\n" "resourceGroup: rg-fallback\n" "location: eastus\n" + + (f'properties:\n aioRelease: "{release}"\n' if release else "") ) from siteops.orchestrator import Orchestrator orch = Orchestrator(workspace, extra_trusted_sites_dirs=[tmp_path / "rendered"]) site = orch.load_site("fallback-test-site") assert site.name == "fallback-test-site" - # The base inherits should have been applied: aioRelease comes from base-site.yaml. - assert site.properties.get("aioRelease") == "2608" + assert site.properties.get("aioRelease") == (release or "2609") def test_extras_dir_overlays_workspace_site_with_same_name(self, tmp_path, workspace): """When an extras dir contains a site file with the same name as one diff --git a/workspaces/iot-operations/manifests/aio-install/entry.yaml b/workspaces/iot-operations/manifests/aio-install/entry.yaml index d1afa3a8..0f8ef02c 100644 --- a/workspaces/iot-operations/manifests/aio-install/entry.yaml +++ b/workspaces/iot-operations/manifests/aio-install/entry.yaml @@ -30,7 +30,7 @@ inputs: type: string requirement: required sensitivity: non-sensitive - description: The resolved Site needs an AIO release. The configured example inherits one, while the typed AIO route defaults to 2608. + description: The resolved Site needs an AIO release. The configured example inherits one, while the typed AIO route defaults to 2609. defaultBehavior: Review the effective AIO release in the selected Site before deployment. source: sites/base-site.yaml - field: labels diff --git a/workspaces/iot-operations/manifests/aio-install/inputs.yaml b/workspaces/iot-operations/manifests/aio-install/inputs.yaml index e0210128..4aa38a89 100644 --- a/workspaces/iot-operations/manifests/aio-install/inputs.yaml +++ b/workspaces/iot-operations/manifests/aio-install/inputs.yaml @@ -81,7 +81,7 @@ inputs: type: string description: AIO release configuration selected by this workspace. sitePath: properties.aioRelease - default: "2608" + default: "2609" - name: enableCertManager type: boolean description: Install the cert-manager Arc extension. diff --git a/workspaces/iot-operations/manifests/secretsync/README.md b/workspaces/iot-operations/manifests/secretsync/README.md index 99f6b66b..827644e3 100644 --- a/workspaces/iot-operations/manifests/secretsync/README.md +++ b/workspaces/iot-operations/manifests/secretsync/README.md @@ -8,7 +8,7 @@ by that manifest's Site capability flag. ## Use an existing instance ID With an approved source named `official` that you enrolled independently, select the release -from its instructions and supply the existing AIO 2607 or 2608 instance ID: +from its instructions and supply the existing AIO 2607, 2608 or 2609 instance ID: ```text siteops deploy secretsync --source "official@" --input "instance=" @@ -19,7 +19,7 @@ within the same subscription and resource group. They verify existing OIDC and workload identity before deployment. The operation uses the actual instance name and the API shared by those releases. It does not reinstall AIO, change extension versions or guess the installed AIO release. -The `2608` content profile supplies the pinned API configuration only. +The `2609` content profile supplies the pinned API configuration only. Deploy displays the prepared plan and asks for confirmation before executing it. Use `--yes` for unattended execution, or `plan` for a separate preview. diff --git a/workspaces/iot-operations/manifests/secretsync/entry.yaml b/workspaces/iot-operations/manifests/secretsync/entry.yaml index 382233ab..85bd6a2c 100644 --- a/workspaces/iot-operations/manifests/secretsync/entry.yaml +++ b/workspaces/iot-operations/manifests/secretsync/entry.yaml @@ -7,7 +7,7 @@ documentation: [README.md] outcome: Enable Secret Sync, backed by workload identity, on an existing AIO instance. prerequisites: - An existing AIO instance and cluster with OIDC issuer and workload identity enabled. - - An instance resource ID for the guided 2607/2608 route, or a configured Site with the actual instance name and appropriate AIO release selection. + - An instance resource ID for the guided 2607/2608/2609 route, or a configured Site with the actual instance name and appropriate AIO release selection. - Permissions for identities, vault resources and role assignments as applicable. effects: - Can create or configure identity, vault, federation, permissions and Secret Provider Class resources, and update the instance. diff --git a/workspaces/iot-operations/manifests/secretsync/inputs.yaml b/workspaces/iot-operations/manifests/secretsync/inputs.yaml index b762ee39..6a3c084d 100644 --- a/workspaces/iot-operations/manifests/secretsync/inputs.yaml +++ b/workspaces/iot-operations/manifests/secretsync/inputs.yaml @@ -4,11 +4,11 @@ nameFromResource: cluster siteDefaults: properties: # Selects the shared API profile, not an observed or requested extension version. - aioRelease: "2608" + aioRelease: "2609" inputs: - name: instance type: azureResourceId - description: Existing AIO instance resource ID for the API used by releases 2607 and 2608. + description: Existing AIO instance resource ID for the API used by releases 2607, 2608 and 2609. resource: type: Microsoft.IoTOperations/instances apiVersion: "2026-07-01" diff --git a/workspaces/iot-operations/parameters/aio-releases/2609.yaml b/workspaces/iot-operations/parameters/aio-releases/2609.yaml new file mode 100644 index 00000000..e935726f --- /dev/null +++ b/workspaces/iot-operations/parameters/aio-releases/2609.yaml @@ -0,0 +1,21 @@ +# AIO release 2609: version configuration +# Source: https://github.com/Azure/azure-iot-ops-cli-extension/blob/v2.10.0/azext_edge/edge/providers/orchestration/template.py +# API versions: https://github.com/Azure/azure-iot-ops-cli-extension/blob/v2.10.0/azext_edge/edge/util/az_client.py + +aioVersion: "1.4.112" +aioTrain: stable +aioApiVersion: "2026-07-01" +adrApiVersion: "2026-04-01" +aioReleaseConfiguration: + extension: + wasmGraphControllerMqttTrust: true + resources: + opcUaConnector: + version: "1.4.14" +certManagerVersion: "1.1.2" +certManagerTrain: stable +certManagerConfigurationOverrides: + trust-manager.secretTargets.enabled: "false" + trust-manager.secretTargets.authorizedSecretsAll: "false" +secretStoreVersion: "1.5.3" +secretStoreTrain: stable diff --git a/workspaces/iot-operations/parameters/inputs/aio-upgrade-deploy-release-resources.yaml b/workspaces/iot-operations/parameters/inputs/aio-upgrade-deploy-release-resources.yaml index 4fc4711c..ce06b021 100644 --- a/workspaces/iot-operations/parameters/inputs/aio-upgrade-deploy-release-resources.yaml +++ b/workspaces/iot-operations/parameters/inputs/aio-upgrade-deploy-release-resources.yaml @@ -3,3 +3,4 @@ # aioInstanceName comes from the common parameter file attached at manifest level. customLocationId: "{{ steps.resolve-aio.outputs.customLocationId }}" +features: "{{ steps.resolve-aio.outputs.features }}" diff --git a/workspaces/iot-operations/samples/aio-with-opc-ua/README.md b/workspaces/iot-operations/samples/aio-with-opc-ua/README.md index 74312768..60adc635 100644 --- a/workspaces/iot-operations/samples/aio-with-opc-ua/README.md +++ b/workspaces/iot-operations/samples/aio-with-opc-ua/README.md @@ -38,7 +38,7 @@ Telemetry lags the deploy. The OPC UA connector reconciles the asset, establishes its session, and warms up polling before the first message reaches the broker, after which the dataflow forwards it to Event Hub. -AIO release `2608`, which Sites inherit by default, deploys the template and +AIO release `2609`, which Sites inherit by default, deploys the template and creates the connector pod on demand. AIO releases before 2607 deploy the connector statically. AIO release `2607` has the documented limitation of a missing connector template. See diff --git a/workspaces/iot-operations/samples/opc-ua-solution/README.md b/workspaces/iot-operations/samples/opc-ua-solution/README.md index 7183bca4..6e0802ae 100644 --- a/workspaces/iot-operations/samples/opc-ua-solution/README.md +++ b/workspaces/iot-operations/samples/opc-ua-solution/README.md @@ -16,7 +16,7 @@ Once running, the OPC UA connector polls the simulator over `opc.tcp://opcplc-00 The sample creates its device, asset, Event Hub, and dataflow on every AIO release. -AIO release `2608`, which Sites inherit by default, deploys the connector template +AIO release `2609`, which Sites inherit by default, deploys the connector template during both install and upgrade. The supervisor then creates the connector pod on demand, and the complete telemetry path described above is available. diff --git a/workspaces/iot-operations/samples/resource-set-composition/README.md b/workspaces/iot-operations/samples/resource-set-composition/README.md index 8a0b2b60..a61b30c7 100644 --- a/workspaces/iot-operations/samples/resource-set-composition/README.md +++ b/workspaces/iot-operations/samples/resource-set-composition/README.md @@ -39,7 +39,7 @@ The boiler telemetry nodes follow the OPC PLC boiler simulation used by ## Prerequisites - AIO is installed on the cluster. The committed sample Site uses - AIO release `2608`. + AIO release `2609`. - cert-manager is enabled because the simulator creates a local certificate. - Arc Cluster Connect is enabled. - The deployment identity has Kubernetes permission to create Deployments, diff --git a/workspaces/iot-operations/siteops-index.inputs.json b/workspaces/iot-operations/siteops-index.inputs.json index 2b2c2e4f..d76c187b 100644 --- a/workspaces/iot-operations/siteops-index.inputs.json +++ b/workspaces/iot-operations/siteops-index.inputs.json @@ -27,7 +27,7 @@ "templates/host-bootstrap/aksee/_partial.yaml", "templates/host-ops/aksee-upgrade/_partial.yaml" ], - "indexDigest": "20ea1b5078ffe60e7cb4066234c9c9defe4c227c5aca2a307c41ddcfa0d916e4", + "indexDigest": "aa7c3c088e72964ffca84084b2f5f781cfd6eab80ae456bbf4ea3339726ec121", "inputs": [ { "digests": { @@ -135,9 +135,9 @@ }, { "digests": { - "git-blob-sha1": "d1afa3a834d3d1afcb01b129127bbea5eee3061d", - "git-blob-sha1-crlf": "8209e67733b6e8122203f3d56f15a0cff0b40c9f", - "sha256": "161370c3e51f9962ca1215780c4112faa66fbc698f90077d00c9b2057bb8a49e" + "git-blob-sha1": "0f8ef02c8ef00c477abab4830bef344330f057fc", + "git-blob-sha1-crlf": "67e5b7269a9058b5e9503da229c86047f70fb52c", + "sha256": "0ac0d0b497c55b98cde6034c029e10a3ea0eae884024d682ca2c4ab4ac4ece46" }, "path": "manifests/aio-install/entry.yaml" }, @@ -215,9 +215,9 @@ }, { "digests": { - "git-blob-sha1": "382233ab8814a4148b0b22a3cbd693cc8d81af2f", - "git-blob-sha1-crlf": "18fa26693470a59da065f6c77073d419eba501fa", - "sha256": "90d6d366d5831c591da972ec4b6e8712722575a7dfb13f6508f9074b1ea1da88" + "git-blob-sha1": "85bd6a2cef877f1210a75de86aaefbfbbeca6952", + "git-blob-sha1-crlf": "3bf1007c3c435b299b6b7ef93b8d182a1d6a0939", + "sha256": "2493d973c1338b122f4d18fd916931d048b4c4ec9c5bb9f4e1d6854d12be4f13" }, "path": "manifests/secretsync/entry.yaml" }, diff --git a/workspaces/iot-operations/siteops-index.json b/workspaces/iot-operations/siteops-index.json index a29ac68a..48c030db 100644 --- a/workspaces/iot-operations/siteops-index.json +++ b/workspaces/iot-operations/siteops-index.json @@ -136,7 +136,7 @@ }, { "defaultBehavior": "Review the effective AIO release in the selected Site before deployment.", - "description": "The resolved Site needs an AIO release. The configured example inherits one, while the typed AIO route defaults to 2608.", + "description": "The resolved Site needs an AIO release. The configured example inherits one, while the typed AIO route defaults to 2609.", "field": "properties.aioRelease", "requirement": "required", "sensitivity": "non-sensitive", @@ -357,7 +357,7 @@ "outcome": "Enable Secret Sync, backed by workload identity, on an existing AIO instance.", "prerequisites": [ "An existing AIO instance and cluster with OIDC issuer and workload identity enabled.", - "An instance resource ID for the guided 2607/2608 route, or a configured Site with the actual instance name and appropriate AIO release selection.", + "An instance resource ID for the guided 2607/2608/2609 route, or a configured Site with the actual instance name and appropriate AIO release selection.", "Permissions for identities, vault resources and role assignments as applicable." ], "removal": [ diff --git a/workspaces/iot-operations/sites/base-site.yaml b/workspaces/iot-operations/sites/base-site.yaml index 67e21d2a..649c63cf 100644 --- a/workspaces/iot-operations/sites/base-site.yaml +++ b/workspaces/iot-operations/sites/base-site.yaml @@ -11,7 +11,7 @@ labels: properties: # AIO release, which determines extension versions # See: parameters/aio-releases/ for available AIO releases - aioRelease: "2608" + aioRelease: "2609" # Deployment options: control which components are deployed deployOptions: diff --git a/workspaces/iot-operations/templates/aio/modules/instance-2026-07-01.bicep b/workspaces/iot-operations/templates/aio/modules/instance-2026-07-01.bicep index b31d0b24..a982fda8 100644 --- a/workspaces/iot-operations/templates/aio/modules/instance-2026-07-01.bicep +++ b/workspaces/iot-operations/templates/aio/modules/instance-2026-07-01.bicep @@ -97,6 +97,9 @@ var releaseResourceConfiguration = aioReleaseConfiguration.?resources ?? {} var opcUaConnectorConfiguration = releaseResourceConfiguration.?opcUaConnector ?? {} var releaseAioConfigurationOverrides = releaseExtensionConfiguration.?configurationOverrides ?? {} var opcuaConnectorVersion = string(opcUaConnectorConfiguration.?version ?? '') +// Honor the legacy connectors alias. The canonical opcua feature takes precedence. +var opcUaFeature = features.?opcua ?? features.?connectors ?? {} +var opcUaFeatureMode = opcUaFeature.?mode ?? 'Stable' var configureWasmGraphControllerMqttTrust = bool(releaseExtensionConfiguration.?wasmGraphControllerMqttTrust ?? false) var releaseDerivedAioConfiguration = configureWasmGraphControllerMqttTrust ? { @@ -333,7 +336,7 @@ resource artifactRegistryEndpoint 'Microsoft.IoTOperations/instances/registryEnd } } -module opcUaConnectorTemplate './opcua-connector-template-2026-07-01.bicep' = if (!empty(opcuaConnectorVersion)) { +module opcUaConnectorTemplate './opcua-connector-template-2026-07-01.bicep' = if (!empty(opcuaConnectorVersion) && opcUaFeatureMode != 'Disabled') { name: 'opcua-connector-template-${uniqueString(aioInstance.name)}' params: { aioInstanceName: aioInstance.name diff --git a/workspaces/iot-operations/templates/aio/upgrade/deploy-release-resources.bicep b/workspaces/iot-operations/templates/aio/upgrade/deploy-release-resources.bicep index 8db89e69..fccdb9eb 100644 --- a/workspaces/iot-operations/templates/aio/upgrade/deploy-release-resources.bicep +++ b/workspaces/iot-operations/templates/aio/upgrade/deploy-release-resources.bicep @@ -27,12 +27,16 @@ param aioApiVersion string @description('Release-owned AIO settings that distinguish releases sharing an ARM API generation.') param aioReleaseConfiguration object = {} +@description('Resolved features of the existing IoT Operations instance.') +param features object = {} + module releaseResources_2025 './modules/deploy-release-resources-2025-10-01.bicep' = if (aioApiVersion == '2025-10-01') { name: 'deploy-release-resources-2025-10-${uniqueString(aioInstanceName)}' params: { aioInstanceName: aioInstanceName aioReleaseConfiguration: aioReleaseConfiguration customLocationId: customLocationId + features: features } } @@ -42,6 +46,7 @@ module releaseResources_2026_03 './modules/deploy-release-resources-2026-03-01.b aioInstanceName: aioInstanceName aioReleaseConfiguration: aioReleaseConfiguration customLocationId: customLocationId + features: features } } @@ -51,5 +56,6 @@ module releaseResources_2026_07 './modules/deploy-release-resources-2026-07-01.b aioInstanceName: aioInstanceName aioReleaseConfiguration: aioReleaseConfiguration customLocationId: customLocationId + features: features } } diff --git a/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2025-10-01.bicep b/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2025-10-01.bicep index 98c68452..e14480a7 100644 --- a/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2025-10-01.bicep +++ b/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2025-10-01.bicep @@ -15,3 +15,7 @@ param customLocationId string @description('Release-owned AIO settings that distinguish releases sharing this ARM API generation.') #disable-next-line no-unused-params param aioReleaseConfiguration object = {} + +@description('Resolved features of the existing IoT Operations instance.') +#disable-next-line no-unused-params +param features object = {} diff --git a/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2026-03-01.bicep b/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2026-03-01.bicep index 57f0b5ca..b8c333eb 100644 --- a/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2026-03-01.bicep +++ b/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2026-03-01.bicep @@ -15,3 +15,7 @@ param customLocationId string @description('Release-owned AIO settings that distinguish releases sharing this ARM API generation.') #disable-next-line no-unused-params param aioReleaseConfiguration object = {} + +@description('Resolved features of the existing IoT Operations instance.') +#disable-next-line no-unused-params +param features object = {} diff --git a/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2026-07-01.bicep b/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2026-07-01.bicep index 9278ea40..1933a898 100644 --- a/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2026-07-01.bicep +++ b/workspaces/iot-operations/templates/aio/upgrade/modules/deploy-release-resources-2026-07-01.bicep @@ -13,13 +13,19 @@ param customLocationId string @description('Release-owned AIO settings that distinguish releases sharing this ARM API generation.') param aioReleaseConfiguration object = {} +@description('Resolved features of the existing IoT Operations instance.') +param features object = {} + // Keep resource interpretation aligned with instance-2026-07-01.bicep so // greenfield install and upgrade deploy the same release requirements. var releaseResourceConfiguration = aioReleaseConfiguration.?resources ?? {} var opcUaConnectorConfiguration = releaseResourceConfiguration.?opcUaConnector ?? {} var opcuaConnectorVersion = string(opcUaConnectorConfiguration.?version ?? '') +// Honor the legacy connectors alias. The canonical opcua feature takes precedence. +var opcUaFeature = features.?opcua ?? features.?connectors ?? {} +var opcUaFeatureMode = opcUaFeature.?mode ?? 'Stable' -module opcUaConnectorTemplate '../../modules/opcua-connector-template-2026-07-01.bicep' = if (!empty(opcuaConnectorVersion)) { +module opcUaConnectorTemplate '../../modules/opcua-connector-template-2026-07-01.bicep' = if (!empty(opcuaConnectorVersion) && opcUaFeatureMode != 'Disabled') { name: 'opcua-connector-template-${uniqueString(aioInstanceName)}' params: { aioInstanceName: aioInstanceName From e9f53b82684c584c9b87b00b89059f6cc392a0cd Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Wed, 14 Oct 2026 10:10:32 -0700 Subject: [PATCH 2/2] Pin the 2608 OPC UA connector template to 1.4.11 The AIO 1.4.73 release templates deploy the OPC UA connector template with connectors 1.4.11, which the 2608 release configuration now pins. The AIO releases guide notes that the disabled OPC UA guard covers 2608 as well as 2609, because both deploy the same connector template. --- docs/aio-releases.md | 3 ++- tests/workspace/test_aio_2608_contracts.py | 4 ++-- workspaces/iot-operations/parameters/aio-releases/2608.yaml | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/aio-releases.md b/docs/aio-releases.md index 96ebcf35..bf11a561 100644 --- a/docs/aio-releases.md +++ b/docs/aio-releases.md @@ -122,7 +122,8 @@ Source of truth for every pinned version number is the YAML itself. Check it aga 2609 uses the same AIO and ADR API versions as 2608. The [AIO CLI v2.10.0 release notes](https://github.com/Azure/azure-iot-ops-cli-extension/releases/tag/v2.10.0) record the fix that skips the OPC UA connector template when OPC UA is disabled. -Scale Kit applies that guard to both install and upgrade. +Scale Kit applies that guard to both install and upgrade, for 2608 as well as +2609, because both releases deploy the same connector template. For a configured Site, set: diff --git a/tests/workspace/test_aio_2608_contracts.py b/tests/workspace/test_aio_2608_contracts.py index e1f79239..3a1cf727 100644 --- a/tests/workspace/test_aio_2608_contracts.py +++ b/tests/workspace/test_aio_2608_contracts.py @@ -16,7 +16,7 @@ def _release(workspace: Path, release: str) -> dict: @pytest.mark.parametrize(("release_name", "aio", "connector", "cert_manager", "secret_store"), [ - ("2608", "1.4.73", "1.4.10", "1.0.0", "1.5.2"), + ("2608", "1.4.73", "1.4.11", "1.0.0", "1.5.2"), ("2609", "1.4.112", "1.4.14", "1.1.2", "1.5.3"), ]) def test_release_metadata_matches_the_public_release(workspace, release_name, aio, connector, cert_manager, secret_store): @@ -47,7 +47,7 @@ def test_2608_enables_only_its_release_specific_aio_settings(workspace): }, "resources": { "opcUaConnector": { - "version": "1.4.10", + "version": "1.4.11", }, }, } diff --git a/workspaces/iot-operations/parameters/aio-releases/2608.yaml b/workspaces/iot-operations/parameters/aio-releases/2608.yaml index 2937567d..73d9c319 100644 --- a/workspaces/iot-operations/parameters/aio-releases/2608.yaml +++ b/workspaces/iot-operations/parameters/aio-releases/2608.yaml @@ -1,5 +1,7 @@ # AIO release 2608: version configuration # Source: https://github.com/Azure/azure-iot-ops-cli-extension/wiki/IoT-Operations-versions#2608 +# OPC UA connector template version: release/azure-iot-operations-instance.bicep +# at https://github.com/Azure/azure-iot-operations/tree/v1.4.73 aioVersion: "1.4.73" aioTrain: stable @@ -10,7 +12,7 @@ aioReleaseConfiguration: wasmGraphControllerMqttTrust: true resources: opcUaConnector: - version: "1.4.10" + version: "1.4.11" certManagerVersion: "1.0.0" certManagerTrain: stable certManagerConfigurationOverrides: