diff --git a/.github/chatmodes/task-researcher.chatmode.md b/.github/chatmodes/task-researcher.chatmode.md
index 0f20f710..f7e9302f 100644
--- a/.github/chatmodes/task-researcher.chatmode.md
+++ b/.github/chatmodes/task-researcher.chatmode.md
@@ -1,6 +1,6 @@
---
description: 'Task research specialist for comprehensive project analysis - Brought to you by microsoft/edge-ai'
-tools: ['usages', 'think', 'problems', 'fetch', 'githubRepo', 'runCommands', 'edit/createFile', 'edit/createDirectory', 'edit/editFiles', 'search', 'Bicep (EXPERIMENTAL)/*', 'terraform/*', 'context7/*', 'microsoft-docs/*','runSubagent', 'runSubagent2']
+tools: ['execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalSelection', 'read/terminalLastCommand', 'read/problems', 'read/readFile', 'agent', 'context7/*', 'terraform/*', 'edit/createDirectory', 'edit/createFile', 'edit/editFiles', 'search', 'web', 'azure-mcp/search', 'microsoft-docs/*']
handoffs:
- label: "๐ฏ Start Planning"
agent: task-planner
diff --git a/blueprints/README.md b/blueprints/README.md
index 93a99349..950be9fb 100644
--- a/blueprints/README.md
+++ b/blueprints/README.md
@@ -65,6 +65,21 @@ Each blueprint in this repository follows a consistent structure:
- **CNCF Cluster Script Only**: Ideal for environments with existing infrastructure or custom deployment processes
- **Azure Fabric Environment**: For users looking to provision Azure Fabric environments with options to deploy Lakehouse, EventStream, and Fabric workspace
+## Testing Blueprints
+
+Selected blueprints include comprehensive test suites for validation and quality assurance. Tests validate both infrastructure declarations (contract tests) and actual deployments (integration tests).
+
+**Available Tests:**
+
+- **Contract Tests** - Fast static validation ensuring output declarations match expectations (zero cost, runs in seconds)
+- **Deployment Tests** - Full end-to-end validation creating real Azure resources and testing functionality
+
+**Blueprints with Test Coverage:**
+
+- [Full Single Cluster](./full-single-node-cluster/tests/) - Complete test suite for Terraform and Bicep
+
+See individual blueprint `tests/` directories for detailed testing documentation, setup instructions, and maintainer guidelines.
+
## Using Existing Resource Groups
All blueprints support deploying to existing resource groups rather than creating new ones.
diff --git a/blueprints/full-multi-node-cluster/bicep/README.md b/blueprints/full-multi-node-cluster/bicep/README.md
index 13dae4f4..ffc95475 100644
--- a/blueprints/full-multi-node-cluster/bicep/README.md
+++ b/blueprints/full-multi-node-cluster/bicep/README.md
@@ -7,52 +7,54 @@ Deploys a complete end-to-end environment for Azure IoT Operations on a multi-no
## Parameters
-| Name | Description | Type | Default | Required |
-|:----------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------|:---------|
-| common | The common component configuration. | `[_5.Common](#user-defined-types)` | n/a | yes |
-| resourceGroupName | The name for the resource group. If not provided, a default name will be generated. | `string` | [format('rg-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| useExistingResourceGroup | Whether to use an existing resource group instead of creating a new one. | `bool` | `false` | no |
-| telemetry_opt_out | Whether to opt-out of telemetry. Set to true to disable telemetry. | `bool` | `false` | no |
-| adminPassword | Password used for the host VM. | `securestring` | n/a | yes |
-| hostMachineCount | The number of host VMs to create for the cluster. (The first host VM will be the cluster server) | `int` | 3 | no |
-| customLocationsOid | The object id of the Custom Locations Entra ID application for your tenant.
Can be retrieved using:
az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv
| `string` | n/a | yes |
-| serverToken | The token that will be given to the server for the cluster or used by agent nodes. (Required for multi-node clusters where hostMachineCount > 1) | `securestring` | n/a | no |
-| shouldCreateAcrPrivateEndpoint | Whether to create a private endpoint for the Azure Container Registry. | `bool` | `false` | no |
-| shouldDeployAiFoundry | Whether to deploy AI Foundry resources. | `bool` | `false` | no |
-| aiFoundryConfig | The AI Foundry configuration settings. | `[_2.AiFoundryConfig](#user-defined-types)` | [variables('_2.aiFoundryConfigDefaults')] | no |
-| aiFoundryProjects | Array of AI Foundry projects to create. | `array` | [] | no |
-| aiFoundryRaiPolicies | Array of RAI policies to create. | `array` | [] | no |
-| aiFoundryModelDeployments | Array of model deployments to create. | `array` | [] | no |
-| shouldCreateAiFoundryPrivateEndpoint | Whether to create a private endpoint for AI Foundry. | `bool` | `false` | no |
-| shouldEnableNatGateway | Whether to enable NAT Gateway for managed outbound access. | `bool` | `false` | no |
-| shouldDisableDefaultOutboundAccess | Whether to disable default outbound access for subnets when NAT gateway is enabled. | `bool` | `true` | no |
-| natGatewayPublicIpCount | Number of public IP addresses for NAT Gateway (1-16). | `int` | 1 | no |
-| natGatewayIdleTimeoutMinutes | Idle timeout in minutes for NAT gateway connections (4-120). | `int` | 4 | no |
-| natGatewayZones | Availability zones for NAT Gateway. Empty array for regional deployment. | `array` | [] | no |
-| shouldEnableVpnGateway | Whether to deploy VPN Gateway for remote access. | `bool` | `false` | no |
-| vpnGatewayConfig | VPN Gateway configuration settings. | `[_1.VpnGatewayConfig](#user-defined-types)` | [variables('_1.vpnGatewayConfigDefaults')] | no |
-| vpnGatewayAzureAdConfig | Azure AD authentication configuration for VPN Gateway. | `[_1.AzureAdConfig](#user-defined-types)` | [variables('_1.azureAdConfigDefaults')] | no |
-| shouldEnablePrivateEndpoints | Whether to enable private endpoints across Key Vault, storage, and observability resources. | `bool` | `false` | no |
-| shouldEnablePrivateResolver | Whether to enable Azure Private Resolver for VPN client DNS resolution of private endpoints. | `bool` | `false` | no |
-| resolverSubnetAddressPrefix | Address prefix for the private resolver subnet; must be /28 or larger and not overlap with other subnets. | `string` | 10.0.9.0/28 | no |
-| shouldEnableKeyVaultPublicNetworkAccess | Whether to enable public network access for the Key Vault. | `bool` | `true` | no |
-| shouldEnableStoragePublicNetworkAccess | Whether to enable public network access for the storage account. | `bool` | `true` | no |
-| subnetAddressPrefixAcr | Address prefix for the ACR subnet. | `string` | 10.0.4.0/24 | no |
-| subnetAddressPrefixAks | Address prefix for the AKS subnet. | `string` | 10.0.5.0/24 | no |
-| subnetAddressPrefixAksPod | Address prefix for the AKS pod subnet. | `string` | 10.0.6.0/24 | no |
-| shouldCreateAks | Whether to create an Azure Kubernetes Service cluster. | `bool` | `false` | no |
-| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous AIO MQ Broker Listener. (Should only be used for dev or test environments) | `bool` | `false` | no |
-| shouldInitAio | Whether to deploy the Azure IoT Operations initial connected cluster resources, Secret Sync, ACSA, OSM, AIO Platform. | `bool` | `true` | no |
-| shouldDeployAio | Whether to deploy an Azure IoT Operations Instance and all of its required components into the connected cluster. | `bool` | `true` | no |
-| namespacedDevices | List of namespaced devices to create. | `array` | [] | no |
-| assetEndpointProfiles | List of asset endpoint profiles to create. | `array` | [] | no |
-| legacyAssets | List of legacy assets to create. | `array` | [] | no |
-| namespacedAssets | List of namespaced assets to create. | `array` | [] | no |
-| shouldEnableAkriRestConnector | Deploy Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriMediaConnector | Deploy Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `array` | [] | no |
+| Name | Description | Type | Default | Required |
+|:----------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------|:---------|
+| common | The common component configuration. | `[_5.Common](#user-defined-types)` | n/a | yes |
+| resourceGroupName | The name for the resource group. If not provided, a default name will be generated. | `string` | [format('rg-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| useExistingResourceGroup | Whether to use an existing resource group instead of creating a new one. | `bool` | `false` | no |
+| telemetry_opt_out | Whether to opt-out of telemetry. Set to true to disable telemetry. | `bool` | `false` | no |
+| adminPassword | Password used for the host VM. | `securestring` | n/a | yes |
+| hostMachineCount | The number of host VMs to create for the cluster. (The first host VM will be the cluster server) | `int` | 3 | no |
+| customLocationsOid | The object id of the Custom Locations Entra ID application for your tenant.
Can be retrieved using:
az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv
| `string` | n/a | yes |
+| serverToken | The token that will be given to the server for the cluster or used by agent nodes. (Required for multi-node clusters where hostMachineCount > 1) | `securestring` | n/a | no |
+| shouldCreateAcrPrivateEndpoint | Whether to create a private endpoint for the Azure Container Registry. | `bool` | `false` | no |
+| shouldDeployAiFoundry | Whether to deploy AI Foundry resources. | `bool` | `false` | no |
+| aiFoundryConfig | The AI Foundry configuration settings. | `[_2.AiFoundryConfig](#user-defined-types)` | [variables('_2.aiFoundryConfigDefaults')] | no |
+| aiFoundryProjects | Array of AI Foundry projects to create. | `[_2.AiProject](#user-defined-types)[]` | [] | no |
+| aiFoundryRaiPolicies | Array of RAI policies to create. | `[_2.RaiPolicy](#user-defined-types)[]` | [] | no |
+| aiFoundryModelDeployments | Array of model deployments to create. | `[_2.ModelDeployment](#user-defined-types)[]` | [] | no |
+| shouldCreateAiFoundryPrivateEndpoint | Whether to create a private endpoint for AI Foundry. | `bool` | `false` | no |
+| shouldEnableNatGateway | Whether to enable NAT Gateway for managed outbound access. | `bool` | `false` | no |
+| shouldDisableDefaultOutboundAccess | Whether to disable default outbound access for subnets when NAT gateway is enabled. | `bool` | `true` | no |
+| natGatewayPublicIpCount | Number of public IP addresses for NAT Gateway (1-16). | `int` | 1 | no |
+| natGatewayIdleTimeoutMinutes | Idle timeout in minutes for NAT gateway connections (4-120). | `int` | 4 | no |
+| natGatewayZones | Availability zones for NAT Gateway. Empty array for regional deployment. | `string[]` | [] | no |
+| shouldEnableVpnGateway | Whether to deploy VPN Gateway for remote access. | `bool` | `false` | no |
+| vpnGatewayConfig | VPN Gateway configuration settings. | `[_1.VpnGatewayConfig](#user-defined-types)` | [variables('_1.vpnGatewayConfigDefaults')] | no |
+| vpnGatewayAzureAdConfig | Azure AD authentication configuration for VPN Gateway. | `[_1.AzureAdConfig](#user-defined-types)` | [variables('_1.azureAdConfigDefaults')] | no |
+| shouldEnablePrivateEndpoints | Whether to enable private endpoints across Key Vault, storage, and observability resources. | `bool` | `false` | no |
+| shouldEnablePrivateResolver | Whether to enable Azure Private Resolver for VPN client DNS resolution of private endpoints. | `bool` | `false` | no |
+| resolverSubnetAddressPrefix | Address prefix for the private resolver subnet; must be /28 or larger and not overlap with other subnets. | `string` | 10.0.9.0/28 | no |
+| shouldEnableKeyVaultPublicNetworkAccess | Whether to enable public network access for the Key Vault. | `bool` | `true` | no |
+| shouldEnableStoragePublicNetworkAccess | Whether to enable public network access for the storage account. | `bool` | `true` | no |
+| subnetAddressPrefixAcr | Address prefix for the ACR subnet. | `string` | 10.0.4.0/24 | no |
+| subnetAddressPrefixAks | Address prefix for the AKS subnet. | `string` | 10.0.5.0/24 | no |
+| subnetAddressPrefixAksPod | Address prefix for the AKS pod subnet. | `string` | 10.0.6.0/24 | no |
+| shouldCreateAks | Whether to create an Azure Kubernetes Service cluster. | `bool` | `false` | no |
+| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous AIO MQ Broker Listener. (Should only be used for dev or test environments) | `bool` | `false` | no |
+| shouldInitAio | Whether to deploy the Azure IoT Operations initial connected cluster resources, Secret Sync, ACSA, OSM, AIO Platform. | `bool` | `true` | no |
+| shouldDeployAio | Whether to deploy an Azure IoT Operations Instance and all of its required components into the connected cluster. | `bool` | `true` | no |
+| namespacedDevices | List of namespaced devices to create. | `[_4.NamespacedDevice](#user-defined-types)[]` | [] | no |
+| assetEndpointProfiles | List of asset endpoint profiles to create. | `[_4.AssetEndpointProfile](#user-defined-types)[]` | [] | no |
+| legacyAssets | List of legacy assets to create. | `[_4.LegacyAsset](#user-defined-types)[]` | [] | no |
+| namespacedAssets | List of namespaced assets to create. | `[_4.NamespacedAsset](#user-defined-types)[]` | [] | no |
+| shouldEnableAkriRestConnector | Deploy Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriMediaConnector | Deploy Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `[_3.AkriConnectorTemplate](#user-defined-types)[]` | [] | no |
+| registryEndpoints | Custom container registry endpoints to add alongside the default MCR endpoint. | `[_3.RegistryEndpointConfig](#user-defined-types)[]` | [] | no |
+| shouldIncludeAcrRegistryEndpoint | Whether to include the deployed ACR as a registry endpoint with System Assigned Managed Identity authentication. | `bool` | `false` | no |
## Resources
@@ -232,6 +234,7 @@ Deploys Azure observability resources including Azure Monitor Workspace, Log Ana
| logAnalyticsName | `string` | The Log Analytics Workspace name. |
| logAnalyticsId | `string` | The Log Analytics Workspace ID. |
| grafanaName | `string` | The Azure Managed Grafana name. |
+| grafanaEndpoint | `string` | The Azure Managed Grafana endpoint. |
| metricsDataCollectionRuleName | `string` | The metrics data collection rule name. |
| logsDataCollectionRuleName | `string` | The logs data collection rule name. |
| monitorPrivateLinkScopeId | `string` | Azure Monitor Private Link Scope resource ID. |
@@ -709,6 +712,7 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `array` | [] | no |
+| registryEndpoints | List of additional container registry endpoints for pulling custom artifacts. MCR is always added automatically. | `array` | [] | no |
| akriMqttSharedConfig | Shared MQTT connection configuration for all Akri connectors. | `[_1.AkriMqttConfig](#user-defined-types)` | {'host': 'aio-broker:18883', 'audience': 'aio-internal', 'caConfigmap': 'azure-iot-operations-aio-ca-trust-bundle'} | no |
| customLocationName | The name for the Custom Locations resource. | `string` | [format('{0}-cl', parameters('arcConnectedClusterName'))] | no |
| additionalClusterExtensionIds | Additional cluster extension IDs to include in the custom location. (Appended to the default Secret Store and IoT Operations extension IDs) | `array` | [] | no |
@@ -739,27 +743,29 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| postInitScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
| iotOpsInstance | `Microsoft.Resources/deployments` | 2025-04-01 |
| akriConnectors | `Microsoft.Resources/deployments` | 2025-04-01 |
+| registryEndpointsModule | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScriptsSecrets | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
#### Outputs for edgeIotOps
-| Name | Type | Description |
-|:---------------------------|:---------|:-------------------------------------------------------------------|
-| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
-| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
-| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
-| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
-| customLocationId | `string` | The ID of the deployed Custom Location. |
-| customLocationName | `string` | The name of the deployed Custom Location. |
-| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
-| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
-| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
-| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
-| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
-| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
+| Name | Type | Description |
+|:---------------------------|:---------|:----------------------------------------------------------------------|
+| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
+| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
+| aioNamespace | `string` | The namespace in the cluster where Azure IoT Operations is installed. |
+| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
+| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
+| customLocationId | `string` | The ID of the deployed Custom Location. |
+| customLocationName | `string` | The name of the deployed Custom Location. |
+| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
+| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
+| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
+| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
+| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
+| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
### edgeAssets
@@ -1104,6 +1110,14 @@ Trust settings for Akri connector.
|:-------------------|:---------|:------------------------------------|
| trustListSecretRef | `string` | Reference to the trust list secret. |
+### `_3.ArtifactPullSecretSettings`
+
+Authentication settings for Artifact Pull Secret.
+
+| Property | Type | Description |
+|:----------|:---------|:--------------------------------------------------------------------------|
+| secretRef | `string` | The name of the kubernetes secret that contains the artifact pull secret. |
+
### `_3.BrokerPersistence`
Broker persistence configuration for disk-backed message storage.
@@ -1163,6 +1177,57 @@ The mode of the AIO instance feature. Either "Stable", "Preview" or "Disabled".
The setting value of the AIO instance feature. Either "Enabled" or "Disabled".
+### `_3.RegistryAuthAnonymous`
+
+Anonymous authentication for registry endpoint.
+
+| Property | Type | Description |
+|:------------------|:---------|:--------------------------------------------------|
+| method | `string` | Authentication method. |
+| anonymousSettings | `object` | Anonymous authentication settings (empty object). |
+
+### `_3.RegistryAuthArtifactPullSecret`
+
+Artifact Pull Secret authentication for registry endpoint.
+
+| Property | Type | Description |
+|:---------------------------|:-------------------------------------------------------|:-------------------------------|
+| method | `string` | Authentication method. |
+| artifactPullSecretSettings | `[_3.ArtifactPullSecretSettings](#user-defined-types)` | Artifact pull secret settings. |
+
+### `_3.RegistryAuthentication`
+
+Authentication configuration for a registry endpoint.
+
+### `_3.RegistryAuthSystemAssignedManagedIdentity`
+
+System-Assigned Managed Identity authentication for registry endpoint.
+
+| Property | Type | Description |
+|:--------------------------------------|:------------------------------------------------------------------|:-------------------------------------------|
+| method | `string` | Authentication method. |
+| systemAssignedManagedIdentitySettings | `[_3.SystemAssignedManagedIdentitySettings](#user-defined-types)` | System-assigned managed identity settings. |
+
+### `_3.RegistryAuthUserAssignedManagedIdentity`
+
+User-Assigned Managed Identity authentication for registry endpoint.
+
+| Property | Type | Description |
+|:------------------------------------|:----------------------------------------------------------------|:-----------------------------------------|
+| method | `string` | Authentication method. |
+| userAssignedManagedIdentitySettings | `[_3.UserAssignedManagedIdentitySettings](#user-defined-types)` | User-assigned managed identity settings. |
+
+### `_3.RegistryEndpointConfig`
+
+Container registry endpoint configuration for AIO instance.
+
+| Property | Type | Description |
+|:---------------|:---------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|
+| name | `string` | Unique name for the registry endpoint (3-63 chars, lowercase alphanumeric and hyphens). |
+| host | `string` | Container registry hostname (e.g., myregistry.azurecr.io). |
+| acrResourceId | `string` | Optional ACR resource ID for automatic AcrPull role assignment. Only applicable when authentication.method is SystemAssignedManagedIdentity. |
+| authentication | `[_3.RegistryAuthentication](#user-defined-types)` | Authentication configuration for the registry. |
+
### `_3.Release`
The common settings for Azure Arc Extensions.
@@ -1216,6 +1281,14 @@ The configuration for Self-Signed Issuer for Azure IoT Operations certificates.
|:------------|:---------|:------------|
| trustSource | `string` | |
+### `_3.SystemAssignedManagedIdentitySettings`
+
+Authentication settings for System-Assigned Managed Identity.
+
+| Property | Type | Description |
+|:---------|:---------|:--------------------------------------------------------------------------------------------------------|
+| audience | `string` | Audience of the service to authenticate against. Defaults to "" for ACR. |
+
### `_3.TrustConfigSource`
The config source of trust for how to use or generate Azure IoT Operations certificates.
@@ -1239,6 +1312,16 @@ The configuration for the trust settings of Azure IoT Operations certificates.
The source of trust for Azure IoT Operations certificates.
+### `_3.UserAssignedManagedIdentitySettings`
+
+Authentication settings for User-Assigned Managed Identity.
+
+| Property | Type | Description |
+|:---------|:---------|:---------------------------------------------------------------|
+| clientId | `string` | Client ID for the user-assigned managed identity. |
+| tenantId | `string` | Tenant ID where the managed identity is located. |
+| scope | `string` | Resource identifier (application ID URI) with .default suffix. |
+
### `_4.AssetAction`
Management action configuration for assets.
diff --git a/blueprints/full-multi-node-cluster/bicep/main.bicep b/blueprints/full-multi-node-cluster/bicep/main.bicep
index e10150b3..f08ee4dd 100644
--- a/blueprints/full-multi-node-cluster/bicep/main.bicep
+++ b/blueprints/full-multi-node-cluster/bicep/main.bicep
@@ -234,6 +234,32 @@ param shouldEnableAkriSseConnector bool = false
@description('List of custom Akri connector templates with user-defined endpoint types and container images.')
param customAkriConnectors types.AkriConnectorTemplate[] = []
+@description('Custom container registry endpoints to add alongside the default MCR endpoint.')
+param registryEndpoints types.RegistryEndpointConfig[] = []
+
+@description('Whether to include the deployed ACR as a registry endpoint with System Assigned Managed Identity authentication.')
+param shouldIncludeAcrRegistryEndpoint bool = false
+
+/*
+ Local Variables
+*/
+
+var acrRegistryEndpoint = shouldIncludeAcrRegistryEndpoint
+ ? [
+ {
+ name: 'acr-${common.resourcePrefix}'
+ host: '${cloudAcr.outputs.acrName}.azurecr.io'
+ acrResourceId: cloudAcr.outputs.acrId
+ authentication: {
+ method: 'SystemAssignedManagedIdentity'
+ systemAssignedManagedIdentitySettings: {}
+ }
+ }
+ ]
+ : []
+
+var combinedRegistryEndpoints = concat(registryEndpoints, acrRegistryEndpoint)
+
/*
Resources
*/
@@ -494,6 +520,7 @@ module edgeIotOps '../../../src/100-edge/110-iot-ops/bicep/main.bicep' = {
shouldEnableAkriOnvifConnector: shouldEnableAkriOnvifConnector
shouldEnableAkriSseConnector: shouldEnableAkriSseConnector
customAkriConnectors: customAkriConnectors
+ registryEndpoints: combinedRegistryEndpoints
}
}
diff --git a/blueprints/full-multi-node-cluster/terraform/README.md b/blueprints/full-multi-node-cluster/terraform/README.md
index 3cf82632..7dadec3a 100644
--- a/blueprints/full-multi-node-cluster/terraform/README.md
+++ b/blueprints/full-multi-node-cluster/terraform/README.md
@@ -56,114 +56,116 @@ with the single-node blueprint while preserving multi-node specific capabilities
## Inputs
-| Name | Description | Type | Default | Required |
-|------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------|:--------:|
-| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
-| location | Location for all resources in this module | `string` | n/a | yes |
-| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
-| acr\_allow\_trusted\_services | Whether trusted Azure services can bypass ACR network rules | `bool` | `true` | no |
-| acr\_allowed\_public\_ip\_ranges | CIDR ranges permitted to reach the ACR public endpoint | `list(string)` | `[]` | no |
-| acr\_data\_endpoint\_enabled | Whether to enable the dedicated ACR data endpoint | `bool` | `true` | no |
-| acr\_public\_network\_access\_enabled | Whether to enable the ACR public endpoint alongside private connectivity | `bool` | `false` | no |
-| acr\_sku | SKU name for the Azure Container Registry | `string` | `"Premium"` | no |
-| ai\_foundry\_model\_deployments | Map of model deployments for AI Foundry | ```map(object({ name = string model = object({ format = string name = string version = string }) scale = object({ type = string capacity = number }) rai_policy_name = optional(string) version_upgrade_option = optional(string, "OnceNewDefaultVersionAvailable") }))``` | `{}` | no |
-| ai\_foundry\_private\_dns\_zone\_ids | List of private DNS zone IDs for the AI Foundry private endpoint | `list(string)` | `[]` | no |
-| ai\_foundry\_projects | Map of AI Foundry projects to create. SKU defaults to 'S0' (currently the only supported value) | ```map(object({ name = string display_name = string description = string sku = optional(string, "S0") }))``` | `{}` | no |
-| ai\_foundry\_rai\_policies | Map of Responsible AI (RAI) content filtering policies. Must be created before referenced in model deployments. | ```map(object({ name = string base_policy_name = optional(string, "Microsoft.Default") mode = optional(string, "Blocking") content_filters = optional(list(object({ name = string enabled = optional(bool, true) blocking = optional(bool, true) severity_threshold = optional(string, "Medium") source = string })), []) }))``` | `{}` | no |
-| ai\_foundry\_should\_enable\_local\_auth | Whether to enable local (API key) authentication for AI Foundry | `bool` | `true` | no |
-| ai\_foundry\_should\_enable\_private\_endpoint | Whether to enable private endpoint for AI Foundry | `bool` | `false` | no |
-| ai\_foundry\_should\_enable\_public\_network\_access | Whether to enable public network access to AI Foundry | `bool` | `true` | no |
-| ai\_foundry\_sku | SKU name for the AI Foundry account | `string` | `"S0"` | no |
-| aio\_features | AIO instance features with mode ('Stable', 'Preview', 'Disabled') and settings ('Enabled', 'Disabled') | ```map(object({ mode = optional(string) settings = optional(map(string)) }))``` | `null` | no |
-| aks\_private\_dns\_zone\_id | ID of the private DNS zone to use for AKS private cluster. Use 'system', 'none', or a resource ID | `string` | `null` | no |
-| aks\_should\_enable\_private\_cluster | Whether to enable private cluster mode for AKS | `bool` | `false` | no |
-| aks\_should\_enable\_private\_cluster\_public\_fqdn | Whether to create a private cluster public FQDN for AKS | `bool` | `false` | no |
-| arc\_machine\_count | Number of Arc-enabled machines to target for the cluster when should\_use\_arc\_machines is true | `number` | `1` | no |
-| arc\_machine\_name\_prefix | Prefix for the Arc-enabled machine names; otherwise resource\_prefix when should\_use\_arc\_machines is true | `string` | `null` | no |
-| arc\_machine\_resource\_group\_name | Resource group name that contains the Arc-enabled servers when should\_use\_arc\_machines is true | `string` | `null` | no |
-| azureml\_ml\_workload\_subjects | Custom Kubernetes service account subjects for AzureML workload federation. Example: ['system:serviceaccount:azureml:azureml-workload', 'system:serviceaccount:osmo:osmo-workload'] | `list(string)` | `null` | no |
-| azureml\_registry\_should\_enable\_public\_network\_access | Whether to enable public network access to the Azure Machine Learning registry when deployed | `bool` | `true` | no |
-| azureml\_should\_create\_compute\_cluster | Whether to create a compute cluster for Azure Machine Learning training workloads | `bool` | `true` | no |
-| azureml\_should\_create\_ml\_workload\_identity | Whether to create a user-assigned managed identity for AzureML workload federation. | `bool` | `false` | no |
-| azureml\_should\_deploy\_registry | Whether to deploy Azure Machine Learning registry resources alongside the workspace | `bool` | `false` | no |
-| azureml\_should\_enable\_private\_endpoint | Whether to enable a private endpoint for the Azure Machine Learning workspace | `bool` | `false` | no |
-| azureml\_should\_enable\_public\_network\_access | Whether to enable public network access to the Azure Machine Learning workspace | `bool` | `true` | no |
-| certificate\_subject | Certificate subject information for auto-generated certificates | ```object({ common_name = optional(string, "Full Multi Node VPN Gateway Root Certificate") organization = optional(string, "Edge AI Accelerator") organizational_unit = optional(string, "IT") country = optional(string, "US") province = optional(string, "WA") locality = optional(string, "Redmond") })``` | `{}` | no |
-| certificate\_validity\_days | Validity period in days for auto-generated certificates | `number` | `365` | no |
-| cluster\_server\_host\_machine\_username | Username for the Arc or VM host machines that receive kube-config during setup Otherwise, resource\_prefix when the user exists on the machine | `string` | `null` | no |
-| cluster\_server\_ip | IP address for the cluster server used by node machines when should\_use\_arc\_machines is true | `string` | `null` | no |
-| custom\_akri\_connectors | List of custom Akri connector templates with user-defined endpoint types and container images. Supports built-in types (rest, media, onvif, sse) or custom types with custom\_endpoint\_type and custom\_image\_name. Built-in connectors default to mcr.microsoft.com/azureiotoperations/akri-connectors/connector\_type:0.5.1. | ```list(object({ name = string type = string // "rest", "media", "onvif", "sse", "custom" // Custom Connector Fields (required when type = "custom") custom_endpoint_type = optional(string) // e.g., "Contoso.Modbus", "Acme.CustomProtocol" custom_image_name = optional(string) // e.g., "my_acr.azurecr.io/custom-connector" custom_endpoint_version = optional(string, "1.0") // Runtime Configuration (defaults applied based on connector type) registry = optional(string) // Defaults: mcr.microsoft.com for built-in types image_tag = optional(string) // Defaults: 0.5.1 for built-in types, latest for custom replicas = optional(number, 1) image_pull_policy = optional(string) // Default: IfNotPresent // Diagnostics log_level = optional(string) // Default: info (lowercase: trace, debug, info, warning, error, critical) // MQTT Override (uses shared config if not provided) mqtt_config = optional(object({ host = string audience = string ca_configmap = string keep_alive_seconds = optional(number, 60) max_inflight_messages = optional(number, 100) session_expiry_seconds = optional(number, 600) })) // Optional Advanced Fields aio_min_version = optional(string) aio_max_version = optional(string) allocation = optional(object({ policy = string // "Bucketized" bucket_size = number // 1-100 })) additional_configuration = optional(map(string)) secrets = optional(list(object({ secret_alias = string secret_key = string secret_ref = string }))) trust_settings = optional(object({ trust_list_secret_ref = string })) }))``` | `[]` | no |
-| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant If none is provided, the script attempts to retrieve this value which requires 'Application.Read.All' or 'Directory.Read.All' permissions ```sh az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv``` | `string` | `null` | no |
-| dns\_prefix | DNS prefix for the AKS cluster. When null a value is generated | `string` | `null` | no |
-| enable\_auto\_scaling | Whether to enable auto-scaling for the default node pool | `bool` | `false` | no |
-| existing\_certificate\_name | Name of the existing certificate in Key Vault when vpn\_gateway\_should\_generate\_ca is false | `string` | `null` | no |
-| host\_machine\_count | Number of edge host virtual machines to create for the multi-node cluster | `number` | `3` | no |
-| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
-| max\_count | Maximum node count for the default node pool | `number` | `null` | no |
-| min\_count | Minimum node count for the default node pool | `number` | `null` | no |
-| namespaced\_assets | List of namespaced assets with enhanced configuration support | ```list(object({ name = string display_name = optional(string) device_ref = optional(object({ device_name = string endpoint_name = string })) asset_endpoint_profile_ref = optional(string) default_datasets_configuration = optional(string) default_streams_configuration = optional(string) default_events_configuration = optional(string) description = optional(string) documentation_uri = optional(string) enabled = optional(bool, true) hardware_revision = optional(string) manufacturer = optional(string) manufacturer_uri = optional(string) model = optional(string) product_code = optional(string) serial_number = optional(string) software_revision = optional(string) attributes = optional(map(string), {}) datasets = optional(list(object({ name = string data_points = list(object({ data_point_configuration = optional(string) data_source = string name = string observability_mode = optional(string) rest_sampling_interval_ms = optional(number) rest_mqtt_topic = optional(string) rest_include_state_store = optional(bool) rest_state_store_key = optional(string) })) dataset_configuration = optional(string) data_source = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) type_ref = optional(string) })), []) streams = optional(list(object({ name = string stream_configuration = optional(string) type_ref = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) })), []) event_groups = optional(list(object({ name = string data_source = optional(string) event_group_configuration = optional(string) type_ref = optional(string) default_destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) events = list(object({ name = string data_source = string event_configuration = optional(string) type_ref = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) })) })), []) management_groups = optional(list(object({ name = string data_source = optional(string) management_group_configuration = optional(string) type_ref = optional(string) default_topic = optional(string) default_timeout_in_seconds = optional(number, 100) actions = list(object({ name = string action_type = string target_uri = string topic = optional(string) timeout_in_seconds = optional(number) action_configuration = optional(string) type_ref = optional(string) })) })), []) }))``` | `[]` | no |
-| namespaced\_devices | List of namespaced devices to create; otherwise, an empty list | ```list(object({ name = string enabled = optional(bool, true) endpoints = object({ outbound = optional(object({ assigned = object({}) }), { assigned = {} }) inbound = map(object({ endpoint_type = string address = string version = optional(string, null) additionalConfiguration = optional(string) authentication = object({ method = string usernamePasswordCredentials = optional(object({ usernameSecretName = string passwordSecretName = string })) x509Credentials = optional(object({ certificateSecretName = string })) }) trustSettings = optional(object({ trustList = string })) })) }) }))``` | `[]` | no |
-| nat\_gateway\_idle\_timeout\_minutes | Idle timeout in minutes for NAT gateway connections | `number` | `4` | no |
-| nat\_gateway\_public\_ip\_count | Number of public IP addresses to associate with the NAT gateway (example: 2) | `number` | `1` | no |
-| nat\_gateway\_zones | Availability zones for NAT gateway resources when zone redundancy is required (example: ['1','2']) | `list(string)` | `[]` | no |
-| node\_count | Number of nodes for the agent pool in the AKS cluster | `number` | `1` | no |
-| node\_pools | Additional node pools for the AKS cluster; map key is used as the node pool name | ```map(object({ node_count = number vm_size = string subnet_address_prefixes = list(string) pod_subnet_address_prefixes = list(string) node_taints = optional(list(string), []) enable_auto_scaling = optional(bool, false) min_count = optional(number, null) max_count = optional(number, null) }))``` | `{}` | no |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster | `string` | `"Standard_D8ds_v5"` | no |
-| postgresql\_admin\_password | Administrator password for PostgreSQL server. (Otherwise, generated when postgresql\_should\_generate\_admin\_password is true). | `string` | `null` | no |
-| postgresql\_admin\_username | Administrator username for PostgreSQL server | `string` | `"pgadmin"` | no |
-| postgresql\_databases | Map of databases to create with collation and charset | ```map(object({ collation = string charset = string }))``` | `null` | no |
-| postgresql\_delegated\_subnet\_id | Subnet ID with delegation to Microsoft.DBforPostgreSQL/flexibleServers | `string` | `null` | no |
-| postgresql\_should\_enable\_extensions | Whether to enable PostgreSQL extensions via azure.extensions | `bool` | `true` | no |
-| postgresql\_should\_enable\_geo\_redundant\_backup | Whether to enable geo-redundant backups for PostgreSQL | `bool` | `false` | no |
-| postgresql\_should\_enable\_timescaledb | Whether to enable TimescaleDB extension for PostgreSQL | `bool` | `true` | no |
-| postgresql\_should\_generate\_admin\_password | Whether to auto-generate PostgreSQL admin password. | `bool` | `true` | no |
-| postgresql\_should\_store\_credentials\_in\_key\_vault | Whether to store PostgreSQL admin credentials in Key Vault. | `bool` | `true` | no |
-| postgresql\_sku\_name | SKU name for PostgreSQL server | `string` | `"GP_Standard_D2s_v3"` | no |
-| postgresql\_storage\_mb | Storage size in megabytes for PostgreSQL | `number` | `32768` | no |
-| postgresql\_version | PostgreSQL server version | `string` | `"16"` | no |
-| redis\_clustering\_policy | Clustering policy for Redis cache (OSSCluster or EnterpriseCluster) | `string` | `"OSSCluster"` | no |
-| redis\_should\_enable\_high\_availability | Whether to enable high availability for Redis cache | `bool` | `true` | no |
-| redis\_sku\_name | SKU name for Azure Managed Redis cache | `string` | `"Balanced_B10"` | no |
-| resolver\_subnet\_address\_prefix | Address prefix for the private resolver subnet; must be /28 or larger and not overlap with other subnets | `string` | `"10.0.9.0/28"` | no |
-| resource\_group\_name | Name of the resource group to create or use. Otherwise, 'rg-{resource\_prefix}-{environment}-{instance}' | `string` | `null` | no |
-| should\_add\_current\_user\_cluster\_admin | Whether to give the current signed-in user cluster-admin permissions on the new cluster | `bool` | `true` | no |
-| should\_create\_aks | Whether to deploy Azure Kubernetes Service | `bool` | `false` | no |
-| should\_create\_aks\_identity | Whether to create a user-assigned identity for the AKS cluster when using custom private DNS zones | `bool` | `false` | no |
-| should\_create\_anonymous\_broker\_listener | Whether to enable an insecure anonymous AIO MQ broker listener; use only for dev or test environments | `bool` | `false` | no |
-| should\_create\_azure\_functions | Whether to create the Azure Functions resources including the App Service plan | `bool` | `false` | no |
-| should\_deploy\_ai\_foundry | Whether to deploy Azure AI Foundry resources | `bool` | `false` | no |
-| should\_deploy\_azureml | Whether to deploy the Azure Machine Learning workspace and optional compute cluster | `bool` | `false` | no |
-| should\_deploy\_edge\_azureml | Whether to deploy the Azure Machine Learning edge extension when Azure ML is enabled | `bool` | `false` | no |
-| should\_deploy\_postgresql | Whether to deploy PostgreSQL Flexible Server component | `bool` | `false` | no |
-| should\_deploy\_redis | Whether to deploy Azure Managed Redis component | `bool` | `false` | no |
-| should\_deploy\_resource\_sync\_rules | Whether to deploy resource sync rules | `bool` | `false` | no |
-| should\_enable\_akri\_media\_connector | Whether to deploy the Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| should\_enable\_akri\_onvif\_connector | Whether to deploy the Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| should\_enable\_akri\_rest\_connector | Whether to deploy the Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| should\_enable\_akri\_sse\_connector | Whether to deploy the Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| should\_enable\_key\_vault\_public\_network\_access | Whether to enable public network access for the Key Vault | `bool` | `true` | no |
-| should\_enable\_managed\_outbound\_access | Whether to enable managed outbound egress via NAT gateway instead of platform default internet access | `bool` | `true` | no |
-| should\_enable\_oidc\_issuer | Whether to enable the OIDC issuer URL for the cluster | `bool` | `true` | no |
-| should\_enable\_opc\_ua\_simulator | Whether to deploy the OPC UA simulator to the cluster | `bool` | `false` | no |
-| should\_enable\_otel\_collector | Whether to deploy the OpenTelemetry Collector and Azure Monitor ConfigMap | `bool` | `true` | no |
-| should\_enable\_private\_endpoints | Whether to enable private endpoints across Key Vault, storage, and observability resources so Prometheus ingestion remains on private link | `bool` | `false` | no |
-| should\_enable\_private\_resolver | Whether to enable Azure Private Resolver for VPN client DNS resolution of private endpoints | `bool` | `false` | no |
-| should\_enable\_storage\_public\_network\_access | Whether to enable public network access for the storage account | `bool` | `true` | no |
-| should\_enable\_vpn\_gateway | Whether to create a VPN gateway for secure access to private endpoints | `bool` | `false` | no |
-| should\_enable\_workload\_identity | Whether to enable Azure AD workload identity for the cluster | `bool` | `true` | no |
-| should\_get\_custom\_locations\_oid | Whether to get the Custom Locations object ID using Terraform's azuread provider Otherwise, provide 'custom\_locations\_oid' or rely on `az connectedk8s enable-features` during cluster setup | `bool` | `true` | no |
-| should\_use\_arc\_machines | Whether to orchestrate the cluster using existing Arc-enabled servers instead of deploying Azure virtual machines | `bool` | `false` | no |
-| storage\_account\_is\_hns\_enabled | Whether to enable hierarchical namespace on the storage account when Azure Machine Learning is not deployed; automatically forced to false when should\_deploy\_azureml is true | `bool` | `true` | no |
-| subnet\_address\_prefixes\_aks | Address prefixes for the AKS subnet | `list(string)` | ```[ "10.0.4.0/24" ]``` | no |
-| subnet\_address\_prefixes\_aks\_pod | Address prefixes for the AKS pod subnet | `list(string)` | ```[ "10.0.5.0/24" ]``` | no |
-| use\_existing\_resource\_group | Whether to use an existing resource group with the provided or computed name instead of creating a new one | `bool` | `false` | no |
-| vpn\_gateway\_config | VPN gateway configuration including SKU, generation, client address pool, and supported protocols | ```object({ sku = optional(string, "VpnGw1") generation = optional(string, "Generation1") client_address_pool = optional(list(string), ["192.168.200.0/24"]) protocols = optional(list(string), ["OpenVPN", "IkeV2"]) })``` | `{}` | no |
-| vpn\_gateway\_should\_generate\_ca | Whether to generate a new CA certificate; when false, uses an existing certificate from Key Vault | `bool` | `true` | no |
-| vpn\_gateway\_should\_use\_azure\_ad\_auth | Whether to use Azure AD authentication for the VPN gateway; otherwise, certificate authentication is used | `bool` | `true` | no |
-| vpn\_gateway\_subnet\_address\_prefixes | Address prefixes for the GatewaySubnet; must be /27 or larger | `list(string)` | ```[ "10.0.2.0/27" ]``` | no |
-| vpn\_site\_connections | Site-to-site VPN connection definitions. Provide on-premises address spaces that do not overlap with Azure VNets | ```list(object({ name = string address_spaces = list(string) shared_key_reference = string connection_mode = optional(string, "Default") dpd_timeout_seconds = optional(number) gateway_fqdn = optional(string) gateway_ip_address = optional(string) ike_protocol = optional(string, "IKEv2") use_policy_based_selectors = optional(bool, false) bgp_settings = optional(object({ asn = number peer_address = string peer_weight = optional(number) })) ipsec_policy = optional(object({ dh_group = string ike_encryption = string ike_integrity = string ipsec_encryption = string ipsec_integrity = string pfs_group = string sa_datasize_kb = optional(number) sa_lifetime_seconds = optional(number) })) }))``` | `[]` | no |
-| vpn\_site\_default\_ipsec\_policy | Fallback IPsec parameters applied when site definitions omit ipsec\_policy | ```object({ dh_group = string ike_encryption = string ike_integrity = string ipsec_encryption = string ipsec_integrity = string pfs_group = string sa_datasize_kb = optional(number) sa_lifetime_seconds = optional(number) })``` | `null` | no |
-| vpn\_site\_shared\_keys | Pre-shared keys for site connections keyed by shared\_key\_reference. Manage values in secure secret stores | `map(string)` | `{}` | no |
+| Name | Description | Type | Default | Required |
+|------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------|:--------:|
+| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
+| location | Location for all resources in this module | `string` | n/a | yes |
+| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| acr\_allow\_trusted\_services | Whether trusted Azure services can bypass ACR network rules | `bool` | `true` | no |
+| acr\_allowed\_public\_ip\_ranges | CIDR ranges permitted to reach the ACR public endpoint | `list(string)` | `[]` | no |
+| acr\_data\_endpoint\_enabled | Whether to enable the dedicated ACR data endpoint | `bool` | `true` | no |
+| acr\_public\_network\_access\_enabled | Whether to enable the ACR public endpoint alongside private connectivity | `bool` | `false` | no |
+| acr\_sku | SKU name for the Azure Container Registry | `string` | `"Premium"` | no |
+| ai\_foundry\_model\_deployments | Map of model deployments for AI Foundry | ```map(object({ name = string model = object({ format = string name = string version = string }) scale = object({ type = string capacity = number }) rai_policy_name = optional(string) version_upgrade_option = optional(string, "OnceNewDefaultVersionAvailable") }))``` | `{}` | no |
+| ai\_foundry\_private\_dns\_zone\_ids | List of private DNS zone IDs for the AI Foundry private endpoint | `list(string)` | `[]` | no |
+| ai\_foundry\_projects | Map of AI Foundry projects to create. SKU defaults to 'S0' (currently the only supported value) | ```map(object({ name = string display_name = string description = string sku = optional(string, "S0") }))``` | `{}` | no |
+| ai\_foundry\_rai\_policies | Map of Responsible AI (RAI) content filtering policies. Must be created before referenced in model deployments. | ```map(object({ name = string base_policy_name = optional(string, "Microsoft.Default") mode = optional(string, "Blocking") content_filters = optional(list(object({ name = string enabled = optional(bool, true) blocking = optional(bool, true) severity_threshold = optional(string, "Medium") source = string })), []) }))``` | `{}` | no |
+| ai\_foundry\_should\_enable\_local\_auth | Whether to enable local (API key) authentication for AI Foundry | `bool` | `true` | no |
+| ai\_foundry\_should\_enable\_private\_endpoint | Whether to enable private endpoint for AI Foundry | `bool` | `false` | no |
+| ai\_foundry\_should\_enable\_public\_network\_access | Whether to enable public network access to AI Foundry | `bool` | `true` | no |
+| ai\_foundry\_sku | SKU name for the AI Foundry account | `string` | `"S0"` | no |
+| aio\_features | AIO instance features with mode ('Stable', 'Preview', 'Disabled') and settings ('Enabled', 'Disabled') | ```map(object({ mode = optional(string) settings = optional(map(string)) }))``` | `null` | no |
+| aks\_private\_dns\_zone\_id | ID of the private DNS zone to use for AKS private cluster. Use 'system', 'none', or a resource ID | `string` | `null` | no |
+| aks\_should\_enable\_private\_cluster | Whether to enable private cluster mode for AKS | `bool` | `false` | no |
+| aks\_should\_enable\_private\_cluster\_public\_fqdn | Whether to create a private cluster public FQDN for AKS | `bool` | `false` | no |
+| arc\_machine\_count | Number of Arc-enabled machines to target for the cluster when should\_use\_arc\_machines is true | `number` | `1` | no |
+| arc\_machine\_name\_prefix | Prefix for the Arc-enabled machine names; otherwise resource\_prefix when should\_use\_arc\_machines is true | `string` | `null` | no |
+| arc\_machine\_resource\_group\_name | Resource group name that contains the Arc-enabled servers when should\_use\_arc\_machines is true | `string` | `null` | no |
+| azureml\_ml\_workload\_subjects | Custom Kubernetes service account subjects for AzureML workload federation. Example: ['system:serviceaccount:azureml:azureml-workload', 'system:serviceaccount:osmo:osmo-workload'] | `list(string)` | `null` | no |
+| azureml\_registry\_should\_enable\_public\_network\_access | Whether to enable public network access to the Azure Machine Learning registry when deployed | `bool` | `true` | no |
+| azureml\_should\_create\_compute\_cluster | Whether to create a compute cluster for Azure Machine Learning training workloads | `bool` | `true` | no |
+| azureml\_should\_create\_ml\_workload\_identity | Whether to create a user-assigned managed identity for AzureML workload federation. | `bool` | `false` | no |
+| azureml\_should\_deploy\_registry | Whether to deploy Azure Machine Learning registry resources alongside the workspace | `bool` | `false` | no |
+| azureml\_should\_enable\_private\_endpoint | Whether to enable a private endpoint for the Azure Machine Learning workspace | `bool` | `false` | no |
+| azureml\_should\_enable\_public\_network\_access | Whether to enable public network access to the Azure Machine Learning workspace | `bool` | `true` | no |
+| certificate\_subject | Certificate subject information for auto-generated certificates | ```object({ common_name = optional(string, "Full Multi Node VPN Gateway Root Certificate") organization = optional(string, "Edge AI Accelerator") organizational_unit = optional(string, "IT") country = optional(string, "US") province = optional(string, "WA") locality = optional(string, "Redmond") })``` | `{}` | no |
+| certificate\_validity\_days | Validity period in days for auto-generated certificates | `number` | `365` | no |
+| cluster\_server\_host\_machine\_username | Username for the Arc or VM host machines that receive kube-config during setup Otherwise, resource\_prefix when the user exists on the machine | `string` | `null` | no |
+| cluster\_server\_ip | IP address for the cluster server used by node machines when should\_use\_arc\_machines is true | `string` | `null` | no |
+| custom\_akri\_connectors | List of custom Akri connector templates with user-defined endpoint types and container images. Supports built-in types (rest, media, onvif, sse) or custom types with custom\_endpoint\_type and custom\_image\_name. Built-in connectors default to mcr.microsoft.com/azureiotoperations/akri-connectors/connector\_type:0.5.1. | ```list(object({ name = string type = string // "rest", "media", "onvif", "sse", "custom" // Custom Connector Fields (required when type = "custom") custom_endpoint_type = optional(string) // e.g., "Contoso.Modbus", "Acme.CustomProtocol" custom_image_name = optional(string) // e.g., "my_acr.azurecr.io/custom-connector" custom_endpoint_version = optional(string, "1.0") // Runtime Configuration (defaults applied based on connector type) registry = optional(string) // Defaults: mcr.microsoft.com for built-in types image_tag = optional(string) // Defaults: 0.5.1 for built-in types, latest for custom replicas = optional(number, 1) image_pull_policy = optional(string) // Default: IfNotPresent // Diagnostics log_level = optional(string) // Default: info (lowercase: trace, debug, info, warning, error, critical) // MQTT Override (uses shared config if not provided) mqtt_config = optional(object({ host = string audience = string ca_configmap = string keep_alive_seconds = optional(number, 60) max_inflight_messages = optional(number, 100) session_expiry_seconds = optional(number, 600) })) // Optional Advanced Fields aio_min_version = optional(string) aio_max_version = optional(string) allocation = optional(object({ policy = string // "Bucketized" bucket_size = number // 1-100 })) additional_configuration = optional(map(string)) secrets = optional(list(object({ secret_alias = string secret_key = string secret_ref = string }))) trust_settings = optional(object({ trust_list_secret_ref = string })) }))``` | `[]` | no |
+| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant If none is provided, the script attempts to retrieve this value which requires 'Application.Read.All' or 'Directory.Read.All' permissions ```sh az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv``` | `string` | `null` | no |
+| dns\_prefix | DNS prefix for the AKS cluster. When null a value is generated | `string` | `null` | no |
+| enable\_auto\_scaling | Whether to enable auto-scaling for the default node pool | `bool` | `false` | no |
+| existing\_certificate\_name | Name of the existing certificate in Key Vault when vpn\_gateway\_should\_generate\_ca is false | `string` | `null` | no |
+| host\_machine\_count | Number of edge host virtual machines to create for the multi-node cluster | `number` | `3` | no |
+| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
+| max\_count | Maximum node count for the default node pool | `number` | `null` | no |
+| min\_count | Minimum node count for the default node pool | `number` | `null` | no |
+| namespaced\_assets | List of namespaced assets with enhanced configuration support | ```list(object({ name = string display_name = optional(string) device_ref = optional(object({ device_name = string endpoint_name = string })) asset_endpoint_profile_ref = optional(string) default_datasets_configuration = optional(string) default_streams_configuration = optional(string) default_events_configuration = optional(string) description = optional(string) documentation_uri = optional(string) enabled = optional(bool, true) hardware_revision = optional(string) manufacturer = optional(string) manufacturer_uri = optional(string) model = optional(string) product_code = optional(string) serial_number = optional(string) software_revision = optional(string) attributes = optional(map(string), {}) datasets = optional(list(object({ name = string data_points = list(object({ data_point_configuration = optional(string) data_source = string name = string observability_mode = optional(string) rest_sampling_interval_ms = optional(number) rest_mqtt_topic = optional(string) rest_include_state_store = optional(bool) rest_state_store_key = optional(string) })) dataset_configuration = optional(string) data_source = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) type_ref = optional(string) })), []) streams = optional(list(object({ name = string stream_configuration = optional(string) type_ref = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) })), []) event_groups = optional(list(object({ name = string data_source = optional(string) event_group_configuration = optional(string) type_ref = optional(string) default_destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) events = list(object({ name = string data_source = string event_configuration = optional(string) type_ref = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) })) })), []) management_groups = optional(list(object({ name = string data_source = optional(string) management_group_configuration = optional(string) type_ref = optional(string) default_topic = optional(string) default_timeout_in_seconds = optional(number, 100) actions = list(object({ name = string action_type = string target_uri = string topic = optional(string) timeout_in_seconds = optional(number) action_configuration = optional(string) type_ref = optional(string) })) })), []) }))``` | `[]` | no |
+| namespaced\_devices | List of namespaced devices to create; otherwise, an empty list | ```list(object({ name = string enabled = optional(bool, true) endpoints = object({ outbound = optional(object({ assigned = object({}) }), { assigned = {} }) inbound = map(object({ endpoint_type = string address = string version = optional(string, null) additionalConfiguration = optional(string) authentication = object({ method = string usernamePasswordCredentials = optional(object({ usernameSecretName = string passwordSecretName = string })) x509Credentials = optional(object({ certificateSecretName = string })) }) trustSettings = optional(object({ trustList = string })) })) }) }))``` | `[]` | no |
+| nat\_gateway\_idle\_timeout\_minutes | Idle timeout in minutes for NAT gateway connections | `number` | `4` | no |
+| nat\_gateway\_public\_ip\_count | Number of public IP addresses to associate with the NAT gateway (example: 2) | `number` | `1` | no |
+| nat\_gateway\_zones | Availability zones for NAT gateway resources when zone redundancy is required (example: ['1','2']) | `list(string)` | `[]` | no |
+| node\_count | Number of nodes for the agent pool in the AKS cluster | `number` | `1` | no |
+| node\_pools | Additional node pools for the AKS cluster; map key is used as the node pool name | ```map(object({ node_count = number vm_size = string subnet_address_prefixes = list(string) pod_subnet_address_prefixes = list(string) node_taints = optional(list(string), []) enable_auto_scaling = optional(bool, false) min_count = optional(number, null) max_count = optional(number, null) }))``` | `{}` | no |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster | `string` | `"Standard_D8ds_v5"` | no |
+| postgresql\_admin\_password | Administrator password for PostgreSQL server. (Otherwise, generated when postgresql\_should\_generate\_admin\_password is true). | `string` | `null` | no |
+| postgresql\_admin\_username | Administrator username for PostgreSQL server | `string` | `"pgadmin"` | no |
+| postgresql\_databases | Map of databases to create with collation and charset | ```map(object({ collation = string charset = string }))``` | `null` | no |
+| postgresql\_delegated\_subnet\_id | Subnet ID with delegation to Microsoft.DBforPostgreSQL/flexibleServers | `string` | `null` | no |
+| postgresql\_should\_enable\_extensions | Whether to enable PostgreSQL extensions via azure.extensions | `bool` | `true` | no |
+| postgresql\_should\_enable\_geo\_redundant\_backup | Whether to enable geo-redundant backups for PostgreSQL | `bool` | `false` | no |
+| postgresql\_should\_enable\_timescaledb | Whether to enable TimescaleDB extension for PostgreSQL | `bool` | `true` | no |
+| postgresql\_should\_generate\_admin\_password | Whether to auto-generate PostgreSQL admin password. | `bool` | `true` | no |
+| postgresql\_should\_store\_credentials\_in\_key\_vault | Whether to store PostgreSQL admin credentials in Key Vault. | `bool` | `true` | no |
+| postgresql\_sku\_name | SKU name for PostgreSQL server | `string` | `"GP_Standard_D2s_v3"` | no |
+| postgresql\_storage\_mb | Storage size in megabytes for PostgreSQL | `number` | `32768` | no |
+| postgresql\_version | PostgreSQL server version | `string` | `"16"` | no |
+| redis\_clustering\_policy | Clustering policy for Redis cache (OSSCluster or EnterpriseCluster) | `string` | `"OSSCluster"` | no |
+| redis\_should\_enable\_high\_availability | Whether to enable high availability for Redis cache | `bool` | `true` | no |
+| redis\_sku\_name | SKU name for Azure Managed Redis cache | `string` | `"Balanced_B10"` | no |
+| registry\_endpoints | List of additional container registry endpoints for pulling custom artifacts (WASM modules, graph definitions, connector templates). MCR (mcr.microsoft.com) is always added automatically with anonymous authentication. The `acr_resource_id` field enables automatic AcrPull role assignment for ACR endpoints using SystemAssignedManagedIdentity authentication. When `should_assign_acr_pull_for_aio` is true and `acr_resource_id` is provided, the AIO extension's identity will be granted AcrPull access to the specified ACR. | ```list(object({ name = string host = string acr_resource_id = optional(string) should_assign_acr_pull_for_aio = optional(bool, false) authentication = object({ method = string system_assigned_managed_identity_settings = optional(object({ audience = optional(string) })) user_assigned_managed_identity_settings = optional(object({ client_id = string tenant_id = string scope = optional(string) })) artifact_pull_secret_settings = optional(object({ secret_ref = string })) }) }))``` | `[]` | no |
+| resolver\_subnet\_address\_prefix | Address prefix for the private resolver subnet; must be /28 or larger and not overlap with other subnets | `string` | `"10.0.9.0/28"` | no |
+| resource\_group\_name | Name of the resource group to create or use. Otherwise, 'rg-{resource\_prefix}-{environment}-{instance}' | `string` | `null` | no |
+| should\_add\_current\_user\_cluster\_admin | Whether to give the current signed-in user cluster-admin permissions on the new cluster | `bool` | `true` | no |
+| should\_create\_aks | Whether to deploy Azure Kubernetes Service | `bool` | `false` | no |
+| should\_create\_aks\_identity | Whether to create a user-assigned identity for the AKS cluster when using custom private DNS zones | `bool` | `false` | no |
+| should\_create\_anonymous\_broker\_listener | Whether to enable an insecure anonymous AIO MQ broker listener; use only for dev or test environments | `bool` | `false` | no |
+| should\_create\_azure\_functions | Whether to create the Azure Functions resources including the App Service plan | `bool` | `false` | no |
+| should\_deploy\_ai\_foundry | Whether to deploy Azure AI Foundry resources | `bool` | `false` | no |
+| should\_deploy\_azureml | Whether to deploy the Azure Machine Learning workspace and optional compute cluster | `bool` | `false` | no |
+| should\_deploy\_edge\_azureml | Whether to deploy the Azure Machine Learning edge extension when Azure ML is enabled | `bool` | `false` | no |
+| should\_deploy\_postgresql | Whether to deploy PostgreSQL Flexible Server component | `bool` | `false` | no |
+| should\_deploy\_redis | Whether to deploy Azure Managed Redis component | `bool` | `false` | no |
+| should\_deploy\_resource\_sync\_rules | Whether to deploy resource sync rules | `bool` | `false` | no |
+| should\_enable\_akri\_media\_connector | Whether to deploy the Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| should\_enable\_akri\_onvif\_connector | Whether to deploy the Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| should\_enable\_akri\_rest\_connector | Whether to deploy the Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| should\_enable\_akri\_sse\_connector | Whether to deploy the Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| should\_enable\_key\_vault\_public\_network\_access | Whether to enable public network access for the Key Vault | `bool` | `true` | no |
+| should\_enable\_managed\_outbound\_access | Whether to enable managed outbound egress via NAT gateway instead of platform default internet access | `bool` | `true` | no |
+| should\_enable\_oidc\_issuer | Whether to enable the OIDC issuer URL for the cluster | `bool` | `true` | no |
+| should\_enable\_opc\_ua\_simulator | Whether to deploy the OPC UA simulator to the cluster | `bool` | `false` | no |
+| should\_enable\_otel\_collector | Whether to deploy the OpenTelemetry Collector and Azure Monitor ConfigMap | `bool` | `true` | no |
+| should\_enable\_private\_endpoints | Whether to enable private endpoints across Key Vault, storage, and observability resources so Prometheus ingestion remains on private link | `bool` | `false` | no |
+| should\_enable\_private\_resolver | Whether to enable Azure Private Resolver for VPN client DNS resolution of private endpoints | `bool` | `false` | no |
+| should\_enable\_storage\_public\_network\_access | Whether to enable public network access for the storage account | `bool` | `true` | no |
+| should\_enable\_vpn\_gateway | Whether to create a VPN gateway for secure access to private endpoints | `bool` | `false` | no |
+| should\_enable\_workload\_identity | Whether to enable Azure AD workload identity for the cluster | `bool` | `true` | no |
+| should\_get\_custom\_locations\_oid | Whether to get the Custom Locations object ID using Terraform's azuread provider Otherwise, provide 'custom\_locations\_oid' or rely on `az connectedk8s enable-features` during cluster setup | `bool` | `true` | no |
+| should\_include\_acr\_registry\_endpoint | Whether to include the deployed ACR as a registry endpoint with System Assigned Managed Identity authentication | `bool` | `false` | no |
+| should\_use\_arc\_machines | Whether to orchestrate the cluster using existing Arc-enabled servers instead of deploying Azure virtual machines | `bool` | `false` | no |
+| storage\_account\_is\_hns\_enabled | Whether to enable hierarchical namespace on the storage account when Azure Machine Learning is not deployed; automatically forced to false when should\_deploy\_azureml is true | `bool` | `true` | no |
+| subnet\_address\_prefixes\_aks | Address prefixes for the AKS subnet | `list(string)` | ```[ "10.0.4.0/24" ]``` | no |
+| subnet\_address\_prefixes\_aks\_pod | Address prefixes for the AKS pod subnet | `list(string)` | ```[ "10.0.5.0/24" ]``` | no |
+| use\_existing\_resource\_group | Whether to use an existing resource group with the provided or computed name instead of creating a new one | `bool` | `false` | no |
+| vpn\_gateway\_config | VPN gateway configuration including SKU, generation, client address pool, and supported protocols | ```object({ sku = optional(string, "VpnGw1") generation = optional(string, "Generation1") client_address_pool = optional(list(string), ["192.168.200.0/24"]) protocols = optional(list(string), ["OpenVPN", "IkeV2"]) })``` | `{}` | no |
+| vpn\_gateway\_should\_generate\_ca | Whether to generate a new CA certificate; when false, uses an existing certificate from Key Vault | `bool` | `true` | no |
+| vpn\_gateway\_should\_use\_azure\_ad\_auth | Whether to use Azure AD authentication for the VPN gateway; otherwise, certificate authentication is used | `bool` | `true` | no |
+| vpn\_gateway\_subnet\_address\_prefixes | Address prefixes for the GatewaySubnet; must be /27 or larger | `list(string)` | ```[ "10.0.2.0/27" ]``` | no |
+| vpn\_site\_connections | Site-to-site VPN connection definitions. Provide on-premises address spaces that do not overlap with Azure VNets | ```list(object({ name = string address_spaces = list(string) shared_key_reference = string connection_mode = optional(string, "Default") dpd_timeout_seconds = optional(number) gateway_fqdn = optional(string) gateway_ip_address = optional(string) ike_protocol = optional(string, "IKEv2") use_policy_based_selectors = optional(bool, false) bgp_settings = optional(object({ asn = number peer_address = string peer_weight = optional(number) })) ipsec_policy = optional(object({ dh_group = string ike_encryption = string ike_integrity = string ipsec_encryption = string ipsec_integrity = string pfs_group = string sa_datasize_kb = optional(number) sa_lifetime_seconds = optional(number) })) }))``` | `[]` | no |
+| vpn\_site\_default\_ipsec\_policy | Fallback IPsec parameters applied when site definitions omit ipsec\_policy | ```object({ dh_group = string ike_encryption = string ike_integrity = string ipsec_encryption = string ipsec_integrity = string pfs_group = string sa_datasize_kb = optional(number) sa_lifetime_seconds = optional(number) })``` | `null` | no |
+| vpn\_site\_shared\_keys | Pre-shared keys for site connections keyed by shared\_key\_reference. Manage values in secure secret stores | `map(string)` | `{}` | no |
## Outputs
diff --git a/blueprints/full-multi-node-cluster/terraform/main.tf b/blueprints/full-multi-node-cluster/terraform/main.tf
index 0bbc0c76..6c1827e3 100644
--- a/blueprints/full-multi-node-cluster/terraform/main.tf
+++ b/blueprints/full-multi-node-cluster/terraform/main.tf
@@ -12,6 +12,21 @@ locals {
vm_host_virtual_machines = try(module.cloud_vm_host[0].virtual_machines, [])
cluster_machine_count = local.should_use_arc_machines ? var.arc_machine_count : var.host_machine_count
cluster_node_machine_count = max(local.cluster_machine_count - 1, 0)
+
+ acr_registry_endpoint = var.should_include_acr_registry_endpoint ? [{
+ name = "acr-${var.resource_prefix}"
+ host = "${module.cloud_acr.acr.name}.azurecr.io"
+ acr_resource_id = module.cloud_acr.acr.id
+ should_assign_acr_pull_for_aio = true
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ system_assigned_managed_identity_settings = null
+ user_assigned_managed_identity_settings = null
+ artifact_pull_secret_settings = null
+ }
+ }] : []
+
+ combined_registry_endpoints = concat(var.registry_endpoints, local.acr_registry_endpoint)
}
/*
@@ -442,6 +457,7 @@ module "edge_iot_ops" {
should_enable_akri_onvif_connector = var.should_enable_akri_onvif_connector
should_enable_akri_sse_connector = var.should_enable_akri_sse_connector
custom_akri_connectors = var.custom_akri_connectors
+ registry_endpoints = local.combined_registry_endpoints
should_enable_otel_collector = var.should_enable_otel_collector
}
diff --git a/blueprints/full-multi-node-cluster/terraform/variables.tf b/blueprints/full-multi-node-cluster/terraform/variables.tf
index 51565954..975d6552 100644
--- a/blueprints/full-multi-node-cluster/terraform/variables.tf
+++ b/blueprints/full-multi-node-cluster/terraform/variables.tf
@@ -1004,6 +1004,100 @@ variable "custom_akri_connectors" {
}
}
+variable "registry_endpoints" {
+ type = list(object({
+ name = string
+ host = string
+ acr_resource_id = optional(string)
+ should_assign_acr_pull_for_aio = optional(bool, false)
+ authentication = object({
+ method = string
+ system_assigned_managed_identity_settings = optional(object({
+ audience = optional(string)
+ }))
+ user_assigned_managed_identity_settings = optional(object({
+ client_id = string
+ tenant_id = string
+ scope = optional(string)
+ }))
+ artifact_pull_secret_settings = optional(object({
+ secret_ref = string
+ }))
+ })
+ }))
+
+ default = []
+ description = <<-EOT
+ List of additional container registry endpoints for pulling custom artifacts (WASM modules, graph definitions, connector templates).
+ MCR (mcr.microsoft.com) is always added automatically with anonymous authentication.
+
+ The `acr_resource_id` field enables automatic AcrPull role assignment for ACR endpoints
+ using SystemAssignedManagedIdentity authentication. When `should_assign_acr_pull_for_aio` is true
+ and `acr_resource_id` is provided, the AIO extension's identity will be granted AcrPull access to the specified ACR.
+ EOT
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ can(regex("^[a-z0-9][a-z0-9-]*[a-z0-9]$", ep.name)) && length(ep.name) >= 3 && length(ep.name) <= 63
+ ])
+ error_message = "Registry endpoint name must be 3-63 characters, contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ contains(["SystemAssignedManagedIdentity", "UserAssignedManagedIdentity", "ArtifactPullSecret", "Anonymous"], ep.authentication.method)
+ ])
+ error_message = "Authentication method must be one of: SystemAssignedManagedIdentity, UserAssignedManagedIdentity, ArtifactPullSecret, Anonymous"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.authentication.method != "UserAssignedManagedIdentity" || (
+ ep.authentication.user_assigned_managed_identity_settings != null &&
+ ep.authentication.user_assigned_managed_identity_settings.client_id != null &&
+ ep.authentication.user_assigned_managed_identity_settings.tenant_id != null
+ )
+ ])
+ error_message = "UserAssignedManagedIdentity authentication requires client_id and tenant_id in user_assigned_managed_identity_settings"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.authentication.method != "ArtifactPullSecret" || (
+ ep.authentication.artifact_pull_secret_settings != null &&
+ ep.authentication.artifact_pull_secret_settings.secret_ref != null
+ )
+ ])
+ error_message = "ArtifactPullSecret authentication requires secret_ref in artifact_pull_secret_settings"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.name != "mcr" && ep.name != "default"
+ ])
+ error_message = "Registry endpoint names 'mcr' and 'default' are reserved"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.acr_resource_id == null || ep.authentication.method == "SystemAssignedManagedIdentity"
+ ])
+ error_message = "acr_resource_id can only be specified with SystemAssignedManagedIdentity authentication method"
+ }
+}
+
+variable "should_include_acr_registry_endpoint" {
+ type = bool
+ default = false
+ description = "Whether to include the deployed ACR as a registry endpoint with System Assigned Managed Identity authentication"
+}
+
# ------------------------------------------------------------------------------
# AI Foundry Variables
# ------------------------------------------------------------------------------
diff --git a/blueprints/full-single-node-cluster/README.md b/blueprints/full-single-node-cluster/README.md
index c9ca317e..83b45b26 100644
--- a/blueprints/full-single-node-cluster/README.md
+++ b/blueprints/full-single-node-cluster/README.md
@@ -239,6 +239,54 @@ See [rest-connector-assets.tfvars.example](terraform/rest-connector-assets.tfvar
Follow detailed deployment instructions from the blueprints README.md, [Detailed Deployment Workflow](../README.md#detailed-deployment-workflow)
+## Testing and Validation
+
+This blueprint includes comprehensive test suites for validating deployments and maintaining code quality.
+
+### Test Directory
+
+**Location:** [tests/](tests/)
+
+The tests directory contains contract tests and end-to-end deployment validation for both Terraform and Bicep implementations.
+
+**Key Components:**
+
+- **Contract Tests** - Fast static validation ensuring output declarations match test expectations (zero cost, no Azure resources)
+- **Deployment Tests** - Full end-to-end deployment validation with infrastructure creation and functional testing
+- **Helper Scripts** - `run-contract-tests.sh` and `run-deployment-tests.sh` for simplified test execution
+
+**See:** [tests/README.md](tests/README.md) for complete testing documentation including setup, usage, and troubleshooting
+
+### For Maintainers
+
+When modifying this blueprint:
+
+#### Before Making Changes
+
+1. **Run contract tests** to establish baseline: `cd tests && ./run-contract-tests.sh both`
+2. **Review test structure** in [tests/outputs.go](tests/outputs.go) to understand output contract
+
+#### After Making Changes
+
+1. **Update output contract** if adding/removing/renaming outputs:
+ - Update struct fields in [tests/outputs.go](tests/outputs.go)
+ - Update framework configurations: [terraform/outputs.tf](terraform/outputs.tf) or [bicep/main.bicep](bicep/main.bicep)
+
+2. **Run contract tests** to verify declarations: `cd tests && ./run-contract-tests.sh both`
+
+3. **Update deployment tests** if changing deployment behavior:
+ - Review [tests/deploy_terraform_test.go](tests/deploy_terraform_test.go) or [tests/deploy_bicep_test.go](tests/deploy_bicep_test.go)
+ - Update validation logic in [tests/validation.go](tests/validation.go) if needed
+
+4. **Run deployment tests** to validate changes: `cd tests && ./run-deployment-tests.sh `
+ - Valid framework values: 'terraform', 'bicep', or 'both'
+ - Set `CLEANUP_RESOURCES=true` to auto-delete resources after testing
+ - Expected duration: 30-45 minutes for full deployment
+
+5. **Update documentation** if changing parameters, modules, or architecture:
+ - Update this README.md with new parameters or module references
+ - Update [tests/README.md](tests/README.md) if test behavior changes
+
---
diff --git a/blueprints/full-single-node-cluster/bicep/README.md b/blueprints/full-single-node-cluster/bicep/README.md
index 21b8159a..0de23241 100644
--- a/blueprints/full-single-node-cluster/bicep/README.md
+++ b/blueprints/full-single-node-cluster/bicep/README.md
@@ -7,49 +7,51 @@ Deploys a complete end-to-end environment for Azure IoT Operations on a single-n
## Parameters
-| Name | Description | Type | Default | Required |
-|:----------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------|:---------|
-| common | The common component configuration. | `[_5.Common](#user-defined-types)` | n/a | yes |
-| resourceGroupName | The name for the resource group. If not provided, a default name will be generated. | `string` | [format('rg-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| useExistingResourceGroup | Whether to use an existing resource group instead of creating a new one. | `bool` | `false` | no |
-| telemetry_opt_out | Whether to opt-out of telemetry. Set to true to disable telemetry. | `bool` | `false` | no |
-| adminPassword | Password used for the host VM. | `securestring` | n/a | yes |
-| shouldCreateAcrPrivateEndpoint | Whether to create a private endpoint for the Azure Container Registry. | `bool` | `false` | no |
-| shouldDeployAiFoundry | Whether to deploy AI Foundry resources. | `bool` | `false` | no |
-| aiFoundryConfig | The AI Foundry configuration settings. | `[_2.AiFoundryConfig](#user-defined-types)` | [variables('_2.aiFoundryConfigDefaults')] | no |
-| aiFoundryProjects | Array of AI Foundry projects to create. | `array` | [] | no |
-| aiFoundryRaiPolicies | Array of RAI policies to create. | `array` | [] | no |
-| aiFoundryModelDeployments | Array of model deployments to create. | `array` | [] | no |
-| shouldCreateAiFoundryPrivateEndpoint | Whether to create a private endpoint for AI Foundry. | `bool` | `false` | no |
-| shouldEnableManagedOutboundAccess | Whether to enable managed outbound access via NAT gateway instead of platform default internet access. | `bool` | `true` | no |
-| natGatewayPublicIpCount | Number of public IP addresses for NAT Gateway (1-16). | `int` | 1 | no |
-| natGatewayIdleTimeoutMinutes | Idle timeout in minutes for NAT gateway connections (4-120). | `int` | 4 | no |
-| natGatewayZones | Availability zones for NAT Gateway. Empty array for regional deployment. | `array` | [] | no |
-| shouldEnablePrivateEndpoints | Whether to enable private endpoints across Key Vault, storage, and observability resources. | `bool` | `false` | no |
-| shouldEnablePrivateResolver | Whether to enable Azure Private Resolver for VPN client DNS resolution of private endpoints. | `bool` | `false` | no |
-| resolverSubnetAddressPrefix | Address prefix for the private resolver subnet; must be /28 or larger and not overlap with other subnets. | `string` | 10.0.9.0/28 | no |
-| shouldEnableKeyVaultPublicNetworkAccess | Whether to enable public network access for the Key Vault. | `bool` | `true` | no |
-| shouldEnableStoragePublicNetworkAccess | Whether to enable public network access for the storage account. | `bool` | `true` | no |
-| subnetAddressPrefixAcr | Address prefix for the ACR subnet. | `string` | 10.0.4.0/24 | no |
-| subnetAddressPrefixAks | Address prefix for the AKS subnet. | `string` | 10.0.5.0/24 | no |
-| subnetAddressPrefixAksPod | Address prefix for the AKS pod subnet. | `string` | 10.0.6.0/24 | no |
-| shouldEnableVpnGateway | Whether to deploy VPN Gateway for remote access. | `bool` | `false` | no |
-| vpnGatewayConfig | VPN Gateway configuration settings. | `[_1.VpnGatewayConfig](#user-defined-types)` | [variables('_1.vpnGatewayConfigDefaults')] | no |
-| vpnGatewayAzureAdConfig | Azure AD authentication configuration for VPN Gateway. | `[_1.AzureAdConfig](#user-defined-types)` | [variables('_1.azureAdConfigDefaults')] | no |
-| shouldCreateAks | Whether to create an Azure Kubernetes Service cluster. | `bool` | `false` | no |
-| customLocationsOid | The object id of the Custom Locations Entra ID application for your tenant.
Can be retrieved using:
az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv
| `string` | n/a | yes |
-| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous AIO MQ Broker Listener. (Should only be used for dev or test environments) | `bool` | `false` | no |
-| shouldInitAio | Whether to deploy the Azure IoT Operations initial connected cluster resources, Secret Sync, ACSA, OSM, AIO Platform. | `bool` | `true` | no |
-| shouldDeployAio | Whether to deploy an Azure IoT Operations Instance and all of its required components into the connected cluster. | `bool` | `true` | no |
-| namespacedDevices | List of namespaced devices to create. | `array` | [] | no |
-| assetEndpointProfiles | List of asset endpoint profiles to create. | `array` | [] | no |
-| legacyAssets | List of legacy assets to create. | `array` | [] | no |
-| namespacedAssets | List of namespaced assets to create. | `array` | [] | no |
-| shouldEnableAkriRestConnector | Deploy Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriMediaConnector | Deploy Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `array` | [] | no |
+| Name | Description | Type | Default | Required |
+|:----------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------|:---------|
+| common | The common component configuration. | `[_5.Common](#user-defined-types)` | n/a | yes |
+| resourceGroupName | The name for the resource group. If not provided, a default name will be generated. | `string` | [format('rg-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| useExistingResourceGroup | Whether to use an existing resource group instead of creating a new one. | `bool` | `false` | no |
+| telemetry_opt_out | Whether to opt-out of telemetry. Set to true to disable telemetry. | `bool` | `false` | no |
+| adminPassword | Password used for the host VM. | `securestring` | n/a | yes |
+| shouldCreateAcrPrivateEndpoint | Whether to create a private endpoint for the Azure Container Registry. | `bool` | `false` | no |
+| shouldDeployAiFoundry | Whether to deploy AI Foundry resources. | `bool` | `false` | no |
+| aiFoundryConfig | The AI Foundry configuration settings. | `[_2.AiFoundryConfig](#user-defined-types)` | [variables('_2.aiFoundryConfigDefaults')] | no |
+| aiFoundryProjects | Array of AI Foundry projects to create. | `[_2.AiProject](#user-defined-types)[]` | [] | no |
+| aiFoundryRaiPolicies | Array of RAI policies to create. | `[_2.RaiPolicy](#user-defined-types)[]` | [] | no |
+| aiFoundryModelDeployments | Array of model deployments to create. | `[_2.ModelDeployment](#user-defined-types)[]` | [] | no |
+| shouldCreateAiFoundryPrivateEndpoint | Whether to create a private endpoint for AI Foundry. | `bool` | `false` | no |
+| shouldEnableManagedOutboundAccess | Whether to enable managed outbound access via NAT gateway instead of platform default internet access. | `bool` | `true` | no |
+| natGatewayPublicIpCount | Number of public IP addresses for NAT Gateway (1-16). | `int` | 1 | no |
+| natGatewayIdleTimeoutMinutes | Idle timeout in minutes for NAT gateway connections (4-120). | `int` | 4 | no |
+| natGatewayZones | Availability zones for NAT Gateway. Empty array for regional deployment. | `string[]` | [] | no |
+| shouldEnablePrivateEndpoints | Whether to enable private endpoints across Key Vault, storage, and observability resources. | `bool` | `false` | no |
+| shouldEnablePrivateResolver | Whether to enable Azure Private Resolver for VPN client DNS resolution of private endpoints. | `bool` | `false` | no |
+| resolverSubnetAddressPrefix | Address prefix for the private resolver subnet; must be /28 or larger and not overlap with other subnets. | `string` | 10.0.9.0/28 | no |
+| shouldEnableKeyVaultPublicNetworkAccess | Whether to enable public network access for the Key Vault. | `bool` | `true` | no |
+| shouldEnableStoragePublicNetworkAccess | Whether to enable public network access for the storage account. | `bool` | `true` | no |
+| subnetAddressPrefixAcr | Address prefix for the ACR subnet. | `string` | 10.0.4.0/24 | no |
+| subnetAddressPrefixAks | Address prefix for the AKS subnet. | `string` | 10.0.5.0/24 | no |
+| subnetAddressPrefixAksPod | Address prefix for the AKS pod subnet. | `string` | 10.0.6.0/24 | no |
+| shouldEnableVpnGateway | Whether to deploy VPN Gateway for remote access. | `bool` | `false` | no |
+| vpnGatewayConfig | VPN Gateway configuration settings. | `[_1.VpnGatewayConfig](#user-defined-types)` | [variables('_1.vpnGatewayConfigDefaults')] | no |
+| vpnGatewayAzureAdConfig | Azure AD authentication configuration for VPN Gateway. | `[_1.AzureAdConfig](#user-defined-types)` | [variables('_1.azureAdConfigDefaults')] | no |
+| shouldCreateAks | Whether to create an Azure Kubernetes Service cluster. | `bool` | `false` | no |
+| customLocationsOid | The object id of the Custom Locations Entra ID application for your tenant.
Can be retrieved using:
az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv
| `string` | n/a | yes |
+| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous AIO MQ Broker Listener. (Should only be used for dev or test environments) | `bool` | `false` | no |
+| shouldInitAio | Whether to deploy the Azure IoT Operations initial connected cluster resources, Secret Sync, ACSA, OSM, AIO Platform. | `bool` | `true` | no |
+| shouldDeployAio | Whether to deploy an Azure IoT Operations Instance and all of its required components into the connected cluster. | `bool` | `true` | no |
+| namespacedDevices | List of namespaced devices to create. | `[_4.NamespacedDevice](#user-defined-types)[]` | [] | no |
+| assetEndpointProfiles | List of asset endpoint profiles to create. | `[_4.AssetEndpointProfile](#user-defined-types)[]` | [] | no |
+| legacyAssets | List of legacy assets to create. | `[_4.LegacyAsset](#user-defined-types)[]` | [] | no |
+| namespacedAssets | List of namespaced assets to create. | `[_4.NamespacedAsset](#user-defined-types)[]` | [] | no |
+| shouldEnableAkriRestConnector | Deploy Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriMediaConnector | Deploy Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `[_3.AkriConnectorTemplate](#user-defined-types)[]` | [] | no |
+| registryEndpoints | List of additional container registry endpoints. MCR is always added automatically. | `[_3.RegistryEndpointConfig](#user-defined-types)[]` | [] | no |
+| shouldIncludeAcrRegistryEndpoint | Whether to include the deployed ACR as a registry endpoint with System Assigned Managed Identity authentication. | `bool` | `false` | no |
## Resources
@@ -229,6 +231,7 @@ Deploys Azure observability resources including Azure Monitor Workspace, Log Ana
| logAnalyticsName | `string` | The Log Analytics Workspace name. |
| logAnalyticsId | `string` | The Log Analytics Workspace ID. |
| grafanaName | `string` | The Azure Managed Grafana name. |
+| grafanaEndpoint | `string` | The Azure Managed Grafana endpoint. |
| metricsDataCollectionRuleName | `string` | The metrics data collection rule name. |
| logsDataCollectionRuleName | `string` | The logs data collection rule name. |
| monitorPrivateLinkScopeId | `string` | Azure Monitor Private Link Scope resource ID. |
@@ -706,6 +709,7 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `array` | [] | no |
+| registryEndpoints | List of additional container registry endpoints for pulling custom artifacts. MCR is always added automatically. | `array` | [] | no |
| akriMqttSharedConfig | Shared MQTT connection configuration for all Akri connectors. | `[_1.AkriMqttConfig](#user-defined-types)` | {'host': 'aio-broker:18883', 'audience': 'aio-internal', 'caConfigmap': 'azure-iot-operations-aio-ca-trust-bundle'} | no |
| customLocationName | The name for the Custom Locations resource. | `string` | [format('{0}-cl', parameters('arcConnectedClusterName'))] | no |
| additionalClusterExtensionIds | Additional cluster extension IDs to include in the custom location. (Appended to the default Secret Store and IoT Operations extension IDs) | `array` | [] | no |
@@ -736,27 +740,29 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| postInitScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
| iotOpsInstance | `Microsoft.Resources/deployments` | 2025-04-01 |
| akriConnectors | `Microsoft.Resources/deployments` | 2025-04-01 |
+| registryEndpointsModule | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScriptsSecrets | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
#### Outputs for edgeIotOps
-| Name | Type | Description |
-|:---------------------------|:---------|:-------------------------------------------------------------------|
-| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
-| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
-| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
-| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
-| customLocationId | `string` | The ID of the deployed Custom Location. |
-| customLocationName | `string` | The name of the deployed Custom Location. |
-| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
-| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
-| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
-| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
-| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
-| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
+| Name | Type | Description |
+|:---------------------------|:---------|:----------------------------------------------------------------------|
+| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
+| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
+| aioNamespace | `string` | The namespace in the cluster where Azure IoT Operations is installed. |
+| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
+| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
+| customLocationId | `string` | The ID of the deployed Custom Location. |
+| customLocationName | `string` | The name of the deployed Custom Location. |
+| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
+| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
+| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
+| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
+| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
+| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
### edgeAssets
@@ -1101,6 +1107,14 @@ Trust settings for Akri connector.
|:-------------------|:---------|:------------------------------------|
| trustListSecretRef | `string` | Reference to the trust list secret. |
+### `_3.ArtifactPullSecretSettings`
+
+Authentication settings for Artifact Pull Secret.
+
+| Property | Type | Description |
+|:----------|:---------|:--------------------------------------------------------------------------|
+| secretRef | `string` | The name of the kubernetes secret that contains the artifact pull secret. |
+
### `_3.BrokerPersistence`
Broker persistence configuration for disk-backed message storage.
@@ -1160,6 +1174,57 @@ The mode of the AIO instance feature. Either "Stable", "Preview" or "Disabled".
The setting value of the AIO instance feature. Either "Enabled" or "Disabled".
+### `_3.RegistryAuthAnonymous`
+
+Anonymous authentication for registry endpoint.
+
+| Property | Type | Description |
+|:------------------|:---------|:--------------------------------------------------|
+| method | `string` | Authentication method. |
+| anonymousSettings | `object` | Anonymous authentication settings (empty object). |
+
+### `_3.RegistryAuthArtifactPullSecret`
+
+Artifact Pull Secret authentication for registry endpoint.
+
+| Property | Type | Description |
+|:---------------------------|:-------------------------------------------------------|:-------------------------------|
+| method | `string` | Authentication method. |
+| artifactPullSecretSettings | `[_3.ArtifactPullSecretSettings](#user-defined-types)` | Artifact pull secret settings. |
+
+### `_3.RegistryAuthentication`
+
+Authentication configuration for a registry endpoint.
+
+### `_3.RegistryAuthSystemAssignedManagedIdentity`
+
+System-Assigned Managed Identity authentication for registry endpoint.
+
+| Property | Type | Description |
+|:--------------------------------------|:------------------------------------------------------------------|:-------------------------------------------|
+| method | `string` | Authentication method. |
+| systemAssignedManagedIdentitySettings | `[_3.SystemAssignedManagedIdentitySettings](#user-defined-types)` | System-assigned managed identity settings. |
+
+### `_3.RegistryAuthUserAssignedManagedIdentity`
+
+User-Assigned Managed Identity authentication for registry endpoint.
+
+| Property | Type | Description |
+|:------------------------------------|:----------------------------------------------------------------|:-----------------------------------------|
+| method | `string` | Authentication method. |
+| userAssignedManagedIdentitySettings | `[_3.UserAssignedManagedIdentitySettings](#user-defined-types)` | User-assigned managed identity settings. |
+
+### `_3.RegistryEndpointConfig`
+
+Container registry endpoint configuration for AIO instance.
+
+| Property | Type | Description |
+|:---------------|:---------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|
+| name | `string` | Unique name for the registry endpoint (3-63 chars, lowercase alphanumeric and hyphens). |
+| host | `string` | Container registry hostname (e.g., myregistry.azurecr.io). |
+| acrResourceId | `string` | Optional ACR resource ID for automatic AcrPull role assignment. Only applicable when authentication.method is SystemAssignedManagedIdentity. |
+| authentication | `[_3.RegistryAuthentication](#user-defined-types)` | Authentication configuration for the registry. |
+
### `_3.Release`
The common settings for Azure Arc Extensions.
@@ -1213,6 +1278,14 @@ The configuration for Self-Signed Issuer for Azure IoT Operations certificates.
|:------------|:---------|:------------|
| trustSource | `string` | |
+### `_3.SystemAssignedManagedIdentitySettings`
+
+Authentication settings for System-Assigned Managed Identity.
+
+| Property | Type | Description |
+|:---------|:---------|:--------------------------------------------------------------------------------------------------------|
+| audience | `string` | Audience of the service to authenticate against. Defaults to "" for ACR. |
+
### `_3.TrustConfigSource`
The config source of trust for how to use or generate Azure IoT Operations certificates.
@@ -1236,6 +1309,16 @@ The configuration for the trust settings of Azure IoT Operations certificates.
The source of trust for Azure IoT Operations certificates.
+### `_3.UserAssignedManagedIdentitySettings`
+
+Authentication settings for User-Assigned Managed Identity.
+
+| Property | Type | Description |
+|:---------|:---------|:---------------------------------------------------------------|
+| clientId | `string` | Client ID for the user-assigned managed identity. |
+| tenantId | `string` | Tenant ID where the managed identity is located. |
+| scope | `string` | Resource identifier (application ID URI) with .default suffix. |
+
### `_4.AssetAction`
Management action configuration for assets.
@@ -1501,40 +1584,29 @@ Common settings for the components.
## Outputs
-| Name | Type | Description |
-|:-----------------------------|:---------|:--------------------------------------------------------------------------------------------------------------------------------------------|
-| arcConnectedClusterName | `string` | The name of the Arc-enabled Kubernetes cluster that was connected to Azure. This can be used to reference the cluster in other deployments. |
-| vmUsername | `string` | The administrative username that can be used to SSH into the deployed virtual machines. |
-| vmNames | `array` | An array containing the names of all virtual machines that were deployed as part of this blueprint. |
-| aksName | `string` | The AKS cluster name. |
-| acrName | `string` | The Azure Container Registry name. |
-| aiFoundryName | `string` | The AI Foundry account name. |
-| aiFoundryEndpoint | `string` | The AI Foundry account endpoint. |
-| aiFoundryPrincipalId | `string` | The AI Foundry account principal ID. |
-| aioCertManagerExtensionId | `string` | The ID of the Azure IoT Operations Cert-Manager Extension. |
-| aioCertManagerExtensionName | `string` | The name of the Azure IoT Operations Cert-Manager Extension. |
-| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
-| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
-| customLocationId | `string` | The ID of the deployed Custom Location. |
-| customLocationName | `string` | The name of the deployed Custom Location. |
-| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
-| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
-| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
-| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
-| natGatewayId | `string` | The NAT Gateway ID (if enabled). |
-| natGatewayName | `string` | The NAT Gateway name (if enabled). |
-| defaultOutboundAccessEnabled | `bool` | Whether default outbound access is enabled (inverse of managed outbound). |
-| privateResolverId | `string` | The Private DNS Resolver ID (if enabled). |
-| privateResolverName | `string` | The Private DNS Resolver name (if enabled). |
-| dnsServerIp | `string` | The DNS server IP from Private Resolver (if enabled). |
-| privateEndpointsEnabled | `bool` | Whether private endpoints are enabled. |
-| keyVaultPrivateEndpointId | `string` | The Key Vault private endpoint ID (if enabled). |
-| storageBlobPrivateEndpointId | `string` | The storage account blob private endpoint ID (if enabled). |
-| vpnGatewayId | `string` | The VPN Gateway ID (if enabled). |
-| vpnGatewayName | `string` | The VPN Gateway name (if enabled). |
-| vpnGatewayPublicIp | `string` | The VPN Gateway public IP address (if enabled). |
-| vpnClientConnectionInfo | `object` | VPN client connection information (if enabled). |
+| Name | Type | Description |
+|:------------------------|:---------|:-------------------------------------------------------------------|
+| azureIotOperations | `object` | Azure IoT Operations deployment details. |
+| assets | `object` | IoT asset resources. |
+| clusterConnection | `object` | Commands and information to connect to the deployed cluster. |
+| containerRegistry | `object` | Azure Container Registry resources. |
+| acrNetworkPosture | `object` | Azure Container Registry network posture metadata. |
+| kubernetes | `object` | Azure Kubernetes Service resources. |
+| dataStorage | `object` | Data storage resources. |
+| deploymentSummary | `object` | Summary of the deployment configuration. |
+| natGateway | `object` | NAT gateway resource when managed outbound access is enabled. |
+| natGatewayPublicIps | `array` | Public IP resources associated with the NAT gateway keyed by name. |
+| messaging | `object` | Cloud messaging resources. |
+| aiFoundry | `object` | Azure AI Foundry account resources. |
+| aiFoundryProjects | `array` | Azure AI Foundry project resources. |
+| aiFoundryDeployments | `array` | Azure AI Foundry model deployments. |
+| vmHost | `array` | Virtual machine host resources. |
+| arcConnectedCluster | `object` | Azure Arc connected cluster resources. |
+| observability | `object` | Monitoring and observability resources. |
+| securityIdentity | `object` | Security and identity resources. |
+| vpnGateway | `object` | VPN Gateway configuration when enabled. |
+| vpnGatewayPublicIp | `string` | VPN Gateway public IP address for client configuration. |
+| vpnClientConnectionInfo | `object` | VPN client connection information including download URLs. |
+| privateResolverDnsIp | `string` | Private Resolver DNS IP address for VPN client configuration. |
\ No newline at end of file
diff --git a/blueprints/full-single-node-cluster/bicep/main.bicep b/blueprints/full-single-node-cluster/bicep/main.bicep
index 0c935724..6e430e4c 100644
--- a/blueprints/full-single-node-cluster/bicep/main.bicep
+++ b/blueprints/full-single-node-cluster/bicep/main.bicep
@@ -226,6 +226,32 @@ param shouldEnableAkriSseConnector bool = false
@description('List of custom Akri connector templates with user-defined endpoint types and container images.')
param customAkriConnectors types.AkriConnectorTemplate[] = []
+@description('List of additional container registry endpoints. MCR is always added automatically.')
+param registryEndpoints types.RegistryEndpointConfig[] = []
+
+@description('Whether to include the deployed ACR as a registry endpoint with System Assigned Managed Identity authentication.')
+param shouldIncludeAcrRegistryEndpoint bool = false
+
+/*
+ Local Variables
+*/
+
+var acrRegistryEndpoint = shouldIncludeAcrRegistryEndpoint
+ ? [
+ {
+ name: 'acr-${common.resourcePrefix}'
+ host: '${cloudAcr.outputs.acrName}.azurecr.io'
+ acrResourceId: cloudAcr.outputs.acrId
+ authentication: {
+ method: 'SystemAssignedManagedIdentity'
+ systemAssignedManagedIdentitySettings: {}
+ }
+ }
+ ]
+ : []
+
+var combinedRegistryEndpoints = concat(registryEndpoints, acrRegistryEndpoint)
+
/*
Resources
*/
@@ -475,6 +501,9 @@ module edgeIotOps '../../../src/100-edge/110-iot-ops/bicep/main.bicep' = {
shouldEnableAkriOnvifConnector: shouldEnableAkriOnvifConnector
shouldEnableAkriSseConnector: shouldEnableAkriSseConnector
customAkriConnectors: customAkriConnectors
+
+ // Registry Endpoints Parameters
+ registryEndpoints: combinedRegistryEndpoints
}
}
@@ -532,121 +561,187 @@ module edgeMessaging '../../../src/100-edge/130-messaging/bicep/main.bicep' = {
Outputs
*/
-@description('The name of the Arc-enabled Kubernetes cluster that was connected to Azure. This can be used to reference the cluster in other deployments.')
-output arcConnectedClusterName string = edgeCncfCluster.outputs.connectedClusterName
-
-@description('The administrative username that can be used to SSH into the deployed virtual machines.')
-output vmUsername string = cloudVmHost.outputs.adminUsername
-
-@description('An array containing the names of all virtual machines that were deployed as part of this blueprint.')
-output vmNames array = cloudVmHost.outputs.vmNames
-
-@description('The AKS cluster name.')
-output aksName string? = cloudKubernetes.outputs.?aksName
+/*
+ Azure IoT Operations Outputs
+*/
+@description('Azure IoT Operations deployment details.')
+output azureIotOperations object = {
+ customLocationId: edgeIotOps.outputs.customLocationId
+ instanceName: edgeIotOps.outputs.aioInstanceName
+ namespace: edgeIotOps.outputs.aioNamespace
+}
-@description('The Azure Container Registry name.')
-output acrName string = cloudAcr.outputs.acrName
+@description('IoT asset resources.')
+output assets object = {
+ assets: edgeAssets.outputs.namespacedAssets
+ assetEndpointProfiles: edgeAssets.outputs.assetEndpointProfiles
+}
-@description('The AI Foundry account name.')
-output aiFoundryName string? = shouldDeployAiFoundry ? cloudAiFoundry.?outputs.?aiFoundryName : null
+/*
+ Cluster Connection Outputs
+*/
-@description('The AI Foundry account endpoint.')
-output aiFoundryEndpoint string? = shouldDeployAiFoundry ? cloudAiFoundry.?outputs.?aiFoundryEndpoint : null
+@description('Commands and information to connect to the deployed cluster.')
+output clusterConnection object = {
+ arcClusterName: edgeCncfCluster.outputs.connectedClusterName
+ arcClusterResourceGroup: cloudResourceGroup.outputs.resourceGroupName
+ arcProxyCommand: edgeCncfCluster.outputs.azureArcProxyCommand
+}
-@description('The AI Foundry account principal ID.')
-output aiFoundryPrincipalId string? = shouldDeployAiFoundry ? cloudAiFoundry.?outputs.?aiFoundryPrincipalId : null
+/*
+ Container Registry Outputs
+*/
-@description('The ID of the Azure IoT Operations Cert-Manager Extension.')
-output aioCertManagerExtensionId string = edgeArcExtensions.outputs.certManagerExtensionId
+@description('Azure Container Registry resources.')
+output containerRegistry object = {
+ id: cloudAcr.outputs.acrId
+ name: cloudAcr.outputs.acrName
+}
-@description('The name of the Azure IoT Operations Cert-Manager Extension.')
-output aioCertManagerExtensionName string = edgeArcExtensions.outputs.certManagerExtensionName
+@description('Azure Container Registry network posture metadata.')
+output acrNetworkPosture object = {
+ isNatGatewayEnabled: cloudAcr.outputs.isNatGatewayEnabled
+}
-@description('The ID of the Secret Store Extension.')
-output secretStoreExtensionId string = edgeIotOps.outputs.secretStoreExtensionId
+@description('Azure Kubernetes Service resources.')
+output kubernetes object? = shouldCreateAks
+ ? {
+ id: cloudKubernetes.outputs.?aksId
+ name: cloudKubernetes.outputs.?aksName
+ principalId: cloudKubernetes.outputs.?aksPrincipalId
+ }
+ : null
-@description('The name of the Secret Store Extension.')
-output secretStoreExtensionName string = edgeIotOps.outputs.secretStoreExtensionName
+/*
+ Data Storage Outputs
+*/
-@description('The ID of the deployed Custom Location.')
-output customLocationId string = edgeIotOps.outputs.customLocationId
+@description('Data storage resources.')
+output dataStorage object = {
+ schemaRegistryName: cloudData.outputs.schemaRegistryName
+ storageAccountName: cloudData.outputs.storageAccountName
+}
-@description('The name of the deployed Custom Location.')
-output customLocationName string = edgeIotOps.outputs.customLocationName
+/*
+ Deployment Summary Outputs
+*/
-@description('The ID of the deployed Azure IoT Operations instance.')
-output aioInstanceId string = edgeIotOps.outputs.aioInstanceId
+@description('Summary of the deployment configuration.')
+output deploymentSummary object = {
+ resourceGroup: cloudResourceGroup.outputs.resourceGroupName
+}
-@description('The name of the deployed Azure IoT Operations instance.')
-output aioInstanceName string = edgeIotOps.outputs.aioInstanceName
+/*
+ Networking Outputs
+*/
-@description('The ID of the deployed Azure IoT Operations Data Flow Profile.')
-output dataFlowProfileId string = edgeIotOps.outputs.dataFlowProfileId
+@description('NAT gateway resource when managed outbound access is enabled.')
+output natGateway object? = shouldEnableManagedOutboundAccess
+ ? {
+ id: cloudNetworking.outputs.?natGatewayId
+ name: cloudNetworking.outputs.?natGatewayName
+ }
+ : null
-@description('The name of the deployed Azure IoT Operations Data Flow Profile.')
-output dataFlowProfileName string = edgeIotOps.outputs.dataFlowProfileName
+@description('Public IP resources associated with the NAT gateway keyed by name.')
+output natGatewayPublicIps array? = shouldEnableManagedOutboundAccess && cloudNetworking.outputs.?natGatewayPublicIps != null
+ ? cloudNetworking.outputs.?natGatewayPublicIps
+ : null
-@description('The ID of the deployed Azure IoT Operations Data Flow Endpoint.')
-output dataFlowEndpointId string = edgeIotOps.outputs.dataFlowEndpointId
+/*
+ Messaging Outputs
+*/
-@description('The name of the deployed Azure IoT Operations Data Flow Endpoint.')
-output dataFlowEndpointName string = edgeIotOps.outputs.dataFlowEndpointName
+@description('Cloud messaging resources.')
+output messaging object = {
+ eventGridTopicEndpoint: cloudMessaging.outputs.eventGridMqttEndpoint != ''
+ ? cloudMessaging.outputs.eventGridMqttEndpoint
+ : 'Not deployed'
+ eventGridTopicName: cloudMessaging.outputs.eventGridTopicNames != ''
+ ? cloudMessaging.outputs.eventGridTopicNames
+ : 'Not deployed'
+ eventhubName: length(cloudMessaging.outputs.eventHubNames) > 0
+ ? cloudMessaging.outputs.eventHubNames[0]
+ : 'Not deployed'
+ eventhubNamespaceName: cloudMessaging.outputs.eventHubNamespaceName != ''
+ ? cloudMessaging.outputs.eventHubNamespaceName
+ : 'Not deployed'
+}
/*
- NAT Gateway Outputs
+ AI Foundry Outputs
*/
-@description('The NAT Gateway ID (if enabled).')
-output natGatewayId string? = shouldEnableManagedOutboundAccess ? cloudNetworking.outputs.?natGatewayId : null
+@description('Azure AI Foundry account resources.')
+output aiFoundry object? = shouldDeployAiFoundry ? cloudAiFoundry.?outputs.?aiFoundry : null
-@description('The NAT Gateway name (if enabled).')
-output natGatewayName string? = shouldEnableManagedOutboundAccess ? cloudNetworking.outputs.?natGatewayName : null
+@description('Azure AI Foundry project resources.')
+output aiFoundryProjects array? = shouldDeployAiFoundry ? cloudAiFoundry.?outputs.?projectsArray : null
-@description('Whether default outbound access is enabled (inverse of managed outbound).')
-output defaultOutboundAccessEnabled bool = !shouldEnableManagedOutboundAccess
+@description('Azure AI Foundry model deployments.')
+output aiFoundryDeployments array? = shouldDeployAiFoundry ? cloudAiFoundry.?outputs.?deploymentsArray : null
/*
- Private Resolver Outputs
+ Edge Infrastructure Outputs
*/
-@description('The Private DNS Resolver ID (if enabled).')
-output privateResolverId string? = shouldEnablePrivateResolver ? cloudNetworking.outputs.?privateResolverId : null
-
-@description('The Private DNS Resolver name (if enabled).')
-output privateResolverName string? = shouldEnablePrivateResolver ? cloudNetworking.outputs.?privateResolverName : null
+@description('Virtual machine host resources.')
+output vmHost array = [
+ {
+ id: cloudVmHost.outputs.vmIds[0]
+ location: common.location
+ name: cloudVmHost.outputs.vmNames[0]
+ }
+]
-@description('The DNS server IP from Private Resolver (if enabled).')
-output dnsServerIp string? = shouldEnablePrivateResolver ? cloudNetworking.outputs.?dnsServerIp : null
+@description('Azure Arc connected cluster resources.')
+output arcConnectedCluster object = {
+ name: edgeCncfCluster.outputs.connectedClusterName
+ location: common.location
+}
/*
- Private Endpoint Outputs
+ Observability Outputs
*/
-@description('Whether private endpoints are enabled.')
-output privateEndpointsEnabled bool = shouldEnablePrivateEndpoints
+@description('Monitoring and observability resources.')
+output observability object = {
+ azureMonitorWorkspaceName: cloudObservability.outputs.monitorWorkspaceName
+ grafanaEndpoint: cloudObservability.outputs.grafanaEndpoint
+ grafanaName: cloudObservability.outputs.grafanaName
+ logAnalyticsWorkspaceName: cloudObservability.outputs.logAnalyticsName
+}
-@description('The Key Vault private endpoint ID (if enabled).')
-output keyVaultPrivateEndpointId string? = shouldEnablePrivateEndpoints
- ? cloudSecurityIdentity.outputs.?keyVaultPrivateEndpointId
- : null
+/*
+ Security and Identity Outputs
+*/
-@description('The storage account blob private endpoint ID (if enabled).')
-output storageBlobPrivateEndpointId string? = shouldEnablePrivateEndpoints
- ? cloudData.outputs.?storageBlobPrivateEndpointId
- : null
+@description('Security and identity resources.')
+output securityIdentity object = {
+ aioIdentity: cloudSecurityIdentity.outputs.aioIdentityName
+ keyVaultName: cloudSecurityIdentity.outputs.?keyVaultName ?? 'Not deployed'
+ keyVaultUri: cloudSecurityIdentity.outputs.?keyVaultName != null
+ ? 'https://${cloudSecurityIdentity.outputs.?keyVaultName}${environment().suffixes.keyvaultDns}/'
+ : 'Not deployed'
+}
/*
VPN Gateway Outputs
*/
-@description('The VPN Gateway ID (if enabled).')
-output vpnGatewayId string? = shouldEnableVpnGateway ? cloudVpnGateway.?outputs.?vpnGatewayId : null
-
-@description('The VPN Gateway name (if enabled).')
-output vpnGatewayName string? = shouldEnableVpnGateway ? cloudVpnGateway.?outputs.?vpnGatewayName : null
+@description('VPN Gateway configuration when enabled.')
+output vpnGateway object? = shouldEnableVpnGateway
+ ? {
+ id: cloudVpnGateway.?outputs.?vpnGatewayId
+ name: cloudVpnGateway.?outputs.?vpnGatewayName
+ }
+ : null
-@description('The VPN Gateway public IP address (if enabled).')
+@description('VPN Gateway public IP address for client configuration.')
output vpnGatewayPublicIp string? = shouldEnableVpnGateway ? cloudVpnGateway.?outputs.?vpnGatewayPublicIp : null
-@description('VPN client connection information (if enabled).')
+@description('VPN client connection information including download URLs.')
output vpnClientConnectionInfo object? = shouldEnableVpnGateway ? cloudVpnGateway.?outputs.?clientConnectionInfo : null
+
+@description('Private Resolver DNS IP address for VPN client configuration.')
+output privateResolverDnsIp string? = cloudNetworking.outputs.?dnsServerIp
diff --git a/blueprints/full-single-node-cluster/terraform/README.md b/blueprints/full-single-node-cluster/terraform/README.md
index 22db4563..739e8c8e 100644
--- a/blueprints/full-single-node-cluster/terraform/README.md
+++ b/blueprints/full-single-node-cluster/terraform/README.md
@@ -41,100 +41,102 @@ for a single-node cluster deployment, including observability, messaging, and da
## Inputs
-| Name | Description | Type | Default | Required |
-|------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------|:--------:|
-| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
-| location | Location for all resources in this module | `string` | n/a | yes |
-| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
-| acr\_allow\_trusted\_services | Whether trusted Azure services can bypass ACR network rules | `bool` | `true` | no |
-| acr\_allowed\_public\_ip\_ranges | CIDR ranges permitted to reach the ACR public endpoint | `list(string)` | `[]` | no |
-| acr\_data\_endpoint\_enabled | Whether to enable the dedicated ACR data endpoint | `bool` | `true` | no |
-| acr\_public\_network\_access\_enabled | Whether to enable the ACR public endpoint alongside private connectivity | `bool` | `false` | no |
-| acr\_sku | SKU name for the Azure Container Registry | `string` | `"Premium"` | no |
-| ai\_foundry\_model\_deployments | Map of model deployments for AI Foundry | ```map(object({ name = string model = object({ format = string name = string version = string }) scale = object({ type = string capacity = number }) rai_policy_name = optional(string) version_upgrade_option = optional(string, "OnceNewDefaultVersionAvailable") }))``` | `{}` | no |
-| ai\_foundry\_private\_dns\_zone\_ids | List of private DNS zone IDs for the AI Foundry private endpoint | `list(string)` | `[]` | no |
-| ai\_foundry\_projects | Map of AI Foundry projects to create. SKU defaults to 'S0' (currently the only supported value) | ```map(object({ name = string display_name = string description = string sku = optional(string, "S0") }))``` | `{}` | no |
-| ai\_foundry\_rai\_policies | Map of Responsible AI (RAI) content filtering policies. Must be created before referenced in model deployments. | ```map(object({ name = string base_policy_name = optional(string, "Microsoft.Default") mode = optional(string, "Blocking") content_filters = optional(list(object({ name = string enabled = optional(bool, true) blocking = optional(bool, true) severity_threshold = optional(string, "Medium") source = string })), []) }))``` | `{}` | no |
-| ai\_foundry\_should\_enable\_local\_auth | Whether to enable local (API key) authentication for AI Foundry | `bool` | `true` | no |
-| ai\_foundry\_should\_enable\_private\_endpoint | Whether to enable private endpoint for AI Foundry | `bool` | `false` | no |
-| ai\_foundry\_should\_enable\_public\_network\_access | Whether to enable public network access to AI Foundry | `bool` | `true` | no |
-| ai\_foundry\_sku | SKU name for the AI Foundry account | `string` | `"S0"` | no |
-| aio\_features | AIO instance features with mode ('Stable', 'Preview', 'Disabled') and settings ('Enabled', 'Disabled') | ```map(object({ mode = optional(string) settings = optional(map(string)) }))``` | `null` | no |
-| aks\_should\_enable\_private\_cluster | Whether to enable private cluster mode for AKS | `bool` | `true` | no |
-| aks\_should\_enable\_private\_cluster\_public\_fqdn | Whether to create a private cluster public FQDN for AKS | `bool` | `false` | no |
-| azureml\_ml\_workload\_subjects | Custom Kubernetes service account subjects for AzureML workload federation. Example: ['system:serviceaccount:azureml:azureml-workload', 'system:serviceaccount:osmo:osmo-workload'] | `list(string)` | `null` | no |
-| azureml\_registry\_should\_enable\_public\_network\_access | Whether to enable public network access to the Azure Machine Learning registry when deployed | `bool` | `true` | no |
-| azureml\_should\_create\_compute\_cluster | Whether to create a compute cluster for Azure Machine Learning training workloads | `bool` | `true` | no |
-| azureml\_should\_create\_ml\_workload\_identity | Whether to create a user-assigned managed identity for AzureML workload federation. | `bool` | `false` | no |
-| azureml\_should\_deploy\_registry | Whether to deploy Azure Machine Learning registry resources alongside the workspace | `bool` | `false` | no |
-| azureml\_should\_enable\_private\_endpoint | Whether to enable a private endpoint for the Azure Machine Learning workspace | `bool` | `false` | no |
-| azureml\_should\_enable\_public\_network\_access | Whether to enable public network access to the Azure Machine Learning workspace | `bool` | `true` | no |
-| certificate\_subject | Certificate subject information for auto-generated certificates | ```object({ common_name = optional(string, "Full Single Node VPN Gateway Root Certificate") organization = optional(string, "Edge AI Accelerator") organizational_unit = optional(string, "IT") country = optional(string, "US") province = optional(string, "WA") locality = optional(string, "Redmond") })``` | `{}` | no |
-| certificate\_validity\_days | Validity period in days for auto-generated certificates | `number` | `365` | no |
-| custom\_akri\_connectors | List of custom Akri connector templates with user-defined endpoint types and container images. Supports built-in types (rest, media, onvif, sse) or custom types with custom\_endpoint\_type and custom\_image\_name. Built-in connectors default to mcr.microsoft.com/azureiotoperations/akri-connectors/connector\_type:0.5.1. | ```list(object({ name = string type = string // "rest", "media", "onvif", "sse", "custom" // Custom Connector Fields (required when type = "custom") custom_endpoint_type = optional(string) // e.g., "Contoso.Modbus", "Acme.CustomProtocol" custom_image_name = optional(string) // e.g., "my_acr.azurecr.io/custom-connector" custom_endpoint_version = optional(string, "1.0") // Runtime Configuration (defaults applied based on connector type) registry = optional(string) // Defaults: mcr.microsoft.com for built-in types image_tag = optional(string) // Defaults: 0.5.1 for built-in types, latest for custom replicas = optional(number, 1) image_pull_policy = optional(string) // Default: IfNotPresent // Diagnostics log_level = optional(string) // Default: info (lowercase: trace, debug, info, warning, error, critical) // MQTT Override (uses shared config if not provided) mqtt_config = optional(object({ host = string audience = string ca_configmap = string keep_alive_seconds = optional(number, 60) max_inflight_messages = optional(number, 100) session_expiry_seconds = optional(number, 600) })) // Optional Advanced Fields aio_min_version = optional(string) aio_max_version = optional(string) allocation = optional(object({ policy = string // "Bucketized" bucket_size = number // 1-100 })) additional_configuration = optional(map(string)) secrets = optional(list(object({ secret_alias = string secret_key = string secret_ref = string }))) trust_settings = optional(object({ trust_list_secret_ref = string })) }))``` | `[]` | no |
-| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant If none is provided, the script attempts to retrieve this value which requires 'Application.Read.All' or 'Directory.Read.All' permissions ```sh az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv``` | `string` | `null` | no |
-| existing\_certificate\_name | Name of the existing certificate in Key Vault when vpn\_gateway\_should\_generate\_ca is false | `string` | `null` | no |
-| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
-| namespaced\_assets | List of namespaced assets with enhanced configuration support | ```list(object({ name = string display_name = optional(string) device_ref = optional(object({ device_name = string endpoint_name = string })) asset_endpoint_profile_ref = optional(string) default_datasets_configuration = optional(string) default_streams_configuration = optional(string) default_events_configuration = optional(string) description = optional(string) documentation_uri = optional(string) enabled = optional(bool, true) hardware_revision = optional(string) manufacturer = optional(string) manufacturer_uri = optional(string) model = optional(string) product_code = optional(string) serial_number = optional(string) software_revision = optional(string) attributes = optional(map(string), {}) datasets = optional(list(object({ name = string data_points = list(object({ data_point_configuration = optional(string) data_source = string name = string observability_mode = optional(string) rest_sampling_interval_ms = optional(number) rest_mqtt_topic = optional(string) rest_include_state_store = optional(bool) rest_state_store_key = optional(string) })) dataset_configuration = optional(string) data_source = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) type_ref = optional(string) })), []) streams = optional(list(object({ name = string stream_configuration = optional(string) type_ref = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) })), []) event_groups = optional(list(object({ name = string data_source = optional(string) event_group_configuration = optional(string) type_ref = optional(string) default_destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) events = list(object({ name = string data_source = string event_configuration = optional(string) type_ref = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) })) })), []) management_groups = optional(list(object({ name = string data_source = optional(string) management_group_configuration = optional(string) type_ref = optional(string) default_topic = optional(string) default_timeout_in_seconds = optional(number, 100) actions = list(object({ name = string action_type = string target_uri = string topic = optional(string) timeout_in_seconds = optional(number) action_configuration = optional(string) type_ref = optional(string) })) })), []) }))``` | `[]` | no |
-| namespaced\_devices | List of namespaced devices to create; otherwise, an empty list | ```list(object({ name = string enabled = optional(bool, true) endpoints = object({ outbound = optional(object({ assigned = object({}) }), { assigned = {} }) inbound = map(object({ endpoint_type = string address = string version = optional(string, null) additionalConfiguration = optional(string) authentication = object({ method = string usernamePasswordCredentials = optional(object({ usernameSecretName = string passwordSecretName = string })) x509Credentials = optional(object({ certificateSecretName = string })) }) trustSettings = optional(object({ trustList = string })) })) }) }))``` | `[]` | no |
-| nat\_gateway\_idle\_timeout\_minutes | Idle timeout in minutes for NAT gateway connections | `number` | `4` | no |
-| nat\_gateway\_public\_ip\_count | Number of public IP addresses to associate with the NAT gateway (example: 2) | `number` | `1` | no |
-| nat\_gateway\_zones | Availability zones for NAT gateway resources when zone redundancy is required (example: ['1','2']) | `list(string)` | `[]` | no |
-| node\_count | Number of nodes for the agent pool in the AKS cluster | `number` | `1` | no |
-| node\_pools | Additional node pools for the AKS cluster; map key is used as the node pool name | ```map(object({ node_count = number vm_size = string subnet_address_prefixes = list(string) pod_subnet_address_prefixes = list(string) node_taints = optional(list(string), []) enable_auto_scaling = optional(bool, false) min_count = optional(number, null) max_count = optional(number, null) }))``` | `{}` | no |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster | `string` | `"Standard_D8ds_v5"` | no |
-| postgresql\_admin\_password | Administrator password for PostgreSQL server. (Otherwise, generated when postgresql\_should\_generate\_admin\_password is true). | `string` | `null` | no |
-| postgresql\_admin\_username | Administrator username for PostgreSQL server | `string` | `"pgadmin"` | no |
-| postgresql\_databases | Map of databases to create with collation and charset | ```map(object({ collation = string charset = string }))``` | `null` | no |
-| postgresql\_delegated\_subnet\_id | Subnet ID with delegation to Microsoft.DBforPostgreSQL/flexibleServers | `string` | `null` | no |
-| postgresql\_should\_enable\_extensions | Whether to enable PostgreSQL extensions via azure.extensions | `bool` | `true` | no |
-| postgresql\_should\_enable\_geo\_redundant\_backup | Whether to enable geo-redundant backups for PostgreSQL | `bool` | `false` | no |
-| postgresql\_should\_enable\_timescaledb | Whether to enable TimescaleDB extension for PostgreSQL | `bool` | `true` | no |
-| postgresql\_should\_generate\_admin\_password | Whether to auto-generate PostgreSQL admin password. | `bool` | `true` | no |
-| postgresql\_should\_store\_credentials\_in\_key\_vault | Whether to store PostgreSQL admin credentials in Key Vault. | `bool` | `true` | no |
-| postgresql\_sku\_name | SKU name for PostgreSQL server | `string` | `"GP_Standard_D2s_v3"` | no |
-| postgresql\_storage\_mb | Storage size in megabytes for PostgreSQL | `number` | `32768` | no |
-| postgresql\_version | PostgreSQL server version | `string` | `"16"` | no |
-| redis\_clustering\_policy | Clustering policy for Redis cache (OSSCluster or EnterpriseCluster) | `string` | `"OSSCluster"` | no |
-| redis\_should\_enable\_high\_availability | Whether to enable high availability for Redis cache | `bool` | `true` | no |
-| redis\_sku\_name | SKU name for Azure Managed Redis cache | `string` | `"Balanced_B10"` | no |
-| resolver\_subnet\_address\_prefix | Address prefix for the private resolver subnet; must be /28 or larger and not overlap with other subnets | `string` | `"10.0.9.0/28"` | no |
-| resource\_group\_name | Name of the resource group to create or use. Otherwise, 'rg-{resource\_prefix}-{environment}-{instance}' | `string` | `null` | no |
-| should\_add\_current\_user\_cluster\_admin | Whether to give the current signed-in user cluster-admin permissions on the new cluster | `bool` | `true` | no |
-| should\_create\_aks | Whether to deploy Azure Kubernetes Service | `bool` | `false` | no |
-| should\_create\_aks\_identity | Whether to create a user-assigned identity for the AKS cluster when using custom private DNS zones | `bool` | `false` | no |
-| should\_create\_anonymous\_broker\_listener | Whether to enable an insecure anonymous AIO MQ broker listener; use only for dev or test environments | `bool` | `false` | no |
-| should\_create\_azure\_functions | Whether to create the Azure Functions resources including the App Service plan | `bool` | `false` | no |
-| should\_deploy\_ai\_foundry | Whether to deploy Azure AI Foundry resources | `bool` | `false` | no |
-| should\_deploy\_azureml | Whether to deploy the Azure Machine Learning workspace and optional compute cluster | `bool` | `false` | no |
-| should\_deploy\_edge\_azureml | Whether to deploy the Azure Machine Learning edge extension when Azure ML is enabled | `bool` | `false` | no |
-| should\_deploy\_postgresql | Whether to deploy PostgreSQL Flexible Server component | `bool` | `false` | no |
-| should\_deploy\_redis | Whether to deploy Azure Managed Redis component | `bool` | `false` | no |
-| should\_deploy\_resource\_sync\_rules | Whether to deploy resource sync rules | `bool` | `true` | no |
-| should\_enable\_akri\_media\_connector | Whether to deploy the Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| should\_enable\_akri\_onvif\_connector | Whether to deploy the Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| should\_enable\_akri\_rest\_connector | Whether to deploy the Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| should\_enable\_akri\_sse\_connector | Whether to deploy the Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| should\_enable\_key\_vault\_public\_network\_access | Whether to enable public network access for the Key Vault | `bool` | `true` | no |
-| should\_enable\_managed\_outbound\_access | Whether to enable managed outbound egress via NAT gateway instead of platform default internet access | `bool` | `true` | no |
-| should\_enable\_oidc\_issuer | Whether to enable the OIDC issuer URL for the cluster | `bool` | `true` | no |
-| should\_enable\_opc\_ua\_simulator | Whether to deploy the OPC UA simulator to the cluster | `bool` | `false` | no |
-| should\_enable\_private\_endpoints | Whether to enable private endpoints across Key Vault, storage, and observability resources to route monitoring ingestion through private link | `bool` | `false` | no |
-| should\_enable\_private\_resolver | Whether to enable Azure Private Resolver for VPN client DNS resolution of private endpoints | `bool` | `false` | no |
-| should\_enable\_storage\_public\_network\_access | Whether to enable public network access for the storage account | `bool` | `true` | no |
-| should\_enable\_vpn\_gateway | Whether to create a VPN gateway for secure access to private endpoints | `bool` | `false` | no |
-| should\_enable\_workload\_identity | Whether to enable Azure AD workload identity for the cluster | `bool` | `true` | no |
-| should\_get\_custom\_locations\_oid | Whether to get the Custom Locations object ID using Terraform's azuread provider Otherwise, provide 'custom\_locations\_oid' or rely on `az connectedk8s enable-features` during cluster setup | `bool` | `true` | no |
-| storage\_account\_is\_hns\_enabled | Whether to enable hierarchical namespace on the storage account when Azure Machine Learning is not deployed; automatically forced to false when should\_deploy\_azureml is true | `bool` | `true` | no |
-| tags | Tags to apply to all resources in this blueprint | `map(string)` | `{}` | no |
-| use\_existing\_resource\_group | Whether to use an existing resource group with the provided or computed name instead of creating a new one | `bool` | `false` | no |
-| vpn\_gateway\_config | VPN gateway configuration including SKU, generation, client address pool, and supported protocols | ```object({ sku = optional(string, "VpnGw1") generation = optional(string, "Generation1") client_address_pool = optional(list(string), ["192.168.200.0/24"]) protocols = optional(list(string), ["OpenVPN", "IkeV2"]) })``` | `{}` | no |
-| vpn\_gateway\_should\_generate\_ca | Whether to generate a new CA certificate; when false, uses an existing certificate from Key Vault | `bool` | `true` | no |
-| vpn\_gateway\_should\_use\_azure\_ad\_auth | Whether to use Azure AD authentication for the VPN gateway; otherwise, certificate authentication is used | `bool` | `true` | no |
-| vpn\_gateway\_subnet\_address\_prefixes | Address prefixes for the GatewaySubnet; must be /27 or larger | `list(string)` | ```[ "10.0.2.0/27" ]``` | no |
-| vpn\_site\_connections | Site-to-site VPN site definitions. Use non-overlapping on-premises address spaces and reference shared keys via shared\_key\_reference | ```list(object({ name = string address_spaces = list(string) shared_key_reference = string connection_mode = optional(string, "Default") dpd_timeout_seconds = optional(number) gateway_fqdn = optional(string) gateway_ip_address = optional(string) ike_protocol = optional(string, "IKEv2") use_policy_based_selectors = optional(bool, false) bgp_settings = optional(object({ asn = number peer_address = string peer_weight = optional(number) })) ipsec_policy = optional(object({ dh_group = string ike_encryption = string ike_integrity = string ipsec_encryption = string ipsec_integrity = string pfs_group = string sa_datasize_kb = optional(number) sa_lifetime_seconds = optional(number) })) }))``` | `[]` | no |
-| vpn\_site\_default\_ipsec\_policy | Fallback IPsec policy applied when site definitions omit ipsec\_policy overrides | ```object({ dh_group = string ike_encryption = string ike_integrity = string ipsec_encryption = string ipsec_integrity = string pfs_group = string sa_datasize_kb = optional(number) sa_lifetime_seconds = optional(number) })``` | `null` | no |
-| vpn\_site\_shared\_keys | Pre-shared keys for site definitions keyed by shared\_key\_reference. Source values from secure secret storage | `map(string)` | `{}` | no |
+| Name | Description | Type | Default | Required |
+|------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------|:--------:|
+| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
+| location | Location for all resources in this module | `string` | n/a | yes |
+| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| acr\_allow\_trusted\_services | Whether trusted Azure services can bypass ACR network rules | `bool` | `true` | no |
+| acr\_allowed\_public\_ip\_ranges | CIDR ranges permitted to reach the ACR public endpoint | `list(string)` | `[]` | no |
+| acr\_data\_endpoint\_enabled | Whether to enable the dedicated ACR data endpoint | `bool` | `true` | no |
+| acr\_public\_network\_access\_enabled | Whether to enable the ACR public endpoint alongside private connectivity | `bool` | `false` | no |
+| acr\_sku | SKU name for the Azure Container Registry | `string` | `"Premium"` | no |
+| ai\_foundry\_model\_deployments | Map of model deployments for AI Foundry | ```map(object({ name = string model = object({ format = string name = string version = string }) scale = object({ type = string capacity = number }) rai_policy_name = optional(string) version_upgrade_option = optional(string, "OnceNewDefaultVersionAvailable") }))``` | `{}` | no |
+| ai\_foundry\_private\_dns\_zone\_ids | List of private DNS zone IDs for the AI Foundry private endpoint | `list(string)` | `[]` | no |
+| ai\_foundry\_projects | Map of AI Foundry projects to create. SKU defaults to 'S0' (currently the only supported value) | ```map(object({ name = string display_name = string description = string sku = optional(string, "S0") }))``` | `{}` | no |
+| ai\_foundry\_rai\_policies | Map of Responsible AI (RAI) content filtering policies. Must be created before referenced in model deployments. | ```map(object({ name = string base_policy_name = optional(string, "Microsoft.Default") mode = optional(string, "Blocking") content_filters = optional(list(object({ name = string enabled = optional(bool, true) blocking = optional(bool, true) severity_threshold = optional(string, "Medium") source = string })), []) }))``` | `{}` | no |
+| ai\_foundry\_should\_enable\_local\_auth | Whether to enable local (API key) authentication for AI Foundry | `bool` | `true` | no |
+| ai\_foundry\_should\_enable\_private\_endpoint | Whether to enable private endpoint for AI Foundry | `bool` | `false` | no |
+| ai\_foundry\_should\_enable\_public\_network\_access | Whether to enable public network access to AI Foundry | `bool` | `true` | no |
+| ai\_foundry\_sku | SKU name for the AI Foundry account | `string` | `"S0"` | no |
+| aio\_features | AIO instance features with mode ('Stable', 'Preview', 'Disabled') and settings ('Enabled', 'Disabled') | ```map(object({ mode = optional(string) settings = optional(map(string)) }))``` | `null` | no |
+| aks\_should\_enable\_private\_cluster | Whether to enable private cluster mode for AKS | `bool` | `true` | no |
+| aks\_should\_enable\_private\_cluster\_public\_fqdn | Whether to create a private cluster public FQDN for AKS | `bool` | `false` | no |
+| azureml\_ml\_workload\_subjects | Custom Kubernetes service account subjects for AzureML workload federation. Example: ['system:serviceaccount:azureml:azureml-workload', 'system:serviceaccount:osmo:osmo-workload'] | `list(string)` | `null` | no |
+| azureml\_registry\_should\_enable\_public\_network\_access | Whether to enable public network access to the Azure Machine Learning registry when deployed | `bool` | `true` | no |
+| azureml\_should\_create\_compute\_cluster | Whether to create a compute cluster for Azure Machine Learning training workloads | `bool` | `true` | no |
+| azureml\_should\_create\_ml\_workload\_identity | Whether to create a user-assigned managed identity for AzureML workload federation. | `bool` | `false` | no |
+| azureml\_should\_deploy\_registry | Whether to deploy Azure Machine Learning registry resources alongside the workspace | `bool` | `false` | no |
+| azureml\_should\_enable\_private\_endpoint | Whether to enable a private endpoint for the Azure Machine Learning workspace | `bool` | `false` | no |
+| azureml\_should\_enable\_public\_network\_access | Whether to enable public network access to the Azure Machine Learning workspace | `bool` | `true` | no |
+| certificate\_subject | Certificate subject information for auto-generated certificates | ```object({ common_name = optional(string, "Full Single Node VPN Gateway Root Certificate") organization = optional(string, "Edge AI Accelerator") organizational_unit = optional(string, "IT") country = optional(string, "US") province = optional(string, "WA") locality = optional(string, "Redmond") })``` | `{}` | no |
+| certificate\_validity\_days | Validity period in days for auto-generated certificates | `number` | `365` | no |
+| custom\_akri\_connectors | List of custom Akri connector templates with user-defined endpoint types and container images. Supports built-in types (rest, media, onvif, sse) or custom types with custom\_endpoint\_type and custom\_image\_name. Built-in connectors default to mcr.microsoft.com/azureiotoperations/akri-connectors/connector\_type:0.5.1. | ```list(object({ name = string type = string // "rest", "media", "onvif", "sse", "custom" // Custom Connector Fields (required when type = "custom") custom_endpoint_type = optional(string) // e.g., "Contoso.Modbus", "Acme.CustomProtocol" custom_image_name = optional(string) // e.g., "my_acr.azurecr.io/custom-connector" custom_endpoint_version = optional(string, "1.0") // Runtime Configuration (defaults applied based on connector type) registry = optional(string) // Defaults: mcr.microsoft.com for built-in types image_tag = optional(string) // Defaults: 0.5.1 for built-in types, latest for custom replicas = optional(number, 1) image_pull_policy = optional(string) // Default: IfNotPresent // Diagnostics log_level = optional(string) // Default: info (lowercase: trace, debug, info, warning, error, critical) // MQTT Override (uses shared config if not provided) mqtt_config = optional(object({ host = string audience = string ca_configmap = string keep_alive_seconds = optional(number, 60) max_inflight_messages = optional(number, 100) session_expiry_seconds = optional(number, 600) })) // Optional Advanced Fields aio_min_version = optional(string) aio_max_version = optional(string) allocation = optional(object({ policy = string // "Bucketized" bucket_size = number // 1-100 })) additional_configuration = optional(map(string)) secrets = optional(list(object({ secret_alias = string secret_key = string secret_ref = string }))) trust_settings = optional(object({ trust_list_secret_ref = string })) }))``` | `[]` | no |
+| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant If none is provided, the script attempts to retrieve this value which requires 'Application.Read.All' or 'Directory.Read.All' permissions ```sh az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv``` | `string` | `null` | no |
+| existing\_certificate\_name | Name of the existing certificate in Key Vault when vpn\_gateway\_should\_generate\_ca is false | `string` | `null` | no |
+| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
+| namespaced\_assets | List of namespaced assets with enhanced configuration support | ```list(object({ name = string display_name = optional(string) device_ref = optional(object({ device_name = string endpoint_name = string })) asset_endpoint_profile_ref = optional(string) default_datasets_configuration = optional(string) default_streams_configuration = optional(string) default_events_configuration = optional(string) description = optional(string) documentation_uri = optional(string) enabled = optional(bool, true) hardware_revision = optional(string) manufacturer = optional(string) manufacturer_uri = optional(string) model = optional(string) product_code = optional(string) serial_number = optional(string) software_revision = optional(string) attributes = optional(map(string), {}) datasets = optional(list(object({ name = string data_points = list(object({ data_point_configuration = optional(string) data_source = string name = string observability_mode = optional(string) rest_sampling_interval_ms = optional(number) rest_mqtt_topic = optional(string) rest_include_state_store = optional(bool) rest_state_store_key = optional(string) })) dataset_configuration = optional(string) data_source = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) type_ref = optional(string) })), []) streams = optional(list(object({ name = string stream_configuration = optional(string) type_ref = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) })), []) event_groups = optional(list(object({ name = string data_source = optional(string) event_group_configuration = optional(string) type_ref = optional(string) default_destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) events = list(object({ name = string data_source = string event_configuration = optional(string) type_ref = optional(string) destinations = optional(list(object({ target = string configuration = object({ topic = optional(string) retain = optional(string) qos = optional(string) }) })), []) })) })), []) management_groups = optional(list(object({ name = string data_source = optional(string) management_group_configuration = optional(string) type_ref = optional(string) default_topic = optional(string) default_timeout_in_seconds = optional(number, 100) actions = list(object({ name = string action_type = string target_uri = string topic = optional(string) timeout_in_seconds = optional(number) action_configuration = optional(string) type_ref = optional(string) })) })), []) }))``` | `[]` | no |
+| namespaced\_devices | List of namespaced devices to create; otherwise, an empty list | ```list(object({ name = string enabled = optional(bool, true) endpoints = object({ outbound = optional(object({ assigned = object({}) }), { assigned = {} }) inbound = map(object({ endpoint_type = string address = string version = optional(string, null) additionalConfiguration = optional(string) authentication = object({ method = string usernamePasswordCredentials = optional(object({ usernameSecretName = string passwordSecretName = string })) x509Credentials = optional(object({ certificateSecretName = string })) }) trustSettings = optional(object({ trustList = string })) })) }) }))``` | `[]` | no |
+| nat\_gateway\_idle\_timeout\_minutes | Idle timeout in minutes for NAT gateway connections | `number` | `4` | no |
+| nat\_gateway\_public\_ip\_count | Number of public IP addresses to associate with the NAT gateway (example: 2) | `number` | `1` | no |
+| nat\_gateway\_zones | Availability zones for NAT gateway resources when zone redundancy is required (example: ['1','2']) | `list(string)` | `[]` | no |
+| node\_count | Number of nodes for the agent pool in the AKS cluster | `number` | `1` | no |
+| node\_pools | Additional node pools for the AKS cluster; map key is used as the node pool name | ```map(object({ node_count = number vm_size = string subnet_address_prefixes = list(string) pod_subnet_address_prefixes = list(string) node_taints = optional(list(string), []) enable_auto_scaling = optional(bool, false) min_count = optional(number, null) max_count = optional(number, null) }))``` | `{}` | no |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster | `string` | `"Standard_D8ds_v5"` | no |
+| postgresql\_admin\_password | Administrator password for PostgreSQL server. (Otherwise, generated when postgresql\_should\_generate\_admin\_password is true). | `string` | `null` | no |
+| postgresql\_admin\_username | Administrator username for PostgreSQL server | `string` | `"pgadmin"` | no |
+| postgresql\_databases | Map of databases to create with collation and charset | ```map(object({ collation = string charset = string }))``` | `null` | no |
+| postgresql\_delegated\_subnet\_id | Subnet ID with delegation to Microsoft.DBforPostgreSQL/flexibleServers | `string` | `null` | no |
+| postgresql\_should\_enable\_extensions | Whether to enable PostgreSQL extensions via azure.extensions | `bool` | `true` | no |
+| postgresql\_should\_enable\_geo\_redundant\_backup | Whether to enable geo-redundant backups for PostgreSQL | `bool` | `false` | no |
+| postgresql\_should\_enable\_timescaledb | Whether to enable TimescaleDB extension for PostgreSQL | `bool` | `true` | no |
+| postgresql\_should\_generate\_admin\_password | Whether to auto-generate PostgreSQL admin password. | `bool` | `true` | no |
+| postgresql\_should\_store\_credentials\_in\_key\_vault | Whether to store PostgreSQL admin credentials in Key Vault. | `bool` | `true` | no |
+| postgresql\_sku\_name | SKU name for PostgreSQL server | `string` | `"GP_Standard_D2s_v3"` | no |
+| postgresql\_storage\_mb | Storage size in megabytes for PostgreSQL | `number` | `32768` | no |
+| postgresql\_version | PostgreSQL server version | `string` | `"16"` | no |
+| redis\_clustering\_policy | Clustering policy for Redis cache (OSSCluster or EnterpriseCluster) | `string` | `"OSSCluster"` | no |
+| redis\_should\_enable\_high\_availability | Whether to enable high availability for Redis cache | `bool` | `true` | no |
+| redis\_sku\_name | SKU name for Azure Managed Redis cache | `string` | `"Balanced_B10"` | no |
+| registry\_endpoints | List of additional container registry endpoints for pulling custom artifacts (WASM modules, graph definitions, connector templates). MCR (mcr.microsoft.com) is always added automatically with anonymous authentication. The `acr_resource_id` field enables automatic AcrPull role assignment for ACR endpoints using SystemAssignedManagedIdentity authentication. When `should_assign_acr_pull_for_aio` is true and `acr_resource_id` is provided, the AIO extension's identity will be granted AcrPull access to the specified ACR. | ```list(object({ name = string host = string acr_resource_id = optional(string) should_assign_acr_pull_for_aio = optional(bool, false) authentication = object({ method = string system_assigned_managed_identity_settings = optional(object({ audience = optional(string) })) user_assigned_managed_identity_settings = optional(object({ client_id = string tenant_id = string scope = optional(string) })) artifact_pull_secret_settings = optional(object({ secret_ref = string })) }) }))``` | `[]` | no |
+| resolver\_subnet\_address\_prefix | Address prefix for the private resolver subnet; must be /28 or larger and not overlap with other subnets | `string` | `"10.0.9.0/28"` | no |
+| resource\_group\_name | Name of the resource group to create or use. Otherwise, 'rg-{resource\_prefix}-{environment}-{instance}' | `string` | `null` | no |
+| should\_add\_current\_user\_cluster\_admin | Whether to give the current signed-in user cluster-admin permissions on the new cluster | `bool` | `true` | no |
+| should\_create\_aks | Whether to deploy Azure Kubernetes Service | `bool` | `false` | no |
+| should\_create\_aks\_identity | Whether to create a user-assigned identity for the AKS cluster when using custom private DNS zones | `bool` | `false` | no |
+| should\_create\_anonymous\_broker\_listener | Whether to enable an insecure anonymous AIO MQ broker listener; use only for dev or test environments | `bool` | `false` | no |
+| should\_create\_azure\_functions | Whether to create the Azure Functions resources including the App Service plan | `bool` | `false` | no |
+| should\_deploy\_ai\_foundry | Whether to deploy Azure AI Foundry resources | `bool` | `false` | no |
+| should\_deploy\_azureml | Whether to deploy the Azure Machine Learning workspace and optional compute cluster | `bool` | `false` | no |
+| should\_deploy\_edge\_azureml | Whether to deploy the Azure Machine Learning edge extension when Azure ML is enabled | `bool` | `false` | no |
+| should\_deploy\_postgresql | Whether to deploy PostgreSQL Flexible Server component | `bool` | `false` | no |
+| should\_deploy\_redis | Whether to deploy Azure Managed Redis component | `bool` | `false` | no |
+| should\_deploy\_resource\_sync\_rules | Whether to deploy resource sync rules | `bool` | `true` | no |
+| should\_enable\_akri\_media\_connector | Whether to deploy the Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| should\_enable\_akri\_onvif\_connector | Whether to deploy the Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| should\_enable\_akri\_rest\_connector | Whether to deploy the Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| should\_enable\_akri\_sse\_connector | Whether to deploy the Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| should\_enable\_key\_vault\_public\_network\_access | Whether to enable public network access for the Key Vault | `bool` | `true` | no |
+| should\_enable\_managed\_outbound\_access | Whether to enable managed outbound egress via NAT gateway instead of platform default internet access | `bool` | `true` | no |
+| should\_enable\_oidc\_issuer | Whether to enable the OIDC issuer URL for the cluster | `bool` | `true` | no |
+| should\_enable\_opc\_ua\_simulator | Whether to deploy the OPC UA simulator to the cluster | `bool` | `false` | no |
+| should\_enable\_private\_endpoints | Whether to enable private endpoints across Key Vault, storage, and observability resources to route monitoring ingestion through private link | `bool` | `false` | no |
+| should\_enable\_private\_resolver | Whether to enable Azure Private Resolver for VPN client DNS resolution of private endpoints | `bool` | `false` | no |
+| should\_enable\_storage\_public\_network\_access | Whether to enable public network access for the storage account | `bool` | `true` | no |
+| should\_enable\_vpn\_gateway | Whether to create a VPN gateway for secure access to private endpoints | `bool` | `false` | no |
+| should\_enable\_workload\_identity | Whether to enable Azure AD workload identity for the cluster | `bool` | `true` | no |
+| should\_get\_custom\_locations\_oid | Whether to get the Custom Locations object ID using Terraform's azuread provider Otherwise, provide 'custom\_locations\_oid' or rely on `az connectedk8s enable-features` during cluster setup | `bool` | `true` | no |
+| should\_include\_acr\_registry\_endpoint | Whether to include the deployed ACR as a registry endpoint with System Assigned Managed Identity authentication | `bool` | `false` | no |
+| storage\_account\_is\_hns\_enabled | Whether to enable hierarchical namespace on the storage account when Azure Machine Learning is not deployed; automatically forced to false when should\_deploy\_azureml is true | `bool` | `true` | no |
+| tags | Tags to apply to all resources in this blueprint | `map(string)` | `{}` | no |
+| use\_existing\_resource\_group | Whether to use an existing resource group with the provided or computed name instead of creating a new one | `bool` | `false` | no |
+| vpn\_gateway\_config | VPN gateway configuration including SKU, generation, client address pool, and supported protocols | ```object({ sku = optional(string, "VpnGw1") generation = optional(string, "Generation1") client_address_pool = optional(list(string), ["192.168.200.0/24"]) protocols = optional(list(string), ["OpenVPN", "IkeV2"]) })``` | `{}` | no |
+| vpn\_gateway\_should\_generate\_ca | Whether to generate a new CA certificate; when false, uses an existing certificate from Key Vault | `bool` | `true` | no |
+| vpn\_gateway\_should\_use\_azure\_ad\_auth | Whether to use Azure AD authentication for the VPN gateway; otherwise, certificate authentication is used | `bool` | `true` | no |
+| vpn\_gateway\_subnet\_address\_prefixes | Address prefixes for the GatewaySubnet; must be /27 or larger | `list(string)` | ```[ "10.0.2.0/27" ]``` | no |
+| vpn\_site\_connections | Site-to-site VPN site definitions. Use non-overlapping on-premises address spaces and reference shared keys via shared\_key\_reference | ```list(object({ name = string address_spaces = list(string) shared_key_reference = string connection_mode = optional(string, "Default") dpd_timeout_seconds = optional(number) gateway_fqdn = optional(string) gateway_ip_address = optional(string) ike_protocol = optional(string, "IKEv2") use_policy_based_selectors = optional(bool, false) bgp_settings = optional(object({ asn = number peer_address = string peer_weight = optional(number) })) ipsec_policy = optional(object({ dh_group = string ike_encryption = string ike_integrity = string ipsec_encryption = string ipsec_integrity = string pfs_group = string sa_datasize_kb = optional(number) sa_lifetime_seconds = optional(number) })) }))``` | `[]` | no |
+| vpn\_site\_default\_ipsec\_policy | Fallback IPsec policy applied when site definitions omit ipsec\_policy overrides | ```object({ dh_group = string ike_encryption = string ike_integrity = string ipsec_encryption = string ipsec_integrity = string pfs_group = string sa_datasize_kb = optional(number) sa_lifetime_seconds = optional(number) })``` | `null` | no |
+| vpn\_site\_shared\_keys | Pre-shared keys for site definitions keyed by shared\_key\_reference. Source values from secure secret storage | `map(string)` | `{}` | no |
## Outputs
diff --git a/blueprints/full-single-node-cluster/terraform/main.tf b/blueprints/full-single-node-cluster/terraform/main.tf
index 1e9c412f..758e49bd 100644
--- a/blueprints/full-single-node-cluster/terraform/main.tf
+++ b/blueprints/full-single-node-cluster/terraform/main.tf
@@ -7,6 +7,21 @@
locals {
default_outbound_access_enabled = var.should_enable_managed_outbound_access == false
+
+ acr_registry_endpoint = var.should_include_acr_registry_endpoint ? [{
+ name = "acr-${var.resource_prefix}"
+ host = "${module.cloud_acr.acr.name}.azurecr.io"
+ acr_resource_id = module.cloud_acr.acr.id
+ should_assign_acr_pull_for_aio = true
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ system_assigned_managed_identity_settings = null
+ user_assigned_managed_identity_settings = null
+ artifact_pull_secret_settings = null
+ }
+ }] : []
+
+ combined_registry_endpoints = concat(var.registry_endpoints, local.acr_registry_endpoint)
}
module "cloud_resource_group" {
@@ -402,6 +417,7 @@ module "edge_iot_ops" {
should_enable_akri_onvif_connector = var.should_enable_akri_onvif_connector
should_enable_akri_sse_connector = var.should_enable_akri_sse_connector
custom_akri_connectors = var.custom_akri_connectors
+ registry_endpoints = local.combined_registry_endpoints
}
diff --git a/blueprints/full-single-node-cluster/terraform/outputs.tf b/blueprints/full-single-node-cluster/terraform/outputs.tf
index 88057467..78c2b18e 100644
--- a/blueprints/full-single-node-cluster/terraform/outputs.tf
+++ b/blueprints/full-single-node-cluster/terraform/outputs.tf
@@ -138,8 +138,8 @@ output "messaging" {
description = "Cloud messaging resources."
value = {
event_grid_topic_endpoint = try(module.cloud_messaging.eventgrid.endpoint, "Not deployed")
- event_grid_topic_name = try(module.cloud_messaging.eventgrid.name, "Not deployed")
- eventhub_name = try(module.cloud_messaging.eventhubs[0].name, "Not deployed")
+ event_grid_topic_name = try(module.cloud_messaging.eventgrid.topic_name, "Not deployed")
+ eventhub_name = try(module.cloud_messaging.eventhubs[0].eventhub_name, "Not deployed")
eventhub_namespace_name = try(module.cloud_messaging.eventhubs[0].namespace_name, "Not deployed")
}
}
diff --git a/blueprints/full-single-node-cluster/terraform/variables.tf b/blueprints/full-single-node-cluster/terraform/variables.tf
index 22662de5..1e38095d 100644
--- a/blueprints/full-single-node-cluster/terraform/variables.tf
+++ b/blueprints/full-single-node-cluster/terraform/variables.tf
@@ -897,6 +897,101 @@ variable "custom_akri_connectors" {
}
}
+variable "registry_endpoints" {
+ type = list(object({
+ name = string
+ host = string
+ acr_resource_id = optional(string)
+ should_assign_acr_pull_for_aio = optional(bool, false)
+
+ authentication = object({
+ method = string
+ system_assigned_managed_identity_settings = optional(object({
+ audience = optional(string)
+ }))
+ user_assigned_managed_identity_settings = optional(object({
+ client_id = string
+ tenant_id = string
+ scope = optional(string)
+ }))
+ artifact_pull_secret_settings = optional(object({
+ secret_ref = string
+ }))
+ })
+ }))
+
+ default = []
+ description = <<-EOT
+ List of additional container registry endpoints for pulling custom artifacts (WASM modules, graph definitions, connector templates).
+ MCR (mcr.microsoft.com) is always added automatically with anonymous authentication.
+
+ The `acr_resource_id` field enables automatic AcrPull role assignment for ACR endpoints
+ using SystemAssignedManagedIdentity authentication. When `should_assign_acr_pull_for_aio` is true
+ and `acr_resource_id` is provided, the AIO extension's identity will be granted AcrPull access to the specified ACR.
+ EOT
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ can(regex("^[a-z0-9][a-z0-9-]*[a-z0-9]$", ep.name)) && length(ep.name) >= 3 && length(ep.name) <= 63
+ ])
+ error_message = "Registry endpoint name must be 3-63 characters, contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ contains(["SystemAssignedManagedIdentity", "UserAssignedManagedIdentity", "ArtifactPullSecret", "Anonymous"], ep.authentication.method)
+ ])
+ error_message = "Authentication method must be one of: SystemAssignedManagedIdentity, UserAssignedManagedIdentity, ArtifactPullSecret, Anonymous"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.authentication.method != "UserAssignedManagedIdentity" || (
+ ep.authentication.user_assigned_managed_identity_settings != null &&
+ ep.authentication.user_assigned_managed_identity_settings.client_id != null &&
+ ep.authentication.user_assigned_managed_identity_settings.tenant_id != null
+ )
+ ])
+ error_message = "UserAssignedManagedIdentity authentication requires client_id and tenant_id in user_assigned_managed_identity_settings"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.authentication.method != "ArtifactPullSecret" || (
+ ep.authentication.artifact_pull_secret_settings != null &&
+ ep.authentication.artifact_pull_secret_settings.secret_ref != null
+ )
+ ])
+ error_message = "ArtifactPullSecret authentication requires secret_ref in artifact_pull_secret_settings"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.name != "mcr" && ep.name != "default"
+ ])
+ error_message = "Registry endpoint names 'mcr' and 'default' are reserved"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.acr_resource_id == null || ep.authentication.method == "SystemAssignedManagedIdentity"
+ ])
+ error_message = "acr_resource_id can only be specified with SystemAssignedManagedIdentity authentication method"
+ }
+}
+
+variable "should_include_acr_registry_endpoint" {
+ type = bool
+ default = false
+ description = "Whether to include the deployed ACR as a registry endpoint with System Assigned Managed Identity authentication"
+}
+
# ------------------------------------------------------------------------------
# AI Foundry Variables
# ------------------------------------------------------------------------------
diff --git a/blueprints/full-single-node-cluster/tests/README.md b/blueprints/full-single-node-cluster/tests/README.md
new file mode 100644
index 00000000..3e684868
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/README.md
@@ -0,0 +1,495 @@
+# Tests for Full Single Node Cluster Blueprint
+
+This directory contains comprehensive integration and contract tests for the [Full Single Node Cluster](../README.md) blueprint supporting both Terraform and Bicep deployments.
+
+## Quick Start
+
+```bash
+# Contract tests (fast, zero-cost validation)
+./run-contract-tests.sh both
+
+# Terraform deployment tests (creates Azure resources)
+./run-deployment-tests.sh terraform
+
+# Bicep deployment tests (creates Azure resources, auto-generates password)
+./run-deployment-tests.sh bicep
+
+# Run both deployment frameworks
+./run-deployment-tests.sh both
+```
+
+## Test Architecture
+
+The test suite provides two complementary testing strategies:
+
+### 1. Contract Tests (Fast, Zero-Cost)
+
+**Purpose:** Static validation of IaC output declarations without deployment
+
+- โ
Validates `BlueprintOutputs` struct matches IaC output declarations
+- โก Executes in seconds without Azure authentication
+- ๐ฐ Zero cost - no Azure resources created
+- ๐ง Uses `terraform-docs` and `az bicep build` for static analysis
+- ๐ Catches configuration drift before expensive deployments
+
+### 2. Deployment Tests (Full Integration)
+
+**Purpose:** End-to-end deployment and functional validation
+
+- ๐ Deploys complete infrastructure to Azure
+- โ
Validates resource creation and configuration
+- ๐ Tests Kubernetes cluster connectivity and operations
+- โ๏ธ Verifies IoT Operations deployment and functionality
+- โ ๏ธ **Creates billable Azure resources** - requires cleanup
+
+## Prerequisites
+
+### For Contract Tests
+
+**Required:**
+
+- Go 1.23 or later
+- `terraform-docs` - Terraform contract validation
+- Azure CLI with Bicep (`az bicep`) - Bicep contract validation
+
+**No Azure authentication required** - tests run entirely offline
+
+### For Deployment Tests
+
+**Required (in addition to contract test prerequisites):**
+
+- Terraform CLI (for Terraform deployment tests)
+- Azure CLI authenticated (`az login`)
+- Azure subscription with Owner or Contributor role
+- Sufficient Azure quota for:
+ - Virtual machines (Standard_D4s_v3)
+ - IoT Operations resources
+ - Azure Arc enabled Kubernetes
+
+**Recommended:**
+
+- `kubectl` - Kubernetes cluster validation
+- `jq` - JSON parsing in helper scripts
+
+## Setup
+
+### 1. Install Go Dependencies
+
+Run `go mod download` from the tests directory to fetch required Go packages.
+
+### 2. Install Required Tools
+
+**terraform-docs:** Install via package manager (Homebrew on macOS, manual download on Linux)
+
+**Azure Bicep:** Install via `az bicep install`
+
+**See:** Tool installation documentation in project root or official tool websites
+
+### 3. Configure Azure Credentials (Deployment Tests Only)
+
+Authenticate with `az login`, optionally set subscription with `az account set`, and verify with `az account show`. The deployment scripts automatically export `ARM_SUBSCRIPTION_ID`.
+
+**See:** [run-deployment-tests.sh](run-deployment-tests.sh) for automatic credential handling
+
+## Running Contract Tests
+
+Contract tests validate that all outputs defined in the `BlueprintOutputs` struct are properly declared in both Terraform and Bicep configurations. These tests run entirely offline and complete in seconds.
+
+### Using Helper Script (Recommended)
+
+Use `run-contract-tests.sh` with framework argument (`both`, `terraform`, or `bicep`) and optional `-v` flag for verbose output.
+
+**See:** [run-contract-tests.sh](run-contract-tests.sh) for script implementation
+
+### Using Go Test Directly
+
+Run `go test -v -run Contract` to execute all contract tests, or specify individual test names:
+
+- `TestTerraformOutputsContract` - Terraform validation
+- `TestBicepOutputsContract` - Bicep validation
+
+**See:** [contract_terraform_test.go](contract_terraform_test.go) and [contract_bicep_test.go](contract_bicep_test.go)
+
+## Running Deployment Tests
+
+### Using the Helper Script (Recommended)
+
+The `run-deployment-tests.sh` script handles environment setup, auto-detection, and password generation.
+
+**Usage:** Framework argument (`terraform`, `bicep`, or `both`) with optional `-v` verbose flag
+
+**See:** [run-deployment-tests.sh](run-deployment-tests.sh) for complete implementation
+
+### Environment Variables
+
+**Auto-Detected Variables** (script handles these):
+
+- `ARM_SUBSCRIPTION_ID` - Azure subscription ID from `az account show`
+- `CUSTOM_LOCATIONS_OID` - Custom Locations RP object ID from Azure AD
+- `ADMIN_PASSWORD` - Auto-generated secure password (if not provided)
+
+**Configuration Variables** (override as needed):
+
+Set environment variables before running tests to customize behavior. See "Environment Variables Reference" section below for complete list with defaults.
+
+**Common overrides:**
+
+- `TEST_ENVIRONMENT`, `TEST_LOCATION`, `TEST_RESOURCE_PREFIX`, `TEST_INSTANCE` - Test naming
+- `ADMIN_PASSWORD` - VM admin password (auto-generated if not set)
+- `CLEANUP_RESOURCES` - Enable automatic resource deletion after test
+- `SKIP_BICEP_DEPLOYMENT` + `BICEP_DEPLOYMENT_NAME` - Use existing deployment
+
+### Advanced Go Test Usage
+
+For advanced usage or CI/CD integration:
+
+```bash
+# Initialize Azure subscription context
+source ../../../scripts/az-sub-init.sh
+
+# Configure test parameters
+export TEST_ENVIRONMENT="dev"
+export TEST_LOCATION="eastus2"
+export TEST_RESOURCE_PREFIX="t6"
+export TEST_INSTANCE="001"
+
+# Terraform deployment test
+export TEST_RESOURCE_GROUP_NAME="t6-terraform"
+go test -v -run TestTerraformFullSingleNodeClusterDeploy -timeout 2h
+
+# Bicep deployment test (requires additional variables)
+export TEST_RESOURCE_GROUP_NAME="t6-bicep"
+export ADMIN_PASSWORD="MySecurePassword123!"
+export CUSTOM_LOCATIONS_OID=$(az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv)
+go test -v -run TestBicepFullSingleNodeClusterDeploy -timeout 2h
+```
+
+**Note**: Timeout of 2h allows for full deployment, validation, and optional cleanup.
+
+## Test Organization
+
+### Contract Tests (Static Analysis)
+
+- [contract_terraform_test.go](contract_terraform_test.go) - Terraform output contract validation using `terraform-docs`
+- [contract_bicep_test.go](contract_bicep_test.go) - Bicep output contract validation using `az bicep build`
+
+### Deployment Tests (Integration)
+
+- [deploy_terraform_test.go](deploy_terraform_test.go) - Terraform deployment, validation, and optional cleanup
+- [deploy_bicep_test.go](deploy_bicep_test.go) - Bicep deployment, validation, and optional cleanup
+
+### Shared Infrastructure
+
+- [outputs.go](outputs.go) - `BlueprintOutputs` struct defining the output contract
+- [validation.go](validation.go) - Comprehensive validation test suites for deployed infrastructure
+- [setup.go](setup.go) - Post-deployment configuration (Arc proxy, RBAC permissions)
+
+### Automation Scripts
+
+- [run-contract-tests.sh](run-contract-tests.sh) - Contract test runner with dependency checks
+- [run-deployment-tests.sh](run-deployment-tests.sh) - Deployment test runner with auto-configuration
+
+## Test Files Reference
+
+| File | Purpose | Azure Auth Required | Deploys Resources |
+|------------------------------|-----------------------------|---------------------|-------------------|
+| `contract_terraform_test.go` | Validate Terraform outputs | (-) No | (-) No |
+| `contract_bicep_test.go` | Validate Bicep outputs | (-) No | (-) No |
+| `deploy_terraform_test.go` | Deploy & validate Terraform | (+) Yes | (+) Yes |
+| `deploy_bicep_test.go` | Deploy & validate Bicep | (+) Yes | (+) Yes |
+| `outputs.go` | Output contract definition | (-) No | (-) No |
+| `validation.go` | Shared validation logic | (-) No | (-) No |
+| `setup.go` | Post-deployment setup | (+) Yes | (-) No |
+
+## Important Notes
+
+### Cost Considerations
+
+- **Contract tests:** Free - no Azure resources created
+- **Deployment tests:** Creates billable Azure resources including:
+ - Virtual machines (Standard_D4s_v3)
+ - Azure Arc enabled Kubernetes
+ - IoT Operations resources
+ - Storage accounts and networking
+
+**Always set `CLEANUP_RESOURCES=true` for cost control** or manually delete resources after testing.
+
+### Test Duration
+
+- **Contract tests:** < 1 second (static analysis only)
+- **Deployment tests:** 30-45 minutes (full infrastructure deployment)
+
+### Resource Cleanup
+
+When `CLEANUP_RESOURCES=true`:
+
+- **Terraform:** Runs `terraform destroy` to remove all resources
+- **Bicep:** Deletes entire resource group with `az group delete`
+
+**Manual cleanup** if tests fail during deployment:
+
+Use `az group list` to find test resource groups (typically prefixed with `t6-`), then delete with `az group delete --name --yes --no-wait`.
+
+## Troubleshooting Guide
+
+### Contract Test Issues
+
+**terraform-docs not found:** Install terraform-docs via package manager
+
+**az bicep not found:** Run `az bicep install`
+
+**Missing outputs:** Add to `outputs.tf` (Terraform) or `main.bicep` outputs (Bicep), or remove from `BlueprintOutputs` struct
+
+### Deployment Test Issues
+
+**Authentication errors:** Ensure `az login` completed successfully and verify with `az account show`
+
+**InsufficientQuota:** Request quota increase in Azure Portal or try different region via `TEST_LOCATION` variable
+
+**Timeout errors:** Increase test timeout using `-timeout` flag (e.g., `-timeout 3h`)
+
+**Cluster unreachable:** VM and Arc proxy setup handled automatically by [setup.go](setup.go); check Azure Portal for VM status
+| `validation.go` | Validation test suites | โ
Yes | โ No |
+| `setup.go` | Arc proxy & permissions | โ
Yes | โ No |
+
+## Validation Test Suite
+
+The [validation.go](validation.go) file contains comprehensive validation functions:
+
+### Core Infrastructure Validation
+
+Implemented in `validateCoreInfrastructure()`:
+
+- โ
Deployment summary (resource group, location, environment)
+- โ
Security and identity (Key Vault, managed identities, RBAC)
+- โ
Observability (Log Analytics workspace, Grafana, Azure Monitor)
+- โ
Networking (NAT Gateway, virtual network, subnets)
+- โ
Data storage (Storage Account, Schema Registry)
+- โ
Container registry (Azure Container Registry)
+- โ
VM host resources (compute, networking, storage)
+- โ
Arc-connected cluster (Connected Cluster resource)
+
+### Kubernetes Cluster Validation
+
+Implemented in `validateKubernetesCluster()`:
+
+- โ
Node status and readiness
+- โ
System namespaces and core pods
+- โ
Kubernetes services availability
+- โ
Resource quotas and limits
+
+### Azure IoT Operations Validation
+
+Implemented in `validateAzureIoTOperations()`:
+
+- โ
AIO namespace existence and pod status
+- โ
Custom Location configuration
+- โ
Asset Endpoint Profile resources
+- โ
MQTT Broker deployment (Terraform deployments only)
+
+### Messaging Validation
+
+Implemented in `validateMessagingInfrastructure()` (Terraform only):
+
+- โ
Event Hub message consumption and parsing
+- โ
Message format and schema validation
+- โ
End-to-end data flow verification
+
+## Key Differences: Terraform vs Bicep Tests
+
+| Feature | Terraform | Bicep |
+|--------------------------|--------------------------------------------|-------------------------------------------|
+| **Test Function** | `TestTerraformFullSingleNodeClusterDeploy` | `TestBicepFullSingleNodeClusterDeploy` |
+| **Validation Function** | `validateTerraformDeployment` | `validateBicepDeployment` |
+| **Messaging Tests** | (+) Included (Event Hub validation) | (-) Skipped (dataflow config limitations) |
+| **Password Requirement** | (-) Not required (uses SSH keys) | (+) Required (auto-generated by script) |
+| **Custom Locations OID** | (-) Not required (uses managed identity) | (+) Required (auto-detected by script) |
+| **Deployment Scope** | Resource group | Subscription-level |
+
+## Environment Variables Reference
+
+### Auto-Detected Variables
+
+| Variable | Description | Fallback |
+|------------------------|------------------------------------|------------------------------|
+| `ARM_SUBSCRIPTION_ID` | Azure subscription ID | Script fails if not detected |
+| `CUSTOM_LOCATIONS_OID` | Custom Locations service principal | Script fails if not detected |
+| `ADMIN_PASSWORD` | VM admin password | Auto-generated password |
+
+### Configuration Variables
+
+| Variable | Description | Default |
+|----------------------------|----------------------|-----------------------------------------|
+| `TEST_ENVIRONMENT` | Environment name | `dev` |
+| `TEST_LOCATION` | Azure region | `eastus2` |
+| `TEST_RESOURCE_PREFIX` | Resource name prefix | `t6` |
+| `TEST_INSTANCE` | Instance identifier | `001` |
+| `TEST_RESOURCE_GROUP_NAME` | Resource group name | `${TEST_RESOURCE_PREFIX}-{tf or bicep}` |
+
+### Optional Control Variables
+
+| Variable | Description | Default |
+|-------------------------|----------------------------------|-------------------------|
+| `CLEANUP_RESOURCES` | Auto-delete resources after test | `false` |
+| `SKIP_BICEP_DEPLOYMENT` | Skip deployment, use existing | `false` |
+| `BICEP_DEPLOYMENT_NAME` | Name of existing deployment | `bicep-deployment-test` |
+
+**โ ๏ธ Resource Cleanup Note**: By default, tests **DO NOT** delete resources after completion. Set `CLEANUP_RESOURCES=true` to enable automatic cleanup.
+
+## Common Workflows
+
+### Development Workflow
+
+**Typical development cycle for test improvements:**
+
+```bash
+# Step 1: Validate IaC output contract (fast, no costs)
+./run-contract-tests.sh both
+
+# Step 2: Initial deployment (resources remain for inspection)
+./run-deployment-tests.sh terraform
+
+# Step 3: Iterate on validation logic without redeploying
+export SKIP_BICEP_DEPLOYMENT=true
+go test -v -run TestBicepFullSingleNodeClusterDeploy -timeout 30m
+
+# Step 4: Test cleanup functionality before final commit
+export CLEANUP_RESOURCES=true
+./run-deployment-tests.sh terraform
+```
+
+### CI/CD Integration
+
+**Recommended pipeline stages:**
+
+```bash
+# Stage 1: PR validation (every commit, ~30 seconds)
+./run-contract-tests.sh both
+
+# Stage 2: Nightly integration tests (scheduled, ~90 minutes)
+export CLEANUP_RESOURCES=true
+./run-deployment-tests.sh both
+```
+
+### Adding New Infrastructure Outputs
+
+**Complete workflow for adding new outputs:**
+
+```bash
+# Step 1: Add output to IaC configuration
+# - For Terraform: Edit ../terraform/outputs.tf
+# - For Bicep: Edit ../bicep/main.bicep
+
+# Step 2: Add corresponding field to BlueprintOutputs struct
+# - Edit outputs.go
+# - Add struct field with terraform:"/" or bicep:"/" tag
+
+# Step 3: Verify contract validation passes
+./run-contract-tests.sh both
+
+# Step 4: Add validation logic (if needed)
+# - Edit validation.go
+# - Add assertions for new output values
+
+# Step 5: Run end-to-end deployment test
+./run-deployment-tests.sh terraform
+```
+
+### Debugging Failed Deployments
+
+**Troubleshooting and inspection workflow:**
+
+```bash
+# Deploy without cleanup to inspect resources
+export CLEANUP_RESOURCES=false
+./run-deployment-tests.sh terraform
+
+# Manually inspect resources in Azure Portal or CLI
+az resource list --resource-group t6-terraform -o table
+
+# Test specific validation functions
+go test -v -run TestTerraformFullSingleNodeClusterDeploy/Validate -timeout 30m
+
+# Clean up when done
+export CLEANUP_RESOURCES=true
+go test -v -run TestTerraformFullSingleNodeClusterDeploy -timeout 15m
+```
+
+## Troubleshooting
+
+### Contract Test Failures
+
+```text
+โ Missing 2 required outputs in terraform: [new_output_1, new_output_2]
+```
+
+**Solution**: Add the missing outputs to `terraform/outputs.tf` or `bicep/main.bicep`, or remove them from the `BlueprintOutputs` struct in `outputs.go`.
+
+### Auto-Detection Failures
+
+If `ARM_SUBSCRIPTION_ID` auto-detection fails:
+
+```bash
+az login
+export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv)
+```
+
+If `CUSTOM_LOCATIONS_OID` auto-detection fails:
+
+```bash
+# Ensure you have permissions to query Azure AD
+export CUSTOM_LOCATIONS_OID=$(az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv)
+```
+
+### Arc Proxy Connection Issues
+
+If Arc proxy fails to connect:
+
+```bash
+# Manually test Arc connectivity
+az connectedk8s proxy \
+ --name \
+ --resource-group &
+
+# Wait a few seconds, then test
+kubectl get nodes
+```
+
+### Permission Issues
+
+Required Azure permissions:
+
+- **Contributor** access to subscription
+- **Azure AD read** permissions (for Custom Locations OID query)
+- **Event Hub Data Receiver** role (auto-assigned by test setup)
+
+## Cost Management
+
+**โ ๏ธ WARNING**: Deployment tests create real Azure resources and incur costs!
+
+### Cost Optimization Strategies
+
+1. **Use contract tests for rapid iteration** - Zero cost, instant feedback
+2. **Reuse deployments** - Set `SKIP_BICEP_DEPLOYMENT=true` after initial deployment
+3. **Clean up after testing** - Tests don't auto-destroy resources
+4. **Use smaller configurations** - Modify test variables to use minimal SKUs
+5. **Monitor Azure costs** - Track spending during test development
+
+### Estimated Costs
+
+Approximate costs per deployment test run (varies by region):
+
+- VM hosts: ~$2-5/hour
+- Azure IoT Operations: ~$1-2/hour
+- Networking (NAT Gateway): ~$0.50/hour
+- Storage and monitoring: ~$0.25/hour
+
+**Total**: ~$4-8/hour per deployment
+
+## Additional Resources
+
+- [Blueprint README](../README.md) - Complete blueprint documentation and deployment guide
+- [Test Utilities Package](../../../src/900-tools-utilities/904-test-utilities/) - Shared testing utilities and functions
+- [Terraform Implementation](../terraform/) - Terraform IaC configuration
+- [Bicep Implementation](../bicep/) - Bicep IaC configuration
+- [Terratest Documentation](https://terratest.gruntwork.io/) - Underlying test framework documentation
diff --git a/blueprints/full-single-node-cluster/tests/contract_bicep_test.go b/blueprints/full-single-node-cluster/tests/contract_bicep_test.go
new file mode 100644
index 00000000..32340596
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/contract_bicep_test.go
@@ -0,0 +1,37 @@
+package test
+
+import (
+ "testing"
+
+ testutil "github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities"
+)
+
+// TestBicepOutputsContract validates Bicep output declarations against test requirements.
+//
+// Purpose:
+//
+// Ensures all outputs defined in BlueprintOutputs struct are declared in bicep/main.bicep
+//
+// Behavior:
+// - Static analysis only - no deployment or Azure authentication required
+// - Uses az bicep build to compile Bicep to ARM template
+// - Extracts outputs from compiled ARM template JSON
+// - Automatically converts required output names from snake_case to camelCase
+// since Bicep uses camelCase convention while test structs use snake_case tags
+// - Compares against BlueprintOutputs.GetRequiredOutputKeys() (converted to camelCase)
+// - Fails if any required outputs are missing from main.bicep
+//
+// Naming Convention:
+// - BlueprintOutputs uses snake_case tags (e.g., "deployment_summary")
+// - Bicep outputs must use camelCase (e.g., "deploymentSummary")
+// - ValidateBicepContract handles the automatic conversion
+//
+// Requirements:
+// - Azure CLI with Bicep extension (az bicep install)
+// - Valid Bicep configuration in ../bicep
+//
+// Runtime: Typically completes in < 2 seconds
+func TestBicepOutputsContract(t *testing.T) {
+ var blueprint BlueprintOutputs
+ testutil.ValidateBicepContract(t, "../bicep", blueprint.GetRequiredOutputKeys())
+}
diff --git a/blueprints/full-single-node-cluster/tests/contract_terraform_test.go b/blueprints/full-single-node-cluster/tests/contract_terraform_test.go
new file mode 100644
index 00000000..98cec781
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/contract_terraform_test.go
@@ -0,0 +1,29 @@
+package test
+
+import (
+ "testing"
+
+ testutil "github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities"
+)
+
+// TestTerraformOutputsContract validates Terraform output declarations against test requirements.
+//
+// Purpose:
+//
+// Ensures all outputs defined in BlueprintOutputs struct are declared in terraform/outputs.tf
+//
+// Behavior:
+// - Static analysis only - no deployment or Azure authentication required
+// - Uses terraform-docs to extract output declarations
+// - Compares against BlueprintOutputs.GetRequiredOutputKeys()
+// - Fails if any required outputs are missing from outputs.tf
+//
+// Requirements:
+// - terraform-docs must be installed (brew install terraform-docs)
+// - Valid Terraform configuration in ../terraform
+//
+// Runtime: Typically completes in < 1 second
+func TestTerraformOutputsContract(t *testing.T) {
+ var blueprint BlueprintOutputs
+ testutil.ValidateTerraformContract(t, "../terraform", blueprint.GetRequiredOutputKeys())
+}
diff --git a/blueprints/full-single-node-cluster/tests/deploy_bicep_test.go b/blueprints/full-single-node-cluster/tests/deploy_bicep_test.go
new file mode 100644
index 00000000..8a068cd8
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/deploy_bicep_test.go
@@ -0,0 +1,98 @@
+package test
+
+import (
+ "os"
+ "testing"
+
+ testutil "github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities"
+ "github.com/stretchr/testify/require"
+)
+
+// TestBicepFullSingleNodeClusterDeploy performs full Bicep deployment and validation.
+//
+// WARNING: Deploys real Azure resources that incur costs.
+//
+// Configuration via environment variables:
+//
+// Required (always):
+// ARM_SUBSCRIPTION_ID, TEST_RESOURCE_GROUP_NAME, TEST_ENVIRONMENT,
+// TEST_LOCATION, TEST_RESOURCE_PREFIX, TEST_INSTANCE
+//
+// Required (for deployment):
+// ADMIN_PASSWORD, CUSTOM_LOCATIONS_OID
+//
+// Optional:
+// SKIP_BICEP_DEPLOYMENT=true (use existing deployment outputs)
+// BICEP_DEPLOYMENT_NAME= (defaults to "bicep-deployment-test")
+func TestBicepFullSingleNodeClusterDeploy(t *testing.T) {
+ t.Parallel()
+
+ bicepDir := "../bicep"
+
+ // Get subscription ID for deployment
+ subscriptionID := os.Getenv("ARM_SUBSCRIPTION_ID")
+ require.NotEmpty(t, subscriptionID, "ARM_SUBSCRIPTION_ID environment variable must be set")
+
+ // Check if we should skip deployment and use existing
+ skipDeployment := os.Getenv("SKIP_BICEP_DEPLOYMENT") == "true"
+ deploymentName := os.Getenv("BICEP_DEPLOYMENT_NAME")
+ if deploymentName == "" {
+ deploymentName = "bicep-deployment-test"
+ }
+
+ // Get test configuration from environment variables (set in run-deployment-tests.sh)
+ resourceGroupName := os.Getenv("TEST_RESOURCE_GROUP_NAME")
+ require.NotEmpty(t, resourceGroupName, "TEST_RESOURCE_GROUP_NAME environment variable must be set")
+
+ environment := os.Getenv("TEST_ENVIRONMENT")
+ require.NotEmpty(t, environment, "TEST_ENVIRONMENT environment variable must be set")
+
+ location := os.Getenv("TEST_LOCATION")
+ require.NotEmpty(t, location, "TEST_LOCATION environment variable must be set")
+
+ resourcePrefix := os.Getenv("TEST_RESOURCE_PREFIX")
+ require.NotEmpty(t, resourcePrefix, "TEST_RESOURCE_PREFIX environment variable must be set")
+
+ instance := os.Getenv("TEST_INSTANCE")
+ require.NotEmpty(t, instance, "TEST_INSTANCE environment variable must be set")
+
+ var rawOutputs map[string]any
+
+ if skipDeployment {
+ // Use existing deployment - no password/OID required
+ t.Logf("Skipping deployment, fetching outputs from existing deployment: %s", deploymentName)
+ rawOutputs = testutil.GetBicepOutputs(t, deploymentName)
+ } else {
+ // Run full deployment
+ adminPassword := os.Getenv("ADMIN_PASSWORD")
+ require.NotEmpty(t, adminPassword, "ADMIN_PASSWORD environment variable must be set")
+
+ customLocationsOid := os.Getenv("CUSTOM_LOCATIONS_OID")
+ require.NotEmpty(t, customLocationsOid, "CUSTOM_LOCATIONS_OID environment variable must be set")
+
+ // Required parameters for deployment (Bicep format)
+ params := map[string]any{
+ "common": map[string]any{
+ "environment": environment,
+ "location": location,
+ "resourcePrefix": resourcePrefix,
+ "instance": instance,
+ },
+ "resourceGroupName": resourceGroupName,
+ "adminPassword": adminPassword,
+ "customLocationsOid": customLocationsOid,
+ }
+
+ // Check if resources should be cleaned up after test (defaults to false)
+ shouldCleanup := os.Getenv("CLEANUP_RESOURCES") == "true"
+
+ // Deploy using Bicep and get outputs
+ rawOutputs = testutil.DeployBicep(t, bicepDir, subscriptionID, deploymentName, resourceGroupName, params, shouldCleanup)
+ }
+
+ // Parse outputs and setup permissions/proxy
+ ctx := prepareBicepOutputsAndSetup(t, rawOutputs, subscriptionID, resourceGroupName)
+
+ // Validate deployment and run all tests
+ validateDeploymentWithoutMessaging(t, ctx, subscriptionID, resourceGroupName, resourcePrefix, location)
+}
diff --git a/blueprints/full-single-node-cluster/tests/deploy_terraform_test.go b/blueprints/full-single-node-cluster/tests/deploy_terraform_test.go
new file mode 100644
index 00000000..99ef88ac
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/deploy_terraform_test.go
@@ -0,0 +1,62 @@
+package test
+
+import (
+ "os"
+ "testing"
+
+ testutil "github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities"
+ "github.com/stretchr/testify/require"
+)
+
+// TestTerraformFullSingleNodeClusterDeploy performs full Terraform deployment and validation.
+//
+// WARNING: Deploys real Azure resources that incur costs.
+//
+// Configuration via environment variables:
+//
+// Required:
+// ARM_SUBSCRIPTION_ID, TEST_ENVIRONMENT, TEST_LOCATION,
+// TEST_RESOURCE_PREFIX, TEST_RESOURCE_GROUP_NAME
+func TestTerraformFullSingleNodeClusterDeploy(t *testing.T) {
+ t.Parallel()
+
+ terraformDir := "../terraform"
+
+ // Get test configuration from environment variables (set in run-deployment-tests.sh)
+ environment := os.Getenv("TEST_ENVIRONMENT")
+ require.NotEmpty(t, environment, "TEST_ENVIRONMENT environment variable must be set")
+
+ location := os.Getenv("TEST_LOCATION")
+ require.NotEmpty(t, location, "TEST_LOCATION environment variable must be set")
+
+ resourcePrefix := os.Getenv("TEST_RESOURCE_PREFIX")
+ require.NotEmpty(t, resourcePrefix, "TEST_RESOURCE_PREFIX environment variable must be set")
+
+ resourceGroupName := os.Getenv("TEST_RESOURCE_GROUP_NAME")
+ require.NotEmpty(t, resourceGroupName, "TEST_RESOURCE_GROUP_NAME environment variable must be set")
+
+ // Required variables for deployment
+ vars := map[string]any{
+ "environment": environment,
+ "location": location,
+ "resource_prefix": resourcePrefix,
+ "resource_group_name": resourceGroupName,
+ "should_enable_opc_ua_simulator": true,
+ }
+
+ // Get subscription ID for Azure module functions
+ subscriptionID := os.Getenv("ARM_SUBSCRIPTION_ID")
+ require.NotEmpty(t, subscriptionID, "ARM_SUBSCRIPTION_ID environment variable must be set")
+
+ // Check if resources should be cleaned up after test (defaults to false)
+ shouldCleanup := os.Getenv("CLEANUP_RESOURCES") == "true"
+
+ // Deploy using Terraform and get outputs
+ rawOutputs := testutil.DeployTerraform(t, terraformDir, vars, shouldCleanup)
+
+ // Parse outputs and setup permissions/proxy
+ ctx := prepareTerraformOutputsAndSetup(t, rawOutputs, subscriptionID, resourceGroupName)
+
+ // Validate deployment and run all tests (including Event Hub validation for Terraform)
+ validateDeploymentWithMessaging(t, ctx, subscriptionID, resourceGroupName, resourcePrefix, location)
+}
diff --git a/blueprints/full-single-node-cluster/tests/go.mod b/blueprints/full-single-node-cluster/tests/go.mod
new file mode 100644
index 00000000..ec26cd71
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/go.mod
@@ -0,0 +1,174 @@
+module github.com/microsoft/edge-ai/blueprints/full-single-node-cluster/tests
+
+go 1.25.5
+
+require (
+ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0
+ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1
+ github.com/Azure/azure-sdk-for-go/sdk/messaging/azeventhubs v1.4.0
+ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0
+ github.com/google/uuid v1.6.0
+ github.com/gruntwork-io/terratest v0.54.0
+ github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities v0.0.0-00010101000000-000000000000
+ github.com/microsoftgraph/msgraph-sdk-go v1.93.0
+ github.com/stretchr/testify v1.11.1
+ k8s.io/api v0.34.0
+ k8s.io/apimachinery v0.34.0
+)
+
+require (
+ filippo.io/edwards25519 v1.1.0 // indirect
+ github.com/Azure/azure-sdk-for-go v51.0.0+incompatible // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3 v3.0.0 // indirect
+ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 // indirect
+ github.com/Azure/go-amqp v1.4.0 // indirect
+ github.com/Azure/go-autorest v14.2.0+incompatible // indirect
+ github.com/Azure/go-autorest/autorest v0.11.20 // indirect
+ github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect
+ github.com/Azure/go-autorest/autorest/azure/auth v0.5.8 // indirect
+ github.com/Azure/go-autorest/autorest/azure/cli v0.4.2 // indirect
+ github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
+ github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect
+ github.com/Azure/go-autorest/autorest/validation v0.3.1 // indirect
+ github.com/Azure/go-autorest/logger v0.2.1 // indirect
+ github.com/Azure/go-autorest/tracing v0.6.0 // indirect
+ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
+ github.com/agext/levenshtein v1.2.3 // indirect
+ github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.32.5 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.24 // indirect
+ github.com/aws/aws-sdk-go-v2/service/acm v1.30.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.44.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/dynamodb v1.37.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/iam v1.38.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/kms v1.37.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/lambda v1.69.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/rds v1.91.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 // indirect
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.69.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sns v1.33.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sqs v1.37.1 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect
+ github.com/aws/smithy-go v1.22.1 // indirect
+ github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
+ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
+ github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dimchansky/utfbom v1.1.1 // indirect
+ github.com/emicklei/go-restful/v3 v3.12.2 // indirect
+ github.com/form3tech-oss/jwt-go v3.2.2+incompatible // indirect
+ github.com/fxamacker/cbor/v2 v2.9.0 // indirect
+ github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/go-openapi/jsonpointer v0.21.0 // indirect
+ github.com/go-openapi/jsonreference v0.20.2 // indirect
+ github.com/go-openapi/swag v0.23.0 // indirect
+ github.com/go-sql-driver/mysql v1.8.1 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
+ github.com/google/gnostic-models v0.7.0 // indirect
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
+ github.com/gruntwork-io/go-commons v0.8.0 // indirect
+ github.com/hashicorp/errwrap v1.0.0 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-getter/v2 v2.2.3 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/go-safetemp v1.0.0 // indirect
+ github.com/hashicorp/go-version v1.7.0 // indirect
+ github.com/hashicorp/hcl/v2 v2.22.0 // indirect
+ github.com/hashicorp/terraform-json v0.23.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.7.1 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a // indirect
+ github.com/jmespath/go-jmespath v0.4.0 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/compress v1.16.5 // indirect
+ github.com/kylelemons/godebug v1.1.0 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 // indirect
+ github.com/microsoft/kiota-abstractions-go v1.9.3 // indirect
+ github.com/microsoft/kiota-authentication-azure-go v1.3.1 // indirect
+ github.com/microsoft/kiota-http-go v1.5.4 // indirect
+ github.com/microsoft/kiota-serialization-form-go v1.1.2 // indirect
+ github.com/microsoft/kiota-serialization-json-go v1.1.2 // indirect
+ github.com/microsoft/kiota-serialization-multipart-go v1.1.2 // indirect
+ github.com/microsoft/kiota-serialization-text-go v1.1.3 // indirect
+ github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/mitchellh/go-testing-interface v1.14.1 // indirect
+ github.com/mitchellh/go-wordwrap v1.0.1 // indirect
+ github.com/moby/spdystream v0.5.0 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/pquerna/otp v1.4.0 // indirect
+ github.com/russross/blackfriday/v2 v2.1.0 // indirect
+ github.com/satori/go.uuid v1.2.0 // indirect
+ github.com/spf13/pflag v1.0.6 // indirect
+ github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 // indirect
+ github.com/tmccombs/hcl2json v0.6.4 // indirect
+ github.com/ulikunitz/xz v0.5.10 // indirect
+ github.com/urfave/cli v1.22.16 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
+ github.com/zclconf/go-cty v1.15.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.opentelemetry.io/otel v1.37.0 // indirect
+ go.opentelemetry.io/otel/metric v1.37.0 // indirect
+ go.opentelemetry.io/otel/trace v1.37.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/mod v0.29.0 // indirect
+ golang.org/x/net v0.47.0 // indirect
+ golang.org/x/oauth2 v0.27.0 // indirect
+ golang.org/x/sync v0.18.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/term v0.37.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ golang.org/x/time v0.9.0 // indirect
+ golang.org/x/tools v0.38.0 // indirect
+ google.golang.org/protobuf v1.36.5 // indirect
+ gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
+ gopkg.in/inf.v0 v0.9.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ k8s.io/client-go v0.34.0 // indirect
+ k8s.io/klog/v2 v2.130.1 // indirect
+ k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
+ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
+ sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
+ sigs.k8s.io/randfill v1.0.0 // indirect
+ sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
+ sigs.k8s.io/yaml v1.6.0 // indirect
+)
+
+replace github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities => ../../../src/900-tools-utilities/904-test-utilities
diff --git a/blueprints/full-single-node-cluster/tests/go.sum b/blueprints/full-single-node-cluster/tests/go.sum
new file mode 100644
index 00000000..3e09cad3
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/go.sum
@@ -0,0 +1,470 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/Azure/azure-sdk-for-go v51.0.0+incompatible h1:p7blnyJSjJqf5jflHbSGhIhEpXIgIFmYZNg5uwqweso=
+github.com/Azure/azure-sdk-for-go v51.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
+github.com/Azure/azure-sdk-for-go/sdk/messaging/azeventhubs v1.4.0 h1:BwmN55GUUfwFPSd44bxBVkFD8yJAp+LLjGRjSnpbeUM=
+github.com/Azure/azure-sdk-for-go/sdk/messaging/azeventhubs v1.4.0/go.mod h1:OowfWwCcXlcn1Nkk6oTxeCuGNRElKtYpzkF1/gZ42Ig=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3 v3.0.0 h1:NYYoOOPGOqUXw/bGIVd6OY/K8J23a18IAlAx1tOHWNo=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3 v3.0.0/go.mod h1:LDN3sr8FJ36sY6ZmMes6Q2vHJ+5r1aFsE3wEo7VbXJg=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub v1.3.0 h1:4hGvxD72TluuFIXVr8f4XkKZfqAa7Pj61t0jmQ7+kes=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/eventhub/armeventhub v1.3.0/go.mod h1:TSH7DcFItwAufy0Lz+Ft2cyopExCpxbOxI5SkH4dRNo=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1 h1:lhZdRq7TIx0GJQvSyX2Si406vrYsov2FXGp/RnSEtcs=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.1/go.mod h1:8cl44BDmi+effbARHMQjgOKA2AYvcohNm7KEt42mSV8=
+github.com/Azure/go-amqp v1.4.0 h1:Xj3caqi4comOF/L1Uc5iuBxR/pB6KumejC01YQOqOR4=
+github.com/Azure/go-amqp v1.4.0/go.mod h1:vZAogwdrkbyK3Mla8m/CxSc/aKdnTZ4IbPxl51Y5WZE=
+github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
+github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
+github.com/Azure/go-autorest/autorest v0.11.17/go.mod h1:eipySxLmqSyC5s5k1CLupqet0PSENBEDP93LQ9a8QYw=
+github.com/Azure/go-autorest/autorest v0.11.20 h1:s8H1PbCZSqg/DH7JMlOz6YMig6htWLNPsjDdlLqCx3M=
+github.com/Azure/go-autorest/autorest v0.11.20/go.mod h1:o3tqFY+QR40VOlk+pV4d77mORO64jOXSgEnPQgLK6JY=
+github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
+github.com/Azure/go-autorest/autorest/adal v0.9.11/go.mod h1:nBKAnTomx8gDtl+3ZCJv2v0KACFHWTB2drffI1B68Pk=
+github.com/Azure/go-autorest/autorest/adal v0.9.13 h1:Mp5hbtOePIzM8pJVRa3YLrWWmZtoxRXqUEzCfJt3+/Q=
+github.com/Azure/go-autorest/autorest/adal v0.9.13/go.mod h1:W/MM4U6nLxnIskrw4UwWzlHfGjwUS50aOsc/I3yuU8M=
+github.com/Azure/go-autorest/autorest/azure/auth v0.5.8 h1:TzPg6B6fTZ0G1zBf3T54aI7p3cAT6u//TOXGPmFMOXg=
+github.com/Azure/go-autorest/autorest/azure/auth v0.5.8/go.mod h1:kxyKZTSfKh8OVFWPAgOgQ/frrJgeYQJPyR5fLFmXko4=
+github.com/Azure/go-autorest/autorest/azure/cli v0.4.2 h1:dMOmEJfkLKW/7JsokJqkyoYSgmR08hi9KrhjZb+JALY=
+github.com/Azure/go-autorest/autorest/azure/cli v0.4.2/go.mod h1:7qkJkT+j6b+hIpzMOwPChJhTqS8VbsqqgULzMNRugoM=
+github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
+github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
+github.com/Azure/go-autorest/autorest/mocks v0.4.1 h1:K0laFcLE6VLTOwNgSxaGbUcLPuGXlNkbVvq4cW4nIHk=
+github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k=
+github.com/Azure/go-autorest/autorest/to v0.4.0 h1:oXVqrxakqqV1UZdSazDOPOLvOIz+XA683u8EctwboHk=
+github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcPR4o9jEImooCeWJcYV/zLE=
+github.com/Azure/go-autorest/autorest/validation v0.3.1 h1:AgyqjAd94fwNAoTjl/WQXg4VvFeRFpO+UhNyRXqF1ac=
+github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYXl6JAsWyUe4KVNffhoDhG0zVzUMo3E=
+github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
+github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+ZtXWSmf4Tg=
+github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
+github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
+github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
+github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
+github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
+github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
+github.com/aws/aws-sdk-go-v2 v1.32.5 h1:U8vdWJuY7ruAkzaOdD7guwJjD06YSKmnKCJs7s3IkIo=
+github.com/aws/aws-sdk-go-v2 v1.32.5/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
+github.com/aws/aws-sdk-go-v2/config v1.28.5 h1:Za41twdCXbuyyWv9LndXxZZv3QhTG1DinqlFsSuvtI0=
+github.com/aws/aws-sdk-go-v2/config v1.28.5/go.mod h1:4VsPbHP8JdcdUDmbTVgNL/8w9SqOkM5jyY8ljIxLO3o=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.46 h1:AU7RcriIo2lXjUfHFnFKYsLCwgbz1E7Mm95ieIRDNUg=
+github.com/aws/aws-sdk-go-v2/credentials v1.17.46/go.mod h1:1FmYyLGL08KQXQ6mcTlifyFXfJVCNJTVGuQP4m0d/UA=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 h1:sDSXIrlsFSFJtWKLQS4PUWRvrT580rrnuLydJrCQ/yA=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20/go.mod h1:WZ/c+w0ofps+/OUqMwWgnfrgzZH1DZO1RIkktICsqnY=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41 h1:hqcxMc2g/MwwnRMod9n6Bd+t+9Nf7d5qRg7RaXKPd6o=
+github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.41/go.mod h1:d1eH0VrttvPmrCraU68LOyNdu26zFxQFjrVSb5vdhog=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24 h1:4usbeaes3yJnCFC7kfeyhkdkPtoRYPa/hTmCqMpKpLI=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.24/go.mod h1:5CI1JemjVwde8m2WG3cz23qHKPOxbpkq0HaoreEgLIY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24 h1:N1zsICrQglfzaBnrfM0Ys00860C+QFwu6u/5+LomP+o=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.24/go.mod h1:dCn9HbJ8+K31i8IQ8EWmWj0EiIk0+vKiHNMxTTYveAg=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.24 h1:JX70yGKLj25+lMC5Yyh8wBtvB01GDilyRuJvXJ4piD0=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.24/go.mod h1:+Ln60j9SUTD0LEwnhEB0Xhg61DHqplBrbZpLgyjoEHg=
+github.com/aws/aws-sdk-go-v2/service/acm v1.30.6 h1:fDg0RlN30Xf/yYzEUL/WXqhmgFsjVb/I3230oCfyI5w=
+github.com/aws/aws-sdk-go-v2/service/acm v1.30.6/go.mod h1:zRR6jE3v/TcbfO8C2P+H0Z+kShiKKVaVyoIl8NQRjyg=
+github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0 h1:1KzQVZi7OTixxaVJ8fWaJAUBjme+iQ3zBOCZhE4RgxQ=
+github.com/aws/aws-sdk-go-v2/service/autoscaling v1.51.0/go.mod h1:I1+/2m+IhnK5qEbhS3CrzjeiVloo9sItE/2K+so0fkU=
+github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.44.0 h1:OREVd94+oXW5a+3SSUAo4K0L5ci8cucCLu+PSiek8OU=
+github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.44.0/go.mod h1:Qbr4yfpNqVNl69l/GEDK+8wxLf/vHi0ChoiSDzD7thU=
+github.com/aws/aws-sdk-go-v2/service/dynamodb v1.37.1 h1:vucMirlM6D+RDU8ncKaSZ/5dGrXNajozVwpmWNPn2gQ=
+github.com/aws/aws-sdk-go-v2/service/dynamodb v1.37.1/go.mod h1:fceORfs010mNxZbQhfqUjUeHlTwANmIT4mvHamuUaUg=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0 h1:RhSoBFT5/8tTmIseJUXM6INTXTQDF8+0oyxWBnozIms=
+github.com/aws/aws-sdk-go-v2/service/ec2 v1.193.0/go.mod h1:mzj8EEjIHSN2oZRXiw1Dd+uB4HZTl7hC8nBzX9IZMWw=
+github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6 h1:zg+3FGHA0PBs0KM25qE/rOf2o5zsjNa1g/Qq83+SDI0=
+github.com/aws/aws-sdk-go-v2/service/ecr v1.36.6/go.mod h1:ZSq54Z9SIsOTf1Efwgw1msilSs4XVEfVQiP9nYVnKpM=
+github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0 h1:7/vgFWplkusJN/m+3QOa+W9FNRqa8ujMPNmdufRaJpg=
+github.com/aws/aws-sdk-go-v2/service/ecs v1.52.0/go.mod h1:dPTOvmjJQ1T7Q+2+Xs2KSPrMvx+p0rpyV+HsQVnUK4o=
+github.com/aws/aws-sdk-go-v2/service/iam v1.38.1 h1:hfkzDZHBp9jAT4zcd5mtqckpU4E3Ax0LQaEWWk1VgN8=
+github.com/aws/aws-sdk-go-v2/service/iam v1.38.1/go.mod h1:u36ahDtZcQHGmVm/r+0L1sfKX4fzLEMdCqiKRKkUMVM=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.5 h1:gvZOjQKPxFXy1ft3QnEyXmT+IqneM9QAUWlM3r0mfqw=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.4.5/go.mod h1:DLWnfvIcm9IET/mmjdxeXbBKmTCm0ZB8p1za9BVteM8=
+github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.5 h1:3Y457U2eGukmjYjeHG6kanZpDzJADa2m0ADqnuePYVQ=
+github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.5/go.mod h1:CfwEHGkTjYZpkQ/5PvcbEtT7AJlG68KkEvmtwU8z3/U=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5 h1:wtpJ4zcwrSbwhECWQoI/g6WM9zqCcSpHDJIWSbMLOu4=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.5/go.mod h1:qu/W9HXQbbQ4+1+JcZp0ZNPV31ym537ZJN+fiS7Ti8E=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.5 h1:P1doBzv5VEg1ONxnJss1Kh5ZG/ewoIE4MQtKKc6Crgg=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.5/go.mod h1:NOP+euMW7W3Ukt28tAxPuoWao4rhhqJD3QEBk7oCg7w=
+github.com/aws/aws-sdk-go-v2/service/kms v1.37.6 h1:CZImQdb1QbU9sGgJ9IswhVkxAcjkkD1eQTMA1KHWk+E=
+github.com/aws/aws-sdk-go-v2/service/kms v1.37.6/go.mod h1:YJDdlK0zsyxVBxGU48AR/Mi8DMrGdc1E3Yij4fNrONA=
+github.com/aws/aws-sdk-go-v2/service/lambda v1.69.0 h1:BXt75frE/FYtAmEDBJRBa2HexOw+oAZWZl6QknZEFgg=
+github.com/aws/aws-sdk-go-v2/service/lambda v1.69.0/go.mod h1:guz2K3x4FKSdDaoeB+TPVgJNU9oj2gftbp5cR8ela1A=
+github.com/aws/aws-sdk-go-v2/service/rds v1.91.0 h1:eqHz3Uih+gb0vLE5Cc4Xf733vOxsxDp6GFUUVQU4d7w=
+github.com/aws/aws-sdk-go-v2/service/rds v1.91.0/go.mod h1:h2jc7IleH3xHY7y+h8FH7WAZcz3IVLOB6/jXotIQ/qU=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 h1:wmt05tPp/CaRZpPV5B4SaJ5TwkHKom07/BzHoLdkY1o=
+github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2/go.mod h1:d+K9HESMpGb1EU9/UmmpInbGIUcAkwmcY6ZO/A3zZsw=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.69.0 h1:Q2ax8S21clKOnHhhr933xm3JxdJebql+R7aNo7p7GBQ=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.69.0/go.mod h1:ralv4XawHjEMaHOWnTFushl0WRqim/gQWesAMF6hTow=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6 h1:1KDMKvOKNrpD667ORbZ/+4OgvUoaok1gg/MLzrHF9fw=
+github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6/go.mod h1:DmtyfCfONhOyVAJ6ZMTrDSFIeyCBlEO93Qkfhxwbxu0=
+github.com/aws/aws-sdk-go-v2/service/sns v1.33.6 h1:lEUtRHICiXsd7VRwRjXaY7MApT2X4Ue0Mrwe6XbyBro=
+github.com/aws/aws-sdk-go-v2/service/sns v1.33.6/go.mod h1:SODr0Lu3lFdT0SGsGX1TzFTapwveBrT5wztVoYtppm8=
+github.com/aws/aws-sdk-go-v2/service/sqs v1.37.1 h1:39WvSrVq9DD6UHkD+fx5x19P5KpRQfNdtgReDVNbelc=
+github.com/aws/aws-sdk-go-v2/service/sqs v1.37.1/go.mod h1:3gwPzC9LER/BTQdQZ3r6dUktb1rSjABF1D3Sr6nS7VU=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 h1:mADKqoZaodipGgiZfuAjtlcr4IVBtXPZKVjkzUZCCYM=
+github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0/go.mod h1:l9qF25TzH95FhcIak6e4vt79KE4I7M2Nf59eMUVjj6c=
+github.com/aws/aws-sdk-go-v2/service/sso v1.24.6 h1:3zu537oLmsPfDMyjnUS2g+F2vITgy5pB74tHI+JBNoM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.24.6/go.mod h1:WJSZH2ZvepM6t6jwu4w/Z45Eoi75lPN7DcydSRtJg6Y=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 h1:K0OQAsDywb0ltlFrZm0JHPY3yZp/S9OaoLU33S7vPS8=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5/go.mod h1:ORITg+fyuMoeiQFiVGoqB3OydVTLkClw/ljbblMq6Cc=
+github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 h1:6SZUVRQNvExYlMLbHdlKB48x0fLbc2iVROyaNEwBHbU=
+github.com/aws/aws-sdk-go-v2/service/sts v1.33.1/go.mod h1:GqWyYCwLXnlUB1lOAXQyNSPqPLQJvmo8J0DWBzp9mtg=
+github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
+github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
+github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
+github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
+github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
+github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
+github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
+github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
+github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
+github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
+github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
+github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
+github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
+github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
+github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
+github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
+github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
+github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
+github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0 h1:skJKxRtNmevLqnayafdLe2AsenqRupVmzZSqrvb5caU=
+github.com/go-errors/errors v1.0.2-0.20180813162953-d98b870cc4e0/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
+github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
+github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
+github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
+github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
+github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
+github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
+github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
+github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
+github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
+github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
+github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
+github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
+github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
+github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
+github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
+github.com/gruntwork-io/go-commons v0.8.0 h1:k/yypwrPqSeYHevLlEDmvmgQzcyTwrlZGRaxEM6G0ro=
+github.com/gruntwork-io/go-commons v0.8.0/go.mod h1:gtp0yTtIBExIZp7vyIV9I0XQkVwiQZze678hvDXof78=
+github.com/gruntwork-io/terratest v0.54.0 h1:JOVATYDpU0NAPbEkgYUP50BR2m45UGiR4dbs20sKzck=
+github.com/gruntwork-io/terratest v0.54.0/go.mod h1:QvwQWZMTJmJB4E0d1Uc18quQm7+X53liKKp+fJSuaKA=
+github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk=
+github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo=
+github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I=
+github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
+github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M=
+github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
+github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI=
+github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
+github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o=
+github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s=
+github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
+github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
+github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
+github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
+github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
+github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
+github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 h1:ofNAzWCcyTALn2Zv40+8XitdzCgXY6e9qvXwN9W0YXg=
+github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
+github.com/microsoft/kiota-abstractions-go v1.9.3 h1:cqhbqro+VynJ7kObmo7850h3WN2SbvoyhypPn8uJ1SE=
+github.com/microsoft/kiota-abstractions-go v1.9.3/go.mod h1:f06pl3qSyvUHEfVNkiRpXPkafx7khZqQEb71hN/pmuU=
+github.com/microsoft/kiota-authentication-azure-go v1.3.1 h1:AGta92S6IL1E6ZMDb8YYB7NVNTIFUakbtLKUdY5RTuw=
+github.com/microsoft/kiota-authentication-azure-go v1.3.1/go.mod h1:26zylt2/KfKwEWZSnwHaMxaArpbyN/CuzkbotdYXF0g=
+github.com/microsoft/kiota-http-go v1.5.4 h1:wSUmL1J+bTQlAWHjbRkSwr+SPAkMVYeYxxB85Zw0KFs=
+github.com/microsoft/kiota-http-go v1.5.4/go.mod h1:L+5Ri+SzwELnUcNA0cpbFKp/pBbvypLh3Cd1PR6sjx0=
+github.com/microsoft/kiota-serialization-form-go v1.1.2 h1:SD6MATqNw+Dc5beILlsb/D87C36HKC/Zw7l+N9+HY2A=
+github.com/microsoft/kiota-serialization-form-go v1.1.2/go.mod h1:m4tY2JT42jAZmgbqFwPy3zGDF+NPJACuyzmjNXeuHio=
+github.com/microsoft/kiota-serialization-json-go v1.1.2 h1:eJrPWeQ665nbjO0gsHWJ0Bw6V/ZHHU1OfFPaYfRG39k=
+github.com/microsoft/kiota-serialization-json-go v1.1.2/go.mod h1:deaGt7fjZarywyp7TOTiRsjfYiyWxwJJPQZytXwYQn8=
+github.com/microsoft/kiota-serialization-multipart-go v1.1.2 h1:1pUyA1QgIeKslQwbk7/ox1TehjlCUUT3r1f8cNlkvn4=
+github.com/microsoft/kiota-serialization-multipart-go v1.1.2/go.mod h1:j2K7ZyYErloDu7Kuuk993DsvfoP7LPWvAo7rfDpdPio=
+github.com/microsoft/kiota-serialization-text-go v1.1.3 h1:8z7Cebn0YAAr++xswVgfdxZjnAZ4GOB9O7XP4+r5r/M=
+github.com/microsoft/kiota-serialization-text-go v1.1.3/go.mod h1:NDSvz4A3QalGMjNboKKQI9wR+8k+ih8UuagNmzIRgTQ=
+github.com/microsoftgraph/msgraph-sdk-go v1.93.0 h1:H4WgYetjWYpr79S2ylNJchFDQTBE0ldKx8sR0qxyRGQ=
+github.com/microsoftgraph/msgraph-sdk-go v1.93.0/go.mod h1:sue5+4Z9FCOon6pHgvC1djjybs9ZYB3LZaAGYI1Qcfo=
+github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0 h1:0SrIoFl7TQnMRrsi5TFaeNe0q8KO5lRzRp4GSCCL2So=
+github.com/microsoftgraph/msgraph-sdk-go-core v1.4.0/go.mod h1:A1iXs+vjsRjzANxF6UeKv2ACExG7fqTwHHbwh1FL+EE=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
+github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
+github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
+github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
+github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
+github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
+github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
+github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
+github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
+github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI=
+github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
+github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
+github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
+github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3 h1:7hth9376EoQEd1hH4lAp3vnaLP2UMyxuMMghLKzDHyU=
+github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.3/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tmccombs/hcl2json v0.6.4 h1:/FWnzS9JCuyZ4MNwrG4vMrFrzRgsWEOVi+1AyYUVLGw=
+github.com/tmccombs/hcl2json v0.6.4/go.mod h1:+ppKlIW3H5nsAsZddXPy2iMyvld3SHxyjswOZhavRDk=
+github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
+github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
+github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
+github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ=
+github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
+go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
+go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
+go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
+go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
+go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
+golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
+golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
+google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
+gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE=
+k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug=
+k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0=
+k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
+k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo=
+k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY=
+k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
+k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
+k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
+k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
+k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
+sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
diff --git a/blueprints/full-single-node-cluster/tests/outputs.go b/blueprints/full-single-node-cluster/tests/outputs.go
new file mode 100644
index 00000000..c197d017
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/outputs.go
@@ -0,0 +1,57 @@
+// Package test provides validation and testing utilities for the full-single-node-cluster blueprint.
+// This file defines the strongly-typed output contract between IaC and tests.
+package test
+
+import (
+ "testing"
+
+ testutil "github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities"
+)
+
+// BlueprintOutputs defines the complete contract between IaC outputs and test code.
+// Every field must have an `output` tag matching the IaC output name.
+// To add a new output: add field with tag, then declare output in both terraform/outputs.tf and bicep/main.bicep.
+// Contract tests (terraform_outputs_contract_test.go and bicep_outputs_contract_test.go) enforce this at compile time.
+type BlueprintOutputs struct {
+ DeploymentSummary map[string]any `output:"deployment_summary"`
+ SecurityIdentity map[string]any `output:"security_identity"`
+ Observability map[string]any `output:"observability"`
+ NatGateway any `output:"nat_gateway"`
+ NatGatewayPublicIps any `output:"nat_gateway_public_ips"`
+ DataStorage map[string]any `output:"data_storage"`
+ ContainerRegistry map[string]any `output:"container_registry"`
+ Messaging map[string]any `output:"messaging"`
+ VmHost any `output:"vm_host"`
+ ArcConnectedCluster map[string]any `output:"arc_connected_cluster"`
+ ClusterConnection map[string]any `output:"cluster_connection"`
+ AzureIotOperations map[string]any `output:"azure_iot_operations"`
+ Assets map[string]any `output:"assets"`
+ ACRNetworkPosture any `output:"acr_network_posture"`
+}
+
+// ParseTerraformBlueprintOutputs converts raw Terraform deployment outputs to strongly-typed BlueprintOutputs.
+// Uses reflection to map outputs based on struct field tags.
+// Fails test immediately if any required output is missing from raw outputs.
+// Note: This validates presence of output keys, not their values. Outputs can be nil/null.
+func ParseTerraformBlueprintOutputs(t *testing.T, raw map[string]any) *BlueprintOutputs {
+ outputs := &BlueprintOutputs{}
+ testutil.ParseTerraformOutputsFromMap(t, raw, outputs)
+ return outputs
+}
+
+// ParseBicepBlueprintOutputs converts raw Bicep deployment outputs to strongly-typed BlueprintOutputs.
+// Bicep outputs use camelCase while struct tags use snake_case.
+// Uses reflection to map outputs based on struct field tags after converting to camelCase.
+// Fails test immediately if any required output is missing from raw outputs.
+func ParseBicepBlueprintOutputs(t *testing.T, raw map[string]any) *BlueprintOutputs {
+ outputs := &BlueprintOutputs{}
+ testutil.ParseBicepOutputsFromMap(t, raw, outputs)
+ return outputs
+}
+
+// GetRequiredOutputKeys returns all output names defined in BlueprintOutputs struct.
+// Uses reflection on struct tags to extract output names.
+// Used by contract tests to validate IaC output declarations.
+func (BlueprintOutputs) GetRequiredOutputKeys() []string {
+ return testutil.GetOutputKeysFromStruct(BlueprintOutputs{})
+}
diff --git a/blueprints/full-single-node-cluster/tests/run-contract-tests.sh b/blueprints/full-single-node-cluster/tests/run-contract-tests.sh
new file mode 100755
index 00000000..1d592400
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/run-contract-tests.sh
@@ -0,0 +1,165 @@
+#!/bin/bash
+# Helper script to run contract tests for Terraform and Bicep
+# Contract tests validate IaC outputs without deployment (fast, $0 cost)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+# Color output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+print_usage() {
+ cat << EOF
+Usage: $0 [terraform|bicep|both] [options]
+
+Run static contract tests to validate IaC outputs match test requirements.
+These tests run in seconds without deploying any Azure resources.
+
+Arguments:
+ terraform Run only Terraform contract test
+ bicep Run only Bicep contract test
+ both Run both tests (default)
+
+Options:
+ -v, --verbose Enable verbose test output
+ -h, --help Show this help message
+
+Examples:
+ $0 # Run both contract tests
+ $0 terraform # Run only Terraform contract test
+ $0 bicep -v # Run only Bicep with verbose output
+
+Contract Tests:
+ โ Terraform: Validates outputs.tf using terraform-docs
+ โ Bicep: Validates main.bicep outputs using az bicep build
+ โ Fast: Complete in <5 seconds
+ โ Zero Cost: No Azure resources deployed
+
+Dependencies:
+ - terraform-docs (brew install terraform-docs)
+ - az bicep (az bicep install)
+ - Go toolchain (go test)
+EOF
+}
+
+# Parse arguments
+TEST_TYPE="both"
+VERBOSE_FLAG=""
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ terraform|bicep|both)
+ TEST_TYPE="$1"
+ shift
+ ;;
+ -v|--verbose)
+ VERBOSE_FLAG="-v"
+ shift
+ ;;
+ -h|--help)
+ print_usage
+ exit 0
+ ;;
+ *)
+ echo -e "${RED}Unknown option: $1${NC}"
+ print_usage
+ exit 1
+ ;;
+ esac
+done
+
+echo -e "${BLUE}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"
+echo -e "${BLUE}โ Contract Tests - Static Validation โ${NC}"
+echo -e "${BLUE}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"
+echo ""
+echo -e "${GREEN}Test Type:${NC} $TEST_TYPE"
+echo -e "${GREEN}Directory:${NC} $(basename "$(dirname "$SCRIPT_DIR")")/tests"
+echo ""
+
+# Check dependencies
+echo -e "${YELLOW}Checking dependencies...${NC}"
+
+# Check Go
+if ! command -v go &> /dev/null; then
+ echo -e "${RED}โ Go not found. Please install Go toolchain.${NC}"
+ exit 1
+fi
+echo -e "${GREEN}โ Go: $(go version | awk '{print $3}')${NC}"
+
+# Check terraform-docs
+if [[ "$TEST_TYPE" == "terraform" || "$TEST_TYPE" == "both" ]]; then
+ if ! command -v terraform-docs &> /dev/null; then
+ echo -e "${RED}โ terraform-docs not found${NC}"
+ echo -e "${YELLOW} Install: brew install terraform-docs${NC}"
+ exit 1
+ fi
+ echo -e "${GREEN}โ terraform-docs: $(terraform-docs version | head -n1)${NC}"
+fi
+
+# Check az bicep
+if [[ "$TEST_TYPE" == "bicep" || "$TEST_TYPE" == "both" ]]; then
+ if ! command -v az &> /dev/null; then
+ echo -e "${RED}โ Azure CLI not found${NC}"
+ echo -e "${YELLOW} Install: https://docs.microsoft.com/cli/azure/install-azure-cli${NC}"
+ exit 1
+ fi
+
+ # Check bicep is installed
+ if ! az bicep version &> /dev/null; then
+ echo -e "${RED}โ Bicep not installed${NC}"
+ echo -e "${YELLOW} Install: az bicep install${NC}"
+ exit 1
+ fi
+fi
+
+echo ""
+
+# Run tests
+EXIT_CODE=0
+
+run_test() {
+ local test_name=$1
+ local test_pattern=$2
+
+ echo -e "${BLUE}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"
+ echo -e "${YELLOW}Running: $test_name${NC}"
+ echo -e "${BLUE}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"
+
+ if go test $VERBOSE_FLAG -run "$test_pattern" .; then
+ echo -e "${GREEN}โ $test_name PASSED${NC}"
+ else
+ echo -e "${RED}โ $test_name FAILED${NC}"
+ EXIT_CODE=1
+ fi
+ echo ""
+}
+
+case $TEST_TYPE in
+ terraform)
+ run_test "Terraform Contract Test" "TestTerraformOutputsContract"
+ ;;
+ bicep)
+ run_test "Bicep Contract Test" "TestBicepOutputsContract"
+ ;;
+ both)
+ run_test "Terraform Contract Test" "TestTerraformOutputsContract"
+ run_test "Bicep Contract Test" "TestBicepOutputsContract"
+ ;;
+esac
+
+# Summary
+echo -e "${BLUE}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"
+if [[ $EXIT_CODE -eq 0 ]]; then
+ echo -e "${BLUE}โ${GREEN} All Tests PASSED โ ${BLUE}โ${NC}"
+else
+ echo -e "${BLUE}โ${RED} Some Tests FAILED โ ${BLUE}โ${NC}"
+fi
+echo -e "${BLUE}โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ${NC}"
+
+exit $EXIT_CODE
diff --git a/blueprints/full-single-node-cluster/tests/run-deployment-tests.sh b/blueprints/full-single-node-cluster/tests/run-deployment-tests.sh
new file mode 100755
index 00000000..95d6e856
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/run-deployment-tests.sh
@@ -0,0 +1,163 @@
+#!/bin/bash
+# Helper script to run deployment tests for both Terraform and Bicep
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+# Color output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+print_usage() {
+ echo "Usage: $0 [terraform|bicep|both] [options]"
+ echo ""
+ echo "Arguments:"
+ echo " terraform Run only Terraform deployment tests"
+ echo " bicep Run only Bicep deployment tests"
+ echo " both Run both Terraform and Bicep tests (default)"
+ echo ""
+ echo "Options:"
+ echo " -v, --verbose Enable verbose test output"
+ echo " -h, --help Show this help message"
+ echo ""
+ echo "Environment Variables:"
+ echo " ARM_SUBSCRIPTION_ID Azure subscription ID (auto-detected if not set)"
+ echo " ADMIN_PASSWORD (Required for Bicep) VM admin password"
+ echo " CUSTOM_LOCATIONS_OID Custom Locations OID (auto-detected if not set)"
+ echo ""
+ echo "Examples:"
+ echo " $0 terraform"
+ echo " $0 bicep -v"
+ echo " $0 both"
+}
+
+# Parse arguments
+DEPLOYMENT_TYPE="both"
+VERBOSE_FLAG=""
+
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ terraform|bicep|both)
+ DEPLOYMENT_TYPE="$1"
+ shift
+ ;;
+ -v|--verbose)
+ VERBOSE_FLAG="-v"
+ shift
+ ;;
+ -h|--help)
+ print_usage
+ exit 0
+ ;;
+ *)
+ echo -e "${RED}Unknown option: $1${NC}"
+ print_usage
+ exit 1
+ ;;
+ esac
+done
+
+# Auto-detect ARM_SUBSCRIPTION_ID if not set
+if [[ -z "${ARM_SUBSCRIPTION_ID}" ]]; then
+ echo -e "${YELLOW}ARM_SUBSCRIPTION_ID not set, detecting from Azure CLI...${NC}"
+ ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv 2>/dev/null)
+ if [[ -z "${ARM_SUBSCRIPTION_ID}" ]]; then
+ echo -e "${RED}Error: Could not auto-detect ARM_SUBSCRIPTION_ID. Please run 'az login' or set ARM_SUBSCRIPTION_ID${NC}"
+ exit 1
+ fi
+ echo -e "${GREEN}Detected subscription: ${ARM_SUBSCRIPTION_ID}${NC}"
+ export ARM_SUBSCRIPTION_ID
+fi
+
+# Auto-detect CUSTOM_LOCATIONS_OID if not set (for Bicep tests)
+if [[ -z "${CUSTOM_LOCATIONS_OID}" ]] && [[ "$DEPLOYMENT_TYPE" == "bicep" || "$DEPLOYMENT_TYPE" == "both" ]]; then
+ echo -e "${YELLOW}CUSTOM_LOCATIONS_OID not set, detecting from Azure AD...${NC}"
+ CUSTOM_LOCATIONS_OID=$(az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv 2>/dev/null)
+ if [[ -z "${CUSTOM_LOCATIONS_OID}" ]]; then
+ echo -e "${RED}Error: Could not auto-detect CUSTOM_LOCATIONS_OID. Please ensure you have permissions to query Azure AD${NC}"
+ exit 1
+ fi
+ echo -e "${GREEN}Detected Custom Locations OID: ${CUSTOM_LOCATIONS_OID}${NC}"
+ export CUSTOM_LOCATIONS_OID
+fi
+
+# Generate strong admin password if not provided (for Bicep tests)
+if [[ -z "${ADMIN_PASSWORD}" ]] && [[ "$DEPLOYMENT_TYPE" == "bicep" || "$DEPLOYMENT_TYPE" == "both" ]]; then
+ echo -e "${YELLOW}ADMIN_PASSWORD not set, generating strong password...${NC}"
+ ADMIN_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-24)
+ # Ensure password meets Azure complexity requirements (uppercase, lowercase, digit, special char)
+ ADMIN_PASSWORD="Aa1!${ADMIN_PASSWORD}"
+ echo -e "${GREEN}Generated admin password (save this): ${ADMIN_PASSWORD}${NC}"
+ export ADMIN_PASSWORD
+fi
+
+echo -e "${GREEN}=== Deployment Tests ===${NC}"
+# Set default test configuration values
+export TEST_ENVIRONMENT="${TEST_ENVIRONMENT:-dev}"
+export TEST_LOCATION="${TEST_LOCATION:-eastus2}"
+export TEST_RESOURCE_PREFIX="${TEST_RESOURCE_PREFIX:-t$RANDOM}"
+export TEST_INSTANCE="${TEST_INSTANCE:-001}"
+
+echo "Deployment Type: $DEPLOYMENT_TYPE"
+echo "ARM Subscription: ${ARM_SUBSCRIPTION_ID}"
+echo "Resource Prefix: ${TEST_RESOURCE_PREFIX}"
+echo "Location: ${TEST_LOCATION}"
+echo ""
+
+run_terraform_tests() {
+ export TEST_RESOURCE_GROUP_NAME="${TEST_RESOURCE_GROUP_NAME_PREFIX:-test-}terraform"
+ echo "Resource Group: ${TEST_RESOURCE_GROUP_NAME}"
+
+ echo -e "${YELLOW}Running Terraform deployment tests...${NC}"
+ if go test $VERBOSE_FLAG -run TestTerraformFullSingleNodeClusterDeploy -timeout 2h; then
+ echo -e "${GREEN}โ Terraform tests passed${NC}"
+ return 0
+ else
+ echo -e "${RED}โ Terraform tests failed${NC}"
+ return 1
+ fi
+}
+
+run_bicep_tests() {
+ export TEST_RESOURCE_GROUP_NAME="${TEST_RESOURCE_GROUP_NAME_PREFIX:-test-}bicep"
+ echo "Resource Group: ${TEST_RESOURCE_GROUP_NAME}"
+
+ echo -e "${YELLOW}Running Bicep deployment tests...${NC}"
+ if go test $VERBOSE_FLAG -run TestBicepFullSingleNodeClusterDeploy -timeout 2h; then
+ echo -e "${GREEN}โ Bicep tests passed${NC}"
+ return 0
+ else
+ echo -e "${RED}โ Bicep tests failed${NC}"
+ return 1
+ fi
+}
+
+# Run tests based on deployment type
+EXIT_CODE=0
+
+case $DEPLOYMENT_TYPE in
+ terraform)
+ run_terraform_tests || EXIT_CODE=$?
+ ;;
+ bicep)
+ run_bicep_tests || EXIT_CODE=$?
+ ;;
+ both)
+ run_terraform_tests || EXIT_CODE=$?
+ echo ""
+ run_bicep_tests || EXIT_CODE=$?
+ ;;
+esac
+
+echo ""
+if [[ $EXIT_CODE -eq 0 ]]; then
+ echo -e "${GREEN}=== All tests completed successfully ===${NC}"
+else
+ echo -e "${RED}=== Some tests failed ===${NC}"
+fi
+
+exit $EXIT_CODE
diff --git a/blueprints/full-single-node-cluster/tests/setup.go b/blueprints/full-single-node-cluster/tests/setup.go
new file mode 100644
index 00000000..fd55908c
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/setup.go
@@ -0,0 +1,147 @@
+// Package test provides validation and testing utilities for the full-single-node-cluster blueprint.
+// This file contains post-deployment setup functions for permissions and connectivity.
+package test
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
+ "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+ "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2"
+ "github.com/google/uuid"
+ "github.com/gruntwork-io/terratest/modules/k8s"
+ "github.com/gruntwork-io/terratest/modules/retry"
+ msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// setupEventHubPermissions assigns Event Hub Data Receiver role to the current user.
+// This enables Event Hub message consumption in validation tests.
+// Skips gracefully if Event Hub is not deployed or if permissions cannot be determined.
+func setupEventHubPermissions(t *testing.T, outputs *BlueprintOutputs, subscriptionID, resourceGroupName string) {
+ t.Helper()
+
+ // Get Event Hub namespace name from outputs
+ eventhubNamespace, ok := outputs.Messaging["eventhub_namespace_name"].(string)
+ if !ok || eventhubNamespace == "" || eventhubNamespace == "Not deployed" {
+ t.Log("Event Hub namespace not deployed, skipping role assignment")
+ return
+ }
+
+ t.Logf("Configuring Event Hub permissions for namespace: %s", eventhubNamespace)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ // Create Azure credential
+ cred, err := azidentity.NewDefaultAzureCredential(nil)
+ if err != nil {
+ t.Fatalf("Failed to create Azure credential: %v", err)
+ }
+
+ // Create Microsoft Graph client to get current user's principal ID
+ graphClient, err := msgraphsdk.NewGraphServiceClientWithCredentials(cred, []string{"https://graph.microsoft.com/.default"})
+ if err != nil {
+ t.Logf("Failed to create Microsoft Graph client: %v", err)
+ t.Log("โ ๏ธ Skipping Event Hub role assignment")
+ return
+ }
+
+ // Get current signed-in user
+ user, err := graphClient.Me().Get(ctx, nil)
+ if err != nil {
+ t.Logf("Failed to get signed-in user from Microsoft Graph: %v", err)
+ t.Log("โ ๏ธ Skipping Event Hub role assignment. Please ensure you're signed in with proper permissions")
+ return
+ }
+
+ if user.GetId() == nil {
+ t.Log("โ ๏ธ Unable to get user principal ID, skipping role assignment")
+ return
+ }
+
+ principalID := *user.GetId()
+ t.Logf("Current user principal ID: %s", principalID)
+
+ // Azure Event Hubs Data Receiver role definition ID (built-in role, same across all tenants)
+ roleDefinitionID := fmt.Sprintf("/subscriptions/%s/providers/Microsoft.Authorization/roleDefinitions/a638d3c7-ab3a-418d-83e6-5f17a39d4fde", subscriptionID)
+
+ // Construct Event Hub namespace scope
+ scope := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.EventHub/namespaces/%s",
+ subscriptionID, resourceGroupName, eventhubNamespace)
+
+ // Create role assignments client
+ roleClient, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, cred, nil)
+ if err != nil {
+ t.Fatalf("Failed to create role assignments client: %v", err)
+ }
+
+ // Generate a unique name for the role assignment
+ roleAssignmentName := uuid.New().String()
+
+ // Create role assignment parameters
+ params := armauthorization.RoleAssignmentCreateParameters{
+ Properties: &armauthorization.RoleAssignmentProperties{
+ PrincipalID: to.Ptr(principalID),
+ RoleDefinitionID: to.Ptr(roleDefinitionID),
+ PrincipalType: to.Ptr(armauthorization.PrincipalTypeUser),
+ },
+ }
+
+ t.Log("Creating role assignment...")
+ _, err = roleClient.Create(ctx, scope, roleAssignmentName, params, nil)
+ if err != nil {
+ // Role assignment might already exist, which is fine
+ if strings.Contains(err.Error(), "RoleAssignmentExists") {
+ t.Log("โ Role assignment already exists")
+ } else {
+ t.Logf("โ ๏ธ Failed to create role assignment: %v", err)
+ t.Log("Tests may fail if Event Hub permissions are not configured")
+ }
+ return
+ }
+
+ t.Log("โ Successfully assigned 'Azure Event Hubs Data Receiver' role")
+}
+
+// setupArcProxy establishes connectivity to the Arc-connected Kubernetes cluster.
+// Starts 'az connectedk8s proxy' command and waits for connection to be ready.
+// Returns proxy command (caller must terminate) and kubectl options configured for the proxy.
+func setupArcProxy(t *testing.T, outputs *BlueprintOutputs) (*exec.Cmd, *k8s.KubectlOptions) {
+ t.Helper()
+
+ clusterConnection := outputs.ClusterConnection
+ arcClusterName := clusterConnection["arc_cluster_name"].(string)
+ arcResourceGroup := clusterConnection["arc_cluster_resource_group"].(string)
+
+ proxyCmd := exec.Command("az",
+ "connectedk8s",
+ "proxy",
+ "--name", arcClusterName,
+ "--resource-group", arcResourceGroup,
+ )
+
+ err := proxyCmd.Start()
+ require.NoError(t, err, "Failed to start Arc proxy")
+
+ t.Logf("Arc proxy started (PID: %d)", proxyCmd.Process.Pid)
+
+ t.Logf("Waiting for Arc proxy to establish connection...")
+ kubectlOptions := k8s.NewKubectlOptions("", "", "")
+ retry.DoWithRetry(t, "Wait for proxy", 6, 5*time.Second, func() (string, error) {
+ _, err := k8s.ListNamespacesE(t, kubectlOptions, metav1.ListOptions{})
+ if err != nil {
+ return "", fmt.Errorf("proxy not ready yet: %v", err)
+ }
+ return "Proxy is ready", nil
+ })
+
+ t.Log("โ Arc proxy connection established")
+ return proxyCmd, kubectlOptions
+}
diff --git a/blueprints/full-single-node-cluster/tests/validation.go b/blueprints/full-single-node-cluster/tests/validation.go
new file mode 100644
index 00000000..f2a9a69b
--- /dev/null
+++ b/blueprints/full-single-node-cluster/tests/validation.go
@@ -0,0 +1,379 @@
+// Package test provides validation and testing utilities for the full-single-node-cluster blueprint.
+// This file contains shared validation logic used by both Terraform and Bicep deployment tests.
+package test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
+ "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+ "github.com/Azure/azure-sdk-for-go/sdk/messaging/azeventhubs"
+ "github.com/gruntwork-io/terratest/modules/azure"
+ "github.com/gruntwork-io/terratest/modules/k8s"
+ "github.com/gruntwork-io/terratest/modules/retry"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// DeploymentContext holds the parsed outputs and connection resources for a deployed blueprint.
+// Used to pass deployment state between setup and validation phases.
+type DeploymentContext struct {
+ Outputs *BlueprintOutputs
+ ProxyCmd *exec.Cmd
+ KubectlOptions *k8s.KubectlOptions
+}
+
+// setupPermissionsAndProxy configures post-deployment permissions (Event Hub access) and establishes Arc proxy connection.
+// Returns proxy command for cleanup and kubectl options for cluster access.
+func setupPermissionsAndProxy(t *testing.T, outputs *BlueprintOutputs, subscriptionID, resourceGroupName string) (*exec.Cmd, *k8s.KubectlOptions) {
+ setupEventHubPermissions(t, outputs, subscriptionID, resourceGroupName)
+ return setupArcProxy(t, outputs)
+}
+
+// prepareTerraformOutputsAndSetup converts raw deployment outputs to strongly-typed BlueprintOutputs,
+// configures post-deployment permissions (Event Hub access), and establishes Arc proxy connection.
+func prepareTerraformOutputsAndSetup(t *testing.T, rawOutputs map[string]any, subscriptionID, resourceGroupName string) *DeploymentContext {
+ outputs := ParseTerraformBlueprintOutputs(t, rawOutputs)
+ proxyCmd, kubectlOptions := setupPermissionsAndProxy(t, outputs, subscriptionID, resourceGroupName)
+ return &DeploymentContext{Outputs: outputs, ProxyCmd: proxyCmd, KubectlOptions: kubectlOptions}
+}
+
+// prepareBicepOutputsAndSetup converts raw Bicep deployment outputs to strongly-typed BlueprintOutputs,
+// configures post-deployment permissions (Event Hub access), and establishes Arc proxy connection.
+// Bicep outputs use camelCase while struct tags use snake_case.
+func prepareBicepOutputsAndSetup(t *testing.T, rawOutputs map[string]any, subscriptionID, resourceGroupName string) *DeploymentContext {
+ outputs := ParseBicepBlueprintOutputs(t, rawOutputs)
+ proxyCmd, kubectlOptions := setupPermissionsAndProxy(t, outputs, subscriptionID, resourceGroupName)
+ return &DeploymentContext{Outputs: outputs, ProxyCmd: proxyCmd, KubectlOptions: kubectlOptions}
+}
+
+// validateDeploymentWithoutMessaging runs validation tests for deployed infrastructure.
+func validateDeploymentWithoutMessaging(t *testing.T, ctx *DeploymentContext, subscriptionID, resourceGroupName, resourcePrefix, location string) {
+ runValidationTests(t, ctx.Outputs, subscriptionID, resourceGroupName, resourcePrefix, location, ctx.ProxyCmd, ctx.KubectlOptions)
+}
+
+// validateDeploymentWithMessaging runs validation tests plus Event Hub message consumption validation.
+// Event Hub validation is Terraform-specific due to additional configuration requirements.
+func validateDeploymentWithMessaging(t *testing.T, ctx *DeploymentContext, subscriptionID, resourceGroupName, resourcePrefix, location string) {
+ runValidationTests(t, ctx.Outputs, subscriptionID, resourceGroupName, resourcePrefix, location, ctx.ProxyCmd, ctx.KubectlOptions)
+ runMessagingValidation(t, ctx.Outputs)
+}
+
+// runValidationTests executes the complete validation test suite for deployed infrastructure.
+// Accepts strongly-typed BlueprintOutputs to enforce compile-time contract validation.
+// Tests include: Azure resources, Kubernetes cluster, IoT Operations, and resource connectivity.
+// Automatically cleans up Arc proxy connection on completion.
+func runValidationTests(t *testing.T, outputs *BlueprintOutputs, subscriptionID, resourceGroupName, resourcePrefix, location string, proxyCmd *exec.Cmd, kubectlOptions *k8s.KubectlOptions) {
+ defer func() {
+ if proxyCmd != nil && proxyCmd.Process != nil {
+ t.Logf("Stopping Arc proxy (PID: %d)...", proxyCmd.Process.Pid)
+ _ = proxyCmd.Process.Signal(syscall.SIGTERM)
+ time.Sleep(1 * time.Second)
+ _ = proxyCmd.Process.Kill()
+ }
+ }()
+ // Validate deployment summary
+ t.Run("ValidateDeploymentSummary", func(t *testing.T) {
+ deploymentSummary := outputs.DeploymentSummary
+ require.NotNil(t, deploymentSummary, "deployment_summary output is nil")
+
+ rgName := deploymentSummary["resource_group"].(string)
+ assert.NotEmpty(t, rgName, "Resource group name should be set")
+ assert.Equal(t, resourceGroupName, rgName, "Resource group should match input variable")
+
+ // Use Azure module to verify resource group exists
+ assert.True(t, azure.ResourceGroupExists(t, rgName, subscriptionID),
+ "Resource group should exist in Azure")
+ })
+
+ // Validate security and identity resources
+ t.Run("ValidateSecurityIdentity", func(t *testing.T) {
+ securityIdentity := outputs.SecurityIdentity
+
+ keyVaultName := securityIdentity["key_vault_name"].(string)
+ assert.NotEmpty(t, keyVaultName, "Key Vault name should be set")
+ assert.NotEmpty(t, securityIdentity["key_vault_uri"], "Key Vault URI should be set")
+ assert.NotEmpty(t, securityIdentity["aio_identity"], "AIO identity should be set")
+ assert.Contains(t, securityIdentity["key_vault_uri"], "https://", "Key Vault URI should be HTTPS")
+
+ t.Logf("Verifying Key Vault %s exists in resource group %s", keyVaultName, resourceGroupName)
+ })
+
+ // Validate observability resources
+ t.Run("ValidateObservability", func(t *testing.T) {
+ observability := outputs.Observability
+
+ logAnalyticsName := observability["log_analytics_workspace_name"].(string)
+ assert.NotEmpty(t, logAnalyticsName, "Log Analytics workspace name should be set")
+ assert.NotEmpty(t, observability["azure_monitor_workspace_name"], "Azure Monitor workspace name should be set")
+ assert.NotEmpty(t, observability["grafana_name"], "Grafana name should be set")
+ assert.NotEmpty(t, observability["grafana_endpoint"], "Grafana endpoint should be set")
+ assert.Contains(t, observability["grafana_endpoint"], "https://", "Grafana endpoint should be HTTPS")
+
+ // Use Azure module to verify Log Analytics workspace exists
+ assert.True(t, azure.LogAnalyticsWorkspaceExists(t, logAnalyticsName, resourceGroupName, subscriptionID),
+ "Log Analytics workspace should exist in Azure")
+ })
+
+ // Validate networking resources
+ t.Run("ValidateNetworking", func(t *testing.T) {
+ natGateway := outputs.NatGateway
+ natGatewayIPs := outputs.NatGatewayPublicIps
+
+ if natGateway != nil {
+ natGatewayMap := natGateway.(map[string]any)
+ assert.NotEmpty(t, natGatewayMap["name"], "NAT gateway name should be set")
+ assert.NotEmpty(t, natGatewayMap["id"], "NAT gateway ID should be set")
+ }
+
+ if natGatewayIPs != nil {
+ assert.NotNil(t, natGatewayIPs, "NAT gateway public IPs should be configured")
+ }
+ })
+
+ // Validate data storage outputs
+ t.Run("ValidateDataStorage", func(t *testing.T) {
+ dataStorage := outputs.DataStorage
+
+ storageAccountName := dataStorage["storage_account_name"].(string)
+ assert.NotEmpty(t, storageAccountName, "Storage account name should be set")
+
+ // Use Azure module to verify storage account exists
+ assert.True(t, azure.StorageAccountExists(t, storageAccountName, resourceGroupName, subscriptionID),
+ "Storage account should exist in Azure")
+ })
+
+ // Validate container registry outputs
+ t.Run("ValidateContainerRegistry", func(t *testing.T) {
+ acr := outputs.ContainerRegistry
+
+ acrName := acr["name"].(string)
+ assert.NotEmpty(t, acrName, "ACR name should be set")
+ assert.NotEmpty(t, acr["id"], "ACR ID should be set")
+ assert.Contains(t, acrName, resourcePrefix, "ACR name should contain resource prefix")
+
+ // Use Azure module to verify container registry exists
+ assert.True(t, azure.ContainerRegistryExists(t, acrName, resourceGroupName, subscriptionID),
+ "Container registry should exist in Azure")
+ })
+
+ // Validate messaging resources
+ t.Run("ValidateMessaging", func(t *testing.T) {
+ messaging := outputs.Messaging
+
+ assert.NotEmpty(t, messaging["event_grid_topic_name"], "Event Grid topic name should be set")
+ assert.NotEmpty(t, messaging["event_grid_topic_endpoint"], "Event Grid endpoint should be set")
+ assert.NotEmpty(t, messaging["eventhub_namespace_name"], "Event Hub namespace name should be set")
+ assert.NotEmpty(t, messaging["eventhub_name"], "Event Hub name should be set")
+
+ eventGridEndpoint := messaging["event_grid_topic_endpoint"].(string)
+ assert.True(t, strings.Contains(eventGridEndpoint, "eventgrid.azure.net"),
+ "Event Grid endpoint should be valid Azure Event Grid endpoint")
+ })
+
+ // Validate VM host resources
+ t.Run("ValidateVMHost", func(t *testing.T) {
+ vmHost := outputs.VmHost
+
+ require.NotNil(t, vmHost, "VM host output should exist")
+ vmHosts := vmHost.([]any)
+ assert.NotEmpty(t, vmHosts, "At least one VM host should be deployed")
+
+ for i, host := range vmHosts {
+ hostMap := host.(map[string]any)
+ vmName := hostMap["name"].(string)
+ assert.NotEmpty(t, vmName, fmt.Sprintf("VM %d name should be set", i))
+
+ assert.True(t, azure.VirtualMachineExists(t, vmName, resourceGroupName, subscriptionID),
+ fmt.Sprintf("VM %s should exist in Azure", vmName))
+ }
+ })
+
+ // Validate Arc connected cluster
+ t.Run("ValidateArcConnectedCluster", func(t *testing.T) {
+ arcCluster := outputs.ArcConnectedCluster
+
+ assert.NotEmpty(t, arcCluster["name"], "Arc cluster name should be set")
+ assert.NotEmpty(t, arcCluster["location"], "Arc cluster location should be set")
+ assert.Equal(t, location, arcCluster["location"], "Arc cluster location should match input")
+ })
+
+ // Validate cluster connection outputs
+ t.Run("ValidateClusterConnection", func(t *testing.T) {
+ clusterConnection := outputs.ClusterConnection
+
+ assert.NotEmpty(t, clusterConnection["arc_cluster_name"], "Arc cluster name should be set")
+ assert.NotEmpty(t, clusterConnection["arc_cluster_resource_group"], "Arc resource group should be set")
+ assert.NotEmpty(t, clusterConnection["arc_proxy_command"], "Arc proxy command should be set")
+ assert.Contains(t, clusterConnection["arc_cluster_name"], resourcePrefix, "Cluster name should contain resource prefix")
+ })
+
+ // Validate Azure IoT Operations outputs
+ t.Run("ValidateAzureIoTOperations", func(t *testing.T) {
+ aio := outputs.AzureIotOperations
+
+ assert.NotEmpty(t, aio["instance_name"], "AIO instance name should be set")
+ assert.NotEmpty(t, aio["custom_location_id"], "Custom location ID should be set")
+ assert.NotEmpty(t, aio["namespace"], "AIO namespace should be set")
+ })
+
+ // Validate assets outputs
+ t.Run("ValidateAssets", func(t *testing.T) {
+ assets := outputs.Assets
+
+ require.NotNil(t, assets["assets"], "Assets list should exist")
+ require.NotNil(t, assets["asset_endpoint_profiles"], "Asset endpoint profiles should exist")
+ })
+
+ // Validate ACR network posture
+ t.Run("ValidateACRNetworkPosture", func(t *testing.T) {
+ acrNetworkPosture := outputs.ACRNetworkPosture
+ require.NotNil(t, acrNetworkPosture, "ACR network posture output should exist")
+ })
+
+ // Validate Kubernetes cluster connectivity
+ t.Run("ValidateKubernetesCluster", func(t *testing.T) {
+ t.Run("CheckClusterNodes", func(t *testing.T) {
+ nodes := k8s.GetNodes(t, kubectlOptions)
+ require.NotEmpty(t, nodes, "Cluster should have at least one node")
+
+ for _, node := range nodes {
+ t.Logf("Node: %s, Status: %v", node.Name, node.Status.Conditions)
+ isReady := false
+ for _, condition := range node.Status.Conditions {
+ if condition.Type == "Ready" && condition.Status == "True" {
+ isReady = true
+ break
+ }
+ }
+ assert.True(t, isReady, fmt.Sprintf("Node %s should be in Ready state", node.Name))
+ }
+ })
+
+ t.Run("CheckAzureIoTOperations", func(t *testing.T) {
+ aio := outputs.AzureIotOperations
+ namespace := aio["namespace"].(string)
+
+ retry.DoWithRetry(t, "Check AIO namespace exists", 3, 10*time.Second, func() (string, error) {
+ _, err := k8s.GetNamespaceE(t, kubectlOptions, namespace)
+ if err != nil {
+ return "", fmt.Errorf("namespace %s not found: %v", namespace, err)
+ }
+ return "Namespace found", nil
+ })
+
+ kubectlOptionsAIO := k8s.NewKubectlOptions("", "", namespace)
+ pods := k8s.ListPods(t, kubectlOptionsAIO, metav1.ListOptions{})
+ require.NotEmpty(t, pods, fmt.Sprintf("AIO namespace %s should have running pods", namespace))
+
+ runningCount := 0
+ for _, pod := range pods {
+ if pod.Status.Phase == corev1.PodRunning {
+ runningCount++
+ }
+ }
+ assert.Greater(t, runningCount, 0, "At least one pod should be in Running state")
+ })
+
+ t.Run("CheckBasicResources", func(t *testing.T) {
+ services := k8s.ListServices(t, kubectlOptions, metav1.ListOptions{})
+ assert.NotEmpty(t, services, "Cluster should have services")
+
+ kubectlOptionsSystem := k8s.NewKubectlOptions("", "", "kube-system")
+ systemPods := k8s.ListPods(t, kubectlOptionsSystem, metav1.ListOptions{})
+ assert.NotEmpty(t, systemPods, "kube-system namespace should have pods")
+ })
+ })
+}
+
+// runMessagingValidation validates Event Hub message consumption and processing.
+// Terraform-specific test that verifies messages can be received and parsed.
+// Requires Event Hub Data Receiver role assignment (configured by setupEventHubPermissions).
+func runMessagingValidation(t *testing.T, outputs *BlueprintOutputs) {
+ t.Run("CheckMQTTBroker", func(t *testing.T) {
+ aio := outputs.AzureIotOperations
+ namespace := aio["namespace"].(string)
+
+ kubectlOptionsAIO := k8s.NewKubectlOptions("", "", namespace)
+ services := k8s.ListServices(t, kubectlOptionsAIO, metav1.ListOptions{LabelSelector: "app.kubernetes.io/name=microsoft-iotoperations-mqttbroker"})
+ require.NotEmpty(t, services, "MQTT Broker service should exist in AIO namespace")
+ })
+
+ t.Run("ValidateEventHubsMessages", func(t *testing.T) {
+ messaging := outputs.Messaging
+ eventhubNamespace := messaging["eventhub_namespace_name"].(string)
+ eventhubName := messaging["eventhub_name"].(string)
+
+ t.Logf("Connecting to Event Hub: %s/%s", eventhubNamespace, eventhubName)
+
+ cred, err := azidentity.NewDefaultAzureCredential(nil)
+ require.NoError(t, err, "Failed to create Azure credential")
+
+ consumerClient, err := azeventhubs.NewConsumerClient(
+ eventhubNamespace+".servicebus.windows.net",
+ eventhubName,
+ azeventhubs.DefaultConsumerGroup,
+ cred,
+ nil,
+ )
+ require.NoError(t, err, "Failed to create Event Hub consumer client")
+ defer consumerClient.Close(context.Background())
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ props, err := consumerClient.GetEventHubProperties(ctx, nil)
+ require.NoError(t, err, "Failed to get Event Hub properties")
+
+ t.Logf("Event Hub has %d partitions", len(props.PartitionIDs))
+ require.NotEmpty(t, props.PartitionIDs, "Event Hub should have at least one partition")
+
+ partitionID := props.PartitionIDs[0]
+ t.Logf("Consuming from partition: %s", partitionID)
+
+ partitionClient, err := consumerClient.NewPartitionClient(partitionID, &azeventhubs.PartitionClientOptions{
+ StartPosition: azeventhubs.StartPosition{
+ Earliest: to.Ptr(true),
+ },
+ })
+ require.NoError(t, err, "Failed to create partition client")
+ defer partitionClient.Close(context.Background())
+
+ receiveCtx, receiveCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer receiveCancel()
+
+ events, err := partitionClient.ReceiveEvents(receiveCtx, 5, nil)
+ if err != nil && err != context.DeadlineExceeded {
+ require.NoError(t, err, "Failed to receive events from Event Hub")
+ }
+
+ require.NotEmpty(t, events, "Should receive at least one message from Event Hub")
+ t.Logf("Received %d messages from Event Hub", len(events))
+
+ for i, event := range events {
+ t.Logf("Message %d: %d bytes", i, len(event.Body))
+ assert.NotNil(t, event.Body, "Message body should not be nil")
+
+ var jsonData map[string]any
+ if err := json.Unmarshal(event.Body, &jsonData); err == nil {
+ t.Logf("Message %d is valid JSON with %d fields", i, len(jsonData))
+ assert.NotEmpty(t, jsonData, "JSON message should have fields")
+ } else {
+ t.Logf("Message %d is not JSON: %s", i, string(event.Body))
+ }
+
+ assert.GreaterOrEqual(t, event.SequenceNumber, int64(0), "Sequence number should be non-negative")
+ assert.NotNil(t, event.EnqueuedTime, "Enqueued time should be set")
+ }
+ })
+}
diff --git a/blueprints/minimum-single-node-cluster/bicep/README.md b/blueprints/minimum-single-node-cluster/bicep/README.md
index d8716323..1fde4024 100644
--- a/blueprints/minimum-single-node-cluster/bicep/README.md
+++ b/blueprints/minimum-single-node-cluster/bicep/README.md
@@ -7,21 +7,21 @@ Deploys the minimal set of resources required for Azure IoT Operations on a sing
## Parameters
-| Name | Description | Type | Default | Required |
-|:------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------|:---------------------------------------------------------------------------------------------------------------------------------|:---------|
-| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
-| resourceGroupName | The name for the resource group. If not provided, a default name will be generated. | `string` | [format('rg-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| telemetry_opt_out | Whether to opt-out of telemetry. Set to true to disable telemetry. | `bool` | `false` | no |
-| adminPassword | Password used for the host VM. | `securestring` | n/a | yes |
-| customLocationsOid | The object id of the Custom Locations Entra ID application for your tenant.
Can be retrieved using:
az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv
| `string` | n/a | yes |
-| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous AIO MQ Broker Listener. (Should only be used for dev or test environments) | `bool` | `false` | no |
-| shouldInitAio | Whether to deploy the Azure IoT Operations initial connected cluster resources, Secret Sync, ACSA, OSM, AIO Platform. | `bool` | `true` | no |
-| shouldDeployAio | Whether to deploy an Azure IoT Operations Instance and all of its required components into the connected cluster. | `bool` | `true` | no |
-| namespacedDevices | List of namespaced devices to create. | `array` | [] | no |
-| assetEndpointProfiles | List of asset endpoint profiles to create. | `array` | [] | no |
-| legacyAssets | List of legacy assets to create. | `array` | [] | no |
-| namespacedAssets | List of namespaced assets to create. | `array` | [] | no |
-| shouldCreateDefaultNamespacedAsset | Whether to create a default namespaced asset and device. | `bool` | `true` | no |
+| Name | Description | Type | Default | Required |
+|:------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------|:---------|
+| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
+| resourceGroupName | The name for the resource group. If not provided, a default name will be generated. | `string` | [format('rg-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| telemetry_opt_out | Whether to opt-out of telemetry. Set to true to disable telemetry. | `bool` | `false` | no |
+| adminPassword | Password used for the host VM. | `securestring` | n/a | yes |
+| customLocationsOid | The object id of the Custom Locations Entra ID application for your tenant.
Can be retrieved using:
az ad sp show --id bc313c14-388c-4e7d-a58e-70017303ee3b --query id -o tsv
| `string` | n/a | yes |
+| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous AIO MQ Broker Listener. (Should only be used for dev or test environments) | `bool` | `false` | no |
+| shouldInitAio | Whether to deploy the Azure IoT Operations initial connected cluster resources, Secret Sync, ACSA, OSM, AIO Platform. | `bool` | `true` | no |
+| shouldDeployAio | Whether to deploy an Azure IoT Operations Instance and all of its required components into the connected cluster. | `bool` | `true` | no |
+| namespacedDevices | List of namespaced devices to create. | `[_1.NamespacedDevice](#user-defined-types)[]` | [] | no |
+| assetEndpointProfiles | List of asset endpoint profiles to create. | `[_1.AssetEndpointProfile](#user-defined-types)[]` | [] | no |
+| legacyAssets | List of legacy assets to create. | `[_1.LegacyAsset](#user-defined-types)[]` | [] | no |
+| namespacedAssets | List of namespaced assets to create. | `[_1.NamespacedAsset](#user-defined-types)[]` | [] | no |
+| shouldCreateDefaultNamespacedAsset | Whether to create a default namespaced asset and device. | `bool` | `true` | no |
## Resources
@@ -379,6 +379,7 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `array` | [] | no |
+| registryEndpoints | List of additional container registry endpoints for pulling custom artifacts. MCR is always added automatically. | `array` | [] | no |
| akriMqttSharedConfig | Shared MQTT connection configuration for all Akri connectors. | `[_1.AkriMqttConfig](#user-defined-types)` | {'host': 'aio-broker:18883', 'audience': 'aio-internal', 'caConfigmap': 'azure-iot-operations-aio-ca-trust-bundle'} | no |
| customLocationName | The name for the Custom Locations resource. | `string` | [format('{0}-cl', parameters('arcConnectedClusterName'))] | no |
| additionalClusterExtensionIds | Additional cluster extension IDs to include in the custom location. (Appended to the default Secret Store and IoT Operations extension IDs) | `array` | [] | no |
@@ -409,27 +410,29 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| postInitScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
| iotOpsInstance | `Microsoft.Resources/deployments` | 2025-04-01 |
| akriConnectors | `Microsoft.Resources/deployments` | 2025-04-01 |
+| registryEndpointsModule | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScriptsSecrets | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
#### Outputs for edgeIotOps
-| Name | Type | Description |
-|:---------------------------|:---------|:-------------------------------------------------------------------|
-| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
-| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
-| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
-| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
-| customLocationId | `string` | The ID of the deployed Custom Location. |
-| customLocationName | `string` | The name of the deployed Custom Location. |
-| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
-| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
-| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
-| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
-| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
-| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
+| Name | Type | Description |
+|:---------------------------|:---------|:----------------------------------------------------------------------|
+| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
+| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
+| aioNamespace | `string` | The namespace in the cluster where Azure IoT Operations is installed. |
+| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
+| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
+| customLocationId | `string` | The ID of the deployed Custom Location. |
+| customLocationName | `string` | The name of the deployed Custom Location. |
+| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
+| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
+| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
+| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
+| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
+| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
### edgeAssets
diff --git a/blueprints/only-cloud-single-node-cluster/bicep/README.md b/blueprints/only-cloud-single-node-cluster/bicep/README.md
index fb326625..5a8f74f3 100644
--- a/blueprints/only-cloud-single-node-cluster/bicep/README.md
+++ b/blueprints/only-cloud-single-node-cluster/bicep/README.md
@@ -179,6 +179,7 @@ Deploys Azure observability resources including Azure Monitor Workspace, Log Ana
| logAnalyticsName | `string` | The Log Analytics Workspace name. |
| logAnalyticsId | `string` | The Log Analytics Workspace ID. |
| grafanaName | `string` | The Azure Managed Grafana name. |
+| grafanaEndpoint | `string` | The Azure Managed Grafana endpoint. |
| metricsDataCollectionRuleName | `string` | The metrics data collection rule name. |
| logsDataCollectionRuleName | `string` | The logs data collection rule name. |
| monitorPrivateLinkScopeId | `string` | Azure Monitor Private Link Scope resource ID. |
diff --git a/blueprints/only-edge-iot-ops/bicep/README.md b/blueprints/only-edge-iot-ops/bicep/README.md
index f0fa18bc..a0da84b6 100644
--- a/blueprints/only-edge-iot-ops/bicep/README.md
+++ b/blueprints/only-edge-iot-ops/bicep/README.md
@@ -7,35 +7,35 @@ Deploys Azure IoT Operations on an existing Arc-enabled Kubernetes cluster witho
## Parameters
-| Name | Description | Type | Default | Required |
-|:------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:---------|
-| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
-| telemetry_opt_out | Whether to opt-out of telemetry. Set to true to disable telemetry. | `bool` | `false` | no |
-| customLocationName | The name for the Custom Locations resource. | `string` | [format('{0}-cl', parameters('arcConnectedClusterName'))] | no |
-| sseIdentityName | The name of the User Assigned Managed Identity for Secret Sync Extension. | `string` | [format('id-{0}-sse-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| sseKeyVaultName | The name of the Key Vault for Secret Sync Extension. Required when providing sseIdentityName. | `string` | [format('kv-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| sseKeyVaultResourceGroupName | The name of the Resource Group for the Key Vault for Secret Sync Extension. Required when providing sseIdentityName. | `string` | [resourceGroup().name] | no |
-| shouldAssignSseKeyVaultRoles | Whether to assign roles for Key Vault to the provided Secret Sync Identity. | `bool` | `true` | no |
-| deployKeyVaultName | The name of the Key Vault that will have scripts and secrets for deployment. | `string` | [parameters('sseKeyVaultName')] | no |
-| deployIdentityName | The resource name for a managed identity that will be given deployment admin permissions. | `string` | [format('id-{0}-deploy-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| deployKeyVaultResourceGroupName | The resource group name where the Key Vault is located. Defaults to the current resource group. | `string` | [parameters('sseKeyVaultResourceGroupName')] | no |
-| deployUserTokenSecretName | The name of the secret in Key Vault that has the token for the deploy user with cluster-admin role. | `string` | n/a | no |
-| deploymentScriptsSecretNamePrefix | The prefix used with constructing the secret name that will have the deployment script. | `string` | [format('{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| shouldAssignDeployIdentityRoles | Whether to assign roles to the deploy identity. | `bool` | `true` | no |
-| shouldInitAio | Whether to init Azure IoT Operations. (For debugging) | `bool` | `true` | no |
-| aioIdentityName | The name of the User Assigned Managed Identity for Azure IoT Operations. | `string` | [format('id-{0}-aio-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| aioInstanceName | The name for the Azure IoT Operations Instance resource. | `string` | [format('{0}-ops-instance', parameters('arcConnectedClusterName'))] | no |
-| arcConnectedClusterName | The resource name for the Arc-enabled Kubernetes cluster. | `string` | [format('arck-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| schemaRegistryName | The resource name for the Azure Data Registry Schema Registry for Azure IoT Operations. | `string` | [format('sr-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| adrNamespaceName | The resource name for the ADR Namespace for Azure IoT Operations. Optional parameter for referencing an existing ADR namespace. | `string` | n/a | no |
-| shouldDeployAio | Whether to deploy Azure IoT Operations. (For debugging) | `bool` | `true` | no |
-| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous Azure IoT Operations MQ Broker Listener. Should only be used for dev or test environments. | `bool` | `false` | no |
-| shouldDeployResourceSyncRules | Whether to deploy Custom Locations Resource Sync Rules for the Azure IoT Operations resources. | `bool` | `true` | no |
-| namespacedDevices | List of namespaced devices to create. | `array` | [] | no |
-| assetEndpointProfiles | List of asset endpoint profiles to create. | `array` | [] | no |
-| legacyAssets | List of legacy assets to create. | `array` | [] | no |
-| namespacedAssets | List of namespaced assets to create. | `array` | [] | no |
-| shouldCreateDefaultNamespacedAsset | Whether to create a default namespaced asset and device. | `bool` | `true` | no |
+| Name | Description | Type | Default | Required |
+|:------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------|:---------|
+| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
+| telemetry_opt_out | Whether to opt-out of telemetry. Set to true to disable telemetry. | `bool` | `false` | no |
+| customLocationName | The name for the Custom Locations resource. | `string` | [format('{0}-cl', parameters('arcConnectedClusterName'))] | no |
+| sseIdentityName | The name of the User Assigned Managed Identity for Secret Sync Extension. | `string` | [format('id-{0}-sse-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| sseKeyVaultName | The name of the Key Vault for Secret Sync Extension. Required when providing sseIdentityName. | `string` | [format('kv-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| sseKeyVaultResourceGroupName | The name of the Resource Group for the Key Vault for Secret Sync Extension. Required when providing sseIdentityName. | `string` | [resourceGroup().name] | no |
+| shouldAssignSseKeyVaultRoles | Whether to assign roles for Key Vault to the provided Secret Sync Identity. | `bool` | `true` | no |
+| deployKeyVaultName | The name of the Key Vault that will have scripts and secrets for deployment. | `string` | [parameters('sseKeyVaultName')] | no |
+| deployIdentityName | The resource name for a managed identity that will be given deployment admin permissions. | `string` | [format('id-{0}-deploy-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| deployKeyVaultResourceGroupName | The resource group name where the Key Vault is located. Defaults to the current resource group. | `string` | [parameters('sseKeyVaultResourceGroupName')] | no |
+| deployUserTokenSecretName | The name of the secret in Key Vault that has the token for the deploy user with cluster-admin role. | `string` | n/a | no |
+| deploymentScriptsSecretNamePrefix | The prefix used with constructing the secret name that will have the deployment script. | `string` | [format('{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| shouldAssignDeployIdentityRoles | Whether to assign roles to the deploy identity. | `bool` | `true` | no |
+| shouldInitAio | Whether to init Azure IoT Operations. (For debugging) | `bool` | `true` | no |
+| aioIdentityName | The name of the User Assigned Managed Identity for Azure IoT Operations. | `string` | [format('id-{0}-aio-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| aioInstanceName | The name for the Azure IoT Operations Instance resource. | `string` | [format('{0}-ops-instance', parameters('arcConnectedClusterName'))] | no |
+| arcConnectedClusterName | The resource name for the Arc-enabled Kubernetes cluster. | `string` | [format('arck-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| schemaRegistryName | The resource name for the Azure Data Registry Schema Registry for Azure IoT Operations. | `string` | [format('sr-{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| adrNamespaceName | The resource name for the ADR Namespace for Azure IoT Operations. Optional parameter for referencing an existing ADR namespace. | `string` | n/a | no |
+| shouldDeployAio | Whether to deploy Azure IoT Operations. (For debugging) | `bool` | `true` | no |
+| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous Azure IoT Operations MQ Broker Listener. Should only be used for dev or test environments. | `bool` | `false` | no |
+| shouldDeployResourceSyncRules | Whether to deploy Custom Locations Resource Sync Rules for the Azure IoT Operations resources. | `bool` | `true` | no |
+| namespacedDevices | List of namespaced devices to create. | `[_1.NamespacedDevice](#user-defined-types)[]` | [] | no |
+| assetEndpointProfiles | List of asset endpoint profiles to create. | `[_1.AssetEndpointProfile](#user-defined-types)[]` | [] | no |
+| legacyAssets | List of legacy assets to create. | `[_1.LegacyAsset](#user-defined-types)[]` | [] | no |
+| namespacedAssets | List of namespaced assets to create. | `[_1.NamespacedAsset](#user-defined-types)[]` | [] | no |
+| shouldCreateDefaultNamespacedAsset | Whether to create a default namespaced asset and device. | `bool` | `true` | no |
## Resources
@@ -115,6 +115,7 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `array` | [] | no |
+| registryEndpoints | List of additional container registry endpoints for pulling custom artifacts. MCR is always added automatically. | `array` | [] | no |
| akriMqttSharedConfig | Shared MQTT connection configuration for all Akri connectors. | `[_1.AkriMqttConfig](#user-defined-types)` | {'host': 'aio-broker:18883', 'audience': 'aio-internal', 'caConfigmap': 'azure-iot-operations-aio-ca-trust-bundle'} | no |
| customLocationName | The name for the Custom Locations resource. | `string` | [format('{0}-cl', parameters('arcConnectedClusterName'))] | no |
| additionalClusterExtensionIds | Additional cluster extension IDs to include in the custom location. (Appended to the default Secret Store and IoT Operations extension IDs) | `array` | [] | no |
@@ -145,27 +146,29 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| postInitScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
| iotOpsInstance | `Microsoft.Resources/deployments` | 2025-04-01 |
| akriConnectors | `Microsoft.Resources/deployments` | 2025-04-01 |
+| registryEndpointsModule | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScriptsSecrets | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
#### Outputs for edgeIotOps
-| Name | Type | Description |
-|:---------------------------|:---------|:-------------------------------------------------------------------|
-| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
-| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
-| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
-| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
-| customLocationId | `string` | The ID of the deployed Custom Location. |
-| customLocationName | `string` | The name of the deployed Custom Location. |
-| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
-| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
-| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
-| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
-| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
-| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
+| Name | Type | Description |
+|:---------------------------|:---------|:----------------------------------------------------------------------|
+| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
+| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
+| aioNamespace | `string` | The namespace in the cluster where Azure IoT Operations is installed. |
+| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
+| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
+| customLocationId | `string` | The ID of the deployed Custom Location. |
+| customLocationName | `string` | The name of the deployed Custom Location. |
+| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
+| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
+| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
+| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
+| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
+| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
### edgeAssets
diff --git a/blueprints/only-output-cncf-cluster-script/bicep/README.md b/blueprints/only-output-cncf-cluster-script/bicep/README.md
index 092383bc..a967f2b3 100644
--- a/blueprints/only-output-cncf-cluster-script/bicep/README.md
+++ b/blueprints/only-output-cncf-cluster-script/bicep/README.md
@@ -21,7 +21,7 @@ Generates scripts for Azure IoT Operations CNCF cluster creation without deployi
| shouldAssignRoles | Whether to assign roles for Arc Onboarding. | `bool` | `true` | no |
| clusterAdminOid | The Object ID that will be given cluster-admin permissions. | `string` | n/a | no |
| clusterAdminUpn | The User Principal Name that will be given cluster-admin permissions. | `string` | n/a | no |
-| clusterNodeVirtualMachineNames | The names of the VMs for the cluster nodes. (Only needed if wanting this blueprint to deploy the scripts) | `array` | n/a | no |
+| clusterNodeVirtualMachineNames | The names of the VMs for the cluster nodes. (Only needed if wanting this blueprint to deploy the scripts) | `string[]` | n/a | no |
| clusterServerIp | The IP address for the server for the cluster. (Needed for multi-node cluster) | `string` | n/a | no |
| clusterServerHostMachineUsername | Username used for the host machines that will be given kube-config settings on setup. | `string` | [parameters('common').resourcePrefix] | no |
| clusterServerVirtualMachineName | The name of the VM for the cluster server. (Only needed if wanting this blueprint to deploy the scripts) | `string` | n/a | no |
diff --git a/docs/_parts/infrastructure-sidebar.md b/docs/_parts/infrastructure-sidebar.md
index 9ace62eb..cdbde6d1 100644
--- a/docs/_parts/infrastructure-sidebar.md
+++ b/docs/_parts/infrastructure-sidebar.md
@@ -52,5 +52,6 @@
- [Video Tools](/src/900-tools-utilities/901-video-tools/README)
- [Aio Sidekick](/src/900-tools-utilities/902-aio-sidekick/README)
- [Multi Asset Deploy](/src/900-tools-utilities/903-multi-asset-deploy/README)
+ - [Test Utilities](/src/900-tools-utilities/904-test-utilities/README)
- [Azure Resource Providers](/src/azure-resource-providers/README)
diff --git a/docs/_sidebar.md b/docs/_sidebar.md
index 22a1ac0c..d5726502 100644
--- a/docs/_sidebar.md
+++ b/docs/_sidebar.md
@@ -225,6 +225,7 @@
- [Fabric Rti](/blueprints/fabric-rti/README)
- [Full Multi Node Cluster](/blueprints/full-multi-node-cluster/README)
- [Full Single Node Cluster](/blueprints/full-single-node-cluster/README)
+ - [Tests](/blueprints/full-single-node-cluster/tests/README)
- [Minimum Single Node Cluster](/blueprints/minimum-single-node-cluster/README)
- [Only Cloud Single Node Cluster](/blueprints/only-cloud-single-node-cluster/README)
- [Only Edge Iot Ops](/blueprints/only-edge-iot-ops/README)
@@ -422,6 +423,7 @@
- [Test Assets](/src/900-tools-utilities/901-video-tools/test-assets/README)
- [Aio Sidekick](/src/900-tools-utilities/902-aio-sidekick/README)
- [Multi Asset Deploy](/src/900-tools-utilities/903-multi-asset-deploy/README)
+ - [Test Utilities](/src/900-tools-utilities/904-test-utilities/README)
- [Azure Resource Providers](/src/azure-resource-providers/README)
- [Dataflows Acsa Egmqtt Bidirectional](/src/starter-kit/dataflows-acsa-egmqtt-bidirectional/README)
- [Resource Group](/src/000-cloud/000-resource-group/bicep/README)
@@ -519,6 +521,7 @@
- [Iot Ops Init](/src/100-edge/110-iot-ops/terraform/modules/iot-ops-init/README)
- [Iot Ops Instance](/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/README)
- [Opc Ua Simulator](/src/100-edge/110-iot-ops/terraform/modules/opc-ua-simulator/README)
+ - [Registry Endpoints](/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/README)
- [Role Assignment](/src/100-edge/110-iot-ops/terraform/modules/role-assignment/README)
- [Self Signed Ca](/src/100-edge/110-iot-ops/terraform/modules/self-signed-ca/README)
- [Assets](/src/100-edge/111-assets/terraform/README)
diff --git a/docs/contributing/testing-validation.md b/docs/contributing/testing-validation.md
index 92200e11..b310bb38 100644
--- a/docs/contributing/testing-validation.md
+++ b/docs/contributing/testing-validation.md
@@ -302,9 +302,108 @@ enable_monitoring = true
## Blueprint Testing
-### Integration Testing
+Some blueprints include comprehensive test suites using Go and the Terratest framework. The testing infrastructure validates both IaC declarations and actual deployments.
-Test blueprint deployments end-to-end:
+### Blueprint Test Architecture
+
+**Shared Test Utilities:** [src/900-tools-utilities/904-test-utilities/](../../src/900-tools-utilities/904-test-utilities/)
+
+Provides reusable testing functions for all blueprints including:
+
+- Contract validation functions for Terraform and Bicep
+- Deployment and cleanup utilities
+- Output normalization across frameworks
+
+**Reference Implementation:** [blueprints/full-single-node-cluster/tests/](../../blueprints/full-single-node-cluster/tests/)
+
+Complete test suite demonstrating:
+
+- Contract tests for both Terraform and Bicep
+- End-to-end deployment validation
+- Helper scripts for test execution
+- Output contract definitions
+
+### Contract Testing
+
+**Purpose:** Fast static validation ensuring output declarations match test expectations
+
+**Characteristics:**
+
+- Runs in seconds without Azure authentication
+- Zero cost - no Azure resources created
+- Validates IaC configuration correctness
+- Catches drift before expensive deployments
+
+**Running Contract Tests:**
+
+```bash
+cd blueprints/full-single-node-cluster/tests
+
+# Test both frameworks
+./run-contract-tests.sh both
+
+# Test specific framework
+./run-contract-tests.sh terraform
+./run-contract-tests.sh bicep
+
+# Direct Go execution
+go test -v -run Contract
+```
+
+### Deployment Testing
+
+**Purpose:** Full end-to-end validation with real Azure resource deployment
+
+**Characteristics:**
+
+- Creates billable Azure resources
+- Tests actual infrastructure deployment
+- Validates resource connectivity and functionality
+- Duration: 30-45 minutes per test
+
+**Running Deployment Tests:**
+
+```bash
+cd blueprints/full-single-node-cluster/tests
+
+# Enable automatic cleanup
+export CLEANUP_RESOURCES=true
+
+# Test specific framework
+./run-deployment-tests.sh terraform
+./run-deployment-tests.sh bicep
+
+# Direct Go execution
+go test -v -run TestTerraformFullSingleNodeClusterDeploy -timeout 2h
+go test -v -run TestBicepFullSingleNodeClusterDeploy -timeout 2h
+```
+
+**Environment Variables:**
+
+- `CLEANUP_RESOURCES` - Auto-delete resources after test (default: `false`)
+- `TEST_ENVIRONMENT` - Environment name (default: `dev`)
+- `TEST_LOCATION` - Azure region (default: `eastus2`)
+- `TEST_RESOURCE_PREFIX` - Resource naming prefix (default: `t6`)
+- `SKIP_BICEP_DEPLOYMENT` - Use existing deployment (default: `false`)
+
+### Blueprint Test Organization
+
+Each blueprint test suite includes:
+
+```text
+blueprints/{blueprint-name}/tests/
+โโโ outputs.go # Output contract definition
+โโโ contract_terraform_test.go # Terraform contract validation
+โโโ contract_bicep_test.go # Bicep contract validation
+โโโ deploy_terraform_test.go # Terraform deployment test
+โโโ deploy_bicep_test.go # Bicep deployment test
+โโโ validation.go # Shared validation functions
+โโโ setup.go # Post-deployment setup
+โโโ run-contract-tests.sh # Contract test runner
+โโโ run-deployment-tests.sh # Deployment test runner
+```
+
+### Blueprint Integration Testing
```bash
# Navigate to blueprint directory
@@ -319,13 +418,25 @@ terraform plan -var-file="test.tfvars"
# Apply to test environment
terraform apply -var-file="test.tfvars" -auto-approve
-# Validate deployment
-./scripts/validate-deployment.sh
# Clean up
terraform destroy -var-file="test.tfvars" -auto-approve
```
+### Creating Blueprint Tests
+
+When creating a new blueprint, add comprehensive test coverage:
+
+1. **Define output contract** in `tests/outputs.go` with struct tags for both frameworks
+2. **Create contract tests** for static validation
+3. **Create deployment tests** for end-to-end validation
+4. **Add helper scripts** for simplified test execution
+5. **Document test requirements** in blueprint README
+
+**See:** [Blueprint Developer Guide](../getting-started/blueprint-developer.md#testing-and-validation) for detailed instructions
+
+**See:** [test-utilities README](../../src/900-tools-utilities/904-test-utilities/README.md) for complete API reference
+
### Blueprint Validation Script
Create comprehensive validation:
@@ -522,13 +633,17 @@ go test -v -cpuprofile=cpu.prof -memprofile=mem.prof ./tests/...
- **Use descriptive test names** that explain what is being tested
- **Include both positive and negative test cases**
- **Test error conditions** and edge cases
+- **Implement contract tests** for fast validation before deployment tests
+- **Use test-utilities package** for consistent testing patterns across blueprints
### Best Practices for Test Data
- **Use parameterized tests** for multiple scenarios
-- **Clean up test resources** automatically
+- **Clean up test resources** automatically (set `CLEANUP_RESOURCES=true`)
- **Isolate test environments** to prevent interference
- **Use realistic test data** that represents production scenarios
+- **Run contract tests first** to catch errors before expensive deployments
+- **Enable cleanup in CI/CD** to prevent resource accumulation
### Validation Strategy
diff --git a/docs/getting-started/blueprint-developer.md b/docs/getting-started/blueprint-developer.md
index 1e8fe190..b620736d 100644
--- a/docs/getting-started/blueprint-developer.md
+++ b/docs/getting-started/blueprint-developer.md
@@ -295,6 +295,81 @@ Before coding, define:
## Testing and Validation
+### Blueprint Test Infrastructure
+
+Selected blueprints include comprehensive test suites using Go and the Terratest framework. Tests validate both infrastructure declarations and actual deployments.
+
+**Test Types:**
+
+- **Contract Tests** - Fast static validation ensuring output declarations match test expectations (runs in seconds, zero Azure cost)
+- **Deployment Tests** - Full end-to-end validation creating real Azure resources and testing functionality (30-45 minutes, creates billable resources)
+
+**Available Test Infrastructure:**
+
+- **Shared utilities**: [src/900-tools-utilities/904-test-utilities/](../../src/900-tools-utilities/904-test-utilities/) - Reusable test functions for all blueprints
+- **Reference implementation**: [blueprints/full-single-node-cluster/tests/](../../blueprints/full-single-node-cluster/tests/) - Complete test suite example
+
+### Creating Tests for Your Blueprint
+
+1. **Create test directory structure**:
+
+ ```bash
+ cd blueprints/my-custom-blueprint
+ mkdir tests
+ cd tests
+ ```
+
+2. **Initialize Go module**:
+
+ ```bash
+ go mod init github.com/microsoft/edge-ai/blueprints/my-custom-blueprint/tests
+ go get github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities
+ go get github.com/gruntwork-io/terratest/modules/terraform
+ ```
+
+3. **Define output contract** in `tests/outputs.go`:
+
+ Define a struct matching your blueprint's outputs with framework-specific tags.
+
+ **See:** [full-single-node-cluster/tests/outputs.go](../../blueprints/full-single-node-cluster/tests/outputs.go) for pattern
+
+4. **Create contract tests**:
+
+ Create `contract_terraform_test.go` and `contract_bicep_test.go` that validate declared outputs match your struct.
+
+ **See:** [full-single-node-cluster/tests/contract_terraform_test.go](../../blueprints/full-single-node-cluster/tests/contract_terraform_test.go)
+
+5. **Create deployment tests**:
+
+ Create `deploy_terraform_test.go` and `deploy_bicep_test.go` for end-to-end validation.
+
+ **See:** [full-single-node-cluster/tests/deploy_terraform_test.go](../../blueprints/full-single-node-cluster/tests/deploy_terraform_test.go)
+
+6. **Add helper scripts**:
+
+ Copy and adapt `run-contract-tests.sh` and `run-deployment-tests.sh` from the reference implementation.
+
+### Running Tests
+
+**Contract tests** (fast, run before every commit):
+
+```bash
+cd blueprints/my-custom-blueprint/tests
+./run-contract-tests.sh both
+```
+
+**Deployment tests** (slow, run before PR):
+
+```bash
+# Set cleanup to auto-delete resources
+export CLEANUP_RESOURCES=true
+
+# Run tests
+./run-deployment-tests.sh terraform # or bicep, or both
+```
+
+**Documentation:** See [src/900-tools-utilities/904-test-utilities/README.md](../../src/900-tools-utilities/904-test-utilities/README.md) for complete testing guide and API reference
+
### Linting and Code Quality
Run validation tools before committing:
@@ -353,35 +428,6 @@ npm run lint
az group delete --name "rg-test-blueprint" --yes
```
-### Automated Testing
-
-Create test scripts for CI/CD validation:
-
-```bash
-# Create test script
-mkdir -p tests/blueprints/my-custom-blueprint
-cat > tests/blueprints/my-custom-blueprint/test.sh << 'EOF'
-#!/bin/bash
-set -e
-
-echo "Testing my-custom-blueprint..."
-
-# Test Terraform
-cd blueprints/my-custom-blueprint/terraform
-terraform init
-terraform validate
-terraform plan -var-file="terraform.tfvars.example"
-
-# Test Bicep
-cd ../bicep
-az bicep build --file main.bicep
-
-echo "All tests passed!"
-EOF
-
-chmod +x tests/blueprints/my-custom-blueprint/test.sh
-```
-
## Best Practices
### Blueprint Integration Best Practices
@@ -390,6 +436,16 @@ chmod +x tests/blueprints/my-custom-blueprint/test.sh
2. **Respect dependencies**: Use `depends_on` (Terraform) or `dependsOn` (Bicep) for proper ordering
3. **Maintain consistency**: Ensure Terraform and Bicep implementations produce equivalent results
4. **Follow naming conventions**: Use consistent resource naming across components
+5. **Include test coverage**: Add contract and deployment tests for your blueprint
+6. **Document test requirements**: Update README with test setup and usage instructions
+
+### Testing Best Practices
+
+1. **Run contract tests first**: Catch configuration errors before expensive deployments
+2. **Use CLEANUP_RESOURCES**: Enable automatic cleanup during development to avoid resource accumulation
+3. **Test both frameworks**: Ensure Terraform and Bicep produce equivalent results
+4. **Update tests with changes**: Keep output contracts and validation logic synchronized with blueprint changes
+5. **Validate before PR**: Run full deployment tests before submitting pull requests
### Documentation Standards
diff --git a/scripts/generate-bicep-docs.py b/scripts/generate-bicep-docs.py
index 3e895220..e7ec1d08 100755
--- a/scripts/generate-bicep-docs.py
+++ b/scripts/generate-bicep-docs.py
@@ -178,7 +178,19 @@ def extract_parameters(json_data: Dict[str, Any]) -> List[Dict[str, Any]]:
ref_path = param_info["$ref"].split('/')[-1]
param_data["type"] = f"[{ref_path}](#user-defined-types)"
elif "type" in param_info:
- param_data["type"] = param_info["type"]
+ param_type = param_info["type"]
+ # Check for typed arrays (array with items.$ref)
+ if param_type == "array" and "items" in param_info:
+ items = param_info["items"]
+ if "$ref" in items:
+ ref_path = items["$ref"].split('/')[-1]
+ param_data["type"] = f"[{ref_path}](#user-defined-types)[]"
+ elif "type" in items:
+ param_data["type"] = f"{items['type']}[]"
+ else:
+ param_data["type"] = param_type
+ else:
+ param_data["type"] = param_type
# Extract description and sanitize markdown within it
if "metadata" in param_info and "description" in param_info["metadata"]:
diff --git a/src/000-cloud/020-observability/bicep/README.md b/src/000-cloud/020-observability/bicep/README.md
index d8a06227..a21a1ddd 100644
--- a/src/000-cloud/020-observability/bicep/README.md
+++ b/src/000-cloud/020-observability/bicep/README.md
@@ -73,6 +73,7 @@ Common settings for the components.
| logAnalyticsName | `string` | The Log Analytics Workspace name. |
| logAnalyticsId | `string` | The Log Analytics Workspace ID. |
| grafanaName | `string` | The Azure Managed Grafana name. |
+| grafanaEndpoint | `string` | The Azure Managed Grafana endpoint. |
| metricsDataCollectionRuleName | `string` | The metrics data collection rule name. |
| logsDataCollectionRuleName | `string` | The logs data collection rule name. |
| monitorPrivateLinkScopeId | `string` | Azure Monitor Private Link Scope resource ID. |
diff --git a/src/000-cloud/020-observability/bicep/main.bicep b/src/000-cloud/020-observability/bicep/main.bicep
index bb2d8f55..b26f4bae 100644
--- a/src/000-cloud/020-observability/bicep/main.bicep
+++ b/src/000-cloud/020-observability/bicep/main.bicep
@@ -512,6 +512,9 @@ output logAnalyticsId string = logAnalytics.id
@description('The Azure Managed Grafana name.')
output grafanaName string = grafana.name
+@description('The Azure Managed Grafana endpoint.')
+output grafanaEndpoint string = grafana.properties.endpoint
+
@description('The metrics data collection rule name.')
output metricsDataCollectionRuleName string = metricsDataCollectionRule.name
diff --git a/src/000-cloud/040-messaging/terraform/modules/eventhub/main.tf b/src/000-cloud/040-messaging/terraform/modules/eventhub/main.tf
index 73a8a1e1..3936f9e4 100644
--- a/src/000-cloud/040-messaging/terraform/modules/eventhub/main.tf
+++ b/src/000-cloud/040-messaging/terraform/modules/eventhub/main.tf
@@ -11,6 +11,8 @@ resource "azurerm_eventhub_namespace" "destination_eventhub_namespace" {
sku = "Standard"
capacity = var.capacity
+
+ local_authentication_enabled = false
}
resource "azurerm_eventhub" "destination_eh" {
diff --git a/src/000-cloud/055-vpn-gateway/bicep/README.md b/src/000-cloud/055-vpn-gateway/bicep/README.md
index 2c0f8cdb..cafec8d7 100644
--- a/src/000-cloud/055-vpn-gateway/bicep/README.md
+++ b/src/000-cloud/055-vpn-gateway/bicep/README.md
@@ -8,19 +8,19 @@ Ths component currently only supports Azure AD (Entra ID) authentication for Poi
## Parameters
-| Name | Description | Type | Default | Required |
-|:-----------------------------|:---------------------------------------------------------------|:---------------------------------------------|:-------------------------------------------|:---------|
-| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
-| vpnGatewayConfig | VPN Gateway configuration settings. | `[_1.VpnGatewayConfig](#user-defined-types)` | [variables('_1.vpnGatewayConfigDefaults')] | no |
-| gatewaySubnetAddressPrefix | Gateway subnet address prefix. | `string` | 10.0.2.0/27 | no |
-| virtualNetworkName | Virtual network name for Gateway subnet creation. | `string` | n/a | yes |
-| azureAdConfig | Azure AD configuration for VPN Gateway authentication. | `[_1.AzureAdConfig](#user-defined-types)` | [variables('_1.azureAdConfigDefaults')] | no |
-| defaultOutboundAccessEnabled | Whether default outbound access is enabled for Gateway subnet. | `bool` | `false` | no |
-| tags | Resource tags. | `object` | {} | no |
-| telemetry_opt_out | Whether to opt out of telemetry data collection. | `bool` | `false` | no |
-| vpnSiteConnections | Site-to-site VPN connection definitions. | `array` | [] | no |
-| vpnSiteDefaultIpsecPolicy | Fallback IPsec policy applied when sites omit an override. | `[_1.VpnIpsecPolicy](#user-defined-types)` | n/a | no |
-| vpnSiteSharedKeys | Pre-shared keys keyed by sharedKeyReference values. | `secureObject` | {} | no |
+| Name | Description | Type | Default | Required |
+|:-----------------------------|:---------------------------------------------------------------|:------------------------------------------------|:-------------------------------------------|:---------|
+| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
+| vpnGatewayConfig | VPN Gateway configuration settings. | `[_1.VpnGatewayConfig](#user-defined-types)` | [variables('_1.vpnGatewayConfigDefaults')] | no |
+| gatewaySubnetAddressPrefix | Gateway subnet address prefix. | `string` | 10.0.2.0/27 | no |
+| virtualNetworkName | Virtual network name for Gateway subnet creation. | `string` | n/a | yes |
+| azureAdConfig | Azure AD configuration for VPN Gateway authentication. | `[_1.AzureAdConfig](#user-defined-types)` | [variables('_1.azureAdConfigDefaults')] | no |
+| defaultOutboundAccessEnabled | Whether default outbound access is enabled for Gateway subnet. | `bool` | `false` | no |
+| tags | Resource tags. | `object` | {} | no |
+| telemetry_opt_out | Whether to opt out of telemetry data collection. | `bool` | `false` | no |
+| vpnSiteConnections | Site-to-site VPN connection definitions. | `[_1.VpnSiteConnection](#user-defined-types)[]` | [] | no |
+| vpnSiteDefaultIpsecPolicy | Fallback IPsec policy applied when sites omit an override. | `[_1.VpnIpsecPolicy](#user-defined-types)` | n/a | no |
+| vpnSiteSharedKeys | Pre-shared keys keyed by sharedKeyReference values. | `secureObject` | {} | no |
## Resources
diff --git a/src/000-cloud/070-kubernetes/bicep/README.md b/src/000-cloud/070-kubernetes/bicep/README.md
index b229a93b..16659d32 100644
--- a/src/000-cloud/070-kubernetes/bicep/README.md
+++ b/src/000-cloud/070-kubernetes/bicep/README.md
@@ -44,17 +44,16 @@ Creates subnets for AKS private endpoints in an existing Virtual Network.
#### Parameters for network
-| Name | Description | Type | Default | Required |
-|:-----------------------------|:---------------------------------------------------------------------------------------------|:-----------------------------------|:--------|:---------|
-| common | The common component configuration. | `[_1.Common](#user-defined-types)` | n/a | yes |
-| virtualNetworkName | Virtual network name for subnet creation. | `string` | n/a | yes |
-| networkSecurityGroupName | Network security group name to apply to the subnets. | `string` | n/a | yes |
-| subnetAddressPrefixAks | Address prefix for the AKS system node subnet. | `string` | n/a | yes |
-| subnetAddressPrefixAksPod | Address prefix for the AKS pod subnet. | `string` | n/a | yes |
-| defaultOutboundAccessEnabled | Whether to enable default outbound internet access for AKS subnets. | `bool` | n/a | yes |
-| shouldEnableNatGateway | Whether to associate AKS subnets with a NAT gateway for managed outbound egress. | `bool` | n/a | yes |
-| natGatewayId | NAT gateway ID for associating AKS subnets. | `string` | n/a | no |
-| shouldEnablePrivateEndpoint | Whether to enable private endpoint for AKS cluster; when true, subnet delegation is created. | `bool` | n/a | yes |
+| Name | Description | Type | Default | Required |
+|:-----------------------------|:---------------------------------------------------------------------------------|:-----------------------------------|:--------|:---------|
+| common | The common component configuration. | `[_1.Common](#user-defined-types)` | n/a | yes |
+| virtualNetworkName | Virtual network name for subnet creation. | `string` | n/a | yes |
+| networkSecurityGroupName | Network security group name to apply to the subnets. | `string` | n/a | yes |
+| subnetAddressPrefixAks | Address prefix for the AKS system node subnet. | `string` | n/a | yes |
+| subnetAddressPrefixAksPod | Address prefix for the AKS pod subnet. | `string` | n/a | yes |
+| defaultOutboundAccessEnabled | Whether to enable default outbound internet access for AKS subnets. | `bool` | n/a | yes |
+| shouldEnableNatGateway | Whether to associate AKS subnets with a NAT gateway for managed outbound egress. | `bool` | n/a | yes |
+| natGatewayId | NAT gateway ID for associating AKS subnets. | `string` | n/a | no |
#### Resources for network
diff --git a/src/000-cloud/070-kubernetes/bicep/main.bicep b/src/000-cloud/070-kubernetes/bicep/main.bicep
index 0e9f19c4..84c3aef3 100644
--- a/src/000-cloud/070-kubernetes/bicep/main.bicep
+++ b/src/000-cloud/070-kubernetes/bicep/main.bicep
@@ -87,7 +87,6 @@ module network './modules/network.bicep' = {
defaultOutboundAccessEnabled: aksNetworkConfig.defaultOutboundAccessEnabled
shouldEnableNatGateway: aksNetworkConfig.shouldEnableNatGateway
natGatewayId: natGatewayId
- shouldEnablePrivateEndpoint: aksPrivateClusterConfig.shouldEnablePrivateEndpoint
}
}
diff --git a/src/000-cloud/070-kubernetes/bicep/modules/network.bicep b/src/000-cloud/070-kubernetes/bicep/modules/network.bicep
index 658aed75..62449f77 100644
--- a/src/000-cloud/070-kubernetes/bicep/modules/network.bicep
+++ b/src/000-cloud/070-kubernetes/bicep/modules/network.bicep
@@ -35,9 +35,6 @@ param shouldEnableNatGateway bool
@description('NAT gateway ID for associating AKS subnets.')
param natGatewayId string?
-@description('Whether to enable private endpoint for AKS cluster; when true, subnet delegation is created.')
-param shouldEnablePrivateEndpoint bool
-
/*
Local Variables
*/
@@ -88,16 +85,15 @@ resource snetAksPod 'Microsoft.Network/virtualNetworks/subnets@2025-01-01' = {
id: natGatewayId!
}
: null
- delegations: shouldEnablePrivateEndpoint
- ? [
- {
- name: 'aks-delegation'
- properties: {
- serviceName: 'Microsoft.ContainerService/managedClusters'
- }
- }
- ]
- : []
+ // Delegation required for Azure CNI with dedicated pod subnet (podSubnetID)
+ delegations: [
+ {
+ name: 'aks-delegation'
+ properties: {
+ serviceName: 'Microsoft.ContainerService/managedClusters'
+ }
+ }
+ ]
}
dependsOn: [
snetAks // Make sure subnets are created in sequence to avoid conflicts
diff --git a/src/000-cloud/070-kubernetes/terraform/main.tf b/src/000-cloud/070-kubernetes/terraform/main.tf
index dbf261f9..2a29fa44 100644
--- a/src/000-cloud/070-kubernetes/terraform/main.tf
+++ b/src/000-cloud/070-kubernetes/terraform/main.tf
@@ -45,7 +45,6 @@ module "network" {
subnet_address_prefixes_aks = var.subnet_address_prefixes_aks
subnet_address_prefixes_aks_pod = var.subnet_address_prefixes_aks_pod
node_pools = var.node_pools
- should_enable_private_endpoint = var.should_enable_private_endpoint
}
diff --git a/src/000-cloud/070-kubernetes/terraform/modules/network/README.md b/src/000-cloud/070-kubernetes/terraform/modules/network/README.md
index 6d5dab6d..bd160e27 100644
--- a/src/000-cloud/070-kubernetes/terraform/modules/network/README.md
+++ b/src/000-cloud/070-kubernetes/terraform/modules/network/README.md
@@ -34,21 +34,20 @@ Deploys virtual network resources for AKS
## Inputs
-| Name | Description | Type | Default | Required |
-|-------------------------------------|-----------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|---------|:--------:|
-| default\_outbound\_access\_enabled | Whether to enable default outbound internet access for AKS subnets | `bool` | n/a | yes |
-| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
-| instance | Instance identifier for naming resources: 001, 002, etc | `string` | n/a | yes |
-| nat\_gateway\_id | NAT gateway resource id for associating AKS subnets | `string` | n/a | yes |
-| network\_security\_group | n/a | ```object({ id = string })``` | n/a | yes |
-| resource\_group | Resource group object containing name and id where resources will be deployed | ```object({ name = string })``` | n/a | yes |
-| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
-| should\_enable\_nat\_gateway | Whether to associate AKS subnets with a NAT gateway for managed egress | `bool` | n/a | yes |
-| should\_enable\_private\_endpoint | Whether to enable private endpoint for AKS cluster; when true, subnet delegations are created | `bool` | n/a | yes |
-| subnet\_address\_prefixes\_aks | Address prefixes for the AKS subnet | `list(string)` | n/a | yes |
-| subnet\_address\_prefixes\_aks\_pod | Address prefixes for the AKS pod subnet | `list(string)` | n/a | yes |
-| virtual\_network | n/a | ```object({ name = string })``` | n/a | yes |
-| node\_pools | Configuration for additional node pool subnets; map key is used as the node pool name | ```map(object({ subnet_address_prefixes = list(string) pod_subnet_address_prefixes = list(string) }))``` | `{}` | no |
+| Name | Description | Type | Default | Required |
+|-------------------------------------|---------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|---------|:--------:|
+| default\_outbound\_access\_enabled | Whether to enable default outbound internet access for AKS subnets | `bool` | n/a | yes |
+| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
+| instance | Instance identifier for naming resources: 001, 002, etc | `string` | n/a | yes |
+| nat\_gateway\_id | NAT gateway resource id for associating AKS subnets | `string` | n/a | yes |
+| network\_security\_group | n/a | ```object({ id = string })``` | n/a | yes |
+| resource\_group | Resource group object containing name and id where resources will be deployed | ```object({ name = string })``` | n/a | yes |
+| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| should\_enable\_nat\_gateway | Whether to associate AKS subnets with a NAT gateway for managed egress | `bool` | n/a | yes |
+| subnet\_address\_prefixes\_aks | Address prefixes for the AKS subnet | `list(string)` | n/a | yes |
+| subnet\_address\_prefixes\_aks\_pod | Address prefixes for the AKS pod subnet | `list(string)` | n/a | yes |
+| virtual\_network | n/a | ```object({ name = string })``` | n/a | yes |
+| node\_pools | Configuration for additional node pool subnets; map key is used as the node pool name | ```map(object({ subnet_address_prefixes = list(string) pod_subnet_address_prefixes = list(string) }))``` | `{}` | no |
## Outputs
diff --git a/src/000-cloud/070-kubernetes/terraform/modules/network/main.tf b/src/000-cloud/070-kubernetes/terraform/modules/network/main.tf
index 91e5ccf7..de3af6c6 100644
--- a/src/000-cloud/070-kubernetes/terraform/modules/network/main.tf
+++ b/src/000-cloud/070-kubernetes/terraform/modules/network/main.tf
@@ -41,14 +41,11 @@ resource "azurerm_subnet" "snet_aks_pod" {
address_prefixes = var.subnet_address_prefixes_aks_pod
default_outbound_access_enabled = var.default_outbound_access_enabled
- dynamic "delegation" {
- for_each = var.should_enable_private_endpoint ? [1] : []
- content {
- name = "aks-delegation"
- service_delegation {
- name = "Microsoft.ContainerService/managedClusters"
- actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
- }
+ delegation {
+ name = "aks-delegation"
+ service_delegation {
+ name = "Microsoft.ContainerService/managedClusters"
+ actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
@@ -86,14 +83,11 @@ resource "azurerm_subnet" "snet_aks_node_pool_pod" {
address_prefixes = each.value.pod_subnet_address_prefixes
default_outbound_access_enabled = var.default_outbound_access_enabled
- dynamic "delegation" {
- for_each = var.should_enable_private_endpoint ? [1] : []
- content {
- name = "aks-delegation"
- service_delegation {
- name = "Microsoft.ContainerService/managedClusters"
- actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
- }
+ delegation {
+ name = "aks-delegation"
+ service_delegation {
+ name = "Microsoft.ContainerService/managedClusters"
+ actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
}
}
}
diff --git a/src/000-cloud/070-kubernetes/terraform/modules/network/variables.tf b/src/000-cloud/070-kubernetes/terraform/modules/network/variables.tf
index 6839150c..76f7e9c5 100644
--- a/src/000-cloud/070-kubernetes/terraform/modules/network/variables.tf
+++ b/src/000-cloud/070-kubernetes/terraform/modules/network/variables.tf
@@ -31,8 +31,3 @@ variable "node_pools" {
description = "Configuration for additional node pool subnets; map key is used as the node pool name"
default = {}
}
-
-variable "should_enable_private_endpoint" {
- type = bool
- description = "Whether to enable private endpoint for AKS cluster; when true, subnet delegations are created"
-}
diff --git a/src/000-cloud/085-ai-foundry/bicep/README.md b/src/000-cloud/085-ai-foundry/bicep/README.md
index 842a43fc..1b2a7c78 100644
--- a/src/000-cloud/085-ai-foundry/bicep/README.md
+++ b/src/000-cloud/085-ai-foundry/bicep/README.md
@@ -7,19 +7,19 @@ Deploys Microsoft Foundry account with optional projects, model deployments, RAI
## Parameters
-| Name | Description | Type | Default | Required |
-|:----------------------------|:-------------------------------------------------------------------------------------------------------------|:--------------------------------------------|:------------------------------------------|:---------|
-| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
-| aiFoundryName | Name for the AI Foundry account. If not provided, defaults to aif-{resourcePrefix}-{environment}-{instance}. | `string` | n/a | no |
-| aiFoundryConfig | Configuration settings for the Microsoft Foundry account. | `[_1.AiFoundryConfig](#user-defined-types)` | [variables('_1.aiFoundryConfigDefaults')] | no |
-| tags | Tags to apply to all resources. | `object` | {} | no |
-| telemetry_opt_out | Whether to opt out of telemetry data collection. | `bool` | `false` | no |
-| aiProjects | Array of AI Foundry projects to create. | `array` | [] | no |
-| raiPolicies | Array of RAI policies to create. | `array` | [] | no |
-| modelDeployments | Array of model deployments to create. | `array` | [] | no |
-| shouldCreatePrivateEndpoint | Whether to create a private endpoint for the Microsoft Foundry account. | `bool` | `false` | no |
-| privateEndpointSubnetId | Subnet ID for the private endpoint. | `string` | | no |
-| virtualNetworkId | Virtual network ID for DNS zone links. | `string` | | no |
+| Name | Description | Type | Default | Required |
+|:----------------------------|:-------------------------------------------------------------------------------------------------------------|:----------------------------------------------|:------------------------------------------|:---------|
+| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
+| aiFoundryName | Name for the AI Foundry account. If not provided, defaults to aif-{resourcePrefix}-{environment}-{instance}. | `string` | n/a | no |
+| aiFoundryConfig | Configuration settings for the Microsoft Foundry account. | `[_1.AiFoundryConfig](#user-defined-types)` | [variables('_1.aiFoundryConfigDefaults')] | no |
+| tags | Tags to apply to all resources. | `object` | {} | no |
+| telemetry_opt_out | Whether to opt out of telemetry data collection. | `bool` | `false` | no |
+| aiProjects | Array of AI Foundry projects to create. | `[_1.AiProject](#user-defined-types)[]` | [] | no |
+| raiPolicies | Array of RAI policies to create. | `[_1.RaiPolicy](#user-defined-types)[]` | [] | no |
+| modelDeployments | Array of model deployments to create. | `[_1.ModelDeployment](#user-defined-types)[]` | [] | no |
+| shouldCreatePrivateEndpoint | Whether to create a private endpoint for the Microsoft Foundry account. | `bool` | `false` | no |
+| privateEndpointSubnetId | Subnet ID for the private endpoint. | `string` | | no |
+| virtualNetworkId | Virtual network ID for DNS zone links. | `string` | | no |
## Resources
diff --git a/src/100-edge/100-cncf-cluster/bicep/README.md b/src/100-edge/100-cncf-cluster/bicep/README.md
index 0299aa80..8959505f 100644
--- a/src/100-edge/100-cncf-cluster/bicep/README.md
+++ b/src/100-edge/100-cncf-cluster/bicep/README.md
@@ -21,7 +21,7 @@ The scripts handle primary and secondary node(s) setup, cluster administration,
| shouldEnableArcAutoUpgrade | Whether to enable auto-upgrade for Azure Arc agents. | `bool` | [not(equals(parameters('common').environment, 'prod'))] | no |
| clusterAdminOid | The Object ID that will be given cluster-admin permissions. | `string` | n/a | no |
| clusterAdminUpn | The User Principal Name that will be given cluster-admin permissions. | `string` | n/a | no |
-| clusterNodeVirtualMachineNames | The node virtual machines names. | `array` | n/a | no |
+| clusterNodeVirtualMachineNames | The node virtual machines names. | `string[]` | n/a | no |
| clusterServerVirtualMachineName | The server virtual machines name. | `string` | n/a | no |
| clusterServerHostMachineUsername | Username used for the host machines that will be given kube-config settings on setup. (Otherwise, resource_prefix if it exists as a user) | `string` | [parameters('common').resourcePrefix] | no |
| clusterServerIp | The IP address for the server for the cluster. (Needed for mult-node cluster) | `string` | n/a | no |
diff --git a/src/100-edge/110-iot-ops/bicep/README.md b/src/100-edge/110-iot-ops/bicep/README.md
index 0862c5be..927a11fb 100644
--- a/src/100-edge/110-iot-ops/bicep/README.md
+++ b/src/100-edge/110-iot-ops/bicep/README.md
@@ -7,49 +7,50 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
## Parameters
-| Name | Description | Type | Default | Required |
-|:------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------|:---------|
-| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
-| arcConnectedClusterName | The resource name for the Arc connected cluster. | `string` | n/a | yes |
-| secretStoreConfig | The settings for the Secret Store Extension. | `[_1.SecretStoreExtension](#user-defined-types)` | [variables('_1.secretStoreExtensionDefaults')] | no |
-| shouldInitAio | Whether to deploy the Azure IoT Operations initial connected cluster resources, Secret Sync, ACSA, OSM, AIO Platform. | `bool` | `true` | no |
-| aioIdentityName | The name of the User Assigned Managed Identity for Azure IoT Operations. | `string` | n/a | yes |
-| aioExtensionConfig | The settings for the Azure IoT Operations Extension. | `[_1.AioExtension](#user-defined-types)` | [variables('_1.aioExtensionDefaults')] | no |
-| aioFeatures | AIO Instance features. | `[_1.AioFeatures](#user-defined-types)` | n/a | no |
-| aioInstanceName | The name for the Azure IoT Operations Instance resource. | `string` | [format('{0}-ops-instance', parameters('arcConnectedClusterName'))] | no |
-| aioDataFlowInstanceConfig | The settings for Azure IoT Operations Data Flow Instances. | `[_1.AioDataFlowInstance](#user-defined-types)` | [variables('_1.aioDataFlowInstanceDefaults')] | no |
-| aioMqBrokerConfig | The settings for the Azure IoT Operations MQ Broker. | `[_1.AioMqBroker](#user-defined-types)` | [variables('_1.aioMqBrokerDefaults')] | no |
-| brokerListenerAnonymousConfig | Configuration for the insecure anonymous AIO MQ Broker Listener. | `[_1.AioMqBrokerAnonymous](#user-defined-types)` | [variables('_1.aioMqBrokerAnonymousDefaults')] | no |
-| configurationSettingsOverride | Optional configuration settings to override default IoT Operations extension configuration. Use the same key names as the az iot ops --ops-config parameter. | `object` | {} | no |
-| schemaRegistryName | The resource name for the ADR Schema Registry for Azure IoT Operations. | `string` | n/a | yes |
-| adrNamespaceName | The resource name for the ADR Namespace for Azure IoT Operations. | `string` | n/a | no |
-| shouldDeployAio | Whether to deploy an Azure IoT Operations Instance and all of its required components into the connected cluster. | `bool` | `true` | no |
-| shouldDeployResourceSyncRules | Whether or not to deploy the Custom Locations Resource Sync Rules for the Azure IoT Operations resources. | `bool` | `true` | no |
-| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous AIO MQ Broker Listener. (Should only be used for dev or test environments) | `bool` | `false` | no |
-| shouldEnableOtelCollector | Whether or not to enable the Open Telemetry Collector for Azure IoT Operations. | `bool` | `true` | no |
-| shouldEnableOpcUaSimulator | Whether or not to enable the OPC UA Simulator for Azure IoT Operations. | `bool` | `true` | no |
-| shouldEnableAkriRestConnector | Deploy Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriMediaConnector | Deploy Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
-| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `array` | [] | no |
-| akriMqttSharedConfig | Shared MQTT connection configuration for all Akri connectors. | `[_1.AkriMqttConfig](#user-defined-types)` | {'host': 'aio-broker:18883', 'audience': 'aio-internal', 'caConfigmap': 'azure-iot-operations-aio-ca-trust-bundle'} | no |
-| customLocationName | The name for the Custom Locations resource. | `string` | [format('{0}-cl', parameters('arcConnectedClusterName'))] | no |
-| additionalClusterExtensionIds | Additional cluster extension IDs to include in the custom location. (Appended to the default Secret Store and IoT Operations extension IDs) | `array` | [] | no |
-| trustIssuerSettings | The trust issuer settings for Customer Managed Azure IoT Operations Settings. | `[_1.TrustIssuerConfig](#user-defined-types)` | {'trustSource': 'SelfSigned'} | no |
-| sseKeyVaultName | The name of the Key Vault for Secret Sync. (Required when providing sseIdentityName) | `string` | n/a | yes |
-| sseIdentityName | The name of the User Assigned Managed Identity for Secret Sync. | `string` | n/a | yes |
-| sseKeyVaultResourceGroupName | The name of the Resource Group for the Key Vault for Secret Sync. (Required when providing sseIdentityName) | `string` | [resourceGroup().name] | no |
-| shouldAssignSseKeyVaultRoles | Whether to assign roles for Key Vault to the provided Secret Sync Identity. | `bool` | `true` | no |
-| shouldAssignDeployIdentityRoles | Whether to assign roles to the deploy identity. | `bool` | [not(empty(parameters('deployIdentityName')))] | no |
-| deployIdentityName | The resource name for a managed identity that will be given deployment admin permissions. | `string` | n/a | no |
-| shouldDeployAioDeploymentScripts | Whether to deploy DeploymentScripts for Azure IoT Operations. | `bool` | `false` | no |
-| deployKeyVaultName | The name of the Key Vault that will have scripts and secrets for deployment. | `string` | [parameters('sseKeyVaultName')] | no |
-| deployKeyVaultResourceGroupName | The resource group name where the Key Vault is located. Defaults to the current resource group. | `string` | [parameters('sseKeyVaultResourceGroupName')] | no |
-| deployUserTokenSecretName | The name for the deploy user token secret in Key Vault. | `string` | deploy-user-token | no |
-| deploymentScriptsSecretNamePrefix | The prefix used with constructing the secret name that will have the deployment script. | `string` | [format('{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
-| shouldAddDeployScriptsToKeyVault | Whether to add the deploy scripts for DeploymentScripts to Key Vault as secrets. (Required for DeploymentScripts) | `bool` | `false` | no |
-| telemetry_opt_out | Whether to opt out of telemetry data collection. | `bool` | `false` | no |
+| Name | Description | Type | Default | Required |
+|:------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------|:-----------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------|:---------|
+| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
+| arcConnectedClusterName | The resource name for the Arc connected cluster. | `string` | n/a | yes |
+| secretStoreConfig | The settings for the Secret Store Extension. | `[_1.SecretStoreExtension](#user-defined-types)` | [variables('_1.secretStoreExtensionDefaults')] | no |
+| shouldInitAio | Whether to deploy the Azure IoT Operations initial connected cluster resources, Secret Sync, ACSA, OSM, AIO Platform. | `bool` | `true` | no |
+| aioIdentityName | The name of the User Assigned Managed Identity for Azure IoT Operations. | `string` | n/a | yes |
+| aioExtensionConfig | The settings for the Azure IoT Operations Extension. | `[_1.AioExtension](#user-defined-types)` | [variables('_1.aioExtensionDefaults')] | no |
+| aioFeatures | AIO Instance features. | `[_1.AioFeatures](#user-defined-types)` | n/a | no |
+| aioInstanceName | The name for the Azure IoT Operations Instance resource. | `string` | [format('{0}-ops-instance', parameters('arcConnectedClusterName'))] | no |
+| aioDataFlowInstanceConfig | The settings for Azure IoT Operations Data Flow Instances. | `[_1.AioDataFlowInstance](#user-defined-types)` | [variables('_1.aioDataFlowInstanceDefaults')] | no |
+| aioMqBrokerConfig | The settings for the Azure IoT Operations MQ Broker. | `[_1.AioMqBroker](#user-defined-types)` | [variables('_1.aioMqBrokerDefaults')] | no |
+| brokerListenerAnonymousConfig | Configuration for the insecure anonymous AIO MQ Broker Listener. | `[_1.AioMqBrokerAnonymous](#user-defined-types)` | [variables('_1.aioMqBrokerAnonymousDefaults')] | no |
+| configurationSettingsOverride | Optional configuration settings to override default IoT Operations extension configuration. Use the same key names as the az iot ops --ops-config parameter. | `object` | {} | no |
+| schemaRegistryName | The resource name for the ADR Schema Registry for Azure IoT Operations. | `string` | n/a | yes |
+| adrNamespaceName | The resource name for the ADR Namespace for Azure IoT Operations. | `string` | n/a | no |
+| shouldDeployAio | Whether to deploy an Azure IoT Operations Instance and all of its required components into the connected cluster. | `bool` | `true` | no |
+| shouldDeployResourceSyncRules | Whether or not to deploy the Custom Locations Resource Sync Rules for the Azure IoT Operations resources. | `bool` | `true` | no |
+| shouldCreateAnonymousBrokerListener | Whether to enable an insecure anonymous AIO MQ Broker Listener. (Should only be used for dev or test environments) | `bool` | `false` | no |
+| shouldEnableOtelCollector | Whether or not to enable the Open Telemetry Collector for Azure IoT Operations. | `bool` | `true` | no |
+| shouldEnableOpcUaSimulator | Whether or not to enable the OPC UA Simulator for Azure IoT Operations. | `bool` | `true` | no |
+| shouldEnableAkriRestConnector | Deploy Akri REST HTTP Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriMediaConnector | Deploy Akri Media Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriOnvifConnector | Deploy Akri ONVIF Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| shouldEnableAkriSseConnector | Deploy Akri SSE Connector template to the IoT Operations instance. | `bool` | `false` | no |
+| customAkriConnectors | List of custom Akri connector templates with user-defined endpoint types and container images. | `[_1.AkriConnectorTemplate](#user-defined-types)[]` | [] | no |
+| registryEndpoints | List of additional container registry endpoints for pulling custom artifacts. MCR is always added automatically. | `[_1.RegistryEndpointConfig](#user-defined-types)[]` | [] | no |
+| akriMqttSharedConfig | Shared MQTT connection configuration for all Akri connectors. | `[_1.AkriMqttConfig](#user-defined-types)` | {'host': 'aio-broker:18883', 'audience': 'aio-internal', 'caConfigmap': 'azure-iot-operations-aio-ca-trust-bundle'} | no |
+| customLocationName | The name for the Custom Locations resource. | `string` | [format('{0}-cl', parameters('arcConnectedClusterName'))] | no |
+| additionalClusterExtensionIds | Additional cluster extension IDs to include in the custom location. (Appended to the default Secret Store and IoT Operations extension IDs) | `string[]` | [] | no |
+| trustIssuerSettings | The trust issuer settings for Customer Managed Azure IoT Operations Settings. | `[_1.TrustIssuerConfig](#user-defined-types)` | {'trustSource': 'SelfSigned'} | no |
+| sseKeyVaultName | The name of the Key Vault for Secret Sync. (Required when providing sseIdentityName) | `string` | n/a | yes |
+| sseIdentityName | The name of the User Assigned Managed Identity for Secret Sync. | `string` | n/a | yes |
+| sseKeyVaultResourceGroupName | The name of the Resource Group for the Key Vault for Secret Sync. (Required when providing sseIdentityName) | `string` | [resourceGroup().name] | no |
+| shouldAssignSseKeyVaultRoles | Whether to assign roles for Key Vault to the provided Secret Sync Identity. | `bool` | `true` | no |
+| shouldAssignDeployIdentityRoles | Whether to assign roles to the deploy identity. | `bool` | [not(empty(parameters('deployIdentityName')))] | no |
+| deployIdentityName | The resource name for a managed identity that will be given deployment admin permissions. | `string` | n/a | no |
+| shouldDeployAioDeploymentScripts | Whether to deploy DeploymentScripts for Azure IoT Operations. | `bool` | `false` | no |
+| deployKeyVaultName | The name of the Key Vault that will have scripts and secrets for deployment. | `string` | [parameters('sseKeyVaultName')] | no |
+| deployKeyVaultResourceGroupName | The resource group name where the Key Vault is located. Defaults to the current resource group. | `string` | [parameters('sseKeyVaultResourceGroupName')] | no |
+| deployUserTokenSecretName | The name for the deploy user token secret in Key Vault. | `string` | deploy-user-token | no |
+| deploymentScriptsSecretNamePrefix | The prefix used with constructing the secret name that will have the deployment script. | `string` | [format('{0}-{1}-{2}', parameters('common').resourcePrefix, parameters('common').environment, parameters('common').instance)] | no |
+| shouldAddDeployScriptsToKeyVault | Whether to add the deploy scripts for DeploymentScripts to Key Vault as secrets. (Required for DeploymentScripts) | `bool` | `false` | no |
+| telemetry_opt_out | Whether to opt out of telemetry data collection. | `bool` | `false` | no |
## Resources
@@ -63,6 +64,7 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| postInitScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
| iotOpsInstance | `Microsoft.Resources/deployments` | 2025-04-01 |
| akriConnectors | `Microsoft.Resources/deployments` | 2025-04-01 |
+| registryEndpointsModule | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScriptsSecrets | `Microsoft.Resources/deployments` | 2025-04-01 |
| postInstanceScripts | `Microsoft.Resources/deployments` | 2025-04-01 |
@@ -78,6 +80,7 @@ Deploys Azure IoT Operations extensions, instances, and configurations on Azure
| postInitScripts | Runs deployment scripts for IoT Operations using an Azure deploymentScript resource, including tool installation and script execution. |
| iotOpsInstance | Deploys Azure IoT Operations instance, broker, authentication, listeners, and data flow components on an Azure Arc-enabled Kubernetes cluster. |
| akriConnectors | Deploys multiple Azure IoT Operations Akri Connector Templates as part of the IoT Operations deployment. Supports REST/HTTP, Media, ONVIF, and SSE connector types with configurable runtime and MQTT settings. |
+| registryEndpointsModule | Manages container registry endpoints for Azure IoT Operations, including the default MCR endpoint, custom registry endpoints, and ACR role assignments. |
| postInstanceScriptsSecrets | Creates secrets in Key Vault for deployment script setup and initialization for Azure IoT Operations. |
| postInstanceScripts | Runs deployment scripts for IoT Operations using an Azure deploymentScript resource, including tool installation and script execution. |
@@ -294,7 +297,6 @@ Deploys Azure IoT Operations instance, broker, authentication, listeners, and da
| adrSyncRule | `Microsoft.ExtendedLocation/customLocations/resourceSyncRules` | 2021-08-31-preview |
| defaultSecretSyncSecretProviderClass | `Microsoft.SecretSyncController/azureKeyVaultSecretProviderClasses` | 2024-08-21-preview |
| aioInstance | `Microsoft.IoTOperations/instances` | 2025-10-01 |
-| registryEndpoint | `Microsoft.IoTOperations/instances/registryEndpoints` | 2025-10-01 |
| broker | `Microsoft.IoTOperations/instances/brokers` | 2025-10-01 |
| brokerAuthn | `Microsoft.IoTOperations/instances/brokers/authentications` | 2025-10-01 |
| brokerListener | `Microsoft.IoTOperations/instances/brokers/listeners` | 2025-10-01 |
@@ -328,6 +330,7 @@ Deploys Azure IoT Operations instance, broker, authentication, listeners, and da
| brokerAuthnId | `string` | The ID of the deployed AIO MQ Broker Authentication. |
| brokerListenerName | `string` | The name of the deployed AIO MQ Broker Listener. |
| brokerListenerId | `string` | The ID of the deployed AIO MQ Broker Listener. |
+| extensionIdentity | `object` | AIO Arc extension identity information for role assignments. |
### akriConnectors
@@ -355,6 +358,35 @@ Deploys multiple Azure IoT Operations Akri Connector Templates as part of the Io
| connectorTemplates | `array` | Map of deployed connector templates by name with id and type. |
| connectorTypesDeployed | `array` | List of connector types that were deployed. |
+### registryEndpointsModule
+
+Manages container registry endpoints for Azure IoT Operations, including the default MCR endpoint, custom registry endpoints, and ACR role assignments.
+
+#### Parameters for registryEndpointsModule
+
+| Name | Description | Type | Default | Required |
+|:---------------------|:------------------------------------------------------------------|:---------|:--------|:---------|
+| aioInstanceId | Azure IoT Operations instance ID (parent for registry endpoints). | `string` | n/a | yes |
+| customLocationId | Custom location ID for the Azure IoT Operations deployment. | `string` | n/a | yes |
+| extensionPrincipalId | Principal ID of the AIO Arc extension for ACR role assignments. | `string` | n/a | yes |
+| registryEndpoints | List of custom registry endpoints to configure. | `array` | n/a | yes |
+
+#### Resources for registryEndpointsModule
+
+| Name | Type | API Version |
+|:-----------------------|:------------------------------------------------------|:------------|
+| mcrEndpoint | `Microsoft.IoTOperations/instances/registryEndpoints` | 2025-10-01 |
+| customEndpoints | `Microsoft.IoTOperations/instances/registryEndpoints` | 2025-10-01 |
+| acrPullRoleAssignments | `Microsoft.Authorization/roleAssignments` | 2022-04-01 |
+
+#### Outputs for registryEndpointsModule
+
+| Name | Type | Description |
+|:----------------------|:---------|:------------------------------------|
+| mcrEndpoint | `object` | Default MCR registry endpoint. |
+| customEndpointsOutput | `array` | Array of custom registry endpoints. |
+| acrRoleAssignments | `array` | Array of ACR role assignment IDs. |
+
### postInstanceScriptsSecrets
Creates secrets in Key Vault for deployment script setup and initialization for Azure IoT Operations.
@@ -549,6 +581,14 @@ Trust settings for Akri connector.
|:-------------------|:---------|:------------------------------------|
| trustListSecretRef | `string` | Reference to the trust list secret. |
+### `_1.ArtifactPullSecretSettings`
+
+Authentication settings for Artifact Pull Secret.
+
+| Property | Type | Description |
+|:----------|:---------|:--------------------------------------------------------------------------|
+| secretRef | `string` | The name of the kubernetes secret that contains the artifact pull secret. |
+
### `_1.BrokerPersistence`
Broker persistence configuration for disk-backed message storage.
@@ -608,6 +648,57 @@ The mode of the AIO instance feature. Either "Stable", "Preview" or "Disabled".
The setting value of the AIO instance feature. Either "Enabled" or "Disabled".
+### `_1.RegistryAuthAnonymous`
+
+Anonymous authentication for registry endpoint.
+
+| Property | Type | Description |
+|:------------------|:---------|:--------------------------------------------------|
+| method | `string` | Authentication method. |
+| anonymousSettings | `object` | Anonymous authentication settings (empty object). |
+
+### `_1.RegistryAuthArtifactPullSecret`
+
+Artifact Pull Secret authentication for registry endpoint.
+
+| Property | Type | Description |
+|:---------------------------|:-------------------------------------------------------|:-------------------------------|
+| method | `string` | Authentication method. |
+| artifactPullSecretSettings | `[_1.ArtifactPullSecretSettings](#user-defined-types)` | Artifact pull secret settings. |
+
+### `_1.RegistryAuthentication`
+
+Authentication configuration for a registry endpoint.
+
+### `_1.RegistryAuthSystemAssignedManagedIdentity`
+
+System-Assigned Managed Identity authentication for registry endpoint.
+
+| Property | Type | Description |
+|:--------------------------------------|:------------------------------------------------------------------|:-------------------------------------------|
+| method | `string` | Authentication method. |
+| systemAssignedManagedIdentitySettings | `[_1.SystemAssignedManagedIdentitySettings](#user-defined-types)` | System-assigned managed identity settings. |
+
+### `_1.RegistryAuthUserAssignedManagedIdentity`
+
+User-Assigned Managed Identity authentication for registry endpoint.
+
+| Property | Type | Description |
+|:------------------------------------|:----------------------------------------------------------------|:-----------------------------------------|
+| method | `string` | Authentication method. |
+| userAssignedManagedIdentitySettings | `[_1.UserAssignedManagedIdentitySettings](#user-defined-types)` | User-assigned managed identity settings. |
+
+### `_1.RegistryEndpointConfig`
+
+Container registry endpoint configuration for AIO instance.
+
+| Property | Type | Description |
+|:---------------|:---------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|
+| name | `string` | Unique name for the registry endpoint (3-63 chars, lowercase alphanumeric and hyphens). |
+| host | `string` | Container registry hostname (e.g., myregistry.azurecr.io). |
+| acrResourceId | `string` | Optional ACR resource ID for automatic AcrPull role assignment. Only applicable when authentication.method is SystemAssignedManagedIdentity. |
+| authentication | `[_1.RegistryAuthentication](#user-defined-types)` | Authentication configuration for the registry. |
+
### `_1.Release`
The common settings for Azure Arc Extensions.
@@ -661,6 +752,14 @@ The configuration for Self-Signed Issuer for Azure IoT Operations certificates.
|:------------|:---------|:------------|
| trustSource | `string` | |
+### `_1.SystemAssignedManagedIdentitySettings`
+
+Authentication settings for System-Assigned Managed Identity.
+
+| Property | Type | Description |
+|:---------|:---------|:--------------------------------------------------------------------------------------------------------|
+| audience | `string` | Audience of the service to authenticate against. Defaults to "" for ACR. |
+
### `_1.TrustConfigSource`
The config source of trust for how to use or generate Azure IoT Operations certificates.
@@ -684,6 +783,16 @@ The configuration for the trust settings of Azure IoT Operations certificates.
The source of trust for Azure IoT Operations certificates.
+### `_1.UserAssignedManagedIdentitySettings`
+
+Authentication settings for User-Assigned Managed Identity.
+
+| Property | Type | Description |
+|:---------|:---------|:---------------------------------------------------------------|
+| clientId | `string` | Client ID for the user-assigned managed identity. |
+| tenantId | `string` | Tenant ID where the managed identity is located. |
+| scope | `string` | Resource identifier (application ID URI) with .default suffix. |
+
### `_2.Common`
Common settings for the components.
@@ -697,21 +806,22 @@ Common settings for the components.
## Outputs
-| Name | Type | Description |
-|:---------------------------|:---------|:-------------------------------------------------------------------|
-| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
-| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
-| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
-| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
-| customLocationId | `string` | The ID of the deployed Custom Location. |
-| customLocationName | `string` | The name of the deployed Custom Location. |
-| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
-| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
-| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
-| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
-| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
-| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
-| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
+| Name | Type | Description |
+|:---------------------------|:---------|:----------------------------------------------------------------------|
+| aioPlatformExtensionId | `string` | The ID of the Azure IoT Operations Platform Extension. |
+| aioPlatformExtensionName | `string` | The name of the Azure IoT Operations Platform Extension. |
+| aioNamespace | `string` | The namespace in the cluster where Azure IoT Operations is installed. |
+| secretStoreExtensionId | `string` | The ID of the Secret Store Extension. |
+| secretStoreExtensionName | `string` | The name of the Secret Store Extension. |
+| customLocationId | `string` | The ID of the deployed Custom Location. |
+| customLocationName | `string` | The name of the deployed Custom Location. |
+| aioInstanceId | `string` | The ID of the deployed Azure IoT Operations instance. |
+| aioInstanceName | `string` | The name of the deployed Azure IoT Operations instance. |
+| dataFlowProfileId | `string` | The ID of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowProfileName | `string` | The name of the deployed Azure IoT Operations Data Flow Profile. |
+| dataFlowEndpointId | `string` | The ID of the deployed Azure IoT Operations Data Flow Endpoint. |
+| dataFlowEndpointName | `string` | The name of the deployed Azure IoT Operations Data Flow Endpoint. |
+| akriConnectorTemplates | `array` | Map of deployed Akri connector templates by name with id and type. |
+| akriConnectorTypesDeployed | `array` | List of Akri connector types that were deployed. |
\ No newline at end of file
diff --git a/src/100-edge/110-iot-ops/bicep/main.bicep b/src/100-edge/110-iot-ops/bicep/main.bicep
index 07089eb0..f5fe157a 100644
--- a/src/100-edge/110-iot-ops/bicep/main.bicep
+++ b/src/100-edge/110-iot-ops/bicep/main.bicep
@@ -93,6 +93,9 @@ param shouldEnableAkriSseConnector bool = false
@description('List of custom Akri connector templates with user-defined endpoint types and container images.')
param customAkriConnectors types.AkriConnectorTemplate[] = []
+@description('List of additional container registry endpoints for pulling custom artifacts. MCR is always added automatically.')
+param registryEndpoints types.RegistryEndpointConfig[] = []
+
@description('Shared MQTT connection configuration for all Akri connectors.')
param akriMqttSharedConfig types.AkriMqttConfig = {
host: 'aio-broker:18883'
@@ -379,6 +382,20 @@ module akriConnectors 'modules/akri-connectors.bicep' = if (shouldDeployAkriConn
}
}
+/*
+ Registry Endpoints Module
+*/
+
+module registryEndpointsModule 'modules/registry-endpoints.bicep' = if (shouldDeployAio) {
+ name: '${deployment().name}-regep5'
+ params: {
+ aioInstanceId: iotOpsInstance.?outputs.?aioInstanceId ?? ''
+ customLocationId: iotOpsInstance.?outputs.?customLocationId ?? ''
+ extensionPrincipalId: iotOpsInstance.?outputs.?extensionIdentity.?principalId ?? ''
+ registryEndpoints: registryEndpoints
+ }
+}
+
/*
Post Instance Script Modules
*/
@@ -429,6 +446,9 @@ output aioPlatformExtensionId string = shouldDeployAio ? (iotOpsInstance.?output
@description('The name of the Azure IoT Operations Platform Extension.')
output aioPlatformExtensionName string = shouldDeployAio ? (iotOpsInstance.?outputs.?aioExtensionName ?? '') : ''
+@description('The namespace in the cluster where Azure IoT Operations is installed.')
+output aioNamespace string = aioExtensionConfig.settings.namespace
+
@description('The ID of the Secret Store Extension.')
output secretStoreExtensionId string = (iotOpsInit.?outputs.?secretStoreExtensionId) ?? ''
diff --git a/src/100-edge/110-iot-ops/bicep/modules/iot-ops-instance.bicep b/src/100-edge/110-iot-ops/bicep/modules/iot-ops-instance.bicep
index 91a40978..f9d62c75 100644
--- a/src/100-edge/110-iot-ops/bicep/modules/iot-ops-instance.bicep
+++ b/src/100-edge/110-iot-ops/bicep/modules/iot-ops-instance.bicep
@@ -320,22 +320,6 @@ resource aioInstance 'Microsoft.IoTOperations/instances@2025-10-01' = {
)
}
-resource registryEndpoint 'Microsoft.IoTOperations/instances/registryEndpoints@2025-10-01' = {
- parent: aioInstance
- name: 'default'
- extendedLocation: {
- name: customLocation.id
- type: 'CustomLocation'
- }
- properties: {
- host: 'mcr.microsoft.com'
- authentication: {
- method: 'Anonymous'
- anonymousSettings: {}
- }
- }
-}
-
resource broker 'Microsoft.IoTOperations/instances/brokers@2025-10-01' = {
parent: aioInstance
name: 'default'
@@ -550,3 +534,8 @@ output brokerListenerName string = brokerListener.name
@description('The ID of the deployed AIO MQ Broker Listener.')
output brokerListenerId string = brokerListener.id
+
+@description('AIO Arc extension identity information for role assignments.')
+output extensionIdentity object = {
+ principalId: aioExtension.identity.principalId
+}
diff --git a/src/100-edge/110-iot-ops/bicep/modules/registry-endpoints.bicep b/src/100-edge/110-iot-ops/bicep/modules/registry-endpoints.bicep
new file mode 100644
index 00000000..c0355387
--- /dev/null
+++ b/src/100-edge/110-iot-ops/bicep/modules/registry-endpoints.bicep
@@ -0,0 +1,132 @@
+metadata name = 'Registry Endpoints Module'
+metadata description = 'Manages container registry endpoints for Azure IoT Operations, including the default MCR endpoint, custom registry endpoints, and ACR role assignments.'
+
+import * as types from '../types.bicep'
+
+/*
+ Parameters
+*/
+
+@description('Azure IoT Operations instance ID (parent for registry endpoints).')
+param aioInstanceId string
+
+@description('Custom location ID for the Azure IoT Operations deployment.')
+param customLocationId string
+
+@description('Principal ID of the AIO Arc extension for ACR role assignments.')
+param extensionPrincipalId string
+
+@description('List of custom registry endpoints to configure.')
+param registryEndpoints types.RegistryEndpointConfig[]
+
+/*
+ Variables
+*/
+
+var aioInstanceName = last(split(aioInstanceId, '/'))!
+
+// Filter endpoints that have ACR resource ID and use SystemAssignedManagedIdentity
+var endpointsWithAcr = filter(
+ registryEndpoints,
+ endpoint => endpoint.?acrResourceId != null && endpoint.authentication.method == 'SystemAssignedManagedIdentity'
+)
+
+// Default audience for system-assigned managed identity using environment function
+var defaultSystemAssignedManagedIdentityAudience = environment().resourceManager
+
+/*
+ Resources
+*/
+
+// Default MCR endpoint (always created)
+resource mcrEndpoint 'Microsoft.IoTOperations/instances/registryEndpoints@2025-10-01' = {
+ name: '${aioInstanceName}/mcr'
+ extendedLocation: {
+ type: 'CustomLocation'
+ name: customLocationId
+ }
+ properties: {
+ host: 'mcr.microsoft.com'
+ authentication: {
+ method: 'Anonymous'
+ anonymousSettings: {}
+ }
+ }
+}
+
+// Custom registry endpoints
+resource customEndpoints 'Microsoft.IoTOperations/instances/registryEndpoints@2025-10-01' = [
+ for endpoint in registryEndpoints: {
+ name: '${aioInstanceName}/${endpoint.name}'
+ extendedLocation: {
+ type: 'CustomLocation'
+ name: customLocationId
+ }
+ properties: {
+ host: endpoint.host
+ // Apply default audience for SystemAssignedManagedIdentity when not specified
+ #disable-next-line BCP225
+ authentication: endpoint.authentication.method == 'SystemAssignedManagedIdentity'
+ ? {
+ method: 'SystemAssignedManagedIdentity'
+ systemAssignedManagedIdentitySettings: {
+ audience: endpoint.authentication.systemAssignedManagedIdentitySettings.?audience ?? defaultSystemAssignedManagedIdentityAudience
+ }
+ }
+ : endpoint.authentication
+ }
+ }
+]
+
+// Existing ACR references for role assignments
+resource acrs 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' existing = [
+ for endpoint in endpointsWithAcr: {
+ name: last(split(endpoint.acrResourceId!, '/'))!
+ }
+]
+
+// ACR role assignments for SystemAssignedManagedIdentity endpoints with acrResourceId
+@batchSize(1)
+resource acrPullRoleAssignments 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
+ for (endpoint, i) in endpointsWithAcr: {
+ name: guid(endpoint.acrResourceId!, extensionPrincipalId, 'AcrPull', endpoint.name)
+ scope: acrs[i]
+ properties: {
+ // https://learn.microsoft.com/azure/role-based-access-control/built-in-roles/containers#acrpull
+ roleDefinitionId: subscriptionResourceId(
+ 'Microsoft.Authorization/roleDefinitions',
+ '7f951dda-4ed3-4680-a7ca-43fe172d538d'
+ )
+ principalId: extensionPrincipalId
+ principalType: 'ServicePrincipal'
+ }
+ }
+]
+
+/*
+ Outputs
+*/
+
+@description('Default MCR registry endpoint.')
+output mcrEndpoint object = {
+ id: mcrEndpoint.id
+ name: mcrEndpoint.name
+ host: 'mcr.microsoft.com'
+}
+
+@description('Array of custom registry endpoints.')
+output customEndpointsOutput array = [
+ for (endpoint, i) in registryEndpoints: {
+ id: customEndpoints[i].id
+ name: customEndpoints[i].name
+ host: endpoint.host
+ }
+]
+
+@description('Array of ACR role assignment IDs.')
+output acrRoleAssignments array = [
+ for (endpoint, i) in endpointsWithAcr: {
+ name: endpoint.name
+ id: acrPullRoleAssignments[i].id
+ }
+]
diff --git a/src/100-edge/110-iot-ops/bicep/types.bicep b/src/100-edge/110-iot-ops/bicep/types.bicep
index 8a49929f..a4415082 100644
--- a/src/100-edge/110-iot-ops/bicep/types.bicep
+++ b/src/100-edge/110-iot-ops/bicep/types.bicep
@@ -643,3 +643,101 @@ type AkriConnectorTemplate = {
@description('Trust settings configuration.')
trustSettings: AkriTrustSettings?
}
+
+// ============================================================================
+// Registry Endpoint Types
+// ============================================================================
+
+@export()
+@description('Authentication settings for System-Assigned Managed Identity.')
+type SystemAssignedManagedIdentitySettings = {
+ @description('Audience of the service to authenticate against. Defaults to "https://management.azure.com/" for ACR.')
+ audience: string?
+}
+
+@export()
+@description('Authentication settings for User-Assigned Managed Identity.')
+type UserAssignedManagedIdentitySettings = {
+ @description('Client ID for the user-assigned managed identity.')
+ clientId: string
+
+ @description('Tenant ID where the managed identity is located.')
+ tenantId: string
+
+ @description('Resource identifier (application ID URI) with .default suffix.')
+ scope: string?
+}
+
+@export()
+@description('Authentication settings for Artifact Pull Secret.')
+type ArtifactPullSecretSettings = {
+ @description('The name of the kubernetes secret that contains the artifact pull secret.')
+ secretRef: string
+}
+
+@export()
+@description('Authentication configuration for a registry endpoint.')
+@discriminator('method')
+type RegistryAuthentication =
+ | RegistryAuthSystemAssignedManagedIdentity
+ | RegistryAuthUserAssignedManagedIdentity
+ | RegistryAuthArtifactPullSecret
+ | RegistryAuthAnonymous
+
+@export()
+@description('System-Assigned Managed Identity authentication for registry endpoint.')
+type RegistryAuthSystemAssignedManagedIdentity = {
+ @description('Authentication method.')
+ method: 'SystemAssignedManagedIdentity'
+
+ @description('System-assigned managed identity settings.')
+ systemAssignedManagedIdentitySettings: SystemAssignedManagedIdentitySettings
+}
+
+@export()
+@description('User-Assigned Managed Identity authentication for registry endpoint.')
+type RegistryAuthUserAssignedManagedIdentity = {
+ @description('Authentication method.')
+ method: 'UserAssignedManagedIdentity'
+
+ @description('User-assigned managed identity settings.')
+ userAssignedManagedIdentitySettings: UserAssignedManagedIdentitySettings
+}
+
+@export()
+@description('Artifact Pull Secret authentication for registry endpoint.')
+type RegistryAuthArtifactPullSecret = {
+ @description('Authentication method.')
+ method: 'ArtifactPullSecret'
+
+ @description('Artifact pull secret settings.')
+ artifactPullSecretSettings: ArtifactPullSecretSettings
+}
+
+@export()
+@description('Anonymous authentication for registry endpoint.')
+type RegistryAuthAnonymous = {
+ @description('Authentication method.')
+ method: 'Anonymous'
+
+ @description('Anonymous authentication settings (empty object).')
+ anonymousSettings: object?
+}
+
+@export()
+@description('Container registry endpoint configuration for AIO instance.')
+type RegistryEndpointConfig = {
+ @description('Unique name for the registry endpoint (3-63 chars, lowercase alphanumeric and hyphens).')
+ @minLength(3)
+ @maxLength(63)
+ name: string
+
+ @description('Container registry hostname (e.g., myregistry.azurecr.io).')
+ host: string
+
+ @description('Optional ACR resource ID for automatic AcrPull role assignment. Only applicable when authentication.method is SystemAssignedManagedIdentity.')
+ acrResourceId: string?
+
+ @description('Authentication configuration for the registry.')
+ authentication: RegistryAuthentication
+}
diff --git a/src/100-edge/110-iot-ops/terraform/README.md b/src/100-edge/110-iot-ops/terraform/README.md
index 93d1c777..362ea540 100644
--- a/src/100-edge/110-iot-ops/terraform/README.md
+++ b/src/100-edge/110-iot-ops/terraform/README.md
@@ -25,6 +25,7 @@ Instance can be created, and after.
| iot\_ops\_init | ./modules/iot-ops-init | n/a |
| iot\_ops\_instance | ./modules/iot-ops-instance | n/a |
| opc\_ua\_simulator | ./modules/opc-ua-simulator | n/a |
+| registry\_endpoints | ./modules/registry-endpoints | n/a |
| role\_assignments | ./modules/role-assignment | n/a |
## Inputs
@@ -51,6 +52,7 @@ Instance can be created, and after.
| mqtt\_broker\_config | n/a | ```object({ brokerListenerServiceName = string brokerListenerPort = number serviceAccountAudience = string frontendReplicas = number frontendWorkers = number backendRedundancyFactor = number backendWorkers = number backendPartitions = number memoryProfile = string serviceType = string logsLevel = optional(string, "info") })``` | ```{ "backendPartitions": 2, "backendRedundancyFactor": 2, "backendWorkers": 2, "brokerListenerPort": 18883, "brokerListenerServiceName": "aio-broker", "frontendReplicas": 2, "frontendWorkers": 2, "logsLevel": "info", "memoryProfile": "Medium", "serviceAccountAudience": "aio-internal", "serviceType": "ClusterIp" }``` | no |
| mqtt\_broker\_persistence\_config | Broker persistence configuration for disk-backed message storage | ```object({ enabled = bool max_size = string encryption_enabled = optional(bool) # Dynamic Settings dynamic_settings = optional(object({ user_property_key = string user_property_value = string })) # Retention Policy retain_policy = optional(object({ mode = string # "All", "None", "Custom" custom_settings = optional(object({ topics = optional(list(string)) dynamic_enabled = optional(bool) })) })) # State Store Policy state_store_policy = optional(object({ mode = string # "All", "None", "Custom" custom_settings = optional(object({ state_store_resources = optional(list(object({ key_type = string # "Pattern", "String", "Binary" keys = list(string) }))) dynamic_enabled = optional(bool) })) })) # Subscriber Queue Policy subscriber_queue_policy = optional(object({ mode = string # "All", "None", "Custom" custom_settings = optional(object({ subscriber_client_ids = optional(list(string)) topics = optional(list(string)) dynamic_enabled = optional(bool) })) })) # Persistent Volume Claim Specification persistent_volume_claim_spec = optional(object({ storage_class_name = optional(string) access_modes = optional(list(string)) volume_mode = optional(string) volume_name = optional(string) resources = optional(object({ requests = optional(map(string)) limits = optional(map(string)) })) data_source = optional(object({ api_group = optional(string) kind = string name = string })) selector = optional(object({ match_labels = optional(map(string)) match_expressions = optional(list(object({ key = string operator = string values = list(string) }))) })) })) })``` | `null` | no |
| operations\_config | n/a | ```object({ namespace = string kubernetesDistro = string version = string train = string agentOperationTimeoutInMinutes = number })``` | ```{ "agentOperationTimeoutInMinutes": 120, "kubernetesDistro": "K3s", "namespace": "azure-iot-operations", "train": "stable", "version": "1.2.154" }``` | no |
+| registry\_endpoints | List of additional container registry endpoints for pulling custom artifacts (WASM modules, graph definitions, connector templates). MCR (mcr.microsoft.com) is always added automatically with anonymous authentication. The `acr_resource_id` field enables automatic AcrPull role assignment for ACR endpoints using SystemAssignedManagedIdentity authentication. When `should_assign_acr_pull_for_aio` is true and `acr_resource_id` is provided, the AIO extension's identity will be granted AcrPull access to the specified ACR. | ```list(object({ name = string host = string acr_resource_id = optional(string) should_assign_acr_pull_for_aio = optional(bool, false) authentication = object({ method = string system_assigned_managed_identity_settings = optional(object({ audience = optional(string, "https://management.azure.com/") })) user_assigned_managed_identity_settings = optional(object({ client_id = string tenant_id = string scope = optional(string) })) artifact_pull_secret_settings = optional(object({ secret_ref = string })) }) }))``` | `[]` | no |
| secret\_sync\_controller | n/a | ```object({ version = string train = string })``` | ```{ "train": "stable", "version": "1.1.5" }``` | no |
| should\_assign\_key\_vault\_roles | Whether to assign Key Vault roles to provided Secret Sync identity. | `bool` | `true` | no |
| should\_create\_anonymous\_broker\_listener | Whether to enable an insecure anonymous AIO MQ Broker Listener. Should only be used for dev or test environments | `bool` | `false` | no |
@@ -64,13 +66,16 @@ Instance can be created, and after.
## Outputs
-| Name | Description |
-|----------------------------------|----------------------------------------------------------------------------------------------------------------|
-| aio\_broker\_listener\_anonymous | The anonymous MQTT Broker Listener configuration details. |
-| aio\_dataflow\_profile | The Azure IoT Operations dataflow profile. |
-| aio\_instance | The Azure IoT Operations instance. |
-| aio\_mqtt\_broker | The MQTT Broker configuration details. |
-| aio\_namespace | The Azure IoT Operations namespace. |
-| akri\_connector\_templates | Map of deployed Akri connector templates by name with id and type. Returns null if no connectors are deployed. |
-| custom\_locations | The custom location details. |
+| Name | Description |
+|---------------------------------------------|----------------------------------------------------------------------------------------------------------------|
+| aio\_broker\_listener\_anonymous | The anonymous MQTT Broker Listener configuration details. |
+| aio\_dataflow\_profile | The Azure IoT Operations dataflow profile. |
+| aio\_instance | The Azure IoT Operations instance. |
+| aio\_mqtt\_broker | The MQTT Broker configuration details. |
+| aio\_namespace | The Azure IoT Operations namespace. |
+| akri\_connector\_templates | Map of deployed Akri connector templates by name with id and type. Returns null if no connectors are deployed. |
+| custom\_locations | The custom location details. |
+| registry\_endpoint\_mcr | The default MCR registry endpoint. |
+| registry\_endpoints\_acr\_role\_assignments | Map of ACR role assignments by endpoint name. |
+| registry\_endpoints\_custom | Map of custom registry endpoints by name with id, name, and host. |
diff --git a/src/100-edge/110-iot-ops/terraform/main.tf b/src/100-edge/110-iot-ops/terraform/main.tf
index 8255b86a..b81c7bd5 100644
--- a/src/100-edge/110-iot-ops/terraform/main.tf
+++ b/src/100-edge/110-iot-ops/terraform/main.tf
@@ -207,3 +207,17 @@ module "akri_connectors" {
ca_configmap = "azure-iot-operations-aio-ca-trust-bundle"
}
}
+
+/*
+ * Registry Endpoints
+ */
+
+module "registry_endpoints" {
+ source = "./modules/registry-endpoints"
+ depends_on = [module.iot_ops_instance]
+
+ aio_instance_id = module.iot_ops_instance.aio_instance.id
+ custom_location_id = module.iot_ops_instance.custom_locations.id
+ extension_principal_id = module.iot_ops_instance.extension_identity.principal_id
+ registry_endpoints = var.registry_endpoints
+}
diff --git a/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/README.md b/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/README.md
index 2511759b..98687a45 100644
--- a/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/README.md
+++ b/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/README.md
@@ -31,7 +31,6 @@ Deploys an AIO instance.
| [azapi_resource.data_profiles](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) | resource |
| [azapi_resource.default_aio_keyvault_secret_provider_class](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) | resource |
| [azapi_resource.instance](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) | resource |
-| [azapi_resource.registry_endpoint](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) | resource |
| [azapi_update_resource.aio_instance_secret_sync_update](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/update_resource) | resource |
| [azurerm_arc_kubernetes_cluster_extension.iot_operations](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/arc_kubernetes_cluster_extension) | resource |
| [azurerm_role_assignment.schema_registry](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
@@ -68,12 +67,13 @@ Deploys an AIO instance.
## Outputs
-| Name | Description |
-|----------------------------------|-------------------------------------------------------|
-| aio\_broker\_listener\_anonymous | Anonymous MQTT Broker Listener configuration details. |
-| aio\_dataflow\_profile | Azure IoT Operations dataflow profile details. |
-| aio\_instance | Azure IoT Operations instance details. |
-| aio\_mqtt\_broker | MQTT Broker configuration details. |
-| aio\_namespace | Azure IoT Operations namespace. |
-| custom\_locations | Custom location details. |
+| Name | Description |
+|----------------------------------|--------------------------------------------------------------|
+| aio\_broker\_listener\_anonymous | Anonymous MQTT Broker Listener configuration details. |
+| aio\_dataflow\_profile | Azure IoT Operations dataflow profile details. |
+| aio\_instance | Azure IoT Operations instance details. |
+| aio\_mqtt\_broker | MQTT Broker configuration details. |
+| aio\_namespace | Azure IoT Operations namespace. |
+| custom\_locations | Custom location details. |
+| extension\_identity | AIO Arc extension identity information for role assignments. |
diff --git a/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/main.tf b/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/main.tf
index d051dc2e..b916e74d 100644
--- a/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/main.tf
+++ b/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/main.tf
@@ -171,29 +171,6 @@ resource "azapi_resource" "instance" {
schema_validation_enabled = false # Disable schema validation for azapi_resource for 2025-10-01 until azapi provider supports it
}
-resource "azapi_resource" "registry_endpoint" {
- type = "Microsoft.IoTOperations/instances/registryEndpoints@2025-10-01"
- name = "default"
- parent_id = azapi_resource.instance.id
-
- body = {
- extendedLocation = {
- type = "CustomLocation"
- name = azapi_resource.custom_location.id
- }
- properties = {
- host = "mcr.microsoft.com"
- authentication = {
- method = "Anonymous"
- anonymousSettings = {}
- }
- }
- }
-
- response_export_values = ["name", "id"]
- schema_validation_enabled = false
-}
-
resource "azapi_resource" "broker" {
type = "Microsoft.IoTOperations/instances/brokers@2025-10-01"
name = "default"
diff --git a/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/outputs.tf b/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/outputs.tf
index 3cf70b3a..26e85a9a 100644
--- a/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/outputs.tf
+++ b/src/100-edge/110-iot-ops/terraform/modules/iot-ops-instance/outputs.tf
@@ -35,6 +35,17 @@ output "custom_locations" {
}
}
+/*
+ * Extension Identity Outputs
+ */
+
+output "extension_identity" {
+ description = "AIO Arc extension identity information for role assignments."
+ value = {
+ principal_id = azurerm_arc_kubernetes_cluster_extension.iot_operations.identity[0].principal_id
+ }
+}
+
/*
* MQTT Broker Outputs
*/
diff --git a/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/README.md b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/README.md
new file mode 100644
index 00000000..a9464093
--- /dev/null
+++ b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/README.md
@@ -0,0 +1,46 @@
+
+# Registry Endpoints
+
+Manages container registry endpoints for Azure IoT Operations, including the default
+MCR endpoint and custom registry endpoints with optional ACR role assignments.
+
+## Requirements
+
+| Name | Version |
+|-----------|-----------------|
+| terraform | >= 1.9.8, < 2.0 |
+| azapi | >= 2.0 |
+| azurerm | >= 4.0 |
+
+## Providers
+
+| Name | Version |
+|---------|---------|
+| azapi | >= 2.0 |
+| azurerm | >= 4.0 |
+
+## Resources
+
+| Name | Type |
+|----------------------------------------------------------------------------------------------------------------------------------------------|----------|
+| [azapi_resource.registry_endpoint](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) | resource |
+| [azapi_resource.registry_endpoint_mcr](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) | resource |
+| [azurerm_role_assignment.registry_acr_pull](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
+
+## Inputs
+
+| Name | Description | Type | Default | Required |
+|--------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|:--------:|
+| aio\_instance\_id | Azure IoT Operations instance ID (parent for registry endpoints) | `string` | n/a | yes |
+| custom\_location\_id | Custom location ID for the Azure IoT Operations deployment | `string` | n/a | yes |
+| extension\_principal\_id | Principal ID of the AIO Arc extension's system-assigned identity for ACR role assignments | `string` | n/a | yes |
+| registry\_endpoints | List of custom registry endpoints to configure. | ```list(object({ name = string host = string acr_resource_id = optional(string) should_assign_acr_pull_for_aio = optional(bool, false) authentication = object({ method = string system_assigned_managed_identity_settings = optional(object({ audience = optional(string, "https://management.azure.com/") })) user_assigned_managed_identity_settings = optional(object({ client_id = string tenant_id = string scope = optional(string) })) artifact_pull_secret_settings = optional(object({ secret_ref = string })) }) }))``` | n/a | yes |
+
+## Outputs
+
+| Name | Description |
+|------------------------|-------------------------------------------------|
+| acr\_role\_assignments | Map of ACR role assignment IDs by endpoint name |
+| custom\_endpoints | Map of custom registry endpoints by name |
+| mcr\_endpoint | Default MCR registry endpoint |
+
diff --git a/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/main.tf b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/main.tf
new file mode 100644
index 00000000..03861437
--- /dev/null
+++ b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/main.tf
@@ -0,0 +1,89 @@
+/**
+ * # Registry Endpoints
+ *
+ * Manages container registry endpoints for Azure IoT Operations, including the default
+ * MCR endpoint and custom registry endpoints with optional ACR role assignments.
+ */
+
+// Default MCR endpoint (always created)
+resource "azapi_resource" "registry_endpoint_mcr" {
+ type = "Microsoft.IoTOperations/instances/registryEndpoints@2025-10-01"
+ name = "mcr"
+ parent_id = var.aio_instance_id
+
+ body = {
+ extendedLocation = {
+ type = "CustomLocation"
+ name = var.custom_location_id
+ }
+ properties = {
+ host = "mcr.microsoft.com"
+ authentication = {
+ method = "Anonymous"
+ anonymousSettings = {}
+ }
+ }
+ }
+
+ response_export_values = ["name", "id"]
+ schema_validation_enabled = false
+}
+
+// Custom registry endpoints
+resource "azapi_resource" "registry_endpoint" {
+ for_each = { for endpoint in var.registry_endpoints : endpoint.name => endpoint }
+ type = "Microsoft.IoTOperations/instances/registryEndpoints@2025-10-01"
+ name = each.value.name
+ parent_id = var.aio_instance_id
+
+ body = {
+ extendedLocation = {
+ type = "CustomLocation"
+ name = var.custom_location_id
+ }
+ properties = {
+ host = each.value.host
+ authentication = merge(
+ { method = each.value.authentication.method },
+ each.value.authentication.method == "SystemAssignedManagedIdentity" ? {
+ systemAssignedManagedIdentitySettings = each.value.authentication.system_assigned_managed_identity_settings != null ? {
+ audience = coalesce(each.value.authentication.system_assigned_managed_identity_settings.audience, "https://management.azure.com/")
+ } : {
+ audience = "https://management.azure.com/"
+ }
+ } : {},
+ each.value.authentication.method == "UserAssignedManagedIdentity" ? {
+ userAssignedManagedIdentitySettings = {
+ clientId = each.value.authentication.user_assigned_managed_identity_settings.client_id
+ tenantId = each.value.authentication.user_assigned_managed_identity_settings.tenant_id
+ scope = each.value.authentication.user_assigned_managed_identity_settings.scope
+ }
+ } : {},
+ each.value.authentication.method == "ArtifactPullSecret" ? {
+ artifactPullSecretSettings = {
+ secretRef = each.value.authentication.artifact_pull_secret_settings.secret_ref
+ }
+ } : {},
+ each.value.authentication.method == "Anonymous" ? {
+ anonymousSettings = {}
+ } : {}
+ )
+ }
+ }
+
+ response_export_values = ["name", "id"]
+ schema_validation_enabled = false
+}
+
+// ACR role assignments for SystemAssignedManagedIdentity endpoints with should_assign_acr_pull_for_aio enabled
+resource "azurerm_role_assignment" "registry_acr_pull" {
+ for_each = {
+ for endpoint in var.registry_endpoints : endpoint.name => endpoint
+ if endpoint.authentication.method == "SystemAssignedManagedIdentity" && endpoint.should_assign_acr_pull_for_aio
+ }
+
+ scope = each.value.acr_resource_id
+ role_definition_name = "AcrPull"
+ principal_id = var.extension_principal_id
+ skip_service_principal_aad_check = true
+}
diff --git a/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/outputs.tf b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/outputs.tf
new file mode 100644
index 00000000..05632e1d
--- /dev/null
+++ b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/outputs.tf
@@ -0,0 +1,31 @@
+/*
+ * Registry Endpoint Outputs
+ */
+
+output "mcr_endpoint" {
+ description = "Default MCR registry endpoint"
+ value = {
+ id = azapi_resource.registry_endpoint_mcr.id
+ name = azapi_resource.registry_endpoint_mcr.name
+ host = "mcr.microsoft.com"
+ }
+}
+
+output "custom_endpoints" {
+ description = "Map of custom registry endpoints by name"
+ value = {
+ for name, ep in azapi_resource.registry_endpoint : name => {
+ id = ep.id
+ name = ep.name
+ host = var.registry_endpoints[index(var.registry_endpoints[*].name, name)].host
+ }
+ }
+}
+
+output "acr_role_assignments" {
+ description = "Map of ACR role assignment IDs by endpoint name"
+ value = {
+ for name, ra in azurerm_role_assignment.registry_acr_pull :
+ name => ra.id
+ }
+}
diff --git a/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/variables.tf b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/variables.tf
new file mode 100644
index 00000000..163a78fc
--- /dev/null
+++ b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/variables.tf
@@ -0,0 +1,46 @@
+/*
+ * Required Dependencies
+ */
+
+variable "aio_instance_id" {
+ type = string
+ description = "Azure IoT Operations instance ID (parent for registry endpoints)"
+}
+
+variable "custom_location_id" {
+ type = string
+ description = "Custom location ID for the Azure IoT Operations deployment"
+}
+
+variable "extension_principal_id" {
+ type = string
+ description = "Principal ID of the AIO Arc extension's system-assigned identity for ACR role assignments"
+}
+
+/*
+ * Registry Endpoints Configuration
+ */
+
+variable "registry_endpoints" {
+ type = list(object({
+ name = string
+ host = string
+ acr_resource_id = optional(string)
+ should_assign_acr_pull_for_aio = optional(bool, false)
+ authentication = object({
+ method = string
+ system_assigned_managed_identity_settings = optional(object({
+ audience = optional(string, "https://management.azure.com/")
+ }))
+ user_assigned_managed_identity_settings = optional(object({
+ client_id = string
+ tenant_id = string
+ scope = optional(string)
+ }))
+ artifact_pull_secret_settings = optional(object({
+ secret_ref = string
+ }))
+ })
+ }))
+ description = "List of custom registry endpoints to configure."
+}
diff --git a/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/versions.tf b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/versions.tf
new file mode 100644
index 00000000..3e6fc839
--- /dev/null
+++ b/src/100-edge/110-iot-ops/terraform/modules/registry-endpoints/versions.tf
@@ -0,0 +1,13 @@
+terraform {
+ required_version = ">= 1.9.8, < 2.0"
+ required_providers {
+ azapi = {
+ source = "Azure/azapi"
+ version = ">= 2.0"
+ }
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = ">= 4.0"
+ }
+ }
+}
diff --git a/src/100-edge/110-iot-ops/terraform/outputs.tf b/src/100-edge/110-iot-ops/terraform/outputs.tf
index e7b787aa..4ff7ecf9 100644
--- a/src/100-edge/110-iot-ops/terraform/outputs.tf
+++ b/src/100-edge/110-iot-ops/terraform/outputs.tf
@@ -53,3 +53,22 @@ output "akri_connector_templates" {
length(var.custom_akri_connectors) > 0
]) ? module.akri_connectors[0].connector_templates : null
}
+
+/*
+ * Registry Endpoints Outputs
+ */
+
+output "registry_endpoint_mcr" {
+ description = "The default MCR registry endpoint."
+ value = module.registry_endpoints.mcr_endpoint
+}
+
+output "registry_endpoints_custom" {
+ description = "Map of custom registry endpoints by name with id, name, and host."
+ value = module.registry_endpoints.custom_endpoints
+}
+
+output "registry_endpoints_acr_role_assignments" {
+ description = "Map of ACR role assignments by endpoint name."
+ value = module.registry_endpoints.acr_role_assignments
+}
diff --git a/src/100-edge/110-iot-ops/terraform/tests/iot-ops.tftest.hcl b/src/100-edge/110-iot-ops/terraform/tests/iot-ops.tftest.hcl
index b189f30b..4dc8ffd6 100644
--- a/src/100-edge/110-iot-ops/terraform/tests/iot-ops.tftest.hcl
+++ b/src/100-edge/110-iot-ops/terraform/tests/iot-ops.tftest.hcl
@@ -753,3 +753,231 @@ run "create_with_additional_cluster_extension_ids" {
error_message = "Should contain container storage extension ID"
}
}
+
+# Test default empty registry_endpoints array (MCR endpoint always created)
+run "create_with_default_registry_endpoints" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ }
+
+ assert {
+ condition = length(var.registry_endpoints) == 0
+ error_message = "Default registry_endpoints should be empty array"
+ }
+}
+
+# Test single ACR endpoint with SystemAssignedManagedIdentity
+run "create_with_acr_registry_endpoint" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ registry_endpoints = [
+ {
+ name = "myacr"
+ host = "myregistry.azurecr.io"
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ system_assigned_managed_identity_settings = {
+ audience = "https://management.azure.com/"
+ }
+ }
+ }
+ ]
+ }
+
+ assert {
+ condition = length(var.registry_endpoints) == 1
+ error_message = "Should have one custom registry endpoint"
+ }
+
+ assert {
+ condition = var.registry_endpoints[0].name == "myacr"
+ error_message = "Registry endpoint name should be 'myacr'"
+ }
+
+ assert {
+ condition = var.registry_endpoints[0].authentication.method == "SystemAssignedManagedIdentity"
+ error_message = "Authentication method should be SystemAssignedManagedIdentity"
+ }
+}
+
+# Test multiple endpoints with different authentication methods
+run "create_with_multiple_registry_endpoints" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ registry_endpoints = [
+ {
+ name = "company-acr"
+ host = "contoso.azurecr.io"
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ }
+ },
+ {
+ name = "dockerhub"
+ host = "docker.io"
+ authentication = {
+ method = "ArtifactPullSecret"
+ artifact_pull_secret_settings = {
+ secret_ref = "dockerhub-creds"
+ }
+ }
+ }
+ ]
+ }
+
+ assert {
+ condition = length(var.registry_endpoints) == 2
+ error_message = "Should have two custom registry endpoints"
+ }
+
+ assert {
+ condition = var.registry_endpoints[0].authentication.method == "SystemAssignedManagedIdentity"
+ error_message = "First endpoint should use SystemAssignedManagedIdentity"
+ }
+
+ assert {
+ condition = var.registry_endpoints[1].authentication.method == "ArtifactPullSecret"
+ error_message = "Second endpoint should use ArtifactPullSecret"
+ }
+}
+
+# Test ACR endpoint with acr_resource_id for automatic AcrPull role assignment
+run "create_with_acr_registry_endpoint_and_role_assignment" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ registry_endpoints = [
+ {
+ name = "myacr"
+ host = "myregistry.azurecr.io"
+ acr_resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.ContainerRegistry/registries/myregistry"
+ should_assign_acr_pull_for_aio = true
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ system_assigned_managed_identity_settings = {
+ audience = "https://management.azure.com/"
+ }
+ }
+ }
+ ]
+ }
+
+ assert {
+ condition = length(var.registry_endpoints) == 1
+ error_message = "Should have one registry endpoint"
+ }
+
+ assert {
+ condition = var.registry_endpoints[0].acr_resource_id != null
+ error_message = "Registry endpoint should have acr_resource_id configured"
+ }
+
+ assert {
+ condition = var.registry_endpoints[0].should_assign_acr_pull_for_aio == true
+ error_message = "should_assign_acr_pull_for_aio should be true for AIO extension ACR access"
+ }
+
+ assert {
+ condition = var.registry_endpoints[0].authentication.method == "SystemAssignedManagedIdentity"
+ error_message = "Authentication method should be SystemAssignedManagedIdentity when using acr_resource_id"
+ }
+}
+
+# Test multiple endpoints with mixed acr_resource_id usage
+run "create_with_mixed_acr_registry_endpoints" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ registry_endpoints = [
+ {
+ name = "primary-acr"
+ host = "primary.azurecr.io"
+ acr_resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.ContainerRegistry/registries/primary"
+ should_assign_acr_pull_for_aio = true
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ }
+ },
+ {
+ name = "secondary-acr"
+ host = "secondary.azurecr.io"
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ }
+ },
+ {
+ name = "private-registry"
+ host = "private.example.com"
+ authentication = {
+ method = "ArtifactPullSecret"
+ artifact_pull_secret_settings = {
+ secret_ref = "private-registry-creds"
+ }
+ }
+ }
+ ]
+ }
+
+ assert {
+ condition = length(var.registry_endpoints) == 3
+ error_message = "Should have three registry endpoints"
+ }
+
+ assert {
+ condition = var.registry_endpoints[0].acr_resource_id != null
+ error_message = "First endpoint should have acr_resource_id for automatic role assignment"
+ }
+
+ assert {
+ condition = var.registry_endpoints[0].should_assign_acr_pull_for_aio == true
+ error_message = "First endpoint should have should_assign_acr_pull_for_aio enabled"
+ }
+
+ assert {
+ condition = var.registry_endpoints[1].should_assign_acr_pull_for_aio == false
+ error_message = "Second endpoint should default to should_assign_acr_pull_for_aio = false when omitted"
+ }
+
+ assert {
+ condition = var.registry_endpoints[1].acr_resource_id == null
+ error_message = "Second endpoint should not have acr_resource_id"
+ }
+
+ assert {
+ condition = var.registry_endpoints[2].acr_resource_id == null
+ error_message = "Third endpoint should not have acr_resource_id"
+ }
+}
diff --git a/src/100-edge/110-iot-ops/terraform/tests/variable_validation.tftest.hcl b/src/100-edge/110-iot-ops/terraform/tests/variable_validation.tftest.hcl
index 23f914b4..c355dae9 100644
--- a/src/100-edge/110-iot-ops/terraform/tests/variable_validation.tftest.hcl
+++ b/src/100-edge/110-iot-ops/terraform/tests/variable_validation.tftest.hcl
@@ -1037,3 +1037,238 @@ run "test__custom_akri_connectors__valid_custom_configuration" {
}
# This should pass validation
}
+
+# Test cases for registry_endpoints validation
+
+# Test registry endpoint with reserved name 'mcr'
+run "test__registry_endpoints__error_with_reserved_name_mcr" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "mcr" # Reserved name
+ host = "custom.azurecr.io"
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ }
+ }
+ ]
+ }
+ expect_failures = [var.registry_endpoints]
+}
+
+# Test registry endpoint with reserved name 'default'
+run "test__registry_endpoints__error_with_reserved_name_default" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "default" # Reserved name
+ host = "custom.azurecr.io"
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ }
+ }
+ ]
+ }
+ expect_failures = [var.registry_endpoints]
+}
+
+# Test registry endpoint with invalid name format (uppercase)
+run "test__registry_endpoints__error_with_invalid_name_uppercase" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "MyACR" # Invalid: contains uppercase
+ host = "myacr.azurecr.io"
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ }
+ }
+ ]
+ }
+ expect_failures = [var.registry_endpoints]
+}
+
+# Test registry endpoint with invalid name format (too short)
+run "test__registry_endpoints__error_with_invalid_name_too_short" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "ab" # Invalid: less than 3 characters
+ host = "myacr.azurecr.io"
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ }
+ }
+ ]
+ }
+ expect_failures = [var.registry_endpoints]
+}
+
+# Test registry endpoint with invalid authentication method
+run "test__registry_endpoints__error_with_invalid_auth_method" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "myacr"
+ host = "myacr.azurecr.io"
+ authentication = {
+ method = "InvalidMethod" # Invalid authentication method
+ }
+ }
+ ]
+ }
+ expect_failures = [var.registry_endpoints]
+}
+
+# Test registry endpoint with acr_resource_id on non-SystemAssignedManagedIdentity auth
+run "test__registry_endpoints__error_with_acr_resource_id_wrong_auth" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "myacr"
+ host = "myacr.azurecr.io"
+ acr_resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.ContainerRegistry/registries/myacr"
+ authentication = {
+ method = "ArtifactPullSecret" # acr_resource_id requires SystemAssignedManagedIdentity
+ artifact_pull_secret_settings = {
+ secret_ref = "acr-creds"
+ }
+ }
+ }
+ ]
+ }
+ expect_failures = [var.registry_endpoints]
+}
+
+# Test registry endpoint with UserAssignedManagedIdentity missing required settings
+run "test__registry_endpoints__error_with_uami_missing_settings" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "myacr"
+ host = "myacr.azurecr.io"
+ authentication = {
+ method = "UserAssignedManagedIdentity"
+ # Missing required user_assigned_managed_identity_settings
+ }
+ }
+ ]
+ }
+ expect_failures = [var.registry_endpoints]
+}
+
+# Test registry endpoint with ArtifactPullSecret missing required settings
+run "test__registry_endpoints__error_with_artifact_secret_missing_settings" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "myregistry"
+ host = "myregistry.example.com"
+ authentication = {
+ method = "ArtifactPullSecret"
+ # Missing required artifact_pull_secret_settings
+ }
+ }
+ ]
+ }
+ expect_failures = [var.registry_endpoints]
+}
+
+# Test registry endpoint with valid SystemAssignedManagedIdentity and acr_resource_id
+run "test__registry_endpoints__valid_with_acr_resource_id" {
+ command = plan
+ variables {
+ resource_group = run.setup_tests.aio_resource_group
+ secret_sync_key_vault = run.setup_tests.sse_key_vault
+ secret_sync_identity = run.setup_tests.sse_user_assigned_identity
+ aio_identity = run.setup_tests.aio_user_assigned_identity
+ arc_connected_cluster = run.setup_tests.arc_connected_cluster
+ adr_schema_registry = run.setup_tests.adr_schema_registry
+ adr_namespace = run.setup_tests.adr_namespace
+
+ registry_endpoints = [
+ {
+ name = "myacr"
+ host = "myacr.azurecr.io"
+ acr_resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.ContainerRegistry/registries/myacr"
+ should_assign_acr_pull_for_aio = true
+ authentication = {
+ method = "SystemAssignedManagedIdentity"
+ }
+ }
+ ]
+ }
+ # This should pass validation
+}
diff --git a/src/100-edge/110-iot-ops/terraform/variables.tf b/src/100-edge/110-iot-ops/terraform/variables.tf
index 2ee7297f..7dd1d27f 100644
--- a/src/100-edge/110-iot-ops/terraform/variables.tf
+++ b/src/100-edge/110-iot-ops/terraform/variables.tf
@@ -71,7 +71,90 @@ variable "should_assign_key_vault_roles" {
default = true
}
-/*
- * Required Variables
- */
+variable "registry_endpoints" {
+ type = list(object({
+ name = string
+ host = string
+ acr_resource_id = optional(string)
+ should_assign_acr_pull_for_aio = optional(bool, false)
+ authentication = object({
+ method = string
+ system_assigned_managed_identity_settings = optional(object({
+ audience = optional(string, "https://management.azure.com/")
+ }))
+ user_assigned_managed_identity_settings = optional(object({
+ client_id = string
+ tenant_id = string
+ scope = optional(string)
+ }))
+ artifact_pull_secret_settings = optional(object({
+ secret_ref = string
+ }))
+ })
+ }))
+
+ default = []
+ description = <<-EOT
+ List of additional container registry endpoints for pulling custom artifacts (WASM modules, graph definitions, connector templates).
+ MCR (mcr.microsoft.com) is always added automatically with anonymous authentication.
+
+ The `acr_resource_id` field enables automatic AcrPull role assignment for ACR endpoints
+ using SystemAssignedManagedIdentity authentication. When `should_assign_acr_pull_for_aio` is true
+ and `acr_resource_id` is provided, the AIO extension's identity will be granted AcrPull access to the specified ACR.
+ EOT
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ can(regex("^[a-z0-9][a-z0-9-]*[a-z0-9]$", ep.name)) && length(ep.name) >= 3 && length(ep.name) <= 63
+ ])
+ error_message = "Registry endpoint name must be 3-63 characters, contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ contains(["SystemAssignedManagedIdentity", "UserAssignedManagedIdentity", "ArtifactPullSecret", "Anonymous"], ep.authentication.method)
+ ])
+ error_message = "Authentication method must be one of: SystemAssignedManagedIdentity, UserAssignedManagedIdentity, ArtifactPullSecret, Anonymous"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.authentication.method != "UserAssignedManagedIdentity" || (
+ ep.authentication.user_assigned_managed_identity_settings != null &&
+ ep.authentication.user_assigned_managed_identity_settings.client_id != null &&
+ ep.authentication.user_assigned_managed_identity_settings.tenant_id != null
+ )
+ ])
+ error_message = "UserAssignedManagedIdentity authentication requires client_id and tenant_id in user_assigned_managed_identity_settings"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.authentication.method != "ArtifactPullSecret" || (
+ ep.authentication.artifact_pull_secret_settings != null &&
+ ep.authentication.artifact_pull_secret_settings.secret_ref != null
+ )
+ ])
+ error_message = "ArtifactPullSecret authentication requires secret_ref in artifact_pull_secret_settings"
+ }
+
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.name != "mcr" && ep.name != "default"
+ ])
+ error_message = "Registry endpoint names 'mcr' and 'default' are reserved"
+ }
+ validation {
+ condition = alltrue([
+ for ep in var.registry_endpoints :
+ ep.acr_resource_id == null || ep.authentication.method == "SystemAssignedManagedIdentity"
+ ])
+ error_message = "acr_resource_id can only be specified with SystemAssignedManagedIdentity authentication method"
+ }
+}
diff --git a/src/100-edge/111-assets/bicep/README.md b/src/100-edge/111-assets/bicep/README.md
index bdb21fa2..2a3e6be0 100644
--- a/src/100-edge/111-assets/bicep/README.md
+++ b/src/100-edge/111-assets/bicep/README.md
@@ -7,18 +7,18 @@ Deploys Kubernetes asset definitions to a connected cluster using the namespaced
## Parameters
-| Name | Description | Type | Default | Required |
-|:-----------------------------------|:----------------------------------------------------------------------------------------------|:-----------------------------------|:--------|:---------|
-| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
-| customLocationId | The ID (resource ID) of the custom location to retrieve. | `string` | n/a | yes |
-| adrNamespaceName | Azure Device Registry namespace name to use with Azure IoT Operations. | `string` | n/a | yes |
-| namespacedDevices | List of namespaced devices to create. | `array` | [] | no |
-| assetEndpointProfiles | List of asset endpoint profiles to create. | `array` | [] | no |
-| legacyAssets | List of legacy assets to create. | `array` | [] | no |
-| namespacedAssets | List of namespaced assets to create. | `array` | [] | no |
-| shouldCreateDefaultAsset | Whether to create a default legacy asset and endpoint profile. | `bool` | `false` | no |
-| shouldCreateDefaultNamespacedAsset | Whether to create a default namespaced asset and device. | `bool` | `false` | no |
-| k8sBridgePrincipalId | The principal ID of the K8 Bridge for Azure IoT Operations. Required for OPC asset discovery. | `string` | n/a | no |
+| Name | Description | Type | Default | Required |
+|:-----------------------------------|:----------------------------------------------------------------------------------------------|:---------------------------------------------------|:--------|:---------|
+| common | The common component configuration. | `[_2.Common](#user-defined-types)` | n/a | yes |
+| customLocationId | The ID (resource ID) of the custom location to retrieve. | `string` | n/a | yes |
+| adrNamespaceName | Azure Device Registry namespace name to use with Azure IoT Operations. | `string` | n/a | yes |
+| namespacedDevices | List of namespaced devices to create. | `[_1.NamespacedDevice](#user-defined-types)[]` | [] | no |
+| assetEndpointProfiles | List of asset endpoint profiles to create. | `[_1.AssetEndpointProfile](#user-defined-types)[]` | [] | no |
+| legacyAssets | List of legacy assets to create. | `[_1.LegacyAsset](#user-defined-types)[]` | [] | no |
+| namespacedAssets | List of namespaced assets to create. | `[_1.NamespacedAsset](#user-defined-types)[]` | [] | no |
+| shouldCreateDefaultAsset | Whether to create a default legacy asset and endpoint profile. | `bool` | `false` | no |
+| shouldCreateDefaultNamespacedAsset | Whether to create a default namespaced asset and device. | `bool` | `false` | no |
+| k8sBridgePrincipalId | The principal ID of the K8 Bridge for Azure IoT Operations. Required for OPC asset discovery. | `string` | n/a | no |
## Resources
diff --git a/src/900-tools-utilities/904-test-utilities/README.md b/src/900-tools-utilities/904-test-utilities/README.md
new file mode 100644
index 00000000..44283f44
--- /dev/null
+++ b/src/900-tools-utilities/904-test-utilities/README.md
@@ -0,0 +1,478 @@
+# Blueprint Test Utilities
+
+Shared testing utilities for all edge-ai blueprints providing reusable, framework-agnostic functions for validating and deploying infrastructure as code.
+
+## Package: `testutil`
+
+**Import path:** `github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities`
+
+**Purpose:** Standardized testing primitives for blueprint validation across both Terraform and Bicep implementations.
+
+## Key Features
+
+### 1. Contract Testing
+
+Static validation of IaC output declarations without deployment:
+
+- โก **Fast** - Completes in seconds
+- ๐ฐ **Zero Cost** - No Azure resources created
+- ๐ **Type Safe** - Enforces contract between test code and IaC
+- ๐ **Early Detection** - Catches drift before deployment
+
+### 2. Deployment Functions
+
+Unified deployment and output retrieval:
+
+- ๐ **Framework Agnostic** - Consistent interface for Terraform and Bicep
+- ๐งน **Automatic Cleanup** - Optional resource teardown after tests
+- ๐ **Output Normalization** - Converts outputs to common format
+- ๐ง **Configuration Driven** - Environment variable support
+
+---
+
+## Contract Testing
+
+Contract testing validates that all outputs required by test code are properly declared in the IaC configuration, preventing runtime failures during expensive deployments.
+
+### How It Works
+
+1. **Extract Declarations:** Parse IaC files to find declared outputs
+2. **Compare Contracts:** Match declared outputs against required outputs
+3. **Report Drift:** Fail fast if any required output is missing
+
+### Benefits
+
+- โก **Fast** - Runs in seconds without Azure authentication
+- ๐ฐ **Zero Cost** - No Azure resources created
+- ๐ **Type Safety** - Enforces contract between tests and IaC
+- ๐ **Early Detection** - Catches missing outputs before deployment
+- ๐ **CI/CD Ready** - Perfect for pre-deployment validation gates
+
+### Functions
+
+#### `GetTerraformDeclaredOutputs(t, terraformDir) []string`
+
+Extracts declared output names from Terraform configuration using `terraform-docs`.
+
+**Parameters:**
+
+- `t *testing.T` - Test context for error reporting
+- `terraformDir string` - Absolute or relative path to directory containing `.tf` files
+
+**Returns:** List of output names declared in Terraform configuration
+
+**Requirements:** `terraform-docs` installed and in PATH, valid `.tf` files in directory
+
+**See:** [contract_terraform_test.go](../../../blueprints/full-single-node-cluster/tests/contract_terraform_test.go) for usage example
+
+#### `GetBicepDeclaredOutputs(t, bicepDir) []string`
+
+Extracts declared output names from Bicep configuration using `az bicep build`.
+
+**Parameters:**
+
+- `t *testing.T` - Test context for error reporting
+- `bicepDir string` - Absolute or relative path to directory containing `main.bicep`
+
+**Returns:** List of output names declared in Bicep configuration
+
+**Requirements:** Azure CLI with Bicep installed, `main.bicep` file in directory
+
+**See:** [contract_bicep_test.go](../../../blueprints/full-single-node-cluster/tests/contract_bicep_test.go) for usage example
+
+#### `ValidateOutputContract(t, declaredOutputs, requiredOutputs, framework)`
+
+Validates that all required outputs are declared in IaC configuration. Fails test with detailed error reporting if any outputs are missing.
+
+**Parameters:**
+
+- `t *testing.T` - Test context for error reporting
+- `declaredOutputs []string` - Outputs declared in IaC (from `GetTerraform/BicepDeclaredOutputs`)
+- `requiredOutputs []string` - Outputs required by tests (from `BlueprintOutputs.GetRequiredOutputKeys()`)
+- `framework string` - Framework name for error messages
+
+**Behavior:** Passes if all required outputs are declared, fails with detailed report if any are missing
+
+**See:** [contract_terraform_test.go](../../../blueprints/full-single-node-cluster/tests/contract_terraform_test.go) and [validation.go](../../../blueprints/full-single-node-cluster/tests/validation.go) for implementation
+
+#### `ValidateTerraformContract(t, terraformDir, requiredOutputs)`
+
+Convenience function combining `GetTerraformDeclaredOutputs` and `ValidateOutputContract`.
+
+**Parameters:** Test context, Terraform directory path, required output names list
+
+**Equivalent to:** Calling `GetTerraformDeclaredOutputs` followed by `ValidateOutputContract`
+
+**See:** [contract_terraform_test.go](../../../blueprints/full-single-node-cluster/tests/contract_terraform_test.go) for usage
+
+#### `ValidateBicepContract(t, bicepDir, requiredOutputs)`
+
+Convenience function combining `GetBicepDeclaredOutputs` and `ValidateOutputContract`.
+Automatically converts required output names from snake_case to camelCase for Bicep comparison.
+
+**Parameters:**
+
+- `t *testing.T` - Test context for error reporting
+- `bicepDir string` - Absolute or relative path to directory containing `main.bicep`
+- `requiredOutputs []string` - Output names in snake_case (automatically converted to camelCase)
+
+**Naming Convention Conversion:**
+
+- `deployment_summary` โ `deploymentSummary`
+- `azure_iot_operations` โ `azureIotOperations`
+- `acr_network_posture` โ `acrNetworkPosture`
+
+**Behavior:** Converts required outputs to camelCase, then validates against Bicep declarations
+
+**See:** [contract_bicep_test.go](../../../blueprints/full-single-node-cluster/tests/contract_bicep_test.go) for usage
+
+#### `SnakeToCamelCase(s string) string`
+
+Converts a snake_case string to camelCase. Used internally by `ValidateBicepContract` to transform output names from Terraform convention to Bicep convention.
+
+**Parameters:** `s string` - Input string in snake_case format
+
+**Returns:** String converted to camelCase
+
+**Examples:**
+
+- `"deployment_summary"` โ `"deploymentSummary"`
+- `"azure_iot_operations"` โ `"azureIotOperations"`
+- `"nat_gateway_public_ips"` โ `"natGatewayPublicIps"`
+
+#### `CamelToSnakeCase(s string) string`
+
+Converts a camelCase string to snake_case. Used internally by `ParseBicepOutputsFromMap` to transform map keys from Bicep convention to Terraform convention.
+
+**Parameters:** `s string` - Input string in camelCase format
+
+**Returns:** String converted to snake_case
+
+**Examples:**
+
+- `"resourceGroup"` โ `"resource_group"`
+- `"keyVaultName"` โ `"key_vault_name"`
+- `"aioIdentity"` โ `"aio_identity"`
+
+#### `GetOutputKeysFromStruct(outputStruct any) []string`
+
+Extracts all output names defined in a struct using reflection. Reads `output` tags from struct fields.
+
+**Parameters:** `outputStruct` - Struct instance (value or pointer) with `output` tags
+
+**Returns:** Slice of output names extracted from struct tags
+
+**Usage:** Called by `BlueprintOutputs.GetRequiredOutputKeys()` to get list of required outputs for contract validation.
+
+---
+
+## Output Parsing Functions
+
+These functions convert raw deployment outputs to strongly-typed structs for use in validation code.
+
+### `ParseTerraformOutputsFromMap(t, raw, outputStruct)`
+
+Converts raw Terraform deployment outputs to a strongly-typed output struct using reflection.
+
+**Parameters:**
+
+- `t *testing.T` - Test context for error reporting
+- `raw map[string]any` - Raw output map from `terraform.OutputAll()`
+- `outputStruct any` - Pointer to struct with `output` tags
+
+**Behavior:** Maps outputs by matching `output` struct tag to raw map keys. Fails test if any required output is missing.
+
+### `ParseBicepOutputsFromMap(t, raw, outputStruct)`
+
+Converts raw Bicep deployment outputs to a strongly-typed output struct.
+
+**Parameters:**
+
+- `t *testing.T` - Test context for error reporting
+- `raw map[string]any` - Raw output map from Bicep deployment
+- `outputStruct any` - Pointer to struct with `output` tags (snake_case)
+
+**Key Differences from Terraform:**
+
+1. **Output name conversion:** Converts snake_case struct tags to camelCase for Bicep lookup
+ - Struct tag `output:"deployment_summary"` โ looks up `deploymentSummary` in raw outputs
+2. **Recursive map key transformation:** Converts all map keys from camelCase to snake_case
+ - Bicep returns `{"resourceGroup": "my-rg"}` โ becomes `{"resource_group": "my-rg"}`
+ - This allows validation code to use consistent snake_case keys regardless of framework
+
+**Why This Matters:** Validation code can use the same key names (snake_case) for both Terraform and Bicep outputs:
+
+```go
+// Works for both frameworks:
+outputs.DeploymentSummary["resource_group"]
+outputs.SecurityIdentity["key_vault_name"]
+```
+
+---
+
+## Deployment Functions
+
+These functions handle deployment and output retrieval for both Terraform and Bicep.
+
+### Terraform Deployment
+
+#### `DeployTerraform(t, terraformDir, vars, shouldCleanup) map[string]any`
+
+Executes complete Terraform deployment workflow: init, apply, and optional destroy.
+
+**Parameters:**
+
+- `t *testing.T` - Test context
+- `terraformDir string` - Path to directory containing `.tf` files
+- `vars map[string]any` - Terraform input variables
+- `shouldCleanup bool` - Register automatic cleanup via `t.Cleanup()`
+
+**Returns:** All Terraform outputs as map
+
+**Behavior:** Runs init/apply, retrieves outputs, optionally registers destroy cleanup handler
+
+**Requirements:** Terraform CLI in PATH, `ARM_SUBSCRIPTION_ID` set, Azure authenticated
+
+**Note:** Read `shouldCleanup` from `CLEANUP_RESOURCES` environment variable to avoid accidental deletion
+
+**See:** [deploy_terraform_test.go](../../../blueprints/full-single-node-cluster/tests/deploy_terraform_test.go) for complete usage pattern
+
+### Bicep Deployment
+
+#### DeployBicep
+
+**Signature:**
+
+```go
+DeployBicep(
+ t *testing.T,
+ bicepDir string,
+ subscriptionID string,
+ deploymentName string,
+ resourceGroupName string,
+ params map[string]any,
+ shouldCleanup bool,
+) map[string]any
+```
+
+Executes complete Bicep deployment workflow at subscription scope with optional resource group cleanup.
+
+**Parameters:**
+
+- `t *testing.T` - Test context for logging and error reporting
+- `bicepDir string` - Path to directory containing `main.bicep`
+- `subscriptionID string` - Azure subscription ID (get via `az account show --query id -o tsv`)
+- `deploymentName string` - Unique deployment name (visible in Azure Portal deployments)
+- `resourceGroupName string` - Resource group name to delete during cleanup
+- `params map[string]any` - Bicep parameters as nested map structure
+- `shouldCleanup bool` - If `true`, registers resource group deletion via `t.Cleanup()`
+
+**Returns:** `map[string]any` - All Bicep outputs converted to flat key-value pairs (Terraform-compatible format)
+
+**Behavior:** Writes temp parameters file, runs subscription-level deployment, retrieves and flattens outputs, optionally registers resource group deletion cleanup
+
+**Requirements:** Azure CLI authenticated, Bicep installed, subscription-level deployment permissions
+
+**Important Notes:**
+
+- Creates temporary `test-parameters.json` (auto-cleaned)
+- Cleanup deletes **entire resource group** and all resources
+- Converts Bicep output format to flat key-value pairs for compatibility
+
+**See:** [deploy_bicep_test.go](../../../blueprints/full-single-node-cluster/tests/deploy_bicep_test.go) for complete usage pattern
+
+#### `GetBicepOutputs(t, deploymentName) map[string]any`
+
+Retrieves outputs from an existing Bicep deployment without redeploying. Useful for validating existing infrastructure or skipping deployment in tests.
+
+**Parameters:**
+
+- `t *testing.T` - Test context for error reporting
+- `deploymentName string` - Name of existing subscription-level deployment
+
+**Returns:** `map[string]any` - All deployment outputs in flattened format
+
+#### Skip Deployment Pattern
+
+Use `SKIP_BICEP_DEPLOYMENT=true` and `BICEP_DEPLOYMENT_NAME=` environment variables to query existing deployment instead of creating new one.
+
+**Requirements:** Azure CLI authenticated, deployment exists at subscription level, exact deployment name
+
+**See:** [deploy_bicep_test.go](../../../blueprints/full-single-node-cluster/tests/deploy_bicep_test.go) for conditional deployment pattern
+
+---
+
+## Resource Cleanup Functions
+
+These functions handle resource teardown after testing. **Typically called automatically** via `t.Cleanup()` when `shouldCleanup=true` in deployment functions.
+
+### `DestroyTerraform(t, terraformOptions)`
+
+Executes `terraform destroy` to remove all resources in Terraform state.
+
+**Behavior:** Runs destroy with auto-approve, uses same variables as deployment, retries on transient failures
+
+**Typically called automatically** via `t.Cleanup()` when `DeployTerraform` has `shouldCleanup=true`
+
+### `DestroyBicep(t, subscriptionID, resourceGroupName)`
+
+Executes `az group delete` to remove resource group and all contained resources.
+
+**Behavior:** Deletes entire resource group asynchronously (no wait), more aggressive than Terraform destroy
+
+**Typically called automatically** via `t.Cleanup()` when `DeployBicep` has `shouldCleanup=true`
+
+**Important Notes:**
+
+- โ ๏ธ Bicep cleanup deletes entire resource group (all resources)
+- โ ๏ธ No confirmation prompt when called
+- ๐ Automatic when using Deploy functions with `shouldCleanup=true`
+- ๐งน Manual cleanup required if test fails before cleanup registration
+
+---
+
+## Usage in Blueprint Tests
+
+The [full-single-node-cluster tests](../../../blueprints/full-single-node-cluster/tests/) directory provides a complete reference implementation.
+
+### Step 1: Define Blueprint Outputs
+
+Create `outputs.go` with struct using `tf` and `bicep` tags to map framework-specific output names.
+
+**See:** [outputs.go](../../../blueprints/full-single-node-cluster/tests/outputs.go) for struct definition pattern
+
+### Step 2: Create Contract Tests
+
+Create test files calling `ValidateTerraformContract` and `ValidateBicepContract` with required output keys.
+
+**See:** [contract_terraform_test.go](../../../blueprints/full-single-node-cluster/tests/contract_terraform_test.go) and [contract_bicep_test.go](../../../blueprints/full-single-node-cluster/tests/contract_bicep_test.go)
+
+### Step 3: Create Deployment Tests
+
+Create test files using `DeployTerraform` and `DeployBicep` with variables/parameters, then run validations.
+
+**See:** [deploy_terraform_test.go](../../../blueprints/full-single-node-cluster/tests/deploy_terraform_test.go) and [deploy_bicep_test.go](../../../blueprints/full-single-node-cluster/tests/deploy_bicep_test.go)
+
+---
+
+## Dependencies
+
+### Required Tools
+
+For contract testing:
+
+- `terraform-docs` - Install: `brew install terraform-docs`
+- `az bicep` - Install: `az bicep install`
+
+For deployment testing:
+
+- Terraform (for Terraform deployments)
+- Azure CLI authenticated (`az login`)
+
+### Go Modules
+
+Add to your blueprint's `go.mod`:
+
+```go
+require (
+ github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities v0.0.0-00010101000000-000000000000
+ github.com/gruntwork-io/terratest v0.54.0
+ github.com/stretchr/testify v1.11.1
+)
+```
+
+---
+
+## Best Practices
+
+### 1. Run Contract Tests First
+
+Run fast contract tests (`go test -run Contract`) before expensive deployment tests to catch configuration errors early.
+
+### 2. Control Resource Cleanup
+
+Tests preserve resources by default for inspection. Set `CLEANUP_RESOURCES=true` to enable automatic cleanup.
+
+### 3. Use Struct Tags for Output Mapping
+
+Define framework-specific output names using `tf` and `bicep` struct tags mapping to same field.
+
+**See:** [outputs.go](../../../blueprints/full-single-node-cluster/tests/outputs.go) for tag pattern
+
+### 4. Naming Convention Handling
+
+Use snake_case for output struct tags (matching Terraform convention). The test utilities automatically handle conversion for Bicep:
+
+- **Output names:** `ValidateBicepContract` converts snake_case tags to camelCase for lookup
+- **Map keys:** `ParseBicepOutputsFromMap` recursively converts camelCase keys to snake_case
+
+This allows validation code to use consistent snake_case keys regardless of framework:
+
+```go
+// Same validation code works for both Terraform and Bicep:
+outputs.DeploymentSummary["resource_group"] // Terraform: resource_group, Bicep: resourceGroup โ resource_group
+outputs.SecurityIdentity["key_vault_name"] // Terraform: key_vault_name, Bicep: keyVaultName โ key_vault_name
+```
+
+**Framework conventions:**
+
+- **Terraform outputs:** snake_case (e.g., `deployment_summary`)
+- **Bicep outputs:** camelCase (e.g., `deploymentSummary`)
+- **Struct tags:** Use snake_case, automatic conversion handles Bicep
+
+**See:** [outputs.tf](../../../blueprints/full-single-node-cluster/terraform/outputs.tf) and [main.bicep](../../../blueprints/full-single-node-cluster/bicep/main.bicep)
+
+### 5. Implement Reusable Validation Functions
+
+Create shared validation functions for common infrastructure testing patterns.
+
+**See:** [validation.go](../../../blueprints/full-single-node-cluster/tests/validation.go) and [setup.go](../../../blueprints/full-single-node-cluster/tests/setup.go)
+
+---
+
+## Troubleshooting
+
+### Contract Test Failures
+
+**Missing outputs error:** Add outputs to IaC configuration or remove from `BlueprintOutputs` struct to match
+
+### Tool Installation
+
+**terraform-docs not found:** Install via package manager (e.g., `brew install terraform-docs`)
+
+**az bicep not found:** Run `az bicep install`
+
+### Bicep Output Format
+
+`GetBicepOutputs` automatically converts Azure CLI's nested output format to flat key-value pairs compatible with Terraform output format.
+
+---
+
+## Complete Working Example
+
+The [full-single-node-cluster tests](../../../blueprints/full-single-node-cluster/tests/) directory provides a complete, production-ready reference implementation.
+
+### File Structure
+
+```text
+blueprints/full-single-node-cluster/tests/
+โโโ outputs.go # BlueprintOutputs struct with tf/bicep tags
+โโโ contract_terraform_test.go # Terraform contract validation
+โโโ contract_bicep_test.go # Bicep contract validation
+โโโ deploy_terraform_test.go # Terraform end-to-end deployment test
+โโโ deploy_bicep_test.go # Bicep end-to-end deployment test
+โโโ validation.go # Reusable validation functions
+โโโ setup.go # Post-deployment setup (Arc proxy, RBAC)
+โโโ run-contract-tests.sh # Contract test helper script
+โโโ run-deployment-tests.sh # Deployment test helper script
+```
+
+**Use these files as templates** when creating tests for your own blueprints.
+
+---
+
+## Additional Resources
+
+- [Blueprint Test README](../../../blueprints/full-single-node-cluster/tests/README.md) - Complete testing guide
+- [Terratest Documentation](https://terratest.gruntwork.io/) - Underlying test framework
+- [Azure CLI Bicep Reference](https://learn.microsoft.com/cli/azure/bicep) - Bicep CLI commands
diff --git a/src/900-tools-utilities/904-test-utilities/contract.go b/src/900-tools-utilities/904-test-utilities/contract.go
new file mode 100644
index 00000000..2743251b
--- /dev/null
+++ b/src/900-tools-utilities/904-test-utilities/contract.go
@@ -0,0 +1,405 @@
+// Package testutil provides reusable testing utilities for blueprint deployment and validation.
+// Functions in this file handle output contract validation between IaC and test code.
+package testutil
+
+import (
+ "encoding/json"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// GetTerraformDeclaredOutputs extracts all declared output names from Terraform configuration.
+// Uses 'terraform-docs json' to parse outputs.tf without requiring terraform init or plan.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - terraformDir: Absolute or relative path to directory containing Terraform files
+//
+// Returns:
+// - []string: Output names in the order they appear in outputs.tf
+//
+// Requirements:
+// - terraform-docs must be installed and available on PATH
+// - terraformDir must contain valid Terraform configuration files
+//
+// This is a static analysis tool - no deployment or Azure authentication required.
+func GetTerraformDeclaredOutputs(t *testing.T, terraformDir string) []string {
+ // Run terraform-docs to get output schema
+ cmd := exec.Command("terraform-docs", "json", terraformDir)
+ output, err := cmd.CombinedOutput()
+ require.NoError(t, err, "Failed to run terraform-docs: %s\nOutput: %s", err, string(output))
+
+ // Parse JSON output - terraform-docs returns outputs as an array of objects with "name" fields
+ var result struct {
+ Outputs []struct {
+ Name string `json:"name"`
+ } `json:"outputs"`
+ }
+ err = json.Unmarshal(output, &result)
+ require.NoError(t, err, "Failed to parse terraform-docs JSON output: %s", string(output))
+
+ // Extract output names
+ keys := make([]string, 0, len(result.Outputs))
+ for _, output := range result.Outputs {
+ keys = append(keys, output.Name)
+ }
+
+ return keys
+}
+
+// GetBicepDeclaredOutputs extracts all declared output names from Bicep configuration.
+// Uses 'az bicep build' to compile Bicep to ARM template JSON, then parses outputs.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - bicepDir: Absolute or relative path to directory containing main.bicep
+//
+// Returns:
+// - []string: Output names extracted from compiled ARM template
+//
+// Requirements:
+// - Azure CLI with Bicep extension must be installed (az bicep install)
+// - bicepDir must contain valid main.bicep file
+//
+// Note: Output order may differ from declaration order due to ARM template compilation.
+// This is a static analysis tool - no deployment or Azure authentication required.
+func GetBicepDeclaredOutputs(t *testing.T, bicepDir string) []string {
+ // Build Bicep to ARM template JSON
+ // Use separate stdout/stderr to avoid warnings polluting JSON output
+ bicepFile := filepath.Join(bicepDir, "main.bicep")
+ cmd := exec.Command("az", "bicep", "build", "--file", bicepFile, "--stdout")
+ stdout, err := cmd.Output()
+ require.NoError(t, err, "Failed to compile Bicep: %v", err)
+
+ // Parse ARM template JSON from stdout only
+ var armTemplate struct {
+ Outputs map[string]interface{} `json:"outputs"`
+ }
+ err = json.Unmarshal(stdout, &armTemplate)
+ require.NoError(t, err, "Failed to parse ARM template JSON: %s", string(stdout))
+
+ // Extract output keys
+ keys := make([]string, 0, len(armTemplate.Outputs))
+ for k := range armTemplate.Outputs {
+ keys = append(keys, k)
+ }
+
+ return keys
+}
+
+// ValidateTerraformContract is a convenience function for validating Terraform output contracts.
+// Combines GetTerraformDeclaredOutputs and ValidateOutputContract into a single call.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - terraformDir: Absolute or relative path to directory containing Terraform files
+// - requiredOutputs: List of output names required by test code (from BlueprintOutputs)
+//
+// Fails the test if any required outputs are missing from Terraform configuration.
+// This is the recommended way for blueprints to implement Terraform contract tests.
+func ValidateTerraformContract(t *testing.T, terraformDir string, requiredOutputs []string) {
+ declaredOutputs := GetTerraformDeclaredOutputs(t, terraformDir)
+ ValidateOutputContract(t, declaredOutputs, requiredOutputs, "terraform")
+}
+
+// ValidateBicepContract is a convenience function for validating Bicep output contracts.
+// Combines GetBicepDeclaredOutputs and ValidateOutputContract into a single call.
+// Automatically converts required output names from snake_case to camelCase since
+// Bicep uses camelCase for output names while test structs use snake_case tags.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - bicepDir: Absolute or relative path to directory containing main.bicep
+// - requiredOutputs: List of output names required by test code (from BlueprintOutputs)
+// These should be in snake_case format and will be converted to camelCase
+//
+// Fails the test if any required outputs are missing from Bicep configuration.
+// This is the recommended way for blueprints to implement Bicep contract tests.
+func ValidateBicepContract(t *testing.T, bicepDir string, requiredOutputs []string) {
+ declaredOutputs := GetBicepDeclaredOutputs(t, bicepDir)
+
+ // Convert required outputs from snake_case to camelCase for Bicep comparison
+ camelCaseRequiredOutputs := make([]string, len(requiredOutputs))
+ for i, output := range requiredOutputs {
+ camelCaseRequiredOutputs[i] = SnakeToCamelCase(output)
+ }
+
+ ValidateOutputContract(t, declaredOutputs, camelCaseRequiredOutputs, "bicep")
+}
+
+// ValidateOutputContract verifies all required outputs are declared in IaC configuration.
+// Compares declared outputs (from IaC) against required outputs (from test struct).
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - declaredOutputs: Output names extracted from IaC (Terraform or Bicep)
+// - requiredOutputs: Output names required by test code
+// - framework: Framework name for error messages ("terraform" or "bicep")
+//
+// Behavior:
+// - Logs all declared and required outputs for transparency
+// - Identifies missing outputs by comparing slices
+// - Reports detailed error with missing output names if validation fails
+// - Fails test immediately if any required outputs are missing
+//
+// This validation ensures compile-time contract between IaC and test code.
+func ValidateOutputContract(t *testing.T, declaredOutputs, requiredOutputs []string, framework string) {
+ t.Logf("Declared outputs in %s: %v", framework, declaredOutputs)
+ t.Logf("Required outputs by tests: %v", requiredOutputs)
+
+ // Track missing outputs
+ missing := []string{}
+ declaredMap := make(map[string]bool)
+ for _, declared := range declaredOutputs {
+ declaredMap[declared] = true
+ }
+
+ for _, required := range requiredOutputs {
+ if !declaredMap[required] {
+ missing = append(missing, required)
+ }
+ }
+
+ // Report results
+ if len(missing) > 0 {
+ t.Errorf("โ Missing %d required outputs in %s: %v", len(missing), framework, missing)
+ if framework == "terraform" {
+ t.Errorf(" Ensure these outputs are declared in terraform/outputs.tf")
+ } else if framework == "bicep" {
+ t.Errorf(" Ensure these outputs are declared in bicep/main.bicep")
+ }
+ } else {
+ t.Logf("โ
All %d required outputs are declared in %s", len(requiredOutputs), framework)
+ }
+
+ require.Empty(t, missing, "Output contract validation failed")
+}
+
+// ParseTerraformOutputsFromMap converts raw Terraform deployment outputs to a strongly-typed output struct using reflection.
+// Uses struct field tags with `output` key to map output names to struct fields.
+// Fails test immediately if any required output (with `output` tag) is missing from raw outputs.
+// Note: This validates presence of output keys, not their values. Outputs can be nil/null.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - raw: Raw output map from Terraform deployment (e.g., terraform.OutputAll)
+// - outputStruct: Pointer to struct with `output` tags defining expected outputs
+//
+// Example usage:
+//
+// type MyOutputs struct {
+// ResourceGroup string `output:"resource_group_name"`
+// Location string `output:"location"`
+// }
+// outputs := &MyOutputs{}
+// ParseTerraformOutputsFromMap(t, rawOutputs, outputs)
+func ParseTerraformOutputsFromMap(t *testing.T, raw map[string]any, outputStruct any) {
+ parseOutputsFromMap(t, raw, outputStruct, "terraform", func(key string) string { return key }, false)
+}
+
+// ParseBicepOutputsFromMap converts raw Bicep deployment outputs to a strongly-typed output struct.
+// Similar to ParseTerraformOutputsFromMap but converts snake_case output tags to camelCase for Bicep lookup.
+// Bicep outputs use camelCase while struct tags use snake_case for consistency with Terraform.
+// Also recursively transforms map keys from camelCase to snake_case so validation code works unchanged.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - raw: Raw output map from Bicep deployment
+// - outputStruct: Pointer to struct with `output` tags defining expected outputs (in snake_case)
+//
+// Example usage:
+//
+// type MyOutputs struct {
+// ResourceGroup string `output:"resource_group_name"` // Maps to "resourceGroupName" in Bicep
+// Location string `output:"location"`
+// }
+// outputs := &MyOutputs{}
+// ParseBicepOutputsFromMap(t, rawOutputs, outputs)
+func ParseBicepOutputsFromMap(t *testing.T, raw map[string]any, outputStruct any) {
+ parseOutputsFromMap(t, raw, outputStruct, "bicep", SnakeToCamelCase, true)
+}
+
+// parseOutputsFromMap is the internal implementation for parsing deployment outputs.
+// It uses a keyTransform function to convert output tag names before lookup.
+// If transformMapKeys is true, it also recursively transforms map keys from camelCase to snake_case.
+func parseOutputsFromMap(t *testing.T, raw map[string]any, outputStruct any, framework string, keyTransform func(string) string, transformMapKeys bool) {
+ v := reflect.ValueOf(outputStruct)
+ if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
+ require.Fail(t, "outputStruct must be a pointer to a struct")
+ return
+ }
+
+ v = v.Elem()
+ typ := v.Type()
+
+ var missingOutputs []string
+ for i := 0; i < v.NumField(); i++ {
+ field := typ.Field(i)
+ outputKey := field.Tag.Get("output")
+
+ if outputKey == "" {
+ continue
+ }
+
+ lookupKey := keyTransform(outputKey)
+ value, exists := raw[lookupKey]
+ if !exists {
+ missingOutputs = append(missingOutputs, lookupKey)
+ continue
+ }
+
+ // Transform map keys from camelCase to snake_case for Bicep outputs
+ if transformMapKeys {
+ value = transformMapKeysRecursive(value)
+ }
+
+ fieldValue := v.Field(i)
+ valueType := reflect.TypeOf(value)
+ if value == nil || !fieldValue.CanSet() || !valueType.AssignableTo(fieldValue.Type()) {
+ require.Fail(t, "Type mismatch for output",
+ "Output key '%s' has a type mismatch: expected %s but got %v",
+ lookupKey, fieldValue.Type(), valueType)
+ }
+ fieldValue.Set(reflect.ValueOf(value))
+ }
+
+ if len(missingOutputs) > 0 {
+ var fileHint string
+ if framework == "terraform" {
+ fileHint = "terraform/outputs.tf"
+ } else {
+ fileHint = "bicep/main.bicep"
+ }
+ require.Fail(t, "Missing required deployment outputs",
+ "The following %d output(s) are missing from deployment outputs: %v\n"+
+ "Ensure these outputs are declared in %s",
+ len(missingOutputs), missingOutputs, fileHint)
+ }
+}
+
+// transformMapKeysRecursive recursively transforms map keys from camelCase to snake_case.
+// This allows Bicep outputs (camelCase keys) to work with validation code that expects snake_case keys.
+func transformMapKeysRecursive(value any) any {
+ if value == nil {
+ return nil
+ }
+
+ switch v := value.(type) {
+ case map[string]any:
+ result := make(map[string]any, len(v))
+ for key, val := range v {
+ snakeKey := CamelToSnakeCase(key)
+ result[snakeKey] = transformMapKeysRecursive(val)
+ }
+ return result
+ case []any:
+ result := make([]any, len(v))
+ for i, item := range v {
+ result[i] = transformMapKeysRecursive(item)
+ }
+ return result
+ default:
+ return value
+ }
+}
+
+// GetOutputKeysFromStruct extracts all output names defined in a struct using reflection.
+// Uses struct field tags with `output` key to extract output names.
+// Returns output names in the order they appear in the struct.
+// Used by contract tests to validate IaC output declarations.
+//
+// Parameters:
+// - outputStruct: Struct instance (value or pointer) with `output` tags
+//
+// Returns: Slice of output names extracted from struct tags
+//
+// Example usage:
+//
+// type MyOutputs struct {
+// ResourceGroup string `output:"resource_group_name"`
+// Location string `output:"location"`
+// }
+// keys := GetOutputKeysFromStruct(MyOutputs{})
+// // Returns: []string{"resource_group_name", "location"}
+func GetOutputKeysFromStruct(outputStruct any) []string {
+ v := reflect.ValueOf(outputStruct)
+ if v.Kind() == reflect.Ptr {
+ v = v.Elem()
+ }
+ typ := v.Type()
+
+ keys := make([]string, 0, typ.NumField())
+ for i := 0; i < typ.NumField(); i++ {
+ field := typ.Field(i)
+ key := field.Tag.Get("output")
+ if key != "" {
+ keys = append(keys, key)
+ }
+ }
+ return keys
+}
+
+// SnakeToCamelCase converts a snake_case string to camelCase.
+// Used to transform output names from Terraform convention (snake_case) to Bicep convention (camelCase).
+//
+// Examples:
+//
+// "deployment_summary" -> "deploymentSummary"
+// "azure_iot_operations" -> "azureIotOperations"
+// "acr_network_posture" -> "acrNetworkPosture"
+// "nat_gateway_public_ips" -> "natGatewayPublicIps"
+func SnakeToCamelCase(s string) string {
+ if s == "" {
+ return s
+ }
+
+ parts := strings.Split(s, "_")
+ if len(parts) == 1 {
+ return strings.ToLower(s)
+ }
+
+ var result strings.Builder
+
+ for i, part := range parts {
+ if part == "" {
+ continue
+ }
+ if i == 0 {
+ result.WriteString(strings.ToLower(part))
+ } else {
+ result.WriteString(strings.ToUpper(part[:1]) + strings.ToLower(part[1:]))
+ }
+ }
+
+ return result.String()
+}
+
+// CamelToSnakeCase converts a camelCase string to snake_case.
+// Used to transform map keys from Bicep convention (camelCase) to Terraform convention (snake_case).
+//
+// Examples:
+//
+// "resourceGroup" -> "resource_group"
+// "keyVaultName" -> "key_vault_name"
+// "aioIdentity" -> "aio_identity"
+func CamelToSnakeCase(s string) string {
+ if s == "" {
+ return s
+ }
+
+ var result strings.Builder
+
+ for i, r := range s {
+ if i > 0 && r >= 'A' && r <= 'Z' {
+ result.WriteByte('_')
+ }
+ result.WriteRune(r)
+ }
+
+ return strings.ToLower(result.String())
+}
diff --git a/src/900-tools-utilities/904-test-utilities/deployment.go b/src/900-tools-utilities/904-test-utilities/deployment.go
new file mode 100644
index 00000000..6e0f9eb7
--- /dev/null
+++ b/src/900-tools-utilities/904-test-utilities/deployment.go
@@ -0,0 +1,271 @@
+// Package testutil provides reusable testing utilities for blueprint deployment and validation.
+// Functions in this file handle deployment and output retrieval for both Terraform and Bicep.
+package testutil
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/gruntwork-io/terratest/modules/logger"
+ "github.com/gruntwork-io/terratest/modules/retry"
+ "github.com/gruntwork-io/terratest/modules/shell"
+ "github.com/gruntwork-io/terratest/modules/terraform"
+ "github.com/stretchr/testify/require"
+)
+
+// DeployTerraform executes 'terraform init' and 'terraform apply' with the provided variables.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - terraformDir: Absolute or relative path to directory containing Terraform files
+// - vars: Terraform input variables as key-value map
+// - shouldCleanup: If true, automatically destroys resources via t.Cleanup after test
+//
+// Returns:
+// - map[string]any: All deployment outputs where keys are output names
+//
+// Behavior:
+// - Configures retry logic for common transient errors
+// - Disables colored output for cleaner log readability
+// - Registers cleanup function if shouldCleanup is true
+// - Runs terraform init and apply in sequence
+// - Retrieves all outputs after successful deployment
+//
+// Requirements:
+// - Terraform must be installed and available on PATH
+// - Valid Terraform configuration in terraformDir
+// - Azure authentication configured (ARM_SUBSCRIPTION_ID, etc.)
+func DeployTerraform(t *testing.T, terraformDir string, vars map[string]any, shouldCleanup bool) map[string]any {
+ terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
+ TerraformDir: terraformDir,
+ Vars: vars,
+ NoColor: true,
+ Lock: true,
+ })
+
+ // Defer cleanup of deployed resources only if shouldCleanup is true
+ if shouldCleanup {
+ t.Cleanup(func() {
+ DestroyTerraform(t, terraformOptions)
+ })
+ } else {
+ t.Log("Skipping resource cleanup: shouldCleanup is false")
+ }
+
+ // Run terraform init and apply
+ terraform.InitAndApply(t, terraformOptions)
+
+ // Retrieve and return outputs
+ outputOptions := &terraform.Options{
+ TerraformDir: terraformDir,
+ Vars: vars,
+ NoColor: true,
+ Logger: logger.Discard,
+ }
+ return terraform.OutputAll(t, outputOptions)
+}
+
+// DeployBicep executes 'az deployment sub create' for subscription-level Bicep deployment.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - bicepDir: Absolute or relative path to directory containing main.bicep
+// - subscriptionID: Azure subscription ID for deployment
+// - deploymentName: Name for the Azure deployment (must be unique per subscription)
+// - resourceGroupName: Name of resource group to delete during cleanup
+// - params: Bicep input parameters as key-value map
+// - shouldCleanup: If true, deletes resource group via t.Cleanup after test
+//
+// Returns:
+// - map[string]any: All deployment outputs converted to Terraform-compatible format
+//
+// Behavior:
+// - Creates temporary parameters JSON file in Azure format
+// - Converts params to {"$schema": ..., "parameters": {"key": {"value": val}}} format
+// - Executes az deployment sub create at subscription scope
+// - Registers resource group deletion if shouldCleanup is true
+// - Cleans up temporary parameters file automatically
+// - Retrieves outputs and converts from Azure to Terraform format
+//
+// Requirements:
+// - Azure CLI must be installed with Bicep extension
+// - User must be authenticated (az login)
+// - Sufficient subscription permissions for deployment
+//
+// Note: Cleanup deletes the entire resource group, not just the deployment.
+func DeployBicep(t *testing.T, bicepDir, subscriptionID, deploymentName string, resourceGroupName string, params map[string]any, shouldCleanup bool) map[string]any {
+ // Create temporary parameters JSON file
+ tmpFile, err := os.CreateTemp("", "bicep-params-*.json")
+ require.NoError(t, err, "Failed to create temporary parameters file")
+ paramsFile := tmpFile.Name()
+ tmpFile.Close()
+
+ // Ensure cleanup of temporary file
+ defer os.Remove(paramsFile)
+
+ // Convert parameters to Azure parameter format
+ azureParams := map[string]any{
+ "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
+ "contentVersion": "1.0.0.0",
+ "parameters": make(map[string]any),
+ }
+
+ paramMap := azureParams["parameters"].(map[string]any)
+ for key, value := range params {
+ paramMap[key] = map[string]any{"value": value}
+ }
+
+ paramsJSON, err := json.MarshalIndent(azureParams, "", " ")
+ require.NoError(t, err, "Failed to marshal parameters")
+
+ err = os.WriteFile(paramsFile, paramsJSON, 0644)
+ require.NoError(t, err, "Failed to write parameters file")
+
+ // Deploy Bicep template
+ bicepFile := bicepDir + "/main.bicep"
+ cmd := shell.Command{
+ Command: "az",
+ Args: []string{"deployment", "sub", "create",
+ "--no-prompt",
+ "--verbose",
+ "--name", deploymentName,
+ "--template-file", bicepFile,
+ "--parameters", paramsFile,
+ "--subscription", subscriptionID},
+ }
+
+ // Defer cleanup of deployed resources only if shouldCleanup is true
+ if shouldCleanup {
+ t.Cleanup(func() {
+ DestroyBicep(t, subscriptionID, resourceGroupName)
+ })
+ } else {
+ t.Log("Skipping resource cleanup: shouldCleanup is false")
+ }
+
+ retry.DoWithRetry(t, "Bicep deployment", 3, 5*time.Second, func() (string, error) {
+ return shell.RunCommandAndGetOutputE(t, cmd)
+ })
+
+ // Retrieve and return outputs
+ return GetBicepOutputs(t, deploymentName)
+}
+
+// GetBicepOutputs retrieves outputs from an existing Bicep deployment without redeploying.
+//
+// Parameters:
+// - t: Test context for assertions and logging
+// - deploymentName: Name of existing Azure deployment to query
+//
+// Returns:
+// - map[string]any: Deployment outputs in Terraform-compatible format
+//
+// Behavior:
+// - Executes 'az deployment sub show' to query deployment outputs
+// - Retries on transient failures (3 attempts, 5 second delay)
+// - Converts Azure output format to Terraform-compatible format
+//
+// Output Format Conversion:
+//
+// Azure CLI returns: {"outputName": {"type": "string", "value": "actual_value"}}
+// This function returns: {"outputName": "actual_value"}
+//
+// Requirements:
+// - Azure CLI must be installed and authenticated
+// - Deployment must exist in the current subscription
+//
+// Use Case:
+//
+// Fetch outputs from existing deployment when SKIP_BICEP_DEPLOYMENT=true
+func GetBicepOutputs(t *testing.T, deploymentName string) map[string]any {
+ // Get deployment outputs using Azure CLI
+ cmd := shell.Command{
+ Command: "az",
+ Args: []string{"deployment", "sub", "show",
+ "--name", deploymentName,
+ "--query", "properties.outputs",
+ "-o", "json"},
+ }
+
+ output := retry.DoWithRetry(t, "Get Bicep outputs", 3, 5*time.Second, func() (string, error) {
+ return shell.RunCommandAndGetOutputE(t, cmd)
+ })
+
+ outputBytes := []byte(output)
+
+ var azureOutputs map[string]map[string]any
+ err := json.Unmarshal(outputBytes, &azureOutputs)
+ require.NoError(t, err, "Failed to parse Bicep outputs")
+
+ // Convert Azure CLI output format to Terraform-compatible format
+ // Azure outputs are in format: {"outputName": {"type": "string", "value": "actual_value"}}
+ // Terraform outputs are in format: {"outputName": "actual_value"}
+ terraformCompatible := make(map[string]any)
+ for key, outputObj := range azureOutputs {
+ if val, ok := outputObj["value"]; ok {
+ terraformCompatible[key] = val
+ }
+ }
+
+ return terraformCompatible
+}
+
+// DestroyTerraform executes 'terraform destroy' to tear down all deployed resources.
+//
+// Parameters:
+// - t: Test context for logging
+// - terraformOptions: Terraform options used during deployment (must match original)
+//
+// Behavior:
+// - Uses terraformOptions to ensure proper resource identification
+// - Terratest's built-in retry logic handles transient errors
+// - Blocks until all resources are destroyed
+//
+// Requirements:
+// - Terraform must be installed and available on PATH
+// - Azure authentication must be configured
+// - terraformOptions must match those used for deployment
+//
+// Note: Typically called automatically by DeployTerraform when shouldCleanup=true.
+func DestroyTerraform(t *testing.T, terraformOptions *terraform.Options) {
+ t.Log("Destroying Terraform-managed resources...")
+
+ terraform.Destroy(t, terraformOptions)
+}
+
+// DestroyBicep executes 'az group delete' to tear down the resource group and all resources.
+//
+// Parameters:
+// - t: Test context for logging
+// - subscriptionID: Azure subscription ID containing the resource group
+// - resourceGroupName: Name of resource group to delete
+//
+// Behavior:
+// - Deletes resource group without confirmation prompt (--yes)
+// - Returns immediately without waiting for completion (--no-wait)
+// - Retries on transient failures (3 attempts, 5 second delay)
+//
+// Requirements:
+// - Azure CLI must be installed and authenticated
+// - Sufficient permissions to delete resource groups
+//
+// Warning: Deletes ALL resources in the resource group, not just those from deployment.
+// Note: Typically called automatically by DeployBicep when shouldCleanup=true.
+func DestroyBicep(t *testing.T, subscriptionID, resourceGroupName string) {
+ t.Log("Destroying Bicep-managed resources...")
+
+ cmd := shell.Command{
+ Command: "az",
+ Args: []string{"group", "delete",
+ "--name", resourceGroupName,
+ "--subscription", subscriptionID,
+ "--yes",
+ "--no-wait"},
+ }
+
+ retry.DoWithRetry(t, "Destroy Bicep resources", 3, 5*time.Second, func() (string, error) {
+ return shell.RunCommandAndGetOutputE(t, cmd)
+ })
+}
diff --git a/src/900-tools-utilities/904-test-utilities/go.mod b/src/900-tools-utilities/904-test-utilities/go.mod
new file mode 100644
index 00000000..c18c5c29
--- /dev/null
+++ b/src/900-tools-utilities/904-test-utilities/go.mod
@@ -0,0 +1,41 @@
+module github.com/microsoft/edge-ai/src/900-tools-utilities/904-test-utilities
+
+go 1.25.5
+
+require (
+ github.com/gruntwork-io/terratest v0.54.0
+ github.com/stretchr/testify v1.11.1
+)
+
+require (
+ github.com/agext/levenshtein v1.2.3 // indirect
+ github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
+ github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/hashicorp/errwrap v1.0.0 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-getter/v2 v2.2.3 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/go-safetemp v1.0.0 // indirect
+ github.com/hashicorp/go-version v1.7.0 // indirect
+ github.com/hashicorp/hcl/v2 v2.22.0 // indirect
+ github.com/hashicorp/terraform-json v0.23.0 // indirect
+ github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a // indirect
+ github.com/klauspost/compress v1.16.5 // indirect
+ github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/mitchellh/go-testing-interface v1.14.1 // indirect
+ github.com/mitchellh/go-wordwrap v1.0.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/tmccombs/hcl2json v0.6.4 // indirect
+ github.com/ulikunitz/xz v0.5.10 // indirect
+ github.com/zclconf/go-cty v1.15.0 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/mod v0.29.0 // indirect
+ golang.org/x/net v0.47.0 // indirect
+ golang.org/x/sync v0.18.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ golang.org/x/tools v0.38.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/src/900-tools-utilities/904-test-utilities/go.sum b/src/900-tools-utilities/904-test-utilities/go.sum
new file mode 100644
index 00000000..94e2e56f
--- /dev/null
+++ b/src/900-tools-utilities/904-test-utilities/go.sum
@@ -0,0 +1,74 @@
+github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
+github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
+github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
+github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
+github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas=
+github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
+github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/gruntwork-io/terratest v0.54.0 h1:JOVATYDpU0NAPbEkgYUP50BR2m45UGiR4dbs20sKzck=
+github.com/gruntwork-io/terratest v0.54.0/go.mod h1:QvwQWZMTJmJB4E0d1Uc18quQm7+X53liKKp+fJSuaKA=
+github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk=
+github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo=
+github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I=
+github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
+github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
+github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M=
+github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
+github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI=
+github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c=
+github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o=
+github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s=
+github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI=
+github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326 h1:ofNAzWCcyTALn2Zv40+8XitdzCgXY6e9qvXwN9W0YXg=
+github.com/mattn/go-zglob v0.0.2-0.20190814121620-e3c945676326/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
+github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
+github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
+github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tmccombs/hcl2json v0.6.4 h1:/FWnzS9JCuyZ4MNwrG4vMrFrzRgsWEOVi+1AyYUVLGw=
+github.com/tmccombs/hcl2json v0.6.4/go.mod h1:+ppKlIW3H5nsAsZddXPy2iMyvld3SHxyjswOZhavRDk=
+github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
+github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
+github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ=
+github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
+github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/src/900-tools-utilities/README.md b/src/900-tools-utilities/README.md
index 719705b9..35989c7f 100644
--- a/src/900-tools-utilities/README.md
+++ b/src/900-tools-utilities/README.md
@@ -21,6 +21,7 @@ The 900-tools-utilities components (900-999 range) provide essential development
| **[901-video-tools](./901-video-tools/README.md)** | Video processing utilities including video-to-GIF conversion for captured media files |
| **[902-aio-sidekick](./902-aio-sidekick/README.md)** | Azure IoT Operations diagnostic and troubleshooting sidekick utility |
| **[903-multi-asset-deploy](./903-multi-asset-deploy/README.md)** | Batch deployment tool for multiple IoT assets from CSV files |
+| **[904-test-utilities](./904-test-utilities/README.md)** | Shared testing utilities for blueprint validation across Terraform and Bicep |
## Framework Support