Skip to content
Merged
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
17 changes: 11 additions & 6 deletions docs/guided-inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +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`.

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`, 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.

Expand Down Expand Up @@ -284,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.
Expand Down
17 changes: 8 additions & 9 deletions docs/manifest-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
10 changes: 10 additions & 0 deletions docs/secret-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Key-Vault-resource-ID>
```

A new role assignment can take several minutes to take effect.
39 changes: 39 additions & 0 deletions docs/site-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,45 @@ inventory checks Site identities before writing. A later plan loads that
inventory again so concurrent or subsequent configuration changes are
still checked.

The IoT Operations workspace uses the Site name in Azure resource names, such
as `<name>-aio` for the instance and `<name>-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 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.

#### 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@<release>" --input siteName=<original-name> --input "cluster=<Arc-cluster-resource-ID>" --input aioRelease=<original-AIO-release> --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@<release>" --site-file ./plant-one.yaml
siteops deploy aio-install --source "official@<release>" --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
Expand Down
14 changes: 14 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<name>`. 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=<name>` with lowercase letters, digits and
interior hyphens.

### "already exists. Choose a new file name"

```
Expand Down
5 changes: 4 additions & 1 deletion siteops/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)})"
Expand Down
21 changes: 14 additions & 7 deletions siteops/guided_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

import copy
import difflib
import hashlib
import re
from collections.abc import Mapping
from dataclasses import dataclass
Expand Down Expand Up @@ -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}=<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:
Expand Down
138 changes: 112 additions & 26 deletions tests/test_guided_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
)
Expand Down Expand Up @@ -1949,50 +1954,131 @@ 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 _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"
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"
})
legacy = replace(site, parameters={**site.parameters, "useScopedSchemaRegistryNamespace": False})

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
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"
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",
Expand Down
Loading
Loading