From 1a1578d974483805db8120d5d1b92e0cc0244bf0 Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Fri, 16 Oct 2026 09:44:10 -0700 Subject: [PATCH 1/4] docs: explain Site name uniqueness and operator Key Vault access A Site name becomes part of the schema registry namespace, which must be unique in the Microsoft Entra tenant. The Site configuration guide asks for a name that no other Site in the tenant uses, and the guided inputs guide explains why a generated name carries a code derived from the cluster's resource ID. The Secret Sync guide shows how to grant your own account a Key Vault data role, so you can view or manage the vault's secrets in the IoT Operations portal. --- docs/guided-inputs.md | 13 +++++++++---- docs/secret-sync.md | 10 ++++++++++ docs/site-configuration.md | 7 +++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/guided-inputs.md b/docs/guided-inputs.md index 216138c..49e652e 100644 --- a/docs/guided-inputs.md +++ b/docs/guided-inputs.md @@ -66,10 +66,15 @@ configured Sites without changing its existing pin. A [project pin](projects.md#run-project-pin) is optional for this single Site and useful for repeatable fleets or `--offline-content`. -Site Ops generates a stable name from a lowercase cluster prefix and a hash -of its full resource ID. Review that name in the plan. Add -`--input siteName=plant-one` to the deploy command (and any separate plan) -to choose a different name. +Without `siteName`, Site Ops names the Site after the cluster and adds a short +code derived from the cluster's full resource ID, for example +`plant-a-bafdd6123574`. The same cluster always gets the same name. The code +keeps names distinct when clusters in other resource groups or subscriptions +share a name, because the Site name also becomes part of the schema registry +namespace, which must be unique in your Microsoft Entra tenant. Review the +name in the plan. To choose your own, add `--input siteName=plant-one` to the +deploy command (and any separate plan), and pick a name that no other Site in +your tenant uses. Typed Site names use lowercase letters, digits and interior hyphens, with a maximum of 59 characters. Existing saved Sites retain their names. diff --git a/docs/secret-sync.md b/docs/secret-sync.md index 9264a17..54f5261 100644 --- a/docs/secret-sync.md +++ b/docs/secret-sync.md @@ -341,3 +341,13 @@ Role assignments use deterministic names via `guid(keyVault.id, principalId, rol ### Key Vault RBAC not enabled The enablement template creates Key Vaults with `enableRbacAuthorization: true`. If you bring your own Key Vault, role assignments will still be created successfully regardless of the Key Vault's authorization mode, but they will not take effect until RBAC authorization is enabled. Ensure `enableRbacAuthorization: true` is set on the Key Vault for the managed identity to authenticate. + +### The IoT Operations portal does not show the Key Vault's secrets + +Secret Sync enablement grants Key Vault roles to its managed identity, not to your account. To view or manage the vault's secrets yourself, for example in the IoT Operations portal, assign your account a Key Vault data role such as Key Vault Secrets Officer at the vault scope: + +```bash +az role assignment create --assignee "$(az ad signed-in-user show --query id -o tsv)" --role "Key Vault Secrets Officer" --scope +``` + +A new role assignment can take several minutes to take effect. diff --git a/docs/site-configuration.md b/docs/site-configuration.md index 4fecfc4..e41ff87 100644 --- a/docs/site-configuration.md +++ b/docs/site-configuration.md @@ -114,6 +114,13 @@ inventory checks Site identities before writing. A later plan loads that inventory again so concurrent or subsequent configuration changes are still checked. +Give each Site a name that no other Site in your Microsoft Entra tenant uses, +including Sites in other projects, resource groups and subscriptions. The IoT +Operations workspace uses the Site name in Azure resource names, such as +`-aio` for the instance and `-sr` for the schema registry. The +schema registry namespace must be unique in the tenant, so two Sites with the +same name can conflict when the second one deploys. + **Site at subscription level** (for shared resources): ```yaml From d5a3a7002c8e9a71015f507912d418da36b0215f Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Fri, 16 Oct 2026 10:15:28 -0700 Subject: [PATCH 2/4] feat: name Sites built from typed inputs after their cluster Without siteName, a Site built from a resource ID takes the resource's name in lowercase, so the cluster plant-a deploys as the Site plant-a. When that name does not meet the name input's declared format and length, the command stops after the authorized read and asks for the name input. The public error omits the observed name. The AIO install contract sets useScopedSchemaRegistryNamespace, which adds a hash of the resource group and registry name to the schema registry namespace. Clusters that share a name in different resource groups therefore keep distinct namespaces, which the service requires across the tenant. Configured Sites keep their existing namespaces. --- docs/guided-inputs.md | 22 ++--- docs/manifest-reference.md | 17 ++-- docs/site-configuration.md | 17 ++-- docs/troubleshooting.md | 14 +++ siteops/cli.py | 5 +- siteops/guided_inputs.py | 21 +++-- tests/test_guided_cli.py | 94 ++++++++++++++----- tests/test_guided_inputs.py | 45 +++++---- .../test_aio_release_behavior_contracts.py | 6 ++ .../manifests/aio-install/inputs.yaml | 4 +- .../manifests/secretsync/inputs.yaml | 2 +- .../templates/deps/schema-registry.bicep | 20 ++-- 12 files changed, 182 insertions(+), 85 deletions(-) diff --git a/docs/guided-inputs.md b/docs/guided-inputs.md index 49e652e..d9de8e0 100644 --- a/docs/guided-inputs.md +++ b/docs/guided-inputs.md @@ -66,15 +66,14 @@ configured Sites without changing its existing pin. A [project pin](projects.md#run-project-pin) is optional for this single Site and useful for repeatable fleets or `--offline-content`. -Without `siteName`, Site Ops names the Site after the cluster and adds a short -code derived from the cluster's full resource ID, for example -`plant-a-bafdd6123574`. The same cluster always gets the same name. The code -keeps names distinct when clusters in other resource groups or subscriptions -share a name, because the Site name also becomes part of the schema registry -namespace, which must be unique in your Microsoft Entra tenant. Review the -name in the plan. To choose your own, add `--input siteName=plant-one` to the -deploy command (and any separate plan), and pick a name that no other Site in -your tenant uses. +Without `siteName`, the Site takes the cluster's name in lowercase, so the +cluster `plant-a` deploys as the Site `plant-a`. Clusters in different resource +groups can share a name, and each of their Sites still gets its own schema +registry namespace. To choose a different name, add +`--input siteName=plant-one` to the deploy command (and any separate plan). +When the cluster's name is not a valid Site name, for example because it +contains an underscore, the command stops before deploying and asks for +`siteName`. Typed Site names use lowercase letters, digits and interior hyphens, with a maximum of 59 characters. Existing saved Sites retain their names. @@ -289,8 +288,9 @@ A Site saved outside that inventory remains available by explicit path. An inline Site remains in memory unless you explicitly choose `--save-site`. Saving a Site built from resource observations does not store the observations or recheck their prerequisites on later `--site-file` use. -If you retain the generated name, use the name shown by the input preview -as the Site filename and selector. Without an environment label, the saved +If you keep the cluster's name as the Site name, use it as the Site filename +and selector. Two clusters with the same name need different Site names in +one project, so supply `siteName` for the second. Without an environment label, the saved Site does not match the manifest's default `environment=dev` selector. Use an explicit name selection or supply intentional fleet labels. Keep Site and answer files outside the content cache and verified package. diff --git a/docs/manifest-reference.md b/docs/manifest-reference.md index f1ea7cc..0d51b4b 100644 --- a/docs/manifest-reference.md +++ b/docs/manifest-reference.md @@ -174,19 +174,18 @@ ID does not trigger an Azure read. The example remains incomplete until the operator supplies every requirement through one route. An optional `nameFromResource: cluster` at the top level selects an unconditional -declared resource to generate the Site name after its authorized read. +declared resource whose name becomes the default Site name after its authorized read. The contract must also declare a required unconditional string input mapped to `name`, without another default, derivation or conditional readers. -An explicit name overrides generation. Without the resource, that name +An explicit name overrides the default. Without the resource, that name remains required for the manual route. -The generated name combines a lowercase, sanitized prefix of the resource -name, at most 18 characters long, with a hyphen and 12 hexadecimal SHA-256 -characters from the full resource ID after case normalization. Identical IDs retain their names -across case variations, while different resource groups or subscriptions -contribute to the hash. With this declaration, `inputs --example` prefers -the resource route and omits the fields it supplies. The AIO example -therefore contains only `cluster: null`. +The default is the resource name in lowercase, so resource IDs that differ +only in case give the same name. It must meet the name input's declared +`format` and `maxLength`. Otherwise the command stops after the authorized +read, before any deployment, and asks for the name input. With this +declaration, `inputs --example` prefers the resource route and omits the +fields it supplies. The AIO example therefore contains only `cluster: null`. A resource role can follow an earlier role through a declared relationship: diff --git a/docs/site-configuration.md b/docs/site-configuration.md index e41ff87..0d1cffa 100644 --- a/docs/site-configuration.md +++ b/docs/site-configuration.md @@ -114,12 +114,17 @@ inventory checks Site identities before writing. A later plan loads that inventory again so concurrent or subsequent configuration changes are still checked. -Give each Site a name that no other Site in your Microsoft Entra tenant uses, -including Sites in other projects, resource groups and subscriptions. The IoT -Operations workspace uses the Site name in Azure resource names, such as -`-aio` for the instance and `-sr` for the schema registry. The -schema registry namespace must be unique in the tenant, so two Sites with the -same name can conflict when the second one deploys. +The IoT Operations workspace uses the Site name in Azure resource names, such +as `-aio` for the instance and `-sr` for the schema registry. The +schema registry namespace must be unique in your Microsoft Entra tenant. +Sites built with typed inputs set `useScopedSchemaRegistryNamespace: true` +under `parameters`, which adds a hash of the resource group and registry name +to the namespace, so they can share a name with Sites in other resource +groups. A Site file you write yourself keeps the plain namespace unless it +sets that parameter. Give such a Site a name that no other Site in the tenant +uses, or set the parameter when you create the Site. Keep the setting +unchanged for a Site whose registry already exists, because changing it +changes the namespace. **Site at subscription level** (for shared resources): diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6e3a5fc..c8962cb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -391,6 +391,20 @@ complete. The error codes are `inputs.invalid` and **Solution**: Add the named `--input`, or run `siteops inputs MANIFEST` to list the required answers. See [guided inputs](guided-inputs.md). +### "name does not fit input 'siteName'" + +``` +Error: The cluster name 'Plant_A' does not fit input 'siteName'. Supply `--input siteName=`. Input 'siteName' must be a lowercase DNS label, starting and ending with a letter or digit, with at most 63 characters. +``` + +**Cause**: Without `siteName`, the Site takes the cluster's name in lowercase. +This cluster's name is not a valid Site name, for example because it contains +an underscore or is longer than 59 characters. Redacted output omits the +cluster name. + +**Solution**: Add `--input siteName=` with lowercase letters, digits and +interior hyphens. + ### "already exists. Choose a new file name" ``` diff --git a/siteops/cli.py b/siteops/cli.py index b60ac5d..7063bb7 100644 --- a/siteops/cli.py +++ b/siteops/cli.py @@ -1106,7 +1106,10 @@ def cmd_inputs(args: argparse.Namespace, orchestrator: Orchestrator) -> int: if field.get("derivableFrom"): status += " or derived from " + ", ".join(field["derivableFrom"]) if field.get("defaultFromResource"): - status = "generated from " + field["defaultFromResource"] + ", or supplied" + status = ( + "defaults to the " + field["defaultFromResource"] + + " name in lowercase, or supplied" + ) for line in _wrap( f"{_content_text(field['name'])} " f"({_content_text(field['type'])}, {_content_text(status)})" diff --git a/siteops/guided_inputs.py b/siteops/guided_inputs.py index 4c398af..7f942a1 100644 --- a/siteops/guided_inputs.py +++ b/siteops/guided_inputs.py @@ -7,7 +7,6 @@ import copy import difflib -import hashlib import re from collections.abc import Mapping from dataclasses import dataclass @@ -1079,12 +1078,20 @@ def build_site( item for item in self.fields if item.site_path == ("name",) ) if name_field.name not in values: - prefix = re.sub(r"[^a-z0-9]+", "-", resource.ref.name.casefold()).strip("-") - prefix = prefix[:18].rstrip("-") - suffix = hashlib.sha256( - resource.ref.resource_id.casefold().encode("ascii"), - ).hexdigest()[:12] - values[name_field.name] = f"{prefix}-{suffix}" + # ARM names ignore case, so the lowercase name identifies the same resource. + default = resource.ref.name.casefold() + try: + name_field.validate(default, f"Input '{name_field.name}'") + except GuidedInputError as invalid: + problem = f"name does not fit input '{name_field.name}'" + remedy = f"Supply `--input {name_field.name}=`. {invalid}" + error = GuidedInputError(f"The {field.name} {problem}. {remedy}") + error.private_message = ( + f"The {field.name} name '{resource.ref.name}' does not fit input " + f"'{name_field.name}'. {remedy}" + ) + raise error from None + values[name_field.name] = default sources[name_field.name] = f"resource '{field.name}'" for field in self.fields: if field.resource is not None: diff --git a/tests/test_guided_cli.py b/tests/test_guided_cli.py index bede186..2af927f 100644 --- a/tests/test_guided_cli.py +++ b/tests/test_guided_cli.py @@ -1767,7 +1767,12 @@ def runner(argv: tuple[str, ...], timeout: int) -> subprocess.CompletedProcess[s target = Path(argv[argv.index("--outfile") + 1]) parameters = ( {"tags": {"type": "object"}} - if source.name in {"schema-registry.bicep", "adr-ns.bicep"} + if source.name == "adr-ns.bicep" + else { + "tags": {"type": "object"}, + "useScopedSchemaRegistryNamespace": {"type": "bool", "defaultValue": False}, + } + if source.name == "schema-registry.bicep" else {"existingKeyVaultResourceId": {"type": "string", "defaultValue": ""}} if source.name == "enable-secretsync.bicep" else {} ) @@ -1949,50 +1954,87 @@ def read(ref, *, facts): ]) == 0 document = json.loads(capsys.readouterr().out) assert len(document["plan"]["targets"]) == 1 - assert document["plan"]["targets"][0]["name"].startswith("arc-first-") + assert document["plan"]["targets"][0]["name"] == "arc-first" assert calls == [_CLUSTER_ID] -def test_aio_generated_sites_save_reload_and_obey_fleet_selection(tmp_path, capsys): +def test_typed_aio_sites_scope_only_the_schema_registry_namespace(tmp_path): + workspace = Path(__file__).resolve().parents[1] / "workspaces" / "iot-operations" + manifest = workspace / "manifests" / "aio-install" / "manifest.yaml" + site = load_contract(manifest).resolve(inline=[ + "siteName=plant-one", + "subscription=00000000-0000-0000-0000-000000000001", + "resourceGroup=rg-existing", "location=eastus", "clusterName=existing-arc", + ]) + assert site.parameters["useScopedSchemaRegistryNamespace"] is True + configured = replace(site, parameters={ + key: value for key, value in site.parameters.items() + if key != "useScopedSchemaRegistryNamespace" + }) + + def flags(candidate): + session = _aio_template_session(tmp_path) + with patch("siteops.orchestrator.TemplateCompilationSession", return_value=session): + result = Orchestrator(workspace).build_plan( + manifest, sites=[candidate], intent=PlanIntent.EXECUTABLE, + ) + assert result.status is PlanStatus.PLANNED, result.diagnostics + found = {} + for operation in result.plan.targets[0].operations: + parameters = getattr(operation.details, "parameters", None) + for entry in parameters.entries if parameters is not None else (): + if ( + isinstance(entry.key, LiteralValue) + and entry.key.value == "useScopedSchemaRegistryNamespace" + ): + found[operation.identity.step] = resolve_plan_value(entry.value, {}) + return found + + assert flags(site) == {"schema-registry": True} + assert flags(configured) == {} + + +def test_aio_resource_named_sites_save_reload_and_obey_fleet_selection(tmp_path, capsys): root = Path(__file__).resolve().parents[1] workspace = root / "workspaces" / "iot-operations" project = tmp_path / "factory" (project / "sites").mkdir(parents=True) - resources = [_CLUSTER_ID, _CLUSTER_ID.replace("rg-first", "rg-second")] - contract = load_contract(workspace / "manifests" / "aio-install" / "manifest.yaml") - names = [] + other_group = _CLUSTER_ID.replace("rg-first", "rg-second") + first = project / "sites" / "arc-first.yaml" + second = project / "sites" / "arc-second.yaml" def read(ref, *, facts): return ArmResourceObservation(ref.resource_id, ref.resource_type, "eastus", ref.name, {}) + def save(path, *answers): + inline = [item for answer in answers for item in ("--input", answer)] + return _invoke([ + "--project", str(project), "-w", str(workspace), "inputs", "aio-install", + *inline, "--read-resources", "--save-site", str(path), + ]) + reader = SimpleNamespace(identity=SimpleNamespace(name="fixture", version="1"), read=read) with patch("siteops.cli.new_arm_reader", return_value=reader): - for resource in resources: - bound = contract.bind(inline=[f"cluster={resource}"]) - site = contract.build_site(bound, {"cluster": read(bound.resources[0].ref, facts=frozenset())}) - path = project / "sites" / f"{site.name}.yaml" - assert _invoke([ - "--project", str(project), "-w", str(workspace), "inputs", "aio-install", - "--input", f"cluster={resource}", "--read-resources", "--save-site", str(path), - ]) == 0 - assert Site.from_file(path).name == site.name - assert Site.from_file(path).labels == {} - names.append(site.name) - capsys.readouterr() - assert names[0] != names[1] - assert _invoke([ - "--project", str(project), "-w", str(workspace), "inputs", "aio-install", - "--input", f"cluster={resources[0].upper()}", "--read-resources", - "--save-site", str(project / "sites" / f"{names[0]}.yaml"), - ]) == 1 + assert save(first, f"cluster={_CLUSTER_ID}") == 0 + saved = Site.from_file(first) + assert saved.name == "arc-first" + assert saved.labels == {} + assert saved.parameters["useScopedSchemaRegistryNamespace"] is True + capsys.readouterr() + # A cluster with the same name in another resource group needs its own Site name here. + assert save(second, f"cluster={other_group}") == 1 + assert not second.exists() + capsys.readouterr() + assert save(second, "siteName=arc-second", f"cluster={other_group}") == 0 + assert Site.from_file(second).name == "arc-second" capsys.readouterr() with patch("siteops.cli.new_arm_reader", side_effect=AssertionError("Saved Sites do not read resources")): assert _invoke([ "--project", str(project), "-w", str(workspace), - "plan", "aio-install", "-l", f"name={names[0]}", "--describe", "--output", "json", + "plan", "aio-install", "-l", "name=arc-first", "--describe", "--output", "json", ]) == 0 document = json.loads(capsys.readouterr().out) - assert [target["name"] for target in document["plan"]["targets"]] == [names[0]] + assert [target["name"] for target in document["plan"]["targets"]] == ["arc-first"] orchestrator = Orchestrator(workspace, site_config_root=project) assert orchestrator.resolve_sites(orchestrator.load_manifest( workspace / "manifests" / "aio-install" / "manifest.yaml", diff --git a/tests/test_guided_inputs.py b/tests/test_guided_inputs.py index c80c038..2556ace 100644 --- a/tests/test_guided_inputs.py +++ b/tests/test_guided_inputs.py @@ -1,6 +1,5 @@ """Boundaries for declarative inputs that construct exactly one ordinary Site.""" -import hashlib import os import stat from dataclasses import FrozenInstanceError @@ -208,14 +207,16 @@ def _named_resource_contract(tmp_path): return load_contract(manifest) -@pytest.mark.parametrize("cluster_name", ["arc-first", "A_Very.Long_Cluster.Name_With_More_Characters"]) -def test_resource_name_default_is_stable_and_uses_full_identity(tmp_path, cluster_name): +@pytest.mark.parametrize( + ("cluster_name", "site_name"), + [("Arc-First", "arc-first"), ("1plant--a", "1plant--a"), ("a" * 59, "a" * 59)], +) +def test_resource_name_default_is_the_lowercase_resource_name(tmp_path, cluster_name, site_name): contract = _named_resource_contract(tmp_path) resource = ( "/subscriptions/00000000-0000-0000-0000-000000000001/" f"resourceGroups/rg-first/providers/Microsoft.Kubernetes/connectedClusters/{cluster_name}" ) - names = [] for resource_id in (resource, resource.upper(), resource.replace("rg-first", "rg-second")): bound = contract.bind(inline=[f"cluster={resource_id}"]) assert "siteName" not in bound.active_values @@ -224,15 +225,27 @@ def test_resource_name_default_is_stable_and_uses_full_identity(tmp_path, cluste resource_id, ref.resource_type, "eastus", ref.name, {}, ) site = contract.build_site(bound, {"cluster": observation}) - prefix = "arc-first" if cluster_name == "arc-first" else "a-very-long-cluste" - assert site.name == prefix + "-" + hashlib.sha256( - resource_id.casefold().encode("ascii"), - ).hexdigest()[:12] - assert len(site.name) <= 31 + assert site.name == site_name assert site.labels == {} - names.append(site.name) - assert names[0] == names[1] - assert names[0] != names[2] + + +@pytest.mark.parametrize("cluster_name", ["arc_first", "arc.first", "a" * 60]) +def test_resource_name_default_asks_for_a_name_the_contract_accepts(tmp_path, cluster_name): + contract = _named_resource_contract(tmp_path) + resource = ( + "/subscriptions/00000000-0000-0000-0000-000000000001/" + f"resourceGroups/rg-first/providers/Microsoft.Kubernetes/connectedClusters/{cluster_name}" + ) + bound = contract.bind(inline=[f"cluster={resource}"]) + ref = bound.resources[0].ref + observation = ArmResourceObservation(resource, ref.resource_type, "eastus", ref.name, {}) + with pytest.raises(ValueError, match="The cluster name does not fit input 'siteName'") as error: + contract.build_site(bound, {"cluster": observation}) + assert "Supply `--input siteName=`." in str(error.value) + assert cluster_name not in str(error.value) + assert f"The cluster name '{cluster_name}' does not fit" in error.value.private_message + named = contract.bind(inline=["siteName=arc-one", f"cluster={resource}"]) + assert contract.build_site(named, {"cluster": observation}).name == "arc-one" def test_resource_name_default_retains_overrides_and_manual_route(tmp_path): @@ -363,7 +376,7 @@ def test_resource_name_default_is_generic_and_rejects_conflicting_name_derivatio site = contract.build_site(bound, { "storage": ArmResourceObservation(resource, "Microsoft.Storage/storageAccounts", "eastus", "example", {}), }) - assert site.name.startswith("example-") + assert site.name == "example" document = yaml.safe_load(path.read_text(encoding="utf-8")) document["inputs"][-1]["derive"]["name"] = "siteName" path.write_text(yaml.safe_dump(document), encoding="utf-8") @@ -458,7 +471,7 @@ def test_declared_links_build_one_site_without_asking_for_related_ids(tmp_path): ] site = contract.build_site(bound, _linked_observations()) assert site.parameters == {"instanceName": "machine-one", "clusterName": "cluster-one"} - assert site.name.startswith("machine-one-") + assert site.name == "machine-one" assert "customLocation" not in contract.example()["values"] assert "cluster" not in contract.example()["values"] @@ -524,9 +537,7 @@ def test_related_resource_name_uses_cluster_identity_and_example_uses_root_input assert contract.example()["values"] == {"instance": None} bound = contract.bind(inline=[f"instance={_LINKED_INSTANCE_ID}"]) site = contract.build_site(bound, _linked_observations()) - assert site.name == "cluster-one-" + hashlib.sha256( - _LINKED_CLUSTER_ID.casefold().encode("ascii"), - ).hexdigest()[:12] + assert site.name == "cluster-one" @pytest.mark.parametrize("bad_source", ["missing", "identity", "extra-reference"]) diff --git a/tests/workspace/test_aio_release_behavior_contracts.py b/tests/workspace/test_aio_release_behavior_contracts.py index 5f77b46..ca544ac 100644 --- a/tests/workspace/test_aio_release_behavior_contracts.py +++ b/tests/workspace/test_aio_release_behavior_contracts.py @@ -223,6 +223,12 @@ def test_schema_registry_namespace_fits_the_service_limit_for_every_guided_site_ long_form = "'${toLower(take(schemaRegistryName, 26))}${take(uniqueString(resourceGroup().id, schemaRegistryName), 6)}'" assert long_form in template assert 26 + 6 == 32 + # Typed inputs scope every namespace to its resource group, so Sites that share a name stay distinct. + assert "param useScopedSchemaRegistryNamespace bool = false" in template + scoped = "'${toLower(take(schemaRegistryName, 18))}-${uniqueString(resourceGroup().id, schemaRegistryName)}'" + assert f"useScopedSchemaRegistryNamespace\n ? {scoped}\n : legacySchemaRegistryNamespace" in template + assert 18 + 1 + 13 == 32 + assert contract["siteDefaults"]["parameters"]["useScopedSchemaRegistryNamespace"] is True def test_security_pki_defaults_follow_released_aio_behavior(workspace): diff --git a/workspaces/iot-operations/manifests/aio-install/inputs.yaml b/workspaces/iot-operations/manifests/aio-install/inputs.yaml index 4aa38a8..9e2d3fb 100644 --- a/workspaces/iot-operations/manifests/aio-install/inputs.yaml +++ b/workspaces/iot-operations/manifests/aio-install/inputs.yaml @@ -8,10 +8,12 @@ siteDefaults: enableEdgeSite: false parameters: defaultDataflowInstanceCount: 1 + # Sites built from typed inputs keep distinct schema registry namespaces when they share a name. + useScopedSchemaRegistryNamespace: true inputs: - name: siteName type: string - description: Optional name override with a cluster ID. Required for a manual Site. + description: Optional with a cluster ID, which supplies the cluster's name in lowercase. Required for a manual Site. sitePath: name format: dnsLabel maxLength: 59 diff --git a/workspaces/iot-operations/manifests/secretsync/inputs.yaml b/workspaces/iot-operations/manifests/secretsync/inputs.yaml index 6a3c084..5bb3214 100644 --- a/workspaces/iot-operations/manifests/secretsync/inputs.yaml +++ b/workspaces/iot-operations/manifests/secretsync/inputs.yaml @@ -19,7 +19,7 @@ inputs: name: aioInstanceName - name: siteName type: string - description: Optional Site name override. The default comes from the associated cluster identity. + description: Optional Site name. The default is the associated cluster's name in lowercase. sitePath: name format: dnsLabel maxLength: 59 diff --git a/workspaces/iot-operations/templates/deps/schema-registry.bicep b/workspaces/iot-operations/templates/deps/schema-registry.bicep index 327e935..03f51b7 100644 --- a/workspaces/iot-operations/templates/deps/schema-registry.bicep +++ b/workspaces/iot-operations/templates/deps/schema-registry.bicep @@ -4,7 +4,8 @@ // Hardens the storage account (RBAC-only, deny by default, schema-registry MI granted // Storage Blob Data Contributor on the container). // -// Inputs: schemaRegistryName, optional storageAccountName/containerName/location/tags. +// Inputs: schemaRegistryName, optional storageAccountName/containerName/location/tags and +// useScopedSchemaRegistryNamespace. // Outputs: schemaRegistry { id, name, principalId }, storageAccount { id, name, containerUrl }. // ------------------------------------------------------------------------------------- @@ -23,6 +24,9 @@ param storageAccountName string = '' @description('Name of the blob container for schema storage.') param containerName string = 'schemas' +@description('Include a hash of the resource group and registry name in the schema registry namespace, so Sites that share a name in one tenant keep distinct namespaces. Defaults to false, which keeps existing namespaces. Keep the setting unchanged for an existing registry.') +param useScopedSchemaRegistryNamespace bool = false + @description('Location for all resources. Defaults to resource group location.') param location string = resourceGroup().location @@ -42,13 +46,17 @@ var generatedStorageAccountName = !empty(storageAccountName) var schemaRegistryResourceId = resourceId('Microsoft.DeviceRegistry/schemaRegistries', schemaRegistryName) // The service limits the namespace to 32 characters and requires it to be unique in the tenant. -// A registry name that fits is used unchanged, so registries created earlier keep their -// namespace. A longer name keeps its first 26 characters and gains a hash of the resource group -// and the full name. Sites that share a name in one tenant set schemaRegistryName to keep their -// namespaces distinct. -var schemaRegistryNamespace = length(schemaRegistryName) <= 32 +// With useScopedSchemaRegistryNamespace, the namespace keeps up to 18 characters of the name and +// adds the 13 character hash of the resource group and the full name, so Sites that share a name +// in one tenant keep distinct namespaces. Otherwise a registry name that fits is used unchanged, +// so registries created earlier keep their namespace, and a longer name keeps its first 26 +// characters and gains part of that hash. +var legacySchemaRegistryNamespace = length(schemaRegistryName) <= 32 ? schemaRegistryName : '${toLower(take(schemaRegistryName, 26))}${take(uniqueString(resourceGroup().id, schemaRegistryName), 6)}' +var schemaRegistryNamespace = useScopedSchemaRegistryNamespace + ? '${toLower(take(schemaRegistryName, 18))}-${uniqueString(resourceGroup().id, schemaRegistryName)}' + : legacySchemaRegistryNamespace resource storageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' = { name: generatedStorageAccountName From 61b225b6516ef9005596d41531e0ba379444cac0 Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Fri, 16 Oct 2026 10:25:38 -0700 Subject: [PATCH 3/4] docs: keep existing schema registry namespaces when redeploying A registry keeps the namespace it was created with. The Site configuration guide says to redeploy an existing installation from its original Site file, and that a Site file you write yourself uses the registry name as the namespace when it fits. --- docs/site-configuration.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/site-configuration.md b/docs/site-configuration.md index 0d1cffa..7592378 100644 --- a/docs/site-configuration.md +++ b/docs/site-configuration.md @@ -120,11 +120,15 @@ schema registry namespace must be unique in your Microsoft Entra tenant. Sites built with typed inputs set `useScopedSchemaRegistryNamespace: true` under `parameters`, which adds a hash of the resource group and registry name to the namespace, so they can share a name with Sites in other resource -groups. A Site file you write yourself keeps the plain namespace unless it -sets that parameter. Give such a Site a name that no other Site in the tenant -uses, or set the parameter when you create the Site. Keep the setting -unchanged for a Site whose registry already exists, because changing it -changes the namespace. +groups. A Site file you write yourself uses the registry name as the namespace +when it fits in 32 characters, unless it sets that parameter. Give such a Site +a name that no other Site in the tenant uses, or set the parameter when you +create the Site. + +A registry keeps the namespace it was created with, so redeploy an existing +installation from its original Site file. Typed inputs set the parameter even +when you supply the previous `siteName`, so a Site rebuilt that way does not +match a registry created without it. **Site at subscription level** (for shared resources): From 6e7c568e739c8dff6ef7f07d3c9f8d8987d9f51f Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Fri, 16 Oct 2026 12:08:38 -0700 Subject: [PATCH 4/4] docs: rebuild a Site for a registry created without the scoped namespace A registry keeps the namespace it was created with. The Site configuration guide shows how to rebuild a Site for a registry created without useScopedSchemaRegistryNamespace: save it with the original name, cluster and AIO release, set the parameter to false, and deploy from the file. Tests cover an explicit false reaching only the schema registry step, and a saved Site keeping it through plan and deploy with --site-file without loading the typed contract or reading resources. --- docs/site-configuration.md | 23 ++++++++++++++ tests/test_guided_cli.py | 64 ++++++++++++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/docs/site-configuration.md b/docs/site-configuration.md index 7592378..ee5451d 100644 --- a/docs/site-configuration.md +++ b/docs/site-configuration.md @@ -130,6 +130,29 @@ installation from its original Site file. Typed inputs set the parameter even when you supply the previous `siteName`, so a Site rebuilt that way does not match a registry created without it. +#### Rebuild a Site for a registry created without the setting + +If you no longer have the Site file for such a registry, save a new one with +the original Site name, cluster ID and AIO release: + +```text +siteops inputs aio-install --source "official@" --input siteName= --input "cluster=" --input aioRelease= --read-resources --save-site ./plant-one.yaml +``` + +In the saved file, change `useScopedSchemaRegistryNamespace` under +`parameters` to `false`. Restore any other settings from the original +deployment, because reading the cluster recovers its location and names, not +the whole configuration. Review the plan, then deploy from the file: + +```text +siteops plan aio-install --source "official@" --site-file ./plant-one.yaml +siteops deploy aio-install --source "official@" --site-file ./plant-one.yaml +``` + +Keep the file for later deployments. Use `false` only for a registry created +without the setting, and keep `true` for a registry created with it. Keep the +existing registry rather than deleting it to change its namespace. + **Site at subscription level** (for shared resources): ```yaml diff --git a/tests/test_guided_cli.py b/tests/test_guided_cli.py index 2af927f..290aa25 100644 --- a/tests/test_guided_cli.py +++ b/tests/test_guided_cli.py @@ -1958,6 +1958,16 @@ def read(ref, *, facts): assert calls == [_CLUSTER_ID] +def _parameter_by_step(target, name): + found = {} + for operation in target.operations: + parameters = getattr(operation.details, "parameters", None) + for entry in parameters.entries if parameters is not None else (): + if isinstance(entry.key, LiteralValue) and entry.key.value == name: + found[operation.identity.step] = resolve_plan_value(entry.value, {}) + return found + + def test_typed_aio_sites_scope_only_the_schema_registry_namespace(tmp_path): workspace = Path(__file__).resolve().parents[1] / "workspaces" / "iot-operations" manifest = workspace / "manifests" / "aio-install" / "manifest.yaml" @@ -1971,6 +1981,7 @@ def test_typed_aio_sites_scope_only_the_schema_registry_namespace(tmp_path): key: value for key, value in site.parameters.items() if key != "useScopedSchemaRegistryNamespace" }) + legacy = replace(site, parameters={**site.parameters, "useScopedSchemaRegistryNamespace": False}) def flags(candidate): session = _aio_template_session(tmp_path) @@ -1979,21 +1990,54 @@ def flags(candidate): manifest, sites=[candidate], intent=PlanIntent.EXECUTABLE, ) assert result.status is PlanStatus.PLANNED, result.diagnostics - found = {} - for operation in result.plan.targets[0].operations: - parameters = getattr(operation.details, "parameters", None) - for entry in parameters.entries if parameters is not None else (): - if ( - isinstance(entry.key, LiteralValue) - and entry.key.value == "useScopedSchemaRegistryNamespace" - ): - found[operation.identity.step] = resolve_plan_value(entry.value, {}) - return found + return _parameter_by_step(result.plan.targets[0], "useScopedSchemaRegistryNamespace") assert flags(site) == {"schema-registry": True} + assert flags(legacy) == {"schema-registry": False} assert flags(configured) == {} +def test_saved_site_keeps_an_explicit_false_namespace_setting(tmp_path, capsys): + workspace = Path(__file__).resolve().parents[1] / "workspaces" / "iot-operations" + site_file = tmp_path / "plant-one.yaml" + + def read(ref, *, facts): + return ArmResourceObservation(ref.resource_id, ref.resource_type, "eastus", ref.name, {}) + + reader = SimpleNamespace(identity=SimpleNamespace(name="fixture", version="1"), read=read) + with patch("siteops.cli.new_arm_reader", return_value=reader): + assert _invoke([ + "-w", str(workspace), "inputs", "aio-install", + "--input", "siteName=plant-one", "--input", f"cluster={_CLUSTER_ID}", + "--input", "aioRelease=2608", "--read-resources", "--save-site", str(site_file), + ]) == 0 + capsys.readouterr() + document = yaml.safe_load(site_file.read_text(encoding="utf-8")) + assert document["parameters"]["useScopedSchemaRegistryNamespace"] is True + document["parameters"]["useScopedSchemaRegistryNamespace"] = False + site_file.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + + session = _aio_template_session(tmp_path, include_aio_version=True) + with ( + patch("siteops.cli.new_arm_reader", side_effect=AssertionError("A Site file needs no resource read")), + patch("siteops.cli.load_contract", side_effect=AssertionError("A Site file bypasses the typed contract")), + patch("siteops.orchestrator.TemplateCompilationSession", return_value=session), + patch.object(Orchestrator, "execute_plan", return_value=RunResult.from_sites((), elapsed=0)) as execute, + ): + assert _invoke([ + "-w", str(workspace), "plan", "aio-install", "--site-file", str(site_file), "--output", "json", + ]) == 0 + execute.assert_not_called() + capsys.readouterr() + assert _invoke([ + "-w", str(workspace), "deploy", "aio-install", "--site-file", str(site_file), "--yes", + ]) == 0 + target = execute.call_args.args[0].plan.targets[0] + assert target.name == "plant-one" + assert _parameter_by_step(target, "useScopedSchemaRegistryNamespace") == {"schema-registry": False} + assert _parameter_by_step(target, "aioVersion") == {"aio-instance": "1.4.73"} + + def test_aio_resource_named_sites_save_reload_and_obey_fleet_selection(tmp_path, capsys): root = Path(__file__).resolve().parents[1] workspace = root / "workspaces" / "iot-operations"