Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,9 @@ omit =
azext_edge/edge/params.py
azext_edge/edge/command_map.py
# skip params and command map
azext_edge/edge/providers/rpsaas/adr/params.py
azext_edge/edge/providers/rpsaas/adr/params.py

[paths]
edge =
azext_edge/edge
*/azure-cli-extensions/azure-iot-ops/azext_edge/edge
13 changes: 11 additions & 2 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ docs/
*.zip

# tools
tools/
tools/*
!tools/integration_runner.py

# non-code files
*.md
Expand All @@ -27,6 +28,7 @@ tools/

# venv
env*/
.venv/

# pycache
**/__pycache__/
Expand All @@ -36,7 +38,14 @@ env*/
.pylintrc

# coverage
.coveragerc
.coverage*
!.coveragerc
htmlcov/

# runtime inputs/results are mounted explicitly, never baked into the image
.artifacts/
junit/
pytest.ini

# devcontainer
.devcontainer/
Expand Down
16 changes: 16 additions & 0 deletions .github/actions/build-int-test-matrix/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ inputs:
description: Comma-separated list of scenarios to run
required: false
default: ''
runtime-channels:
description: Comma-separated runtime channels (stable,preview)
default: stable,preview
upgrade-baselines:
description: JSON object of pinned baseline wheels and runtime identities, keyed by channel
default: '{}'
runtime-init-args:
description: Additional shared foundation arguments
default: ''
runtime-create-args:
description: Additional runtime arguments, excluding channel/version/train selection
default: ''

outputs:
matrix:
Expand All @@ -32,5 +44,9 @@ runs:
env:
TEST_SCENARIO_FILE: ${{ inputs.config-file }}
TEST_SCENARIOS: ${{ inputs.test-scenarios }}
RUNTIME_CHANNELS: ${{ inputs.runtime-channels }}
UPGRADE_BASELINES: ${{ inputs.upgrade-baselines }}
RUNTIME_INIT_ARGS: ${{ inputs.runtime-init-args }}
RUNTIME_CREATE_ARGS: ${{ inputs.runtime-create-args }}
run: |
python "${{ github.action_path }}/build_matrix.py"
75 changes: 70 additions & 5 deletions .github/actions/build-int-test-matrix/build_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
# ----------------------------------------------------------------------------------------------
import os
import sys
from contextlib import nullcontext
import json
import re
import shlex
from copy import deepcopy
from json import dumps
from urllib.parse import urlsplit

from yaml import safe_load

Expand All @@ -21,6 +25,7 @@
"test_redeploy", # Whether to test redeployment in the scenario
"env", # Custom environment variable dict for the scenario
"parallel", # Controls parallel execution in pytest-xdist
"requires_baseline", # Opt-in real upgrade, rather than same-version reconciliation
}


Expand Down Expand Up @@ -55,6 +60,8 @@ def process_scenarios(scenarios: list[dict], user_selected: str) -> list[dict]:
# If user provided test selection, only include matching scenarios
if selected_scenarios and name not in selected_scenarios:
continue
if not selected_scenarios and scenario.get("requires_baseline"):
continue

# Warn if unknown fields present
unknown = set(scenario.keys()) - KNOWN_FIELDS
Expand Down Expand Up @@ -83,13 +90,63 @@ def process_scenarios(scenarios: list[dict], user_selected: str) -> list[dict]:
"test_redeploy": bool(scenario.get("test_redeploy", False)),
"env": formatted_env,
"parallel": bool(scenario.get("parallel", True)),
"requires_baseline": bool(scenario.get("requires_baseline", False)),
}

processed_scenarios.append(normalized)

return processed_scenarios


def validate_baseline(baseline: dict) -> None:
required = {"wheel_url", "sha256", "version", "train", "create_args"}
if not isinstance(baseline, dict) or set(baseline) != required:
raise ValueError(f"Upgrade baseline must contain exactly: {', '.join(sorted(required))}.")
if any(not isinstance(value, str) for value in baseline.values()):
raise ValueError("Upgrade baseline values must be strings.")
url = urlsplit(baseline["wheel_url"])
if url.scheme != "https" or not url.hostname or url.username or url.password or url.query or url.fragment:
raise ValueError("Baseline wheel URL must be credential-free HTTPS (no query string or fragment).")
if not url.path.endswith(".whl") or not re.fullmatch(r"[a-fA-F0-9]{64}", baseline["sha256"]):
raise ValueError("Baseline must identify a wheel and its SHA256 digest.")
if not baseline["version"] or baseline["train"] not in {"stable", "preview", "integration"}:
raise ValueError("Baseline requires an explicit version and deployment train.")


def expand_channels(scenarios, channels="stable,preview", baselines=None, init_args="", create_args=""):
"""Each row is an independent cluster; never convert an existing runtime's channel."""
selected = list(dict.fromkeys(channel.strip() for channel in channels.split(",") if channel.strip()))
if not selected or set(selected) - {"stable", "preview"}:
raise ValueError("runtime-channels must contain stable and/or preview.")
baselines = baselines or {}
if not isinstance(baselines, dict) or set(baselines) - {"stable", "preview"}:
raise ValueError("upgrade-baselines must be an object keyed by stable and/or preview.")
rows = []
for scenario in scenarios:
# The channel matrix owns these flags. Arbitrary overrides would make a
# labelled GA job silently deploy preview, or test a different target.
for arguments in (scenario["init_args"], scenario["create_args"], init_args, create_args):
options = {arg.split("=", 1)[0] for arg in shlex.split(arguments)}
if options & {"--use-preview", "--ops-version", "--ops-train"}:
raise ValueError("Runtime selection belongs to runtime-channels / upgrade-baselines, not extra args.")
for channel in selected:
row = deepcopy(scenario)
row["name"] = f"{scenario['name']}-{channel}"
row["description"] = f"{scenario['description']} [{channel}]"
row["channel"] = channel
row["init_args"] = f"{scenario['init_args']} {init_args}".strip()
selector = "--use-preview" if channel == "preview" else ""
row["create_args"] = f"{scenario['create_args']} {create_args} {selector}".strip()
row["baseline"] = None
if row["requires_baseline"]:
if channel not in baselines:
raise ValueError(f"Scenario {scenario['name']} requires an explicit {channel} upgrade baseline.")
validate_baseline(baselines[channel])
row["baseline"] = baselines[channel]
rows.append(row)
return rows


def main() -> None:
"""
Helper utility to build test scenario matrix for GitHub Actions.
Expand All @@ -107,7 +164,13 @@ def main() -> None:
config_scenarios = config.get("scenarios", [])

# Filter based on user input
test_scenarios = process_scenarios(config_scenarios, custom_scenarios)
test_scenarios = expand_channels(
process_scenarios(config_scenarios, custom_scenarios),
channels=os.getenv("RUNTIME_CHANNELS", "stable,preview"),
baselines=json.loads(os.getenv("UPGRADE_BASELINES", "{}")),
init_args=os.getenv("RUNTIME_INIT_ARGS", ""),
create_args=os.getenv("RUNTIME_CREATE_ARGS", ""),
)

# Exit if no valid scenarios
if not test_scenarios:
Expand All @@ -123,9 +186,11 @@ def main() -> None:

# Write to github action output or stdout
output_path = os.environ.get("GITHUB_OUTPUT")
ctx = open(output_path, "a", encoding="utf-8") if output_path else nullcontext(sys.stdout)
with ctx as out:
out.write(f"scenarios={matrix_json}\n")
if output_path:
with open(output_path, "a", encoding="utf-8") as out:
out.write(f"scenarios={matrix_json}\n")
else:
sys.stdout.write(f"scenarios={matrix_json}\n")


if __name__ == "__main__":
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/setup-cluster-resources/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ runs:
echo "SCHEMA_REGISTRY_ID=$registry_id" >> $GITHUB_OUTPUT
echo "Schema registry created: $registry_id"

# ADR namespace - limit name to 30 chars
adr_namespace_name=$(echo "init-adr-ns-${{ inputs.suffix }}" | cut -c 1-30)
# ADR namespace - limit name to 30 chars without leaving an invalid trailing hyphen
adr_namespace_name=$(echo "init-adr-ns-${{ inputs.suffix }}" | cut -c 1-30 | sed 's/-*$//')
echo "Creating ADR namespace: $adr_namespace_name"

adr_namespace_id=$(az iot ops ns create \
Expand Down
55 changes: 51 additions & 4 deletions .github/skills/sync-aio-bicep-templates/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
---
name: sync-aio-bicep-templates
description: Generate and safely integrate updated template.py blueprints from the azure-iot-operations-tests deployment repository and an explicit release ref.
description: Generate and safely integrate GA or preview AIO runtime templates from azure-iot-operations-tests at explicit source refs, excluding GDS Manager from both profiles. Use for template sync, adding a separate preview blueprint, or refreshing release inputs without changing the other runtime profile or shared init dependencies.
---

# Synchronize AIO Bicep templates

Use this workflow when a teammate asks to generate `template.py` for a new AIO release.
Use this workflow when a teammate asks to generate deployment blueprints for an AIO release.

## Choose the synchronization scope first

For a **preview template**, an explicit runtime profile, or a package supporting **both GA and preview**, first read
[Runtime-profile synchronization](./references/runtime-profiles.md). It defines the target files, independent source
refs, review gates, and validation mapping for that scope. Its scope-specific rules replace the single-template
write targets, global constants/version policy, and publication hand-off below; the source safety, provenance,
redaction, and compile checks still apply. Never run the legacy two-assignment replacement against a preview ref.

The existing single-release workflow below remains for a request to refresh the legacy GA/shared templates. It is
not a reason to replace the GA instance blueprint, shared enablement blueprint, or package version when adding a
preview profile. With runtime profiles present, use the profile workflow even for a GA-only instance refresh.

An unspecified scope must be resolved before generation; do not infer it from the source train. A branch explicitly
supplied as the preview source selects preview scope, but does not establish its actual deployment train, final
release approval, or permission to publish it.

## Required inputs

Expand Down Expand Up @@ -54,8 +70,10 @@ Do not require the user to know repository implementation details after these in
version, stop and ask, because recompiling with it rewrites `_generator.version` backwards. Never change the
machine's Bicep installation as a side effect of this workflow — it is shared with everything else on the system.
10. Parse `Deployment/release.json` from the selected ref and confirm its `release` value equals the supplied release
moniker. Fail on a mismatch rather than combining metadata from different releases. The value is stored as an
integer and the moniker is a string, so coerce before comparing. Compare it against the **supplied moniker**,
moniker. Fail on a mismatch rather than combining metadata from different releases. The value may be an integer
or a prefixed string, so coerce to a string without stripping a prefix. If the user supplied only a source ref,
report the discovered moniker; do not invent an expected value from the branch name. Compare an explicitly
supplied moniker against that source value,
never against `AIO_RELEASE`: that constant still holds the *previous* release until section 4 updates it, so
comparing against it here fails every genuinely new release and lets only a re-sync through.
11. Note whether `AIO_RELEASE` in `azext_edge/constants.py` already equals the supplied moniker. If it does, this is
Expand Down Expand Up @@ -213,6 +231,28 @@ not. That combination is only possible if someone edited generated output by han

### Source redactions

#### Required GDS Manager exclusion for GA and preview

Exclude GDS Manager from every selected GA and preview instance template, including same-release refreshes.
This approved policy supersedes earlier approval to retain GDS in internal preview qualification templates.
Do not ask for that settled exclusion again or treat the upstream default as permission to restore it.
A single-profile sync must still preserve the nonselected profile and shared enablement; when both profiles are
selected, apply and validate the exclusion independently in both exports.

Treat this as an approved behavioral release-policy override, not a behavior-preserving substitution:

- Inspect the selected source's compiled dependency closure for GDS-only parameters, variables, resources,
configuration settings, conditions, and their readers. Include `enableGdsManager` and
`connectors.values.gdsManager.enabled`, plus any additional GDS symbols introduced by that ref.
- Remove those inputs and all GDS-only readers from the exported Bicep source before recompiling. Remove orphaned
decorators too. Preserve unrelated expressions and components when an expression contains both GDS and non-GDS
content. Do not merely set the default to `false`, hand-edit generated Python/JSON, or change the source checkout.
- Report the removed source paths and symbols per profile, record the override in provenance, and update stale
generated-module notes and release-specific test expectations that previously claimed GDS was retained.
- Validate each final compiled blueprint independently: no GDS Manager parameter, configuration entry, resource,
or reference may remain. Scan both keys and string values case-insensitively, accounting for the GDS names found
in the selected source. Stop integration if any GDS input or reader survives.

`template.py` is normally byte-identical to the compiled Bicep, and that is the invariant this skill maintains.
Redactions are a deliberate, bounded exception to it: the goal is a template that deploys identically, not one that
matches the compiler byte for byte. Apply a redaction only when it is provably behavior-preserving and there is a
Expand Down Expand Up @@ -298,6 +338,13 @@ depends on the file's structure rather than on one scalar within it.

### Constants and test expectations

For profile-aware syncs, complete the [release-test maintenance steps](./references/runtime-profiles.md#maintain-release-tracking-tests)
in the same change as the selected blueprint and catalog. The GA-only list below is not sufficient for preview.
Do not leave release-specific test updates as a manual task for the user.
Also complete [management API maintenance](./references/runtime-profiles.md#maintain-release-specific-management-apis)
from the approved release inputs. Keep runtime-profile API selection separate from generated deployment API values;
an intentionally pinned blueprint must not be regenerated merely to align those values.

Also update, from the same generated output:

- `EXTENSION_CONFIGS` in `azext_edge/tests/edge/orchestration/test_template_unit.py` from generated
Expand Down
Loading
Loading