diff --git a/.npmrc b/.npmrc
index 61655dc0..61ed3138 100644
--- a/.npmrc
+++ b/.npmrc
@@ -15,4 +15,6 @@ package-lock=true
# Use color in npm output
color=true
# Set log level to warn by default
-loglevel=warn
\ No newline at end of file
+loglevel=warn
+# Disable postinstall scripts for supply chain security hardening
+ignore-scripts=true
diff --git a/blueprints/azure-local/terraform/README.md b/blueprints/azure-local/terraform/README.md
index ce57141b..c486f3cd 100644
--- a/blueprints/azure-local/terraform/README.md
+++ b/blueprints/azure-local/terraform/README.md
@@ -56,7 +56,7 @@ Deploys the cloud and edge resources required to run Azure IoT Operations on an
| azure\_local\_control\_plane\_count | Number of control plane nodes for Azure Local cluster | `number` | `1` | no |
| azure\_local\_control\_plane\_vm\_size | VM size for control plane nodes in Azure Local cluster | `string` | `"Standard_A4_v2"` | no |
| azure\_local\_node\_pool\_count | Number of worker nodes in the default node pool for Azure Local cluster | `number` | `1` | no |
-| azure\_local\_node\_pool\_vm\_size | VM size for worker nodes in Azure Local cluster | `string` | `"Standard_D8s_v3"` | no |
+| azure\_local\_node\_pool\_vm\_size | VM size for worker nodes in Azure Local cluster | `string` | `"Standard_D8s_v6"` | no |
| azure\_local\_pod\_cidr | CIDR range for Kubernetes pods in Azure Local cluster | `string` | `"10.244.0.0/16"` | no |
| custom\_locations\_oid | Resource ID of the custom location for the Azure Stack HCI cluster | `string` | `null` | no |
| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
diff --git a/blueprints/azure-local/terraform/main.tf b/blueprints/azure-local/terraform/main.tf
index 8cb8031e..33ba2456 100644
--- a/blueprints/azure-local/terraform/main.tf
+++ b/blueprints/azure-local/terraform/main.tf
@@ -58,6 +58,8 @@ module "cloud_security_identity" {
should_enable_purge_protection = var.should_enable_key_vault_purge_protection
should_create_aks_identity = false
should_create_ml_workload_identity = false
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_observability" {
@@ -102,7 +104,9 @@ module "cloud_messaging" {
resource_prefix = var.resource_prefix
instance = var.instance
- should_create_azure_functions = var.should_create_azure_functions
+ should_create_azure_functions = var.should_create_azure_functions
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "azure_local_host" {
diff --git a/blueprints/azure-local/terraform/variables.tf b/blueprints/azure-local/terraform/variables.tf
index eddc94e2..c4acde80 100644
--- a/blueprints/azure-local/terraform/variables.tf
+++ b/blueprints/azure-local/terraform/variables.tf
@@ -101,7 +101,7 @@ variable "azure_local_control_plane_vm_size" {
variable "azure_local_node_pool_vm_size" {
type = string
description = "VM size for worker nodes in Azure Local cluster"
- default = "Standard_D8s_v3"
+ default = "Standard_D8s_v6"
}
variable "azure_local_pod_cidr" {
diff --git a/blueprints/azureml/terraform/README.md b/blueprints/azureml/terraform/README.md
index 8dc3d3a5..d4e0400f 100644
--- a/blueprints/azureml/terraform/README.md
+++ b/blueprints/azureml/terraform/README.md
@@ -70,7 +70,7 @@ This blueprint provides Azure Machine Learning capabilities with optional founda
| 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 = optional(number, null) 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) priority = optional(string, "Regular") zones = optional(list(string), null) eviction_policy = optional(string, "Deallocate") gpu_driver = optional(string, null) }))``` | `{}` | no |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v5. | `string` | `"Standard_D8ds_v5"` | no |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v6. | `string` | `"Standard_D8ds_v6"` | 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 |
@@ -137,7 +137,7 @@ This blueprint provides Azure Machine Learning capabilities with optional founda
| vm\_host\_count | Number of VM hosts to create for multi-node scenarios | `number` | `1` | no |
| vm\_max\_bid\_price | Maximum hourly price in USD for Spot VM. Set to -1 (recommended) to pay current spot price without price-based eviction. Custom values support up to 5 decimal places. Only applies when vm\_priority is Spot | `number` | `-1` | no |
| vm\_priority | VM priority: Regular (production, guaranteed capacity) or Spot (cost-optimized, up to 90% savings, can be evicted). Recommended: Spot for dev/test GPU workloads | `string` | `"Regular"` | no |
-| vm\_sku\_size | VM SKU size for the host. Examples: Standard\_D8s\_v3 (general purpose), Standard\_NV36ads\_A10\_v5 (GPU workload) | `string` | `"Standard_D8s_v3"` | no |
+| vm\_sku\_size | VM SKU size for the host. Examples: Standard\_D8s\_v6 (general purpose), Standard\_NV36ads\_A10\_v5 (GPU workload) | `string` | `"Standard_D8s_v6"` | no |
| vm\_user\_principals | Map of Azure AD principals for Virtual Machine User Login role (standard access). Keys are descriptive identifiers (e.g., `user@company.com`), values are principal object IDs. | `map(string)` | `{}` | no |
| vpn\_gateway\_azure\_ad\_config | Azure AD configuration for VPN Gateway authentication. tenant\_id is required when vpn\_gateway\_should\_use\_azure\_ad\_auth is true. audience defaults to Microsoft-registered app. issuer will default to `https://sts.windows.net/{tenant_id}/` when not provided | ```object({ tenant_id = optional(string) audience = optional(string, "c632b3df-fb67-4d84-bdcf-b95ad541b5c8") issuer = optional(string) })``` | `{}` | 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 |
diff --git a/blueprints/azureml/terraform/variables.tf b/blueprints/azureml/terraform/variables.tf
index 307420c9..3bec4301 100644
--- a/blueprints/azureml/terraform/variables.tf
+++ b/blueprints/azureml/terraform/variables.tf
@@ -118,8 +118,8 @@ variable "node_count" {
variable "node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v5."
- default = "Standard_D8ds_v5"
+ description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v6."
+ default = "Standard_D8ds_v6"
}
variable "subnet_address_prefixes_aks" {
@@ -756,8 +756,8 @@ variable "vm_host_count" {
variable "vm_sku_size" {
type = string
- description = "VM SKU size for the host. Examples: Standard_D8s_v3 (general purpose), Standard_NV36ads_A10_v5 (GPU workload)"
- default = "Standard_D8s_v3"
+ description = "VM SKU size for the host. Examples: Standard_D8s_v6 (general purpose), Standard_NV36ads_A10_v5 (GPU workload)"
+ default = "Standard_D8s_v6"
}
variable "vm_priority" {
diff --git a/blueprints/dual-peered-single-node-cluster/terraform/README.md b/blueprints/dual-peered-single-node-cluster/terraform/README.md
index 246dd560..346e76b2 100644
--- a/blueprints/dual-peered-single-node-cluster/terraform/README.md
+++ b/blueprints/dual-peered-single-node-cluster/terraform/README.md
@@ -84,18 +84,19 @@ Each cluster operates independently but can communicate through the peered virtu
| cluster\_a\_min\_count | The minimum number of nodes which should exist in the default node pool for Cluster A. Valid values are between 0 and 1000. | `number` | `null` | no |
| cluster\_a\_node\_count | Number of nodes for the agent pool in the AKS cluster for Cluster A. | `number` | `1` | no |
| cluster\_a\_node\_pools | Additional node pools for the AKS cluster for Cluster A. 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 |
-| cluster\_a\_node\_vm\_size | VM size for the agent pool in the AKS cluster for Cluster A. Default is Standard\_D8ds\_v5. | `string` | `"Standard_D8ds_v5"` | no |
+| cluster\_a\_node\_vm\_size | VM size for the agent pool in the AKS cluster for Cluster A. Default is Standard\_D8ds\_v6. | `string` | `"Standard_D8ds_v6"` | no |
| cluster\_a\_subnet\_address\_prefixes\_acr | Address prefixes for the ACR subnet. | `list(string)` | ```[ "10.1.2.0/24" ]``` | no |
| cluster\_a\_subnet\_address\_prefixes\_aks | Address prefixes for the AKS subnet. | `list(string)` | ```[ "10.1.3.0/24" ]``` | no |
| cluster\_a\_subnet\_address\_prefixes\_aks\_pod | Address prefixes for the AKS pod subnet. | `list(string)` | ```[ "10.1.4.0/24" ]``` | no |
| cluster\_a\_virtual\_network\_config | Configuration for Cluster A virtual network including address space and subnet prefix. | ```object({ address_space = string subnet_address_prefix = string })``` | ```{ "address_space": "10.1.0.0/16", "subnet_address_prefix": "10.1.1.0/24" }``` | no |
+| cluster\_admin\_group\_oid | The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy' | `string` | `null` | no |
| cluster\_b\_dns\_prefix | DNS prefix for the AKS cluster for Cluster B. This is used to create a unique DNS name for the cluster. If not provided, a default value will be generated. | `string` | `null` | no |
| cluster\_b\_enable\_auto\_scaling | Should enable auto-scaler for the default node pool for Cluster B. | `bool` | `false` | no |
| cluster\_b\_max\_count | The maximum number of nodes which should exist in the default node pool for Cluster B. Valid values are between 0 and 1000. | `number` | `null` | no |
| cluster\_b\_min\_count | The minimum number of nodes which should exist in the default node pool for Cluster B. Valid values are between 0 and 1000. | `number` | `null` | no |
| cluster\_b\_node\_count | Number of nodes for the agent pool in the AKS cluster for Cluster B. | `number` | `1` | no |
| cluster\_b\_node\_pools | Additional node pools for the AKS cluster for Cluster B. 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 |
-| cluster\_b\_node\_vm\_size | VM size for the agent pool in the AKS cluster for Cluster B. Default is Standard\_D8ds\_v5. | `string` | `"Standard_D8ds_v5"` | no |
+| cluster\_b\_node\_vm\_size | VM size for the agent pool in the AKS cluster for Cluster B. Default is Standard\_D8ds\_v6. | `string` | `"Standard_D8ds_v6"` | no |
| cluster\_b\_subnet\_address\_prefixes\_acr | Address prefixes for the ACR subnet. | `list(string)` | ```[ "10.2.2.0/24" ]``` | no |
| cluster\_b\_subnet\_address\_prefixes\_aks | Address prefixes for the AKS subnet. | `list(string)` | ```[ "10.2.3.0/24" ]``` | no |
| cluster\_b\_subnet\_address\_prefixes\_aks\_pod | Address prefixes for the AKS pod subnet. | `list(string)` | ```[ "10.2.4.0/24" ]``` | no |
diff --git a/blueprints/dual-peered-single-node-cluster/terraform/main.tf b/blueprints/dual-peered-single-node-cluster/terraform/main.tf
index 2e49efb3..ac0da6c3 100644
--- a/blueprints/dual-peered-single-node-cluster/terraform/main.tf
+++ b/blueprints/dual-peered-single-node-cluster/terraform/main.tf
@@ -187,6 +187,7 @@ module "cluster_a_edge_cncf_cluster" {
should_deploy_arc_machines = false
should_get_custom_locations_oid = var.should_get_custom_locations_oid
custom_locations_oid = var.custom_locations_oid
+ cluster_admin_group_oid = var.cluster_admin_group_oid
// Key Vault for script retrieval
key_vault = module.cluster_a_cloud_security_identity.key_vault
@@ -440,6 +441,7 @@ module "cluster_b_edge_cncf_cluster" {
should_deploy_arc_machines = false
should_get_custom_locations_oid = var.should_get_custom_locations_oid
custom_locations_oid = var.custom_locations_oid
+ cluster_admin_group_oid = var.cluster_admin_group_oid
// Key Vault for script retrieval
key_vault = module.cluster_b_cloud_security_identity.key_vault
diff --git a/blueprints/dual-peered-single-node-cluster/terraform/variables.tf b/blueprints/dual-peered-single-node-cluster/terraform/variables.tf
index 1064569c..b4d7e887 100644
--- a/blueprints/dual-peered-single-node-cluster/terraform/variables.tf
+++ b/blueprints/dual-peered-single-node-cluster/terraform/variables.tf
@@ -131,6 +131,12 @@ variable "aio_namespace" {
default = "azure-iot-operations"
}
+variable "cluster_admin_group_oid" {
+ type = string
+ description = "The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy'"
+ default = null
+}
+
variable "should_get_custom_locations_oid" {
type = bool
description = <<-EOF
@@ -177,8 +183,8 @@ variable "cluster_a_node_count" {
variable "cluster_a_node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster for Cluster A. Default is Standard_D8ds_v5."
- default = "Standard_D8ds_v5"
+ description = "VM size for the agent pool in the AKS cluster for Cluster A. Default is Standard_D8ds_v6."
+ default = "Standard_D8ds_v6"
}
variable "cluster_a_enable_auto_scaling" {
@@ -232,8 +238,8 @@ variable "cluster_b_node_count" {
variable "cluster_b_node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster for Cluster B. Default is Standard_D8ds_v5."
- default = "Standard_D8ds_v5"
+ description = "VM size for the agent pool in the AKS cluster for Cluster B. Default is Standard_D8ds_v6."
+ default = "Standard_D8ds_v6"
}
variable "cluster_b_enable_auto_scaling" {
diff --git a/blueprints/full-multi-node-cluster/terraform/README.md b/blueprints/full-multi-node-cluster/terraform/README.md
index 955f341c..2ad0ca09 100644
--- a/blueprints/full-multi-node-cluster/terraform/README.md
+++ b/blueprints/full-multi-node-cluster/terraform/README.md
@@ -91,6 +91,7 @@ with the single-node blueprint while preserving multi-node specific capabilities
| 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\_admin\_group\_oid | The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy' | `string` | `null` | 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 |
@@ -112,7 +113,7 @@ with the single-node blueprint while preserving multi-node specific capabilities
| 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 |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster | `string` | `"Standard_D8ds_v6"` | no |
| onboard\_identity\_type | Identity type to use for onboarding the cluster to Azure Arc. Allowed values: - id: User-assigned managed identity (default for VM-based deployments) - sp: Service principal - skip: Skip identity creation (use when Arc machines already have system-assigned identity) | `string` | `"id"` | 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 |
diff --git a/blueprints/full-multi-node-cluster/terraform/main.tf b/blueprints/full-multi-node-cluster/terraform/main.tf
index ee8cd61d..f27f3fae 100644
--- a/blueprints/full-multi-node-cluster/terraform/main.tf
+++ b/blueprints/full-multi-node-cluster/terraform/main.tf
@@ -103,6 +103,8 @@ module "cloud_security_identity" {
should_create_aks_identity = var.should_create_aks_identity
should_create_ml_workload_identity = var.azureml_should_create_ml_workload_identity
should_create_secret_sync_identity = var.should_deploy_aio
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_vpn_gateway" {
@@ -243,7 +245,9 @@ module "cloud_messaging" {
resource_prefix = var.resource_prefix
instance = var.instance
- should_create_azure_functions = var.should_create_azure_functions
+ should_create_azure_functions = var.should_create_azure_functions
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_vm_host" {
@@ -287,6 +291,8 @@ module "cloud_acr" {
public_network_access_enabled = var.acr_public_network_access_enabled
should_enable_data_endpoints = var.acr_data_endpoint_enabled
should_enable_export_policy = var.acr_export_policy_enabled
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_kubernetes" {
@@ -361,6 +367,7 @@ module "cloud_azureml" {
should_enable_nat_gateway = var.should_enable_managed_outbound_access
should_enable_public_network_access = var.azureml_should_enable_public_network_access
should_create_compute_cluster = var.azureml_should_create_compute_cluster
+ compute_cluster_node_public_ip_enabled = !var.azureml_should_enable_private_endpoint
ml_workload_identity = try(module.cloud_security_identity.ml_workload_identity, null)
ml_workload_subjects = var.azureml_ml_workload_subjects
@@ -430,6 +437,7 @@ module "edge_cncf_cluster" {
should_generate_cluster_server_token = true
should_get_custom_locations_oid = var.should_get_custom_locations_oid
should_add_current_user_cluster_admin = var.should_add_current_user_cluster_admin
+ cluster_admin_group_oid = var.cluster_admin_group_oid
custom_locations_oid = var.custom_locations_oid
cluster_server_host_machine_username = var.cluster_server_host_machine_username
diff --git a/blueprints/full-multi-node-cluster/terraform/variables.tf b/blueprints/full-multi-node-cluster/terraform/variables.tf
index d06591cb..edaece70 100644
--- a/blueprints/full-multi-node-cluster/terraform/variables.tf
+++ b/blueprints/full-multi-node-cluster/terraform/variables.tf
@@ -95,6 +95,12 @@ variable "should_add_current_user_cluster_admin" {
default = true
}
+variable "cluster_admin_group_oid" {
+ type = string
+ description = "The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy'"
+ default = null
+}
+
variable "should_get_custom_locations_oid" {
type = bool
description = <<-EOT
@@ -402,7 +408,7 @@ variable "node_count" {
variable "node_vm_size" {
type = string
description = "VM size for the agent pool in the AKS cluster"
- default = "Standard_D8ds_v5"
+ default = "Standard_D8ds_v6"
}
variable "enable_auto_scaling" {
diff --git a/blueprints/full-single-node-cluster/terraform/README.md b/blueprints/full-single-node-cluster/terraform/README.md
index e9affc06..d0915edd 100644
--- a/blueprints/full-single-node-cluster/terraform/README.md
+++ b/blueprints/full-single-node-cluster/terraform/README.md
@@ -74,6 +74,7 @@ for a single-node cluster deployment, including observability, messaging, and da
| 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 |
+| cluster\_admin\_group\_oid | The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy' | `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 |
| dataflow\_endpoints | List of dataflow endpoints to create with their type-specific configurations | ```list(object({ name = string endpointType = string hostType = optional(string) dataExplorerSettings = optional(object({ authentication = object({ method = string systemAssignedManagedIdentitySettings = optional(object({ audience = optional(string) })) userAssignedManagedIdentitySettings = optional(object({ clientId = string scope = optional(string) tenantId = string })) }) batching = optional(object({ latencySeconds = optional(number) maxMessages = optional(number) })) database = string host = string })) dataLakeStorageSettings = optional(object({ authentication = object({ accessTokenSettings = optional(object({ secretRef = string })) method = string systemAssignedManagedIdentitySettings = optional(object({ audience = optional(string) })) userAssignedManagedIdentitySettings = optional(object({ clientId = string scope = optional(string) tenantId = string })) }) batching = optional(object({ latencySeconds = optional(number) maxMessages = optional(number) })) host = string })) fabricOneLakeSettings = optional(object({ authentication = object({ method = string systemAssignedManagedIdentitySettings = optional(object({ audience = optional(string) })) userAssignedManagedIdentitySettings = optional(object({ clientId = string scope = optional(string) tenantId = string })) }) batching = optional(object({ latencySeconds = optional(number) maxMessages = optional(number) })) host = string names = object({ lakehouseName = string workspaceName = string }) oneLakePathType = string })) kafkaSettings = optional(object({ authentication = object({ method = string saslSettings = optional(object({ saslType = string secretRef = string })) systemAssignedManagedIdentitySettings = optional(object({ audience = optional(string) })) userAssignedManagedIdentitySettings = optional(object({ clientId = string scope = optional(string) tenantId = string })) x509CertificateSettings = optional(object({ secretRef = string })) }) batching = optional(object({ latencyMs = optional(number) maxBytes = optional(number) maxMessages = optional(number) mode = optional(string) })) cloudEventAttributes = optional(string) compression = optional(string) consumerGroupId = optional(string) copyMqttProperties = optional(string) host = string kafkaAcks = optional(string) partitionStrategy = optional(string) tls = optional(object({ mode = optional(string) trustedCaCertificateConfigMapRef = optional(string) })) })) localStorageSettings = optional(object({ persistentVolumeClaimRef = string })) mqttSettings = optional(object({ authentication = object({ method = string serviceAccountTokenSettings = optional(object({ audience = string })) systemAssignedManagedIdentitySettings = optional(object({ audience = optional(string) })) userAssignedManagedIdentitySettings = optional(object({ clientId = string scope = optional(string) tenantId = string })) x509CertificateSettings = optional(object({ secretRef = string })) }) clientIdPrefix = optional(string) cloudEventAttributes = optional(string) host = optional(string) keepAliveSeconds = optional(number) maxInflightMessages = optional(number) protocol = optional(string) qos = optional(number) retain = optional(string) sessionExpirySeconds = optional(number) tls = optional(object({ mode = optional(string) trustedCaCertificateConfigMapRef = optional(string) })) })) openTelemetrySettings = optional(object({ authentication = object({ method = string anonymousSettings = optional(any) serviceAccountTokenSettings = optional(object({ audience = string })) x509CertificateSettings = optional(object({ secretRef = string })) }) batching = optional(object({ latencySeconds = optional(number) maxMessages = optional(number) })) host = string tls = optional(object({ mode = optional(string) trustedCaCertificateConfigMapRef = optional(string) })) })) }))``` | `[]` | no |
@@ -90,7 +91,7 @@ for a single-node cluster deployment, including observability, messaging, and da
| 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 |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster | `string` | `"Standard_D8ds_v6"` | 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 |
diff --git a/blueprints/full-single-node-cluster/terraform/main.tf b/blueprints/full-single-node-cluster/terraform/main.tf
index 8222ca3a..3fb9aeb6 100644
--- a/blueprints/full-single-node-cluster/terraform/main.tf
+++ b/blueprints/full-single-node-cluster/terraform/main.tf
@@ -95,6 +95,8 @@ module "cloud_security_identity" {
should_create_aks_identity = var.should_create_aks_identity
should_create_ml_workload_identity = var.azureml_should_create_ml_workload_identity
should_create_secret_sync_identity = var.should_deploy_aio
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_vpn_gateway" {
@@ -243,6 +245,9 @@ module "cloud_messaging" {
eventhubs = local.eventhubs
function_app_settings = merge(var.function_app_settings, local.function_app_computed_settings)
+
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_vm_host" {
@@ -283,6 +288,8 @@ module "cloud_acr" {
public_network_access_enabled = var.acr_public_network_access_enabled
should_enable_data_endpoints = var.acr_data_endpoint_enabled
should_enable_export_policy = var.acr_export_policy_enabled
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_kubernetes" {
@@ -351,6 +358,7 @@ module "cloud_azureml" {
should_enable_nat_gateway = var.should_enable_managed_outbound_access
should_enable_public_network_access = var.azureml_should_enable_public_network_access
should_create_compute_cluster = var.azureml_should_create_compute_cluster
+ compute_cluster_node_public_ip_enabled = !var.azureml_should_enable_private_endpoint
ml_workload_identity = try(module.cloud_security_identity.ml_workload_identity, null)
ml_workload_subjects = var.azureml_ml_workload_subjects
@@ -409,6 +417,7 @@ module "edge_cncf_cluster" {
should_deploy_arc_machines = false
should_get_custom_locations_oid = var.should_get_custom_locations_oid
should_add_current_user_cluster_admin = var.should_add_current_user_cluster_admin
+ cluster_admin_group_oid = var.cluster_admin_group_oid
custom_locations_oid = var.custom_locations_oid
// Key Vault for script retrieval
diff --git a/blueprints/full-single-node-cluster/terraform/outputs.tf b/blueprints/full-single-node-cluster/terraform/outputs.tf
index ac68a0be..f4b8e4b6 100644
--- a/blueprints/full-single-node-cluster/terraform/outputs.tf
+++ b/blueprints/full-single-node-cluster/terraform/outputs.tf
@@ -159,6 +159,11 @@ output "function_app" {
value = try(module.cloud_messaging.function_app, null)
}
+output "video_query_storage_role_assignment" {
+ description = "Storage Blob Data Contributor role assignment for the Video Query API Function App."
+ value = try(azurerm_role_assignment.video_query_storage_blob_data_contributor[0], null)
+}
+
/*
* Dataflow Outputs
*/
diff --git a/blueprints/full-single-node-cluster/terraform/variables.tf b/blueprints/full-single-node-cluster/terraform/variables.tf
index ff68d8ce..c0feae5e 100644
--- a/blueprints/full-single-node-cluster/terraform/variables.tf
+++ b/blueprints/full-single-node-cluster/terraform/variables.tf
@@ -66,6 +66,12 @@ variable "should_add_current_user_cluster_admin" {
default = true
}
+variable "cluster_admin_group_oid" {
+ type = string
+ description = "The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy'"
+ default = null
+}
+
variable "should_get_custom_locations_oid" {
type = bool
description = <<-EOT
@@ -344,6 +350,24 @@ variable "function_app_settings" {
sensitive = true
}
+variable "should_deploy_video_capture" {
+ type = bool
+ description = "Whether to deploy video capture query infrastructure including role assignment for Function App access to storage"
+ default = false
+}
+
+variable "function_node_version" {
+ type = string
+ description = "Node.js version for the Function App runtime. Set to null when using Python runtime"
+ default = "20"
+}
+
+variable "function_python_version" {
+ type = string
+ description = "Python version for the Function App runtime. Set to null when using Node.js runtime"
+ default = null
+}
+
/*
* Azure Kubernetes Service Parameters
*/
@@ -384,7 +408,7 @@ variable "node_pools" {
variable "node_vm_size" {
type = string
description = "VM size for the agent pool in the AKS cluster"
- default = "Standard_D8ds_v5"
+ default = "Standard_D8ds_v6"
}
variable "should_create_aks" {
diff --git a/blueprints/full-single-node-cluster/terraform/video-capture-query.tfvars.example b/blueprints/full-single-node-cluster/terraform/video-capture-query.tfvars.example
new file mode 100644
index 00000000..9b18f040
--- /dev/null
+++ b/blueprints/full-single-node-cluster/terraform/video-capture-query.tfvars.example
@@ -0,0 +1,39 @@
+/*
+ * Full Single Node Cluster with Video Capture Query
+ *
+ * Deploys the complete single-node cluster infrastructure with the Video Query API
+ * Azure Function for time-based video recording retrieval.
+ *
+ * IMPORTANT: The Function App runtime is set to Python 3.11 for the Video Query API.
+ * This is mutually exclusive with Node.js-based functions (e.g., leak-detection notification).
+ * Azure Functions supports only one runtime per Function App.
+ *
+ * For combined deployments requiring both Python and Node.js functions,
+ * a dual Function App architecture is needed (see follow-on work).
+ */
+
+// Core Parameters
+environment = "dev"
+location = "eastus2"
+resource_prefix = "aio"
+instance = "001"
+
+// Video Capture Query
+should_deploy_video_capture = true
+should_create_azure_functions = true
+
+// Function App Runtime — Python for Video Query API
+// Setting python_version activates Python runtime; node_version must be null
+function_python_version = "3.11"
+function_node_version = null
+
+// HNS must be disabled for Azure Functions storage access via managed identity
+storage_account_is_hns_enabled = false
+
+// Video Query Function App Settings
+function_app_settings = {
+ "FUNCTIONS_WORKER_RUNTIME" = "python"
+ "TEMP_VIDEOS_CONTAINER" = "temp-videos"
+ "VIDEO_RECORDINGS_CONTAINER" = "video-recordings"
+ "SAS_EXPIRY_HOURS" = "24"
+}
diff --git a/blueprints/minimum-single-node-cluster/terraform/README.md b/blueprints/minimum-single-node-cluster/terraform/README.md
index ad8d52a1..0b7564db 100644
--- a/blueprints/minimum-single-node-cluster/terraform/README.md
+++ b/blueprints/minimum-single-node-cluster/terraform/README.md
@@ -30,19 +30,20 @@ It includes only the essential components and minimizes resource usage.
## Inputs
-| Name | Description | Type | Default | Required |
-|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------|:--------:|
-| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
-| location | Azure region where all resources will be deployed | `string` | n/a | yes |
-| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
-| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant. If none is provided, the script will attempt to retrieve this requiring '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 |
-| 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 |
-| should\_add\_current\_user\_cluster\_admin | Gives the current logged in user cluster-admin permissions with the new cluster. | `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 |
-| should\_deploy\_aio | Whether to deploy Azure IoT Operations and its dependent edge components (assets). When false, deploys Arc-connected cluster with extensions only | `bool` | `true` | no |
-| should\_enable\_private\_endpoints | Whether to enable private endpoints for Key Vault and Storage Account | `bool` | `false` | no |
-| should\_get\_custom\_locations\_oid | Whether to get Custom Locations Object ID using Terraform's azuread provider. (Otherwise, provided by 'custom\_locations\_oid' or `az connectedk8s enable-features` for custom-locations on cluster setup if not provided.) | `bool` | `true` | no |
-| vm\_sku\_size | Size of the VM | `string` | `"Standard_D4_v4"` | no |
+| Name | Description | Type | Default | Required |
+|---------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------|:--------:|
+| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
+| location | Azure region where all resources will be deployed | `string` | n/a | yes |
+| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| cluster\_admin\_group\_oid | The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy' | `string` | `null` | no |
+| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant. If none is provided, the script will attempt to retrieve this requiring '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 |
+| 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 |
+| should\_add\_current\_user\_cluster\_admin | Gives the current logged in user cluster-admin permissions with the new cluster. | `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 |
+| should\_deploy\_aio | Whether to deploy Azure IoT Operations and its dependent edge components (assets). When false, deploys Arc-connected cluster with extensions only | `bool` | `true` | no |
+| should\_enable\_private\_endpoints | Whether to enable private endpoints for Key Vault and Storage Account | `bool` | `false` | no |
+| should\_get\_custom\_locations\_oid | Whether to get Custom Locations Object ID using Terraform's azuread provider. (Otherwise, provided by 'custom\_locations\_oid' or `az connectedk8s enable-features` for custom-locations on cluster setup if not provided.) | `bool` | `true` | no |
+| vm\_sku\_size | Size of the VM | `string` | `"Standard_D4s_v6"` | no |
diff --git a/blueprints/minimum-single-node-cluster/terraform/main.tf b/blueprints/minimum-single-node-cluster/terraform/main.tf
index b5363f33..61437bd7 100644
--- a/blueprints/minimum-single-node-cluster/terraform/main.tf
+++ b/blueprints/minimum-single-node-cluster/terraform/main.tf
@@ -106,6 +106,7 @@ module "edge_cncf_cluster" {
should_get_custom_locations_oid = var.should_get_custom_locations_oid
custom_locations_oid = var.custom_locations_oid
should_add_current_user_cluster_admin = var.should_add_current_user_cluster_admin
+ cluster_admin_group_oid = var.cluster_admin_group_oid
key_vault = module.cloud_security_identity.key_vault
}
diff --git a/blueprints/minimum-single-node-cluster/terraform/variables.tf b/blueprints/minimum-single-node-cluster/terraform/variables.tf
index 0950415b..ea81fd2b 100644
--- a/blueprints/minimum-single-node-cluster/terraform/variables.tf
+++ b/blueprints/minimum-single-node-cluster/terraform/variables.tf
@@ -66,6 +66,12 @@ variable "should_add_current_user_cluster_admin" {
default = true
}
+variable "cluster_admin_group_oid" {
+ type = string
+ description = "The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy'"
+ default = null
+}
+
variable "should_enable_private_endpoints" {
type = bool
description = "Whether to enable private endpoints for Key Vault and Storage Account"
@@ -92,7 +98,7 @@ variable "vm_sku_size" {
type = string
// Minimize resource usage - set smaller VM size
description = "Size of the VM"
- default = "Standard_D4_v4"
+ default = "Standard_D4s_v6"
}
variable "namespaced_devices" {
diff --git a/blueprints/modules/robotics/terraform/README.md b/blueprints/modules/robotics/terraform/README.md
index c0d90afe..5a117428 100644
--- a/blueprints/modules/robotics/terraform/README.md
+++ b/blueprints/modules/robotics/terraform/README.md
@@ -105,7 +105,7 @@ Adds Azure Machine Learning capabilities with optional foundational resource cre
| 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 = optional(number, null) 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) priority = optional(string, "Regular") zones = optional(list(string), null) eviction_policy = optional(string, "Deallocate") gpu_driver = optional(string, null) }))``` | `{}` | no |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v5. | `string` | `"Standard_D8ds_v5"` | no |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v6. | `string` | `"Standard_D8ds_v6"` | 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 |
@@ -177,7 +177,7 @@ Adds Azure Machine Learning capabilities with optional foundational resource cre
| vm\_host\_count | Number of VM hosts to create for multi-node scenarios | `number` | `1` | no |
| vm\_max\_bid\_price | Maximum hourly price in USD for Spot VM. Set to -1 (recommended) to pay current spot price without price-based eviction. Custom values support up to 5 decimal places. Only applies when vm\_priority is Spot | `number` | `-1` | no |
| vm\_priority | VM priority: Regular (production, guaranteed capacity) or Spot (cost-optimized, up to 90% savings, can be evicted). Recommended: Spot for dev/test GPU workloads | `string` | `"Regular"` | no |
-| vm\_sku\_size | VM SKU size for the host. Examples: Standard\_D8s\_v3 (general purpose), Standard\_NV36ads\_A10\_v5 (GPU workload) | `string` | `"Standard_D8s_v3"` | no |
+| vm\_sku\_size | VM SKU size for the host. Examples: Standard\_D8s\_v6 (general purpose), Standard\_NV36ads\_A10\_v5 (GPU workload) | `string` | `"Standard_D8s_v6"` | no |
| vm\_user\_principals | Map of Azure AD principals for Virtual Machine User Login role (standard access). Keys are descriptive identifiers (e.g., `user@company.com`), values are principal object IDs. | `map(string)` | `{}` | no |
| vpn\_gateway\_azure\_ad\_config | Azure AD configuration for VPN Gateway authentication. tenant\_id is required when vpn\_gateway\_should\_use\_azure\_ad\_auth is true. audience defaults to Microsoft-registered app. issuer will default to `https://sts.windows.net/{tenant_id}/` when not provided | ```object({ tenant_id = optional(string) audience = optional(string, "c632b3df-fb67-4d84-bdcf-b95ad541b5c8") issuer = optional(string) })``` | `{}` | 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 |
diff --git a/blueprints/modules/robotics/terraform/main.tf b/blueprints/modules/robotics/terraform/main.tf
index 1e2ad384..b5244d6f 100644
--- a/blueprints/modules/robotics/terraform/main.tf
+++ b/blueprints/modules/robotics/terraform/main.tf
@@ -141,6 +141,8 @@ module "cloud_security_identity" {
key_vault_virtual_network_id = try(module.cloud_networking[0].virtual_network.id, data.azurerm_virtual_network.existing[0].id, null)
should_enable_public_network_access = var.should_enable_public_network_access
should_enable_purge_protection = var.should_enable_key_vault_purge_protection
+ log_analytics_workspace_id = try(module.cloud_observability[0].log_analytics_workspace.id, null)
+ should_enable_diagnostic_settings = true
}
module "cloud_vpn_gateway" {
@@ -337,11 +339,13 @@ module "cloud_acr" {
should_enable_nat_gateway = var.should_enable_managed_outbound_access
nat_gateway = try(module.cloud_networking[0].nat_gateway, null)
- allow_trusted_services = var.acr_allow_trusted_services
- allowed_public_ip_ranges = var.acr_allowed_public_ip_ranges
- public_network_access_enabled = var.acr_public_network_access_enabled
- should_enable_data_endpoints = var.acr_data_endpoint_enabled
- should_enable_export_policy = var.acr_export_policy_enabled
+ allow_trusted_services = var.acr_allow_trusted_services
+ allowed_public_ip_ranges = var.acr_allowed_public_ip_ranges
+ public_network_access_enabled = var.acr_public_network_access_enabled
+ should_enable_data_endpoints = var.acr_data_endpoint_enabled
+ should_enable_export_policy = var.acr_export_policy_enabled
+ log_analytics_workspace_id = try(module.cloud_observability[0].log_analytics_workspace.id, null)
+ should_enable_diagnostic_settings = true
}
module "cloud_kubernetes" {
@@ -438,6 +442,8 @@ module "cloud_azureml" {
compute_cluster_vm_priority = var.compute_cluster_vm_priority
compute_cluster_vm_size = var.compute_cluster_vm_size
+ compute_cluster_node_public_ip_enabled = !var.should_enable_private_endpoints
+
key_vault = try(module.cloud_security_identity[0].key_vault, data.azurerm_key_vault.existing[0], null)
application_insights = try(module.cloud_observability[0].application_insights, data.azurerm_application_insights.existing[0], null)
storage_account = try(module.cloud_data[0].storage_account, data.azurerm_storage_account.existing[0], null)
diff --git a/blueprints/modules/robotics/terraform/variables.tf b/blueprints/modules/robotics/terraform/variables.tf
index 418a25e5..23aee356 100644
--- a/blueprints/modules/robotics/terraform/variables.tf
+++ b/blueprints/modules/robotics/terraform/variables.tf
@@ -109,8 +109,8 @@ variable "node_count" {
variable "node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v5."
- default = "Standard_D8ds_v5"
+ description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v6."
+ default = "Standard_D8ds_v6"
}
variable "subnet_address_prefixes_aks" {
@@ -777,8 +777,8 @@ variable "vm_host_count" {
variable "vm_sku_size" {
type = string
- description = "VM SKU size for the host. Examples: Standard_D8s_v3 (general purpose), Standard_NV36ads_A10_v5 (GPU workload)"
- default = "Standard_D8s_v3"
+ description = "VM SKU size for the host. Examples: Standard_D8s_v6 (general purpose), Standard_NV36ads_A10_v5 (GPU workload)"
+ default = "Standard_D8s_v6"
}
variable "vm_priority" {
diff --git a/blueprints/only-cloud-single-node-cluster/terraform/README.md b/blueprints/only-cloud-single-node-cluster/terraform/README.md
index e19cb05b..5bfe57d4 100644
--- a/blueprints/only-cloud-single-node-cluster/terraform/README.md
+++ b/blueprints/only-cloud-single-node-cluster/terraform/README.md
@@ -43,7 +43,7 @@ This blueprint deploys a complete end-to-end cloud environment as preparation fo
| 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. Default is Standard\_D8ds\_v5. | `string` | `"Standard_D8ds_v5"` | no |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v6. | `string` | `"Standard_D8ds_v6"` | no |
| resource\_group\_name | Name of the resource group | `string` | `null` | no |
| should\_create\_aks | Should create Azure Kubernetes Service. Default is false. | `bool` | `false` | no |
| should\_create\_azure\_functions | Whether to create the Azure Functions resources including App Service Plan | `bool` | `false` | no |
diff --git a/blueprints/only-cloud-single-node-cluster/terraform/main.tf b/blueprints/only-cloud-single-node-cluster/terraform/main.tf
index 3b4ec96b..0db25baf 100644
--- a/blueprints/only-cloud-single-node-cluster/terraform/main.tf
+++ b/blueprints/only-cloud-single-node-cluster/terraform/main.tf
@@ -38,6 +38,8 @@ module "cloud_security_identity" {
should_create_key_vault_private_endpoint = var.should_enable_private_endpoints
key_vault_private_endpoint_subnet_id = var.should_enable_private_endpoints ? module.cloud_networking.subnet_id : null
key_vault_virtual_network_id = var.should_enable_private_endpoints ? module.cloud_networking.virtual_network.id : null
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_observability" {
@@ -76,7 +78,9 @@ module "cloud_messaging" {
resource_prefix = var.resource_prefix
instance = var.instance
- should_create_azure_functions = var.should_create_azure_functions
+ should_create_azure_functions = var.should_create_azure_functions
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_networking" {
@@ -126,6 +130,8 @@ module "cloud_acr" {
should_create_acr_private_endpoint = var.should_enable_private_endpoints
default_outbound_access_enabled = local.default_outbound_access_enabled
should_enable_nat_gateway = var.should_enable_managed_outbound_access
+ log_analytics_workspace_id = module.cloud_observability.log_analytics_workspace.id
+ should_enable_diagnostic_settings = true
}
module "cloud_kubernetes" {
diff --git a/blueprints/only-cloud-single-node-cluster/terraform/variables.tf b/blueprints/only-cloud-single-node-cluster/terraform/variables.tf
index 871a9770..eb682117 100644
--- a/blueprints/only-cloud-single-node-cluster/terraform/variables.tf
+++ b/blueprints/only-cloud-single-node-cluster/terraform/variables.tf
@@ -53,8 +53,8 @@ variable "node_count" {
variable "node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v5."
- default = "Standard_D8ds_v5"
+ description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v6."
+ default = "Standard_D8ds_v6"
}
variable "enable_auto_scaling" {
diff --git a/blueprints/only-output-cncf-cluster-script/terraform/README.md b/blueprints/only-output-cncf-cluster-script/terraform/README.md
index c396ef67..f093117b 100644
--- a/blueprints/only-output-cncf-cluster-script/terraform/README.md
+++ b/blueprints/only-output-cncf-cluster-script/terraform/README.md
@@ -33,25 +33,27 @@ them to Key Vault as secrets for secure storage and retrieval.
## Inputs
-| Name | Description | Type | Default | Required |
-|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|---------|:--------:|
-| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
-| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
-| aio\_resource\_group\_name | The name of the Resource Group that will be used to connect the new cluster to Azure Arc. Otherwise, 'rg-{var.resource\_prefix}-{var.environment}-{var.instance}'. Does not need to exist for output script. | `string` | `null` | no |
-| arc\_onboarding\_identity\_name | The Principal ID for the identity that will be used for onboarding the cluster to Arc. | `string` | `null` | no |
-| arc\_onboarding\_sp | n/a | ```object({ client_id = string object_id = string client_secret = string })``` | `null` | no |
-| cluster\_admin\_oid | The Object ID that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user Object ID if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | `null` | no |
-| cluster\_admin\_upn | The User Principal Name that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user UPN if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | `null` | no |
-| cluster\_server\_host\_machine\_username | 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` | `null` | no |
-| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant. If none is provided, the script will attempt to retrieve this requiring '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 |
-| enable\_arc\_auto\_upgrade | Enable or disable auto-upgrades of Arc agents. (Otherwise, 'false' for 'env=prod' else 'true' for all other envs). | `bool` | `null` | no |
-| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
-| key\_vault\_name | The name of the Key Vault to store secrets. If not provided, defaults to 'kv-{resource\_prefix}-{environment}-{instance}' | `string` | `null` | no |
-| script\_output\_filepath | The location of where to write out the script file. (Otherwise, '{path.root}/out') | `string` | `null` | no |
-| should\_add\_current\_user\_cluster\_admin | Gives the current logged in user cluster-admin permissions with the new cluster. | `bool` | `true` | no |
-| should\_assign\_roles | Whether to assign Key Vault roles to identity or service principal. | `bool` | `false` | no |
-| should\_get\_custom\_locations\_oid | Whether to get Custom Locations Object ID using Terraform's azuread provider. (Otherwise, provided by 'custom\_locations\_oid' or `az connectedk8s enable-features` for custom-locations on cluster setup if not provided.) | `bool` | `true` | no |
-| should\_output\_cluster\_node\_script | Whether to write out the script for setting up cluster node host machines. (Needed for multi-node clusters) | `bool` | `false` | no |
-| should\_output\_cluster\_server\_script | Whether to write out the script for setting up the cluster server host machine. | `bool` | `true` | no |
-| should\_upload\_to\_key\_vault | Whether to upload the scripts to Key Vault as secrets. | `bool` | `false` | no |
+| Name | Description | Type | Default | Required |
+|--------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------|----------|:--------:|
+| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
+| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| aio\_resource\_group\_name | The name of the Resource Group that will be used to connect the new cluster to Azure Arc. Otherwise, 'rg-{var.resource\_prefix}-{var.environment}-{var.instance}'. Does not need to exist for output script. | `string` | `null` | no |
+| arc\_onboarding\_identity\_name | The Principal ID for the identity that will be used for onboarding the cluster to Arc. | `string` | `null` | no |
+| arc\_onboarding\_sp | n/a | ```object({ client_id = string object_id = string client_secret = string })``` | `null` | no |
+| cluster\_admin\_group\_oid | The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy' | `string` | `null` | no |
+| cluster\_admin\_oid | The Object ID that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user Object ID if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | `null` | no |
+| cluster\_admin\_oid\_type | The principal type of cluster\_admin\_oid for Azure RBAC assignments. Ignored when using current user (defaults to 'User') | `string` | `"User"` | no |
+| cluster\_admin\_upn | The User Principal Name that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user UPN if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | `null` | no |
+| cluster\_server\_host\_machine\_username | 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` | `null` | no |
+| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant. If none is provided, the script will attempt to retrieve this requiring '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 |
+| enable\_arc\_auto\_upgrade | Enable or disable auto-upgrades of Arc agents. (Otherwise, 'false' for 'env=prod' else 'true' for all other envs). | `bool` | `null` | no |
+| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
+| key\_vault\_name | The name of the Key Vault to store secrets. If not provided, defaults to 'kv-{resource\_prefix}-{environment}-{instance}' | `string` | `null` | no |
+| script\_output\_filepath | The location of where to write out the script file. (Otherwise, '{path.root}/out') | `string` | `null` | no |
+| should\_add\_current\_user\_cluster\_admin | Gives the current logged in user cluster-admin permissions with the new cluster. | `bool` | `true` | no |
+| should\_assign\_roles | Whether to assign Key Vault roles to identity or service principal. | `bool` | `false` | no |
+| should\_get\_custom\_locations\_oid | Whether to get Custom Locations Object ID using Terraform's azuread provider. (Otherwise, provided by 'custom\_locations\_oid' or `az connectedk8s enable-features` for custom-locations on cluster setup if not provided.) | `bool` | `true` | no |
+| should\_output\_cluster\_node\_script | Whether to write out the script for setting up cluster node host machines. (Needed for multi-node clusters) | `bool` | `false` | no |
+| should\_output\_cluster\_server\_script | Whether to write out the script for setting up the cluster server host machine. | `bool` | `true` | no |
+| should\_upload\_to\_key\_vault | Whether to upload the scripts to Key Vault as secrets. | `bool` | `false` | no |
diff --git a/blueprints/only-output-cncf-cluster-script/terraform/main.tf b/blueprints/only-output-cncf-cluster-script/terraform/main.tf
index 08910445..5c3b0c34 100644
--- a/blueprints/only-output-cncf-cluster-script/terraform/main.tf
+++ b/blueprints/only-output-cncf-cluster-script/terraform/main.tf
@@ -48,7 +48,9 @@ module "edge_cncf_cluster" {
should_add_current_user_cluster_admin = var.should_add_current_user_cluster_admin
should_assign_roles = var.should_assign_roles
cluster_admin_oid = var.cluster_admin_oid
+ cluster_admin_oid_type = var.cluster_admin_oid_type
cluster_admin_upn = var.cluster_admin_upn
+ cluster_admin_group_oid = var.cluster_admin_group_oid
script_output_filepath = var.script_output_filepath
should_get_custom_locations_oid = var.should_get_custom_locations_oid
diff --git a/blueprints/only-output-cncf-cluster-script/terraform/variables.tf b/blueprints/only-output-cncf-cluster-script/terraform/variables.tf
index 4c17a02d..ab0f1a68 100644
--- a/blueprints/only-output-cncf-cluster-script/terraform/variables.tf
+++ b/blueprints/only-output-cncf-cluster-script/terraform/variables.tf
@@ -106,12 +106,28 @@ variable "cluster_admin_oid" {
default = null
}
+variable "cluster_admin_oid_type" {
+ type = string
+ description = "The principal type of cluster_admin_oid for Azure RBAC assignments. Ignored when using current user (defaults to 'User')"
+ default = "User"
+ validation {
+ condition = contains(["User", "Group", "ServicePrincipal"], var.cluster_admin_oid_type)
+ error_message = "Must be one of: User, Group, ServicePrincipal"
+ }
+}
+
variable "cluster_admin_upn" {
type = string
description = "The User Principal Name that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user UPN if 'should_add_current_user_cluster_admin=true')"
default = null
}
+variable "cluster_admin_group_oid" {
+ type = string
+ description = "The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy'"
+ default = null
+}
+
variable "should_output_cluster_server_script" {
type = bool
description = "Whether to write out the script for setting up the cluster server host machine."
diff --git a/blueprints/partial-single-node-cluster/terraform/README.md b/blueprints/partial-single-node-cluster/terraform/README.md
index 800170b3..b261346a 100644
--- a/blueprints/partial-single-node-cluster/terraform/README.md
+++ b/blueprints/partial-single-node-cluster/terraform/README.md
@@ -37,6 +37,7 @@ This blueprint will:
| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
| location | Azure region where all resources will be deployed | `string` | n/a | yes |
| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| cluster\_admin\_group\_oid | The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy' | `string` | `null` | no |
| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant. If none is provided, the script will attempt to retrieve this requiring '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 |
| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
| should\_add\_current\_user\_cluster\_admin | Gives the current logged in user cluster-admin permissions with the new cluster. | `bool` | `true` | no |
diff --git a/blueprints/partial-single-node-cluster/terraform/main.tf b/blueprints/partial-single-node-cluster/terraform/main.tf
index d21dbd87..e67a54ab 100644
--- a/blueprints/partial-single-node-cluster/terraform/main.tf
+++ b/blueprints/partial-single-node-cluster/terraform/main.tf
@@ -85,6 +85,7 @@ module "edge_cncf_cluster" {
should_get_custom_locations_oid = var.should_get_custom_locations_oid
custom_locations_oid = var.custom_locations_oid
should_add_current_user_cluster_admin = var.should_add_current_user_cluster_admin
+ cluster_admin_group_oid = var.cluster_admin_group_oid
// Key Vault configuration
key_vault = module.cloud_security_identity.key_vault
diff --git a/blueprints/partial-single-node-cluster/terraform/variables.tf b/blueprints/partial-single-node-cluster/terraform/variables.tf
index b16fcc72..51ea351d 100644
--- a/blueprints/partial-single-node-cluster/terraform/variables.tf
+++ b/blueprints/partial-single-node-cluster/terraform/variables.tf
@@ -63,6 +63,12 @@ variable "should_add_current_user_cluster_admin" {
default = true
}
+variable "cluster_admin_group_oid" {
+ type = string
+ description = "The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy'"
+ default = null
+}
+
variable "should_enable_private_endpoints" {
type = bool
description = "Whether to enable private endpoints for Key Vault and Storage Account"
diff --git a/blueprints/robotics/terraform/README.md b/blueprints/robotics/terraform/README.md
index 7ff56010..154ab164 100644
--- a/blueprints/robotics/terraform/README.md
+++ b/blueprints/robotics/terraform/README.md
@@ -41,7 +41,7 @@ and optional Azure Machine Learning integration.
| min\_count | The minimum number of nodes which should exist in the default node pool. Valid values are between 0 and 1000 | `number` | `null` | 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 = optional(number, null) 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) priority = optional(string, "Regular") zones = optional(list(string), null) eviction_policy = optional(string, "Deallocate") gpu_driver = optional(string, null) }))``` | `{}` | no |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v5 | `string` | `"Standard_D8ds_v5"` | no |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v6 | `string` | `"Standard_D8ds_v6"` | 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 |
@@ -94,7 +94,7 @@ and optional Azure Machine Learning integration.
| vm\_host\_count | Number of VM hosts to create | `number` | `1` | no |
| vm\_max\_bid\_price | Maximum hourly price for Spot VM (-1 for Azure default) | `number` | `-1` | no |
| vm\_priority | VM priority: Regular or Spot for cost optimization | `string` | `"Regular"` | no |
-| vm\_sku\_size | VM SKU size for the host | `string` | `"Standard_D8s_v3"` | no |
+| vm\_sku\_size | VM SKU size for the host | `string` | `"Standard_D8s_v6"` | no |
| vpn\_site\_connections | Site-to-site VPN site definitions for connecting on-premises networks | ```list(object({ name = string address_spaces = list(string) shared_key_reference = string gateway_ip_address = optional(string) gateway_fqdn = optional(string) bgp_asn = optional(number) bgp_peering_address = optional(string) ike_protocol = optional(string, "IKEv2") }))``` | `[]` | no |
| vpn\_site\_default\_ipsec\_policy | Fallback IPsec policy applied when vpn\_site\_connections 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-to-site VPN connections indexed by connection name | `map(string)` | `{}` | no |
diff --git a/blueprints/robotics/terraform/variables.tf b/blueprints/robotics/terraform/variables.tf
index d2226a5f..c45295eb 100644
--- a/blueprints/robotics/terraform/variables.tf
+++ b/blueprints/robotics/terraform/variables.tf
@@ -320,8 +320,8 @@ variable "subnet_address_prefixes_aks_pod" {
variable "node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v5"
- default = "Standard_D8ds_v5"
+ description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v6"
+ default = "Standard_D8ds_v6"
}
variable "node_count" {
@@ -510,7 +510,7 @@ variable "vm_host_count" {
variable "vm_sku_size" {
type = string
description = "VM SKU size for the host"
- default = "Standard_D8s_v3"
+ default = "Standard_D8s_v6"
}
variable "vm_priority" {
diff --git a/blueprints/video-capture-query/README.md b/blueprints/video-capture-query/README.md
new file mode 100644
index 00000000..5a6ad0b4
--- /dev/null
+++ b/blueprints/video-capture-query/README.md
@@ -0,0 +1,852 @@
+---
+title: Video Capture Query Blueprint
+description: Complete end-to-end deployment for continuous video recording from cameras with time-based query capabilities enabling Data Scientists to retrieve historical video segments
+author: Edge AI Team
+ms.date: 2026-01-08
+ms.topic: reference
+keywords:
+ - video capture
+ - camera recording
+ - time-based query
+ - azure storage
+ - azure functions
+ - ffmpeg
+ - continuous recording
+ - iot operations
+estimated_reading_time: 15
+---
+
+## Video Capture Query Blueprint
+
+This blueprint provides a complete solution for continuous video recording from cameras with time-based query capabilities. It enables Data Scientists to request video from specific cameras at specific locations for defined timeframes (e.g., "capture video from camera-01 at location-a on January 20th from 10:00 to 10:30").
+
+The solution combines edge recording infrastructure with cloud storage and query APIs to deliver historical video segments on demand.
+
+## Architecture
+
+This blueprint implements a continuous recording architecture that captures video 24/7 and stores segments in Azure Blob Storage with automatic lifecycle management for cost optimization.
+
+### High-Level Architecture
+
+```mermaid
+graph TB
+ subgraph DS["👨🔬 Data Scientist"]
+ Request["🔍 Query Request
'Get video from camera-01
on Jan 20, 10:00-10:30'"]
+ Analysis["📊 Video Analysis
Jupyter/Python"]
+ end
+
+ subgraph Edge["🏭 Edge Environment (Factory/Site)"]
+ subgraph Cameras["📹 Camera Infrastructure"]
+ Cam1["Camera-01
ONVIF/RTSP"]
+ Cam2["Camera-02
ONVIF/RTSP"]
+ CamN["Camera-N
ONVIF/RTSP"]
+ end
+
+ subgraph K8s["☸️ Kubernetes (K3s/AKS-EE)"]
+ AIO["Azure IoT Operations
Device Registry
MQTT Broker"]
+ MediaSvc["Media Capture Service
Continuous Recording
5-min segments"]
+ end
+ end
+
+ subgraph Azure["☁️ Azure Cloud"]
+ subgraph Storage["💾 Blob Storage"]
+ Hot["Hot Tier (0-7d)
Fast Access"]
+ Cool["Cool Tier (7-30d)
Lower Cost"]
+ Archive["Archive Tier (30-365d)
Lowest Cost"]
+ end
+
+ VideoAPI["⚡ Video Query API
Azure Function
FFmpeg Stitching"]
+
+ subgraph ML["🤖 Optional Enhancement"]
+ VideoIndexer["Azure Video Indexer
AI-Powered Search"]
+ end
+ end
+
+ Cam1 -->|RTSP Stream| MediaSvc
+ Cam2 -->|RTSP Stream| MediaSvc
+ CamN -->|RTSP Stream| MediaSvc
+
+ AIO <-->|MQTT| MediaSvc
+ MediaSvc -->|5-min segments| Hot
+
+ Hot -->|Auto-tier after 7d| Cool
+ Cool -->|Auto-tier after 30d| Archive
+
+ Request -->|REST API| VideoAPI
+ VideoAPI -->|Query blobs by timestamp| Storage
+ VideoAPI -->|Stitch segments| VideoAPI
+ VideoAPI -->|SAS URL| Request
+ Request -->|Download & Analyze| Analysis
+
+ Storage -.->|Optional| VideoIndexer
+ VideoIndexer -.->|Enhanced Search| Request
+
+ style Request fill:#fff9c4
+ style MediaSvc fill:#fff4e1
+ style VideoAPI fill:#f3e5f5
+ style Hot fill:#ffebee
+ style Cool fill:#e3f2fd
+ style Archive fill:#f3e5f5
+ style Analysis fill:#e8f5e9
+```
+
+### Component Integration Flow
+
+```mermaid
+graph TB
+ subgraph Edge["Edge Environment"]
+ Camera["📹 ONVIF Camera
camera-01
RTSP Stream"]
+ DeviceReg["🔧 Device Registry
111-assets
Camera Config"]
+ MediaCapture["🎬 Media Capture Service
503-media-capture-service
Continuous Recording"]
+ MQTTBroker["📨 MQTT Broker
Azure IoT Operations"]
+ ACSA["💾 ACSA Volume
/media-capture-backed-acsa
Edge-to-Cloud Auto-Sync"]
+ end
+
+ subgraph Cloud["Azure Cloud"]
+ BlobStorage["☁️ Blob Storage
Video Segments
Lifecycle Policies"]
+ VideoAPI["⚡ Video Query API
Azure Function
520-video-query-api"]
+ end
+
+ subgraph DataScientist["Data Scientist Workstation"]
+ SDK["🐍 Python SDK
920-video-query-sdk"]
+ Jupyter["📊 Jupyter Notebook
Analysis Tools"]
+ end
+
+ Camera -->|RTSP Stream| MediaCapture
+ DeviceReg -.->|Camera Config| Camera
+ MediaCapture -->|Subscribe| MQTTBroker
+ SDK -->|Publish Request| MQTTBroker
+ MQTTBroker -->|Capture Request| MediaCapture
+ MediaCapture -->|Write MP4 + JSON| ACSA
+ ACSA -->|Auto-Sync to Cloud| BlobStorage
+ MediaCapture -->|Response| MQTTBroker
+ MQTTBroker -->|Blob URL| SDK
+ SDK -->|Query Historical| VideoAPI
+ VideoAPI -->|List & Download| BlobStorage
+ VideoAPI -->|Stitch & Return| SDK
+ SDK -->|Video URL| Jupyter
+
+ style Camera fill:#e1f5ff
+ style MediaCapture fill:#fff4e1
+ style BlobStorage fill:#e8f5e9
+ style VideoAPI fill:#f3e5f5
+ style SDK fill:#fff9c4
+```
+
+### Blob Storage Organization & Lifecycle
+
+```mermaid
+graph TB
+ subgraph BlobContainer["📦 Blob Container: video-recordings"]
+ subgraph Hash1["342/"]
+ subgraph Cam1["camera-01/"]
+ subgraph Year1["2026/"]
+ subgraph Month1["01/"]
+ subgraph Day1["20/"]
+ Hour10["10/
segment_2026-01-20T10:00:00Z_camera-01.mp4
segment_2026-01-20T10:05:00Z_camera-01.mp4
segment_2026-01-20T10:10:00Z_camera-01.mp4"]
+ Hour11["11/
segment_2026-01-20T11:00:00Z_camera-01.mp4"]
+ end
+ Day21["21/"]
+ end
+ end
+ end
+ end
+
+ subgraph Hash2["789/"]
+ subgraph Cam2["camera-02/"]
+ subgraph Year2["2026/"]
+ subgraph Month2["01/"]
+ Day22["20/
10/
11/"]
+ end
+ end
+ end
+ end
+ end
+
+ subgraph Tags["🏷️ Blob Index Tags"]
+ Tag1["camera_id: camera-01
start_time: 2026-01-20T10:00:00Z
end_time: 2026-01-20T10:05:00Z"]
+ Tag2["camera_id: camera-01
start_time: 2026-01-20T10:05:00Z
end_time: 2026-01-20T10:10:00Z"]
+ end
+
+ Hour10 -.->|Tagged| Tag1
+ Hour10 -.->|Tagged| Tag2
+
+ subgraph Lifecycle["♻️ Lifecycle Policy"]
+ Hot["🔥 Hot (7 days)
$0.018/GB/month
Fast access"]
+ Cool["❄️ Cool (30 days)
$0.01/GB/month
Slower access"]
+ Archive["📦 Archive (90 days)
$0.0015/GB/month
Rare access"]
+ Delete["🗑️ Delete (365 days)
Auto-cleanup"]
+ end
+
+ Hour10 -->|Age: 0-7d| Hot
+ Hot -->|Age: 7-30d| Cool
+ Cool -->|Age: 30-90d| Archive
+ Archive -->|Age: >365d| Delete
+
+ style Hour10 fill:#e1f5ff
+ style Tag1 fill:#fff9c4
+ style Hot fill:#ffebee
+ style Cool fill:#e3f2fd
+ style Archive fill:#f3e5f5
+ style Delete fill:#fafafa
+```
+
+### Data Flow
+
+```mermaid
+flowchart LR
+ subgraph DataScientist["👨🔬 Data Scientist Workflow"]
+ DS_IDE["💻 Jupyter Notebook
Python SDK"]
+ DS_Query["📝 Query Definition
camera_id: camera-01
start: 2026-01-20T10:00
end: 2026-01-20T10:30"]
+ DS_Video["🎬 Video Analysis
Frame-by-frame
ML Inference"]
+ end
+
+ subgraph Cloud["☁️ Azure Cloud Processing"]
+ API["⚡ Video Query API
Azure Function"]
+ Blob["💾 Blob Storage
6 segments found
10:00-10:30"]
+ FFmpeg["🔧 FFmpeg Stitcher
Concat 6 segments"]
+ SAS["🔐 Generate SAS URL
24-hour expiry"]
+ end
+
+ subgraph Edge["🏭 Edge Recording"]
+ Camera["📹 Camera-01"]
+ Recorder["🎬 Continuous Recorder
24/7 Recording
5-min segments"]
+ ACSAVolume["💾 ACSA Volume
/media-capture-backed-acsa"]
+ Upload["📤 ACSA Auto-Sync
Background Upload"]
+ end
+
+ Camera -->|RTSP Stream| Recorder
+ Recorder -->|Write MP4 every 5min| ACSAVolume
+ ACSAVolume -->|Auto-Sync| Upload
+ Upload -->|Cloud Sync| Blob
+
+ DS_IDE -->|1. Define Query| DS_Query
+ DS_Query -->|2. REST API Call| API
+ API -->|3. List blobs| Blob
+ Blob -->|4. Return 6 segments| API
+ API -->|5. Download segments| Blob
+ API -->|6. Stitch| FFmpeg
+ FFmpeg -->|7. Merged video| API
+ API -->|8. Upload temp| Blob
+ Blob -->|9. Generate SAS| SAS
+ SAS -->|10. Return URL| API
+ API -->|11. Video URL| DS_IDE
+ DS_IDE -->|12. Download| Blob
+ DS_IDE -->|13. Analyze| DS_Video
+
+ style DS_IDE fill:#fff9c4
+ style API fill:#f3e5f5
+ style Blob fill:#c8e6c9
+ style FFmpeg fill:#e1f5ff
+ style Recorder fill:#fff4e1
+ style DS_Video fill:#ffebee
+```
+
+## Components
+
+This blueprint orchestrates the following cloud infrastructure components:
+
+| Component | Purpose | Source Location |
+|--------------------|--------------------------------------------------------------------|----------------------------------------------------------------------------|
+| **Resource Group** | Creates Azure resource group for all resources | [src/000-cloud/000-resource-group](../../src/000-cloud/000-resource-group) |
+| **Data Storage** | Azure Storage Account for video recordings with lifecycle policies | [src/000-cloud/030-data](../../src/000-cloud/030-data) |
+| **Messaging** | Azure Functions hosting for Video Query API | [src/000-cloud/040-messaging](../../src/000-cloud/040-messaging) |
+
+### Edge Components (Separate Deployment)
+
+The following components are deployed separately to the edge Kubernetes cluster:
+
+| Component | Purpose | Source Location |
+|---------------------------|-------------------------------------------------------------------|------------------------------------------------------------------------------------------------------|
+| **Camera Assets** | ONVIF camera registration in Azure IoT Operations Device Registry | [src/100-edge/111-assets](../../src/100-edge/111-assets) |
+| **Media Capture Service** | Continuous video recording service with FFmpeg and ACSA sync | [src/500-application/503-media-capture-service](../../src/500-application/503-media-capture-service) |
+
+### Application Components (Function Code Deployment)
+
+| Component | Purpose | Source Location |
+|---------------------|------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|
+| **Video Query API** | Azure Function for time-based video queries and FFmpeg stitching | [src/500-application/520-video-query-api](../../src/500-application/520-video-query-api) |
+| **Video Query SDK** | Python library for Data Scientists to query historical video | [src/900-tools-utilities/920-video-query-sdk](../../src/900-tools-utilities/920-video-query-sdk) |
+
+## Prerequisites
+
+Before deploying this blueprint, ensure you have:
+
+### Azure Subscription & Permissions
+
+* Azure subscription with sufficient quota for:
+ * Storage Account (Standard_LRS or higher)
+ * Function App (Consumption or Premium plan)
+ * Resource Group creation
+* Permissions to create resources:
+ * `Contributor` or `Owner` role on subscription or resource group
+ * `Storage Blob Data Contributor` for blob access
+
+### Tools & CLIs
+
+* **Terraform** >= 1.5.0 (for infrastructure deployment)
+* **Azure CLI** >= 2.50.0 (for authentication and resource management)
+* **kubectl** >= 1.27.0 (for edge Kubernetes deployment)
+* **Helm** >= 3.12.0 (for media capture service deployment)
+* **Azure Functions Core Tools** >= 4.0 (for Function App deployment)
+* **Python** >= 3.9 (for SDK usage)
+
+### Edge Infrastructure
+
+* Kubernetes cluster (K3s, AKS-EE, or AKS) with:
+ * Azure IoT Operations installed
+ * MQTT Broker running
+ * Device Registry configured
+* Network connectivity:
+ * Edge to Azure cloud (for ACSA sync)
+ * Edge to camera RTSP endpoints
+ * Cameras accessible via ONVIF/RTSP protocols
+
+### Camera Requirements
+
+* ONVIF-compliant IP cameras or
+* RTSP stream endpoints
+* Network accessibility from edge cluster
+* Credentials for authentication
+
+## Deployment
+
+### Step 1: Deploy Cloud Infrastructure (< 10 minutes)
+
+Deploy Azure resources for video storage and query API:
+
+```bash
+# Navigate to blueprint terraform directory
+cd blueprints/video-capture-query/terraform
+
+# Initialize Terraform
+terraform init
+
+# Review and customize variables (optional)
+cp terraform.tfvars.example terraform.tfvars
+# Edit terraform.tfvars with your values
+
+# Deploy infrastructure
+terraform apply \
+ -var="environment=prod" \
+ -var="resource_prefix=vidcap" \
+ -var="location=eastus" \
+ -var="recording_mode=continuous"
+
+# Capture outputs for edge configuration
+STORAGE_ACCOUNT=$(terraform output -raw storage_account_name)
+FUNCTION_URL=$(terraform output -raw video_query_function_url)
+```
+
+### Step 2: Deploy Edge Media Capture Service (< 5 minutes)
+
+Configure and deploy the continuous recording service to your edge Kubernetes cluster:
+
+```bash
+# Create namespace (if not exists)
+kubectl create namespace azure-iot-operations --dry-run=client -o yaml | kubectl apply -f -
+
+# Deploy media capture service with continuous recording
+helm install media-capture \
+ ../../src/500-application/503-media-capture-service/charts/media-capture-service \
+ --namespace azure-iot-operations \
+ --set mediaCapture.continuousRecording.enabled=true \
+ --set mediaCapture.continuousRecording.segmentDurationSeconds=300 \
+ --set mediaCapture.continuousRecording.localRetentionHours=24 \
+ --set mediaCapture.continuousRecording.cleanupIntervalMinutes=60 \
+ --set mediaCapture.storage.cloudSyncDir=/cloud-sync/video-recordings \
+ --set mediaCapture.video.rtspUrl="rtsp://192.168.1.100:554/stream1"
+
+# Verify deployment
+kubectl get pods -n azure-iot-operations -l app.kubernetes.io/name=media-capture-service
+```
+
+**Configuration Options**:
+
+* `segmentDurationSeconds`: Video segment duration (default: 300s / 5 min)
+* `localRetentionHours`: How long to keep files locally before cleanup (default: 24 hours)
+* `cleanupIntervalMinutes`: How often to check for old files to delete (default: 60 minutes)
+
+**Note**: ACSA automatically uploads video files from the mounted volume to Azure Blob Storage. No storage connection strings or secrets are required - ACSA uses the cluster's managed identity. Local retention cleanup prevents disk space exhaustion while maintaining a buffer for network interruptions.
+
+### Step 3: Deploy Video Query API Function (< 5 minutes)
+
+Deploy the Azure Function code for video query and stitching:
+
+```bash
+# Navigate to function app directory
+cd ../../src/500-application/520-video-query-api
+
+# Deploy function code
+func azure functionapp publish $FUNCTION_URL \
+ --python
+
+# Function app will use managed identity to access storage
+# No connection strings needed - automatically configured by Terraform
+
+# Verify deployment
+func azure functionapp list-functions $FUNCTION_URL
+```
+
+### Step 4: Install Data Scientist SDK (< 2 minutes)
+
+Install the Python SDK for querying historical video:
+
+```bash
+# Install from source
+cd ../../src/900-tools-utilities/920-video-query-sdk
+pip install -e .
+
+# Or install from wheel (if available)
+pip install video-query-sdk
+
+# Verify installation
+python -c "from video_query_sdk import VideoQueryClient; print('SDK installed successfully')"
+```
+
+### Step 5: Validate Deployment (< 5 minutes)
+
+Test the end-to-end video capture and query workflow:
+
+```bash
+# Wait for first segment to be recorded (5 minutes)
+sleep 300
+
+# Query recent video using Python SDK
+python < 10 seconds
+* Timeout errors for large timeframe queries
+
+**Diagnosis**:
+
+```bash
+# Check blob index tag availability
+az storage blob show \
+ --account-name $STORAGE_ACCOUNT \
+ --container-name video-recordings \
+ --name "camera-01/2026/01/20/10/segment_*.mp4" \
+ --query tags
+
+# Test query performance
+time az storage blob list \
+ --account-name $STORAGE_ACCOUNT \
+ --container-name video-recordings \
+ --prefix "camera-01/2026/01/20/10"
+```
+
+**Resolution**:
+
+* Verify blob index tags are enabled and populated
+* Use prefix-based queries for timeframes < 1 hour
+* Consider splitting large timeframe queries into smaller chunks
+* Upgrade Function App to Premium plan for better performance
+
+### Getting Help
+
+* **Component Documentation**:
+ * [Media Capture Service](../../src/500-application/503-media-capture-service/README.md)
+ * [Video Query API](../../src/500-application/520-video-query-api/README.md)
+ * [Video Query SDK](../../src/900-tools-utilities/920-video-query-sdk/README.md)
+* **Azure IoT Operations**: [Microsoft Learn Documentation](https://learn.microsoft.com/azure/iot-operations/)
+* **Azure Functions**: [Troubleshooting Guide](https://learn.microsoft.com/azure/azure-functions/functions-diagnostics)
+* **Azure Storage**: [Performance Troubleshooting](https://learn.microsoft.com/azure/storage/common/troubleshoot-storage-performance)
+
+## Advanced Configuration
+
+### Multiple Camera Deployment
+
+Configure multiple cameras in media capture service:
+
+```yaml
+# Helm values
+cameras:
+ - name: camera-01
+ rtspUrl: rtsp://192.168.1.100:554/stream1
+ location: factory-floor-a
+ - name: camera-02
+ rtspUrl: rtsp://192.168.1.101:554/stream1
+ location: factory-floor-b
+ - name: camera-03
+ rtspUrl: rtsp://192.168.1.102:554/stream1
+ location: warehouse-entrance
+```
+
+### Custom Video Encoding
+
+Optimize video encoding for your use case:
+
+```yaml
+# High quality (higher storage cost)
+videoEncoding:
+ codec: libx264
+ preset: slow
+ crf: 18
+
+# Balanced (default)
+videoEncoding:
+ codec: libx264
+ preset fast
+ crf: 23
+
+# Low storage (lower quality)
+videoEncoding:
+ codec: libx264
+ preset: ultrafast
+ crf: 28
+```
+
+### Private Endpoint Access
+
+Secure storage access using private endpoints:
+
+```hcl
+# Add to terraform configuration
+storage_account_network_rules = {
+ default_action = "Deny"
+ ip_rules = []
+ virtual_network_subnet_ids = [module.network.subnet_id]
+}
+
+storage_account_enable_private_endpoint = true
+```
+
+## Related Documentation
+
+* [Blueprint Overview](../README.md) - General blueprint deployment patterns
+* [Full Single Node Cluster Blueprint](../full-single-node-cluster/README.md) - Complete IoT Operations deployment
+* [Media Capture Service](../../src/500-application/503-media-capture-service/README.md) - Detailed component documentation
+* [Azure IoT Operations](https://learn.microsoft.com/azure/iot-operations/) - Platform documentation
+
+---
+
+
+*🤖 Crafted with precision by ✨Copilot following brilliant human instruction,
+then carefully refined by our team of discerning human reviewers.*
+
diff --git a/blueprints/video-capture-query/diagrams/01-high-level-architecture.drawio b/blueprints/video-capture-query/diagrams/01-high-level-architecture.drawio
new file mode 100644
index 00000000..b8eb1f12
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/01-high-level-architecture.drawio
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/blueprints/video-capture-query/diagrams/01-high-level-architecture.md b/blueprints/video-capture-query/diagrams/01-high-level-architecture.md
new file mode 100644
index 00000000..f8261a32
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/01-high-level-architecture.md
@@ -0,0 +1,73 @@
+# High-Level Architecture Diagram
+
+This diagram provides a complete overview of the video capture query solution, showing all major components and their relationships.
+
+## System Components
+
+### Data Scientist Environment
+
+- **Query Request**: Data Scientists submit queries like "Get video from camera-01 on Jan 20, 10:00-10:30"
+- **Video Analysis**: Jupyter/Python notebooks for analyzing retrieved video segments
+
+### Edge Environment (Factory/Site)
+
+#### Camera Infrastructure
+
+- **Camera-01, Camera-02, Camera-N**: ONVIF/RTSP cameras continuously streaming video
+- Multiple camera support with standardized ONVIF protocol
+
+#### Kubernetes Cluster (K3s/AKS-EE)
+
+- **Azure IoT Operations**:
+ - Device Registry for camera management
+ - MQTT Broker for communication
+- **Media Capture Service**:
+ - Continuous recording of camera streams
+ - Creates 5-minute video segments
+ - Writes to ACSA volume for automatic cloud sync
+
+### Azure Cloud
+
+#### Blob Storage (Multi-Tier)
+
+- **Hot Tier (0-7 days)**: Fast access for recent video, $0.018/GB/month
+- **Cool Tier (7-30 days)**: Lower cost for less frequently accessed video, $0.01/GB/month
+- **Archive Tier (30-365 days)**: Lowest cost for long-term storage, $0.0015/GB/month
+- **Automatic lifecycle management** moves data between tiers based on age
+
+#### Video Query API
+
+- Azure Function for processing historical video queries
+- FFmpeg-based video stitching for assembling segments into continuous clips
+
+#### Optional Enhancement
+
+- **Azure Video Indexer**: AI-powered search and indexing for advanced video analysis
+
+## Data Flow Patterns
+
+### Continuous Recording Flow
+
+```text
+Cameras → Media Capture Service → ACSA → Hot Storage → Cool Storage → Archive Storage
+```
+
+### Query Flow
+
+```text
+Data Scientist → Video Query API → Blob Storage → Stitched Video → Analysis
+```
+
+### Optional AI Enhancement
+
+```text
+Blob Storage → Video Indexer → Enhanced Search Capabilities
+```
+
+## Key Architecture Principles
+
+1. **Continuous Recording**: 24/7 recording with automatic segmentation
+2. **Cost Optimization**: Automatic lifecycle management reduces storage costs by 91% over one year
+3. **ACSA Integration**: Seamless edge-to-cloud sync without custom code
+4. **Flexible Querying**: Time-based queries for precise video retrieval
+5. **Scalability**: Support for multiple cameras with hash-based storage distribution
diff --git a/blueprints/video-capture-query/diagrams/02-component-integration-flow.drawio b/blueprints/video-capture-query/diagrams/02-component-integration-flow.drawio
new file mode 100644
index 00000000..7b9796c1
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/02-component-integration-flow.drawio
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/blueprints/video-capture-query/diagrams/02-component-integration-flow.md b/blueprints/video-capture-query/diagrams/02-component-integration-flow.md
new file mode 100644
index 00000000..c74a4163
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/02-component-integration-flow.md
@@ -0,0 +1,259 @@
+# Component Integration Flow Diagram
+
+This diagram shows how all system components integrate together, illustrating the continuous recording flow, on-demand query capabilities, and data scientist analysis workflow.
+
+## System Components
+
+### Edge Environment
+
+#### 📹 ONVIF Camera (camera-01)
+
+- **Purpose**: Video source providing continuous RTSP stream
+- **Protocol**: RTSP (Real-Time Streaming Protocol)
+- **Output**: H.264 video at 1920x1080, 30fps
+- **Component**: Physical camera device
+
+#### 🔧 Device Registry (111-assets)
+
+- **Purpose**: Camera configuration and metadata management
+- **Function**: Stores camera URLs, credentials, and streaming settings
+- **Component**: Azure IoT Operations asset management
+
+#### 🎬 Media Capture Service (503-media-capture-service)
+
+- **Purpose**: Continuous video recording with segmentation and local retention management
+- **Functions**:
+ - Captures RTSP streams from cameras
+ - Segments video into 5-minute MP4 files
+ - Writes segments to ACSA volume
+ - Automatic cleanup of old local files (configurable retention period)
+ - Subscribes to MQTT query requests
+ - Publishes query responses
+- **Component**: Custom Rust application running in Kubernetes
+
+#### 📨 MQTT Broker
+
+- **Purpose**: Message hub for edge communication
+- **Functions**: Real-time query request/response messaging
+- **Component**: Azure IoT Operations MQTT broker
+
+#### 💾 ACSA Volume
+
+- **Purpose**: Edge-to-cloud storage synchronization
+- **Path**: `/cloud-sync/video-recordings/`
+- **Functions**:
+ - Local storage for video segments (with automatic retention cleanup)
+ - Automatic background sync to Azure Blob Storage
+ - Offline buffering during network outages
+ - Retry logic for failed uploads
+- **Component**: Azure Container Storage enabled by Arc
+
+### Azure Cloud
+
+#### ☁️ Blob Storage
+
+- **Purpose**: Centralized video segment storage with lifecycle management
+- **Features**:
+ - Multi-tier storage (Hot/Cool/Archive)
+ - Automatic tier transitions based on age
+ - Blob index for efficient querying
+ - 91% cost reduction over one year
+- **Component**: Azure Storage Account
+
+#### ⚡ Video Query API (520-video-query-api)
+
+- **Purpose**: Historical video query and stitching service
+- **Functions**:
+ - Query blob storage by time range
+ - Download video segments
+ - Stitch segments with FFmpeg
+ - Return single continuous video file
+- **Component**: Azure Function (Python)
+
+### Data Scientist Workstation
+
+#### 🐍 Python SDK (920-video-query-sdk)
+
+- **Purpose**: Developer interface for video queries
+- **Functions**:
+ - Submit queries via MQTT (real-time) or REST (historical)
+ - Handle SAS URL generation
+ - Provide simple Python API
+- **Component**: Python library
+
+#### 📊 Jupyter Notebook
+
+- **Purpose**: Video analysis and visualization
+- **Functions**: Load videos, perform CV analysis, visualize results
+- **Component**: Data science environment
+
+## Data Flow Steps
+
+### Continuous Recording Flow
+
+### Step 1: Video Streaming
+
+- Camera streams RTSP video continuously to Media Capture Service
+- Connection maintained 24/7 with automatic reconnection
+
+### Step 2: Write to ACSA
+
+- Media Capture Service writes MP4 segments to ACSA volume
+- Each segment is 5 minutes duration
+- Companion JSON metadata file created for each segment
+
+### Step 3: Auto-Sync to Cloud
+
+- ACSA automatically syncs files to Azure Blob Storage
+- Sync happens in background (typically within 1-2 minutes)
+- Handles network failures with retry logic
+- No application code needed for sync
+
+### Step 4: Lifecycle Management
+
+- Blob Storage automatically moves files between tiers:
+ - Days 0-7: Hot tier (immediate access)
+ - Days 7-30: Cool tier (lower cost)
+ - Days 30-365: Archive tier (lowest cost)
+ - After 365 days: Automatic deletion
+
+### Step 5: Local Retention Cleanup
+
+- Media Capture Service runs background cleanup task (default: hourly)
+- Deletes local segments older than retention period (default: 24 hours)
+- Removes empty timestamp directories after cleanup
+- Prevents disk space exhaustion on edge nodes
+- Cloud-synced files remain available in Blob Storage
+
+### On-Demand Query Flow (MQTT Path)
+
+### Step 6: Query Request
+
+- Data Scientist uses Python SDK to submit query
+- Example: `get_video("camera-01", "2026-01-20T10:00:00Z", "2026-01-20T10:30:00Z")`
+
+### Step 7: Publish to MQTT
+
+- SDK publishes query request to MQTT broker
+- Topic: `video/query/request`
+- Payload includes camera ID, start time, end time
+
+### Step 8: Media Capture Receives Request
+
+- Media Capture Service subscribed to request topic
+- Validates time range
+- Checks if segments exist locally or in cloud
+
+### Step 9: Generate URLs
+
+- Creates SAS (Shared Access Signature) URLs for each segment
+- URLs provide temporary access (1 hour validity)
+- Returns list of segment URLs
+
+### Step 10: Response via MQTT
+
+- Publishes response to `video/query/response` topic
+- SDK receives list of blob URLs
+- Data Scientist downloads segments directly from Blob Storage
+
+### On-Demand Query Flow (REST API Path)
+
+### Step 11: Query via REST API
+
+- Alternative to MQTT: HTTP POST to Video Query API
+- Used when MQTT connectivity not available or stitched video preferred
+
+### Step 12: API Processing
+
+- Query Blob Storage for matching segments
+- Download all segments
+- Stitch together using FFmpeg
+- Upload stitched video to temporary blob
+
+### Step 13: Return Stitched Video
+
+- Returns single SAS URL to stitched video
+- Data Scientist downloads one continuous video file
+- Simplifies analysis compared to multiple segments
+
+### Analysis Flow
+
+### Step 13: Video Analysis
+
+- Data Scientist loads video in Jupyter
+- Performs computer vision analysis:
+ - Object detection
+ - Motion tracking
+ - Event detection
+ - Quality metrics
+
+## Component Interactions
+
+### Media Capture Service Interactions
+
+- **Inbound**:
+ - RTSP video from cameras (continuous)
+ - MQTT query requests (on-demand)
+ - Configuration from Device Registry (startup)
+- **Outbound**:
+ - Video segments to ACSA volume (every 5 minutes)
+ - MQTT query responses (on-demand)
+ - Status updates to MQTT (periodic)
+
+### ACSA Volume Interactions
+
+- **Inbound**:
+ - Video segments from Media Capture Service
+ - Metadata JSON files
+- **Outbound**:
+ - Automatic sync to Blob Storage (background)
+ - Retry on failures
+
+### Video Query API Interactions
+
+- **Inbound**:
+ - HTTP query requests from SDK
+- **Outbound**:
+ - Blob storage queries (Blob Index)
+ - Segment downloads
+ - Stitched video uploads
+ - SAS URL responses
+
+### Python SDK Interactions
+
+- **MQTT Mode**:
+ - Publish to MQTT broker (request)
+ - Subscribe to MQTT broker (response)
+ - Direct downloads from Blob Storage URLs
+- **REST Mode**:
+ - HTTP POST to Video Query API
+ - Download stitched video from returned URL
+
+## Key Architecture Points
+
+### 1. ACSA Centrality
+
+- **No custom sync code**: Application just writes to local volume
+- **Automatic resilience**: Built-in retry and offline buffering
+- **Transparent operation**: Apps don't know they're writing to cloud storage
+- **Optimized sync**: Batching and compression handled automatically
+
+### 2. MQTT as Edge Hub
+
+- **Low latency**: Sub-second query response times
+- **Pub/sub pattern**: Loose coupling between components
+- **Local communication**: All edge traffic stays on-premises
+- **Standardized protocol**: Easy integration with other systems
+
+### 3. Dual Query Paths
+
+- **MQTT**: Fast, returns multiple segments, requires edge connectivity
+- **REST API**: Slower, returns stitched video, works from anywhere
+- **Choice**: Data Scientist picks based on use case
+
+### 4. Component Modularity
+
+- **111-assets**: Camera management can be updated independently
+- **503-media-capture-service**: Recording logic isolated
+- **520-video-query-api**: Query/stitching can scale separately
+- **920-video-query-sdk**: Client library versioned independently
diff --git a/blueprints/video-capture-query/diagrams/03-blob-storage-organization.drawio b/blueprints/video-capture-query/diagrams/03-blob-storage-organization.drawio
new file mode 100644
index 00000000..4e1737c9
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/03-blob-storage-organization.drawio
@@ -0,0 +1,169 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/blueprints/video-capture-query/diagrams/03-blob-storage-organization.md b/blueprints/video-capture-query/diagrams/03-blob-storage-organization.md
new file mode 100644
index 00000000..112107e9
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/03-blob-storage-organization.md
@@ -0,0 +1,144 @@
+# Blob Storage Organization Diagram
+
+This diagram shows the hierarchical structure and organization of video segments in Azure Blob Storage, including path organization, metadata, and lifecycle management.
+
+## Container Structure
+
+**Container Name**: `media-capture-data`
+
+### Path Hierarchy
+
+```text
+media-capture-data/
+├── a1/ # Hash prefix (2 hex chars)
+│ └── camera-01/ # Camera ID
+│ └── 2026/ # Year
+│ └── 01/ # Month
+│ └── 20/ # Day
+│ ├── 10/ # Hour
+│ │ ├── video-2026-01-20T10-00-00Z.mp4 # Video segment
+│ │ ├── video-2026-01-20T10-00-00Z.json # Metadata
+│ │ ├── video-2026-01-20T10-05-00Z.mp4
+│ │ └── video-2026-01-20T10-05-00Z.json
+│ └── 11/
+│ └── ...
+└── b2/ # Different hash prefix
+ └── camera-02/
+ └── ...
+```
+
+## Storage Organization Features
+
+### 1. Hash Prefix Distribution
+
+- **Purpose**: Load balancing and performance optimization
+- **Format**: First 2 characters of MD5 hash of camera ID
+- **Example**: camera-01 → MD5(camera-01) → a1b2c3d4... → prefix "a1"
+- **Benefit**: Distributes camera data across multiple storage partitions
+
+### 2. Hierarchical Folder Structure
+
+- **Year/Month/Day/Hour**: Logical organization for time-based queries
+- **Fast navigation** to specific time ranges
+- **Efficient indexing** with Blob Index Tags
+
+### 3. File Naming Convention
+
+```text
+video-{YYYY}-{MM}-{DD}T{HH}-{mm}-{ss}Z.mp4
+```
+
+- ISO 8601 format with UTC timezone
+- Sortable and parseable filenames
+- Example: `video-2026-01-20T10-00-00Z.mp4`
+
+### 4. Metadata Files
+
+Each video segment has a companion JSON metadata file:
+
+```json
+{
+ "camera_id": "camera-01",
+ "location": "Building-A/Floor-2/Zone-3",
+ "segment_start": "2026-01-20T10:00:00Z",
+ "segment_end": "2026-01-20T10:05:00Z",
+ "duration_seconds": 300,
+ "file_path": "/media-capture-backed-acsa/a1/camera-01/2026/01/20/10/video-2026-01-20T10-00-00Z.mp4"
+}
+```
+
+**Metadata Field Descriptions**:
+
+- `camera_id`: Unique identifier for the camera
+- `location`: Physical location of the camera (e.g., building/floor/zone)
+- `segment_start`: Recording start timestamp in ISO 8601 format (UTC)
+- `segment_end`: Recording end timestamp in ISO 8601 format (UTC)
+- `duration_seconds`: Segment duration in seconds (calculated from end - start)
+- `file_path`: Full ACSA volume path to the video file
+
+## Blob Index Tags
+
+Each video segment is tagged for efficient querying:
+
+| Tag Name | Example Value | Purpose |
+|------------------|----------------------|----------------------------------|
+| camera_id | camera-01 | Filter by specific camera |
+| start_time | 2026-01-20T10:00:00Z | Filter by time range |
+| duration_seconds | 300 | Find segments of specific length |
+| tier | hot/cool/archive | Track current storage tier |
+
+**Query Example**:
+
+```text
+camera_id = 'camera-01' AND
+start_time >= '2026-01-20T10:00:00Z' AND
+start_time < '2026-01-20T11:00:00Z'
+```
+
+## Lifecycle Management Policies
+
+### Automatic Tier Transitions
+
+| Storage Tier | Age Range | Cost per GB/month | Transition Rule |
+|--------------|-------------|-------------------|-----------------|
+| Hot | 0-7 days | $0.018 | Initial upload |
+| Cool | 7-30 days | $0.01 | After 7 days |
+| Archive | 30-365 days | $0.0015 | After 30 days |
+| Deleted | > 365 days | $0 | After 1 year |
+
+### Transition Details
+
+1. **Day 0**: Upload to Hot tier (immediate access)
+2. **Day 7**: Move to Cool tier (access within hours)
+3. **Day 30**: Move to Archive tier (rehydration required)
+4. **Day 365**: Permanent deletion
+
+### Cost Optimization Example
+
+**Scenario**: 100GB video per camera per day
+
+| Period | Storage Amount | Tier | Cost |
+|-------------|----------------|-----------|------------------|
+| Days 0-7 | 700GB | Hot | $12.60/month |
+| Days 7-30 | 2,300GB | Cool | $23.00/month |
+| Days 30-365 | 33,500GB | Archive | $50.25/month |
+| **Total** | **36,500GB** | **Mixed** | **$85.85/month** |
+
+**vs. keeping all in Hot tier**: $657/month
+**Savings**: 87% cost reduction
+
+## Query Optimization
+
+### Efficient Query Patterns
+
+1. **Use Blob Index Tags**: Faster than path-based filtering
+2. **Narrow time ranges**: Request only needed segments
+3. **Leverage hierarchical paths**: Navigate directly to year/month/day/hour
+4. **Cache frequently accessed**: Keep recent queries in Hot tier longer
+
+### Anti-Patterns to Avoid
+
+- ❌ Listing entire container (use tags instead)
+- ❌ Wide time ranges without filtering (query specific hours)
+- ❌ Frequent archive rehydration (move back to Hot if needed regularly)
+- ❌ Ignoring hash prefixes (they're essential for performance)
diff --git a/blueprints/video-capture-query/diagrams/04-data-flow.drawio b/blueprints/video-capture-query/diagrams/04-data-flow.drawio
new file mode 100644
index 00000000..45f8a1aa
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/04-data-flow.drawio
@@ -0,0 +1,263 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/blueprints/video-capture-query/diagrams/04-data-flow.md b/blueprints/video-capture-query/diagrams/04-data-flow.md
new file mode 100644
index 00000000..3016e77f
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/04-data-flow.md
@@ -0,0 +1,212 @@
+# Data Flow Diagram
+
+This diagram provides a detailed 14-step walkthrough of how data flows through the entire system, from query request to analysis.
+
+## Complete Data Flow (14 Steps)
+
+### Phase 1: Continuous Recording (Steps 1-5)
+
+#### Step 1: Video Streaming
+
+- **Source**: ONVIF Camera (camera-01)
+- **Protocol**: RTSP stream
+- **Target**: Media Capture Service
+- **Details**: Continuous H.264 video stream at 1920x1080, 30fps
+
+#### Step 2: Video Recording
+
+- **Component**: Media Capture Service (503-media-capture-service)
+- **Process**:
+ - Captures RTSP stream
+ - Segments video into 5-minute chunks
+ - Generates MP4 files with H.264 codec
+- **Output**: video-2026-01-20T10-00-00Z.mp4 (and subsequent segments)
+
+#### Step 3: Write to ACSA Volume
+
+- **Component**: ACSA Writer module
+- **Process**:
+ - Writes MP4 file to `/cloud-sync/video-recordings/` volume
+ - Creates companion JSON metadata file
+ - Organizes with hierarchical timestamp paths
+- **Files Created**:
+ - `camera-01/2026/01/20/10/segment_2026-01-20T10:00:00Z_camera-01.mp4`
+ - `camera-01/2026/01/20/10/segment_2026-01-20T10:00:00Z_camera-01.json`
+
+#### Step 4: ACSA Auto-Sync
+
+- **Component**: Azure Container Storage enabled by Arc (ACSA)
+- **Process**:
+ - Monitors ACSA volume for new files
+ - Automatically uploads to Azure Blob Storage
+ - Handles network failures with retry logic
+ - Maintains offline buffer during connectivity issues
+- **Target**: Hot tier in Blob Storage
+
+#### Step 5: Local Retention Cleanup
+
+- **Component**: Media Capture Service (background task)
+- **Process**:
+ - Runs cleanup task at configured interval (default: hourly)
+ - Deletes local files older than retention period (default: 24 hours)
+ - Recursively removes empty timestamp directories
+ - Prevents disk space exhaustion on edge nodes
+- **Note**: Cloud-synced files remain available in Blob Storage
+
+### Phase 2: Real-Time Query Request (Steps 6-10)
+
+#### Step 6: Query Request via SDK
+
+- **Actor**: Data Scientist
+- **Component**: Python SDK (920-video-query-sdk)
+- **Request**: `get_video("camera-01", "2026-01-20T10:00:00Z", "2026-01-20T10:30:00Z")`
+- **Method**: MQTT-based query for real-time response
+
+#### Step 7: Publish to MQTT Broker
+
+- **Component**: Python SDK
+- **Protocol**: MQTT over Azure IoT Operations
+- **Topic**: `video/query/request`
+- **Payload**:
+
+```json
+{
+ "request_id": "uuid-1234",
+ "camera_id": "camera-01",
+ "start_time": "2026-01-20T10:00:00Z",
+ "end_time": "2026-01-20T10:30:00Z"
+}
+```
+
+#### Step 8: Media Capture Service Receives Request
+
+- **Component**: Media Capture Service
+- **Process**:
+ - Subscribed to `video/query/request` topic
+ - Receives query request
+ - Validates time range (30 minutes = 6 segments)
+ - Checks if segments exist in ACSA volume or cloud
+
+#### Step 9: Generate Blob URLs
+
+- **Component**: Media Capture Service
+- **Process**:
+ - Generates SAS (Shared Access Signature) URLs for each segment
+ - Creates temporary access tokens (valid for 1 hour)
+ - Lists required segments (6 MP4 files)
+
+#### Step 10: Publish Response to MQTT
+
+- **Component**: Media Capture Service
+- **Protocol**: MQTT response
+- **Topic**: `video/query/response`
+- **Payload**:
+
+```json
+{
+ "request_id": "uuid-1234",
+ "status": "success",
+ "segments": [
+ "https://storage.../a1/camera-01/.../video-2026-01-20T10-00-00Z.mp4?sas=...",
+ "https://storage.../a1/camera-01/.../video-2026-01-20T10-05-00Z.mp4?sas=...",
+ "...6 segments total..."
+ ]
+}
+```
+
+### Phase 3: Historical Query (Steps 11-13)
+
+**Note**: This is an alternative path when real-time MQTT query is not needed.
+
+#### Step 11: Query via REST API
+
+- **Actor**: Data Scientist
+- **Component**: Python SDK
+- **Target**: Video Query API (520-video-query-api)
+- **Request**:
+ - `POST /api/query-video`
+ - Body: `{"camera_id": "camera-01", "start": "2026-01-20T10:00:00Z", "end": "2026-01-20T10:30:00Z"}`
+
+#### Step 12: Video Query API Processing
+
+- **Component**: Azure Function (Video Query API)
+- **Process**:
+ 1. **Query Blob Index**: Find relevant segments using tags
+ 2. **Download Segments**: Retrieve 6 MP4 files from Blob Storage
+ 3. **Stitch with FFmpeg**: Concatenate segments into single video
+ 4. **Upload Result**: Save stitched video to temporary blob
+ 5. **Generate SAS URL**: Create 24-hour access token
+
+#### Step 13: Return Stitched Video
+
+- **Component**: Video Query API
+- **Response**:
+
+```json
+{
+ "status": "success",
+ "video_url": "https://storage.../stitched/camera-01-2026-01-20-10-00.mp4?sas=...",
+ "duration_seconds": 1800,
+ "size_bytes": 180000000
+}
+```
+
+### Phase 4: Analysis (Step 14)
+
+#### Step 14: Video Analysis
+
+- **Component**: Jupyter Notebook / Python Analysis Tools
+- **Process**:
+ - Downloads video from SAS URL
+ - Loads into analysis frameworks (OpenCV, PyTorch, etc.)
+ - Performs computer vision tasks:
+ - Object detection
+ - Motion tracking
+ - Event detection
+ - Quality analysis
+
+## Query Path Comparison
+
+### MQTT Path (Steps 5-9)
+
+- **Pros**: Real-time response, lightweight, no stitching overhead
+- **Cons**: Requires MQTT connectivity, returns multiple segments
+- **Use Case**: Fast queries, edge-connected applications, real-time monitoring
+
+### REST API Path (Steps 10-12)
+
+- **Pros**: Single stitched video, standard HTTP, no edge connectivity needed
+- **Cons**: Higher latency, processing overhead, more bandwidth
+- **Use Case**: Historical analysis, offline queries, external systems
+
+## Performance Characteristics
+
+| Phase | Typical Duration | Bottleneck |
+|----------------------------|------------------|------------------|
+| Continuous Recording (1-5) | Real-time | Camera bandwidth |
+| Real-Time Query (6-10) | < 1 second | MQTT round-trip |
+| Historical Query (11-13) | 10-60 seconds | FFmpeg stitching |
+| Analysis (14) | Varies | Video processing |
+
+## Error Handling
+
+### Network Failures
+
+- **Step 4 (ACSA Sync)**: Automatic retry with exponential backoff
+- **Step 7 (MQTT)**: Connection loss detection and reconnection
+- **Step 12 (Download)**: Segment-level retry logic
+
+### Missing Data
+
+- **Step 8**: Media Capture Service checks for gaps in recording
+- **Step 12**: Video Query API handles missing segments gracefully
+- **Response**: Returns partial results with gap indicators
+
+### Archive Rehydration
+
+- **Scenario**: Requesting video older than 30 days (in Archive tier)
+- **Process**:
+ 1. API detects archive-tier blobs
+ 2. Initiates rehydration (1-15 hours)
+ 3. Returns 202 Accepted with estimated completion time
+ 4. Client polls for completion
diff --git a/blueprints/video-capture-query/diagrams/05-network-topology.drawio b/blueprints/video-capture-query/diagrams/05-network-topology.drawio
new file mode 100644
index 00000000..e0af65fd
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/05-network-topology.drawio
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/blueprints/video-capture-query/diagrams/05-network-topology.md b/blueprints/video-capture-query/diagrams/05-network-topology.md
new file mode 100644
index 00000000..c407b09b
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/05-network-topology.md
@@ -0,0 +1,234 @@
+# Network Topology Diagram
+
+This diagram shows the detailed network architecture, including VLANs, IP addressing, security configurations, and connectivity between edge and cloud environments.
+
+## Network Overview
+
+The solution spans two main network environments connected via Azure Arc:
+
+1. **Azure Virtual Network (Cloud)**
+2. **Edge Factory Network (On-Premises)**
+
+## Azure Virtual Network
+
+### VNet Configuration
+
+- **Name**: `vnet-media-capture`
+- **Address Space**: `10.0.0.0/16`
+- **Region**: East US 2
+- **DNS**: Azure-provided DNS
+
+### Subnet: Default Subnet
+
+- **Address Range**: `10.0.0.0/24`
+- **Available IPs**: 251 (Azure reserves 5)
+- **Resources**:
+ - Azure Functions (Video Query API)
+ - Storage Account private endpoints
+ - Azure Monitor endpoints
+
+### Network Security Group (NSG)
+
+- **Inbound Rules**:
+ - Allow HTTPS (443) from Internet → Azure Functions
+ - Allow Storage (443) from Functions → Storage Account
+ - Deny all other inbound traffic
+- **Outbound Rules**:
+ - Allow HTTPS (443) to Storage Account
+ - Allow HTTPS (443) to Azure Monitor
+ - Allow outbound to Internet (for dependencies)
+
+### Private Endpoints
+
+- **Storage Account**: `mediastore123.blob.core.windows.net`
+ - Private IP: `10.0.0.10`
+ - Eliminates public internet exposure
+ - Faster data transfer within VNet
+
+## Edge Factory Network
+
+### Network Configuration
+
+- **Address Space**: `192.168.100.0/22` (1024 IPs)
+- **Gateway**: `192.168.100.1`
+- **DNS**: `192.168.100.2`
+- **Internet Access**: Firewall with outbound rules
+
+### VLAN 100: Management Network
+
+- **Subnet**: `192.168.100.0/24`
+- **Purpose**: Infrastructure management and administration
+- **Resources**:
+ - Network switches
+ - Management interfaces
+ - Monitoring systems
+
+### VLAN 200: Camera Network
+
+- **Subnet**: `192.168.101.0/24`
+- **Purpose**: ONVIF camera traffic isolation
+- **Resources**:
+ - **Camera-01**: `192.168.101.10`
+ - RTSP Port: 554
+ - ONVIF Port: 80
+ - **Camera-02**: `192.168.101.11`
+ - RTSP Port: 554
+ - ONVIF Port: 80
+ - **Camera-N**: `192.168.101.x`
+
+**Security**:
+
+- Isolated from other VLANs
+- Cameras cannot initiate outbound connections
+- Only K3s cluster can access cameras
+
+### VLAN 300: Kubernetes Cluster
+
+- **Subnet**: `192.168.102.0/24`
+- **Purpose**: K3s cluster nodes and services
+- **Resources**:
+ - **Control Plane Node**: `192.168.102.10`
+ - Kubernetes API: 6443
+ - etcd: 2379-2380
+ - **Worker Node 1**: `192.168.102.11`
+ - **Worker Node 2**: `192.168.102.12`
+ - **LoadBalancer IP Range**: `192.168.102.100-192.168.102.200`
+
+**Services**:
+
+- **Azure IoT Operations MQTT Broker**: `192.168.102.100:1883`
+- **Media Capture Service**: `192.168.102.101:8080`
+
+**Security**:
+
+- Can access VLAN 200 (cameras)
+- Can access VLAN 400 (storage)
+- Firewall rules for Azure Arc connectivity
+
+### VLAN 400: Storage Network
+
+- **Subnet**: `192.168.103.0/24`
+- **Purpose**: High-performance storage access
+- **Resources**:
+ - **NFS Server** (optional local cache): `192.168.103.10`
+ - **ACSA PersistentVolume mount path**: Maps to Azure Blob Storage
+
+**Note**: ACSA volume appears as local storage to applications but automatically syncs to cloud
+
+## Port Usage
+
+### Edge to Cloud Communication
+
+| Source | Destination | Port | Protocol | Purpose |
+|-------------|----------------------|------|----------|---------------------------|
+| K3s Cluster | Azure Arc | 443 | HTTPS | Arc agent communication |
+| K3s Cluster | Azure IoT Operations | 443 | HTTPS | IoT Operations management |
+| K3s Cluster | Blob Storage | 443 | HTTPS | ACSA sync |
+| K3s Cluster | Azure Monitor | 443 | HTTPS | Telemetry and logs |
+
+### Within Edge Network
+
+| Source | Destination | Port | Protocol | Purpose |
+|---------------|-------------|---------|----------|-------------------------|
+| Media Capture | Cameras | 554 | RTSP | Video streaming |
+| Media Capture | Cameras | 80 | HTTP | ONVIF device management |
+| Applications | MQTT Broker | 1883 | MQTT | Message pub/sub |
+| Media Capture | ACSA Volume | NFS/SMB | File | Video segment writes |
+
+## Firewall Configuration
+
+### Outbound Rules (Edge → Internet)
+
+1. **Azure Arc Services**:
+ - *.servicebus.windows.net:443
+ - *.guestconfiguration.azure.com:443
+ - *.azure-automation.net:443
+
+2. **Azure IoT Operations**:
+ - *.azure-devices.net:443
+ - *.eventgrid.azure.net:443
+
+3. **Azure Storage**:
+ - *.blob.core.windows.net:443
+
+4. **Container Registry**:
+ - mcr.microsoft.com:443
+ - *.docker.io:443
+
+### Inbound Rules (Internet → Edge)
+
+- **Block all inbound** (edge initiates all connections)
+- Management access via VPN or Azure Bastion only
+
+## Security Architecture
+
+### Network Segmentation
+
+```text
+Internet
+ ↓ (Firewall)
+Edge Factory Network
+ ├── VLAN 100 (Management) - Isolated
+ ├── VLAN 200 (Cameras) - Isolated, read-only for K3s
+ ├── VLAN 300 (Kubernetes) - Hub, can access 200 & 400
+ └── VLAN 400 (Storage) - Isolated, accessed by K3s
+```
+
+### Zero Trust Principles
+
+1. **Least Privilege**: Each VLAN has minimal required access
+2. **Micro-segmentation**: Cameras cannot communicate with each other
+3. **Identity-Based Access**: Managed identities for Azure resources
+4. **Encryption in Transit**: TLS 1.2+ for all cloud communication
+5. **Private Connectivity**: Private endpoints eliminate public exposure
+
+## High Availability Considerations
+
+### Edge Network HA
+
+- **Redundant Switches**: Active/standby configuration
+- **Multiple Internet Links**: Primary fiber + backup LTE
+- **K3s HA**: 3 control plane nodes with etcd quorum
+- **Storage Redundancy**: Local NFS with RAID + ACSA cloud backup
+
+### Cloud Network HA
+
+- **Azure Functions**: Auto-scaling across availability zones
+- **Storage Account**: Zone-redundant storage (ZRS)
+- **Regional Failover**: Optional geo-redundant storage (GRS)
+
+## Network Performance
+
+### Bandwidth Requirements
+
+| Component | Bandwidth | Notes |
+|-----------------------|-----------|------------------------|
+| Single Camera (1080p) | 4 Mbps | H.264 compressed |
+| 10 Cameras | 40 Mbps | Peak during day shift |
+| ACSA Upload | 4-40 Mbps | Matches recording rate |
+| Query Download | 1-10 Mbps | Bursts during analysis |
+
+### Latency Expectations
+
+| Path | Typical Latency | Notes |
+|------------------------|-----------------|---------------------|
+| Camera → Media Capture | < 10ms | Local network |
+| MQTT Request/Response | < 50ms | Local broker |
+| ACSA → Blob Storage | 20-100ms | Internet latency |
+| REST API Query | 500-2000ms | Includes processing |
+
+## Monitoring and Diagnostics
+
+### Network Monitoring Tools
+
+- **Azure Monitor Network Insights**: Cloud connectivity health
+- **Prometheus/Grafana**: Kubernetes network metrics
+- **SNMP**: Switch and camera monitoring
+- **Azure Arc Diagnostics**: Arc connectivity status
+
+### Key Metrics
+
+- **ACSA Sync Lag**: Time between file creation and cloud availability
+- **Camera Stream Health**: RTSP connection uptime per camera
+- **MQTT Message Latency**: Pub/sub round-trip time
+- **Blob Upload Success Rate**: Percentage of successful syncs
diff --git a/blueprints/video-capture-query/diagrams/06-deployment-sequence.drawio b/blueprints/video-capture-query/diagrams/06-deployment-sequence.drawio
new file mode 100644
index 00000000..4f756a64
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/06-deployment-sequence.drawio
@@ -0,0 +1,248 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/blueprints/video-capture-query/diagrams/06-deployment-sequence.md b/blueprints/video-capture-query/diagrams/06-deployment-sequence.md
new file mode 100644
index 00000000..2c7ebb48
--- /dev/null
+++ b/blueprints/video-capture-query/diagrams/06-deployment-sequence.md
@@ -0,0 +1,587 @@
+# Deployment Sequence Diagram
+
+This diagram shows the complete 5-phase deployment sequence for the video capture query solution, from infrastructure provisioning to production validation.
+
+## Deployment Overview
+
+**Total Deployment Time**: 2-4 hours (automated)
+**Deployment Tool**: Terraform blueprints
+**Target Environment**: Azure + Edge (K3s/AKS-EE)
+
+## Phase 1: Cloud Infrastructure (20-30 minutes)
+
+Deploy all cloud resources using the Terraform blueprint:
+
+### Step 1.1: Configure Terraform Variables
+
+Create `terraform.tfvars` file:
+
+```hcl
+environment = "dev"
+resource_prefix = "videocapture"
+location = "eastus2"
+instance = "001"
+
+tags = {
+ project = "video-capture-query"
+ environment = "dev"
+ managed_by = "terraform"
+}
+```
+
+### Step 1.2: Deploy via Terraform Blueprint
+
+**Blueprint Components** (deployed as single unit):
+
+- `000-cloud/000-resource-group`: Resource group
+- `000-cloud/030-data`: Storage account with lifecycle policies
+- `000-cloud/040-messaging`: Azure Functions only (Event Grid and Event Hubs disabled for video-only blueprint)
+
+**Terraform Deployment**:
+
+```bash
+# Navigate to blueprint directory
+cd blueprints/video-capture-query/terraform
+
+# Initialize Terraform
+terraform init
+
+# Review deployment plan
+terraform plan
+
+# Deploy infrastructure
+terraform apply
+```
+
+**What Gets Created**:
+
+- Resource group with tags and policies
+- Storage account with:
+ - Zone-redundant storage (ZRS)
+ - Hot/Cool/Archive tiers
+ - Blob versioning enabled
+ - Container: `media-capture-data`
+ - Lifecycle policies (7-day Cool, 30-day Archive, 365-day Delete)
+- Azure Function App:
+ - Python 3.11 runtime
+ - Consumption plan
+ - Storage account binding
+ - Managed identity
+
+**Outputs** (captured by Terraform):
+
+```bash
+terraform output storage_account_name
+terraform output storage_connection_string
+terraform output function_app_name
+terraform output function_app_url
+```
+
+### Step 1.3: Deploy Function App Code
+
+After infrastructure is deployed, deploy the Video Query API code:
+
+```bash
+# Navigate to function app directory
+cd ../../../src/500-application/520-video-query-api
+
+# Get function app name from Terraform
+FUNCTION_APP=$(cd ../../../blueprints/video-capture-query/terraform && terraform output -raw function_app_name)
+
+# Deploy function code
+func azure functionapp publish $FUNCTION_APP --python
+```
+
+**Validation**:
+
+```bash
+# Get function app URL
+FUNCTION_URL=$(cd blueprints/video-capture-query/terraform && terraform output -raw function_app_url)
+
+# Test health endpoint
+curl https://$FUNCTION_URL/api/health
+# Expected: {"status": "healthy"}
+```
+
+## Phase 2: Edge Infrastructure (30-45 minutes)
+
+### Step 2.1: K3s Cluster Setup
+
+**Component**: `100-edge/100-cncf-cluster`
+
+- Provision K3s cluster:
+ - 1 control plane node
+ - 2 worker nodes (optional)
+- Install base components:
+ - Calico network plugin
+ - Local-path provisioner
+ - MetalLB load balancer
+- Configure kubeconfig
+
+**Manual Steps**:
+
+```bash
+# On each edge node
+curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644
+```
+
+**Validation**:
+
+```bash
+kubectl get nodes
+# Expected: All nodes Ready
+```
+
+### Step 2.2: Azure Arc Connection
+
+**Component**: `100-edge/100-cncf-cluster` (Arc enablement)
+
+- Connect K3s to Azure Arc
+- Install Arc agents
+- Configure Azure connectivity
+- Set up Arc extensions
+
+**Terraform Apply**:
+
+```bash
+terraform apply -target=azurerm_arc_kubernetes_cluster.main
+```
+
+**Validation**:
+
+```bash
+az connectedk8s show --name --resource-group
+# Expected: connectivityStatus: Connected
+```
+
+### Step 2.3: Azure IoT Operations
+
+**Component**: `100-edge/110-iot-ops`
+
+- Deploy IoT Operations operator
+- Install MQTT broker
+- Configure broker listeners:
+ - Port 1883 (MQTT)
+ - Port 8883 (MQTTS)
+- Set up device registry
+
+**Validation**:
+
+```bash
+kubectl get pods -n azure-iot-operations
+# Expected: All pods Running
+```
+
+### Step 2.4: ACSA Configuration
+
+**Component**: `100-edge/110-iot-ops` (storage configuration)
+
+- Create ACSA-enabled PersistentVolume
+- Configure cloud sync settings:
+ - Target: Azure Blob Storage
+ - Sync interval: 60 seconds
+ - Retry policy: Exponential backoff
+- Mount volume to namespace
+
+**YAML Example**:
+
+```yaml
+apiVersion: v1
+kind: PersistentVolume
+metadata:
+ name: media-capture-backed-acsa
+spec:
+ capacity:
+ storage: 100Gi
+ storageClassName: acsa-storage
+ acsa:
+ storageAccountName: mediastore123
+ containerName: media-capture-data
+ syncInterval: 60s
+```
+
+**Validation**:
+
+```bash
+kubectl get pv media-capture-backed-acsa
+# Expected: STATUS: Bound
+```
+
+## Phase 3: Device Configuration (10-15 minutes)
+
+### Step 3.1: Camera Registration
+
+**Component**: `100-edge/111-assets`
+
+- Discover ONVIF cameras on network
+- Register camera assets:
+ - Camera-01: 192.168.101.10
+ - Camera-02: 192.168.101.11
+- Configure camera profiles:
+ - RTSP URL
+ - Credentials (stored in Key Vault)
+ - Stream settings
+
+**Validation**:
+
+```bash
+kubectl get assets -n azure-iot-operations
+# Expected: camera-01, camera-02 Ready
+```
+
+### Step 3.2: Camera Endpoints
+
+**Component**: `100-edge/111-assets`
+
+- Create asset endpoints for each camera
+- Test RTSP connectivity
+- Validate ONVIF discovery
+
+**Validation**:
+
+```bash
+ffmpeg -i rtsp://192.168.101.10:554/stream -t 5 -f null -
+# Expected: No errors, video frames decoded
+```
+
+## Phase 4: Application Deployment (15-20 minutes)
+
+### Step 4.1: Media Capture Service
+
+**Component**: `500-application/503-media-capture-service`
+
+- Build Rust container image
+- Push to Azure Container Registry
+- Deploy to Kubernetes:
+ - 1 replica per camera
+ - ACSA volume mount
+ - Resource limits: 2 CPU, 4Gi RAM
+- Configure environment:
+ - Camera RTSP URLs
+ - ACSA mount path: `/media-capture-backed-acsa`
+ - Segment duration: 300 seconds
+ - MQTT broker endpoint
+
+**Deployment**:
+
+```bash
+# Get storage connection string from Terraform
+cd blueprints/video-capture-query/terraform
+STORAGE_CONNECTION=$(terraform output -raw storage_connection_string)
+
+# Create namespace
+kubectl create namespace azure-iot-operations --dry-run=client -o yaml | kubectl apply -f -
+
+# Create storage credentials secret
+kubectl create secret generic video-storage-credentials \
+ --from-literal=connection-string="$STORAGE_CONNECTION" \
+ --namespace=azure-iot-operations
+
+# Deploy via Helm chart
+helm upgrade --install media-capture \
+ ../../src/500-application/503-media-capture-service/charts/media-capture-service \
+ --namespace azure-iot-operations \
+ --set mediaCapture.continuousRecording.enabled=true \
+ --set mediaCapture.continuousRecording.segmentDurationSeconds=300 \
+ --set mediaCapture.video.rtspUrl="rtsp://192.168.101.10:554/stream" \
+ --set mediaCapture.video.cameraId="camera-01" \
+ --set mediaCapture.video.cameraLocation="Building-A/Floor-1" \
+ --set mediaCapture.storage.acsaVolumePath="/media-capture-backed-acsa"
+```
+
+**Validation**:
+
+```bash
+kubectl get pods -n azure-iot-operations -l app.kubernetes.io/name=media-capture-service
+# Expected: media-capture-service-xxxxx Running
+
+kubectl logs -n azure-iot-operations -l app.kubernetes.io/name=media-capture-service
+# Expected: "Started continuous recording from camera-01"
+```
+
+### Step 4.2: MQTT Configuration
+
+**Component**: `503-media-capture-service`
+
+- Subscribe to topics:
+ - `video/query/request`
+ - `video/query/control`
+- Publish to topics:
+ - `video/query/response`
+ - `video/status`
+
+**Validation**:
+
+```bash
+# Using mosquitto_sub
+kubectl exec -it mqtt-broker-0 -- mosquitto_sub -t "video/#" -v
+# Expected: See status messages every 60 seconds
+```
+
+## Phase 5: Validation & Testing (30-60 minutes)
+
+### Step 5.1: End-to-End Recording Test
+
+**Objective**: Verify continuous recording and ACSA sync
+
+**Test Steps**:
+
+1. Wait 5 minutes for first segment
+2. Check ACSA volume for MP4 file:
+
+```bash
+kubectl exec -it media-capture-camera-01 -- ls -lh /media-capture-backed-acsa/a1/camera-01/
+# Expected: video-*.mp4 and video-*.json files
+```
+
+1. Wait 2 minutes for ACSA sync
+2. Check Azure Blob Storage:
+
+```bash
+az storage blob list \
+ --account-name mediastore123 \
+ --container-name media-capture-data \
+ --prefix a1/camera-01/ \
+ --query "[].name"
+# Expected: Same files appear in cloud
+```
+
+**Success Criteria**:
+
+- ✅ Video segments created every 5 minutes
+- ✅ Files appear in cloud within 2 minutes
+- ✅ Metadata JSON files included
+
+### Step 5.2: MQTT Query Test
+
+**Objective**: Verify real-time query path
+
+**Test Steps**:
+
+1. Install Python SDK:
+
+```bash
+pip install edge-ai-video-query-sdk
+```
+
+1. Run query script:
+
+```python
+from video_query_sdk import VideoClient
+
+client = VideoClient(mqtt_broker="192.168.102.100:1883")
+result = client.get_video_mqtt(
+ camera_id="camera-01",
+ start_time="2026-01-09T10:00:00Z",
+ duration_minutes=10
+)
+print(f"Found {len(result.segments)} segments")
+for url in result.segment_urls:
+ print(url)
+```
+
+**Success Criteria**:
+
+- ✅ Query completes in < 1 second
+- ✅ Returns 2 segment URLs (10 minutes = 2 × 5-minute segments)
+- ✅ SAS URLs are valid and accessible
+
+### Step 5.3: REST API Query Test
+
+**Objective**: Verify historical query and stitching
+
+**Test Steps**:
+
+1. Call Video Query API:
+
+```bash
+curl -X POST https://.azurewebsites.net/api/query-video \
+ -H "Content-Type: application/json" \
+ -d '{
+ "camera_id": "camera-01",
+ "start_time": "2026-01-09T10:00:00Z",
+ "end_time": "2026-01-09T10:30:00Z"
+ }'
+```
+
+1. Download stitched video:
+
+```bash
+curl -o output.mp4 ""
+```
+
+1. Validate video:
+
+```bash
+ffprobe output.mp4
+# Expected: Duration: 00:30:00, Video: h264, 1920x1080, 30 fps
+```
+
+**Success Criteria**:
+
+- ✅ API returns stitched video URL
+- ✅ Video is exactly 30 minutes long
+- ✅ No gaps or corruption between segments
+
+### Step 5.4: Lifecycle Policy Test
+
+**Objective**: Verify tier transitions (accelerated)
+
+**Test Steps**:
+
+1. Manually set blob tier to Cool:
+
+```bash
+az storage blob set-tier \
+ --account-name mediastore123 \
+ --container-name media-capture-data \
+ --name a1/camera-01/2026/01/09/10/video-*.mp4 \
+ --tier Cool
+```
+
+1. Query same video:
+
+```bash
+# Should still work but may have slight delay
+curl -X POST https://.azurewebsites.net/api/query-video ...
+```
+
+1. Check lifecycle policy:
+
+```bash
+az storage account management-policy show \
+ --account-name mediastore123 \
+ --resource-group
+# Expected: Rules for 7-day Cool, 30-day Archive, 365-day Delete
+```
+
+**Success Criteria**:
+
+- ✅ Cool tier videos are accessible
+- ✅ Lifecycle policies are active
+- ✅ Policy rules match specification
+
+### Step 5.5: Failure Recovery Test
+
+**Objective**: Verify resilience to network failures
+
+**Test Steps**:
+
+1. Simulate network partition:
+
+```bash
+# On edge node, block Azure connectivity
+sudo iptables -A OUTPUT -d *.blob.core.windows.net -j DROP
+```
+
+1. Continue recording for 10 minutes
+2. Check ACSA volume accumulation:
+
+```bash
+kubectl exec media-capture-camera-01 -- du -sh /media-capture-backed-acsa/
+# Expected: Growing storage, ~50MB per 5 minutes
+```
+
+1. Restore connectivity:
+
+```bash
+sudo iptables -D OUTPUT -d *.blob.core.windows.net -j DROP
+```
+
+1. Monitor ACSA sync logs:
+
+```bash
+kubectl logs -n azure-iot-operations acsa-sync-pod
+# Expected: "Syncing backlog: 10 files..."
+```
+
+**Success Criteria**:
+
+- ✅ Recording continues during outage
+- ✅ Files accumulate in ACSA volume
+- ✅ Automatic sync resumes after recovery
+- ✅ All files eventually reach cloud
+
+## Deployment Rollback
+
+### Rollback Phase 4 (Application)
+
+```bash
+# Uninstall Helm release
+helm uninstall media-capture --namespace azure-iot-operations
+
+# Delete secrets
+kubectl delete secret video-storage-credentials --namespace azure-iot-operations
+```
+
+### Rollback Phase 3 (Devices)
+
+```bash
+kubectl delete assets --all -n azure-iot-operations
+```
+
+### Rollback Phase 2 (Edge)
+
+```bash
+# Note: Edge infrastructure typically deployed separately
+# If using Terraform for edge:
+terraform destroy -target=module.iot_ops
+terraform destroy -target=azurerm_arc_kubernetes_cluster.main
+```
+
+### Rollback Phase 1 (Cloud)
+
+```bash
+# Navigate to blueprint directory
+cd blueprints/video-capture-query/terraform
+
+# Destroy all cloud infrastructure
+terraform destroy
+```
+
+## Post-Deployment Configuration
+
+### Monitoring Setup
+
+1. Enable Azure Monitor Container Insights
+2. Configure Prometheus scraping
+3. Set up Grafana dashboards
+4. Configure alerts:
+ - Camera offline > 5 minutes
+ - ACSA sync lag > 10 minutes
+ - Storage capacity > 80%
+
+### Security Hardening
+
+1. Rotate managed identity credentials
+2. Enable Azure Storage encryption at rest
+3. Configure network policies in Kubernetes
+4. Enable audit logging
+
+### Documentation
+
+1. Record deployed version tags
+2. Document custom configurations
+3. Create runbook for operations
+4. Train operations team
+
+## Deployment Checklist
+
+- [ ] Phase 1: Cloud infrastructure deployed
+- [ ] Phase 1: Storage account accessible
+- [ ] Phase 1: Function app responding to health checks
+- [ ] Phase 2: K3s cluster running
+- [ ] Phase 2: Azure Arc connected
+- [ ] Phase 2: IoT Operations pods running
+- [ ] Phase 2: ACSA volume bound
+- [ ] Phase 3: Cameras discovered and registered
+- [ ] Phase 3: RTSP connectivity validated
+- [ ] Phase 4: Media capture pods running
+- [ ] Phase 4: MQTT topics active
+- [ ] Phase 5: End-to-end recording working
+- [ ] Phase 5: MQTT query successful
+- [ ] Phase 5: REST API query successful
+- [ ] Phase 5: Lifecycle policies active
+- [ ] Phase 5: Failure recovery tested
+- [ ] Monitoring configured
+- [ ] Documentation complete
diff --git a/blueprints/video-capture-query/terraform/.gitignore b/blueprints/video-capture-query/terraform/.gitignore
new file mode 100644
index 00000000..56aa1cab
--- /dev/null
+++ b/blueprints/video-capture-query/terraform/.gitignore
@@ -0,0 +1,16 @@
+# Terraform files
+*.tfstate
+*.tfstate.*
+*.tfvars
+!terraform.tfvars.example
+.terraform/
+.terraform.lock.hcl
+crash.log
+crash.*.log
+override.tf
+override.tf.json
+*_override.tf
+*_override.tf.json
+
+# Output files
+out/
diff --git a/blueprints/video-capture-query/terraform/README.md b/blueprints/video-capture-query/terraform/README.md
new file mode 100644
index 00000000..2f09094e
--- /dev/null
+++ b/blueprints/video-capture-query/terraform/README.md
@@ -0,0 +1,61 @@
+
+
+# Video Capture Query Blueprint
+
+Deploys cloud infrastructure for continuous video recording and time-based query capabilities.
+This blueprint orchestrates storage account, lifecycle policies, and Azure Functions for the Video Query API.
+
+## Requirements
+
+| Name | Version |
+|------|---------|
+| terraform | >= 1.9.8, < 2.0 |
+| azapi | >= 2.3.0 |
+| azurerm | >= 4.51.0 |
+
+## Providers
+
+| Name | Version |
+|------|---------|
+| azurerm | >= 4.51.0 |
+
+## Resources
+
+| Name | Type |
+|------|------|
+| [azurerm_role_assignment.video_query_storage_blob_data_contributor](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
+
+## Modules
+
+| Name | Source | Version |
+|------|--------|---------|
+| cloud\_data | ../../../src/000-cloud/030-data/terraform | n/a |
+| cloud\_messaging | ../../../src/000-cloud/040-messaging/terraform | n/a |
+| cloud\_resource\_group | ../../../src/000-cloud/000-resource-group/terraform | n/a |
+
+## 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 |
+| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
+| recording\_mode | Video recording mode: continuous, hybrid, or ring\_buffer\_only | `string` | `"continuous"` | no |
+| segment\_duration\_seconds | Duration of each video segment in seconds | `number` | `300` | no |
+| tags | Tags to apply to all resources in this blueprint | `map(string)` | `{}` | no |
+| video\_retention\_days | Number of days to retain video files before deletion | `number` | `365` | no |
+
+## Outputs
+
+| Name | Description |
+|------|-------------|
+| function\_app | Azure Function App resource for Video Query API. |
+| function\_app\_url | Azure Function App default hostname URL. |
+| function\_storage\_account | Storage Account used by the Function App for internal state. |
+| resource\_group | Resource group for all video capture query resources. |
+| storage\_account | Storage Account resource for video recordings. |
+| storage\_account\_connection\_string | Storage Account primary connection string for edge device configuration. |
+| video\_recording\_config | Video recording configuration parameters for edge deployment. |
+
+
diff --git a/blueprints/video-capture-query/terraform/examples/data-scientist-workflow.py b/blueprints/video-capture-query/terraform/examples/data-scientist-workflow.py
new file mode 100755
index 00000000..b431cc89
--- /dev/null
+++ b/blueprints/video-capture-query/terraform/examples/data-scientist-workflow.py
@@ -0,0 +1,471 @@
+#!/usr/bin/env python3
+"""
+Data Scientist Workflow Example for Video Capture Query Blueprint
+
+This example demonstrates how to:
+1. Query historical video from specific cameras and timeframes
+2. Download video segments for analysis
+3. Perform frame-by-frame analysis with OpenCV
+4. Extract insights from video data
+
+Prerequisites:
+- pip install video-query-sdk opencv-python numpy pandas
+- Azure credentials configured (Azure CLI login or environment variables)
+- Video Query API deployed and accessible
+"""
+
+import argparse
+from datetime import datetime
+from pathlib import Path
+
+import cv2
+import numpy as np
+import pandas as pd
+
+try:
+ from video_query_sdk import VideoQueryClient
+except ImportError:
+ print("ERROR: video_query_sdk not installed")
+ print("Install with: pip install video-query-sdk")
+ exit(1)
+
+
+def parse_arguments() -> argparse.Namespace:
+ """Parse command-line arguments."""
+ parser = argparse.ArgumentParser(
+ description="Query and analyze historical video from cameras"
+ )
+ parser.add_argument(
+ "--api-url",
+ required=True,
+ help="Video Query API URL (e.g., https://func-app.azurewebsites.net/api)"
+ )
+ parser.add_argument(
+ "--camera",
+ required=True,
+ help="Camera identifier (e.g., camera-01)"
+ )
+ parser.add_argument(
+ "--start",
+ required=True,
+ help="Start timestamp in ISO 8601 format (e.g., 2026-01-20T10:00:00Z)"
+ )
+ parser.add_argument(
+ "--end",
+ required=True,
+ help="End timestamp in ISO 8601 format (e.g., 2026-01-20T10:30:00Z)"
+ )
+ parser.add_argument(
+ "--output-dir",
+ default="./video_analysis",
+ help="Output directory for downloaded videos and analysis results"
+ )
+ parser.add_argument(
+ "--analysis",
+ choices=["motion", "object_count", "brightness", "all"],
+ default="all",
+ help="Type of analysis to perform"
+ )
+ return parser.parse_args()
+
+
+def query_video(
+ client: VideoQueryClient,
+ camera_id: str,
+ start_time: datetime,
+ end_time: datetime,
+ output_dir: Path
+) -> Path:
+ """
+ Query video from API and download to local storage.
+
+ Args:
+ client: Initialized VideoQueryClient
+ camera_id: Camera identifier
+ start_time: Start timestamp
+ end_time: End timestamp
+ output_dir: Directory to save downloaded video
+
+ Returns:
+ Path to downloaded video file
+ """
+ print(f"\n🔍 Querying video from {camera_id}")
+ print(f" Timeframe: {start_time} to {end_time}")
+ print(f" Duration: {(end_time - start_time).total_seconds()} seconds")
+
+ try:
+ video_url = client.get_video(
+ camera_id=camera_id,
+ start_time=start_time,
+ end_time=end_time
+ )
+ print(f"✅ Video URL retrieved: {video_url[:50]}...")
+
+ video_filename = f"{camera_id}_{start_time.strftime('%Y%m%d_%H%M%S')}.mp4"
+ video_path = output_dir / video_filename
+
+ print(f"⬇️ Downloading video to {video_path}")
+ client.download_video(video_url, str(video_path))
+ print(
+ f"✅ Download complete: {video_path.stat().st_size / (1024*1024):.2f} MB")
+
+ return video_path
+
+ except Exception as e:
+ print(f"❌ Error querying video: {e}")
+ raise
+
+
+def analyze_motion(video_path: Path) -> pd.DataFrame:
+ """
+ Detect motion in video using frame differencing.
+
+ Args:
+ video_path: Path to video file
+
+ Returns:
+ DataFrame with motion detection results
+ """
+ print("\n🎬 Analyzing motion...")
+
+ cap = cv2.VideoCapture(str(video_path))
+ if not cap.isOpened():
+ raise ValueError(f"Failed to open video: {video_path}")
+
+ fps = cap.get(cv2.CAP_PROP_FPS)
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
+
+ results = []
+ prev_frame = None
+ frame_idx = 0
+
+ while cap.isOpened():
+ ret, frame = cap.read()
+ if not ret:
+ break
+
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
+ gray = cv2.GaussianBlur(gray, (21, 21), 0)
+
+ if prev_frame is not None:
+ frame_diff = cv2.absdiff(prev_frame, gray)
+ thresh = cv2.threshold(frame_diff, 25, 255, cv2.THRESH_BINARY)[1]
+ motion_pixels = np.sum(thresh) / 255
+ motion_percentage = (motion_pixels / thresh.size) * 100
+
+ timestamp = frame_idx / fps
+ results.append({
+ 'frame': frame_idx,
+ 'timestamp_sec': timestamp,
+ 'motion_pixels': motion_pixels,
+ 'motion_percentage': motion_percentage,
+ 'motion_detected': motion_percentage > 1.0
+ })
+
+ prev_frame = gray
+ frame_idx += 1
+
+ if frame_idx % 100 == 0:
+ print(
+ f" Processed {frame_idx}/{frame_count} frames ({frame_idx/frame_count*100:.1f}%)")
+
+ cap.release()
+
+ df = pd.DataFrame(results)
+ print(f"✅ Motion analysis complete: {len(df)} frames analyzed")
+ print(
+ f" Motion detected in {df['motion_detected'].sum()} frames ({df['motion_detected'].mean()*100:.1f}%)")
+
+ return df
+
+
+def analyze_brightness(video_path: Path) -> pd.DataFrame:
+ """
+ Analyze brightness levels in video.
+
+ Args:
+ video_path: Path to video file
+
+ Returns:
+ DataFrame with brightness analysis results
+ """
+ print("\n💡 Analyzing brightness...")
+
+ cap = cv2.VideoCapture(str(video_path))
+ if not cap.isOpened():
+ raise ValueError(f"Failed to open video: {video_path}")
+
+ fps = cap.get(cv2.CAP_PROP_FPS)
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
+
+ results = []
+ frame_idx = 0
+
+ while cap.isOpened():
+ ret, frame = cap.read()
+ if not ret:
+ break
+
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
+ mean_brightness = np.mean(gray)
+ std_brightness = np.std(gray)
+
+ timestamp = frame_idx / fps
+ results.append({
+ 'frame': frame_idx,
+ 'timestamp_sec': timestamp,
+ 'mean_brightness': mean_brightness,
+ 'std_brightness': std_brightness
+ })
+
+ frame_idx += 1
+
+ if frame_idx % 100 == 0:
+ print(
+ f" Processed {frame_idx}/{frame_count} frames ({frame_idx/frame_count*100:.1f}%)")
+
+ cap.release()
+
+ df = pd.DataFrame(results)
+ print("✅ Brightness analysis complete")
+ print(f" Mean brightness: {df['mean_brightness'].mean():.2f} (0-255)")
+ print(
+ f" Brightness range: {df['mean_brightness'].min():.2f} - {df['mean_brightness'].max():.2f}")
+
+ return df
+
+
+def count_objects_simple(video_path: Path) -> pd.DataFrame:
+ """
+ Simple object counting using background subtraction.
+
+ Args:
+ video_path: Path to video file
+
+ Returns:
+ DataFrame with object counting results
+ """
+ print("\n🔢 Counting objects (simple background subtraction)...")
+
+ cap = cv2.VideoCapture(str(video_path))
+ if not cap.isOpened():
+ raise ValueError(f"Failed to open video: {video_path}")
+
+ fps = cap.get(cv2.CAP_PROP_FPS)
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
+
+ back_sub = cv2.createBackgroundSubtractorMOG2(
+ history=500,
+ varThreshold=16,
+ detectShadows=True
+ )
+
+ results = []
+ frame_idx = 0
+
+ while cap.isOpened():
+ ret, frame = cap.read()
+ if not ret:
+ break
+
+ fg_mask = back_sub.apply(frame)
+ fg_mask = cv2.threshold(fg_mask, 244, 255, cv2.THRESH_BINARY)[1]
+ fg_mask = cv2.morphologyEx(
+ fg_mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
+
+ contours, _ = cv2.findContours(
+ fg_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
+
+ object_count = 0
+ for contour in contours:
+ area = cv2.contourArea(contour)
+ if area > 500:
+ object_count += 1
+
+ timestamp = frame_idx / fps
+ results.append({
+ 'frame': frame_idx,
+ 'timestamp_sec': timestamp,
+ 'object_count': object_count,
+ 'foreground_pixels': np.sum(fg_mask) / 255
+ })
+
+ frame_idx += 1
+
+ if frame_idx % 100 == 0:
+ print(
+ f" Processed {frame_idx}/{frame_count} frames ({frame_idx/frame_count*100:.1f}%)")
+
+ cap.release()
+
+ df = pd.DataFrame(results)
+ print("✅ Object counting complete")
+ print(f" Average objects per frame: {df['object_count'].mean():.2f}")
+ print(f" Max objects detected: {df['object_count'].max()}")
+
+ return df
+
+
+def save_analysis_results(
+ output_dir: Path,
+ camera_id: str,
+ start_time: datetime,
+ motion_df: pd.DataFrame = None,
+ brightness_df: pd.DataFrame = None,
+ object_df: pd.DataFrame = None
+) -> None:
+ """
+ Save analysis results to CSV files.
+
+ Args:
+ output_dir: Output directory
+ camera_id: Camera identifier
+ start_time: Start timestamp
+ motion_df: Motion analysis results
+ brightness_df: Brightness analysis results
+ object_df: Object counting results
+ """
+ print("\n💾 Saving analysis results...")
+
+ timestamp_str = start_time.strftime('%Y%m%d_%H%M%S')
+
+ if motion_df is not None:
+ motion_file = output_dir / f"{camera_id}_{timestamp_str}_motion.csv"
+ motion_df.to_csv(motion_file, index=False)
+ print(f"✅ Motion analysis saved: {motion_file}")
+
+ if brightness_df is not None:
+ brightness_file = output_dir / \
+ f"{camera_id}_{timestamp_str}_brightness.csv"
+ brightness_df.to_csv(brightness_file, index=False)
+ print(f"✅ Brightness analysis saved: {brightness_file}")
+
+ if object_df is not None:
+ object_file = output_dir / f"{camera_id}_{timestamp_str}_objects.csv"
+ object_df.to_csv(object_file, index=False)
+ print(f"✅ Object counting saved: {object_file}")
+
+
+def print_summary(
+ camera_id: str,
+ start_time: datetime,
+ end_time: datetime,
+ video_path: Path,
+ motion_df: pd.DataFrame = None,
+ brightness_df: pd.DataFrame = None,
+ object_df: pd.DataFrame = None
+) -> None:
+ """Print analysis summary."""
+ print("\n" + "="*70)
+ print("📊 ANALYSIS SUMMARY")
+ print("="*70)
+ print(f"Camera: {camera_id}")
+ print(f"Timeframe: {start_time} to {end_time}")
+ print(f"Duration: {(end_time - start_time).total_seconds()} seconds")
+ print(f"Video file: {video_path}")
+ print(f"Video size: {video_path.stat().st_size / (1024*1024):.2f} MB")
+ print("-"*70)
+
+ if motion_df is not None:
+ print("\n🎬 Motion Analysis:")
+ print(f" Total frames analyzed: {len(motion_df)}")
+ print(f" Frames with motion: {motion_df['motion_detected'].sum()}")
+ print(
+ f" Motion percentage: {motion_df['motion_detected'].mean()*100:.1f}%")
+ print(
+ f" Average motion intensity: {motion_df['motion_percentage'].mean():.2f}%")
+
+ if brightness_df is not None:
+ print("\n💡 Brightness Analysis:")
+ print(
+ f" Mean brightness: {brightness_df['mean_brightness'].mean():.2f}/255")
+ print(
+ f" Brightness range: {brightness_df['mean_brightness'].min():.2f}"
+ f" - {brightness_df['mean_brightness'].max():.2f}")
+ print(
+ f" Brightness variability (std): {brightness_df['std_brightness'].mean():.2f}")
+
+ if object_df is not None:
+ print("\n🔢 Object Counting:")
+ print(f" Average objects: {object_df['object_count'].mean():.2f}")
+ print(f" Max objects: {object_df['object_count'].max()}")
+ print(
+ f" Frames with objects: {(object_df['object_count'] > 0).sum()}")
+
+ print("="*70)
+
+
+def main():
+ """Main workflow execution."""
+ args = parse_arguments()
+
+ print("="*70)
+ print("🎥 VIDEO CAPTURE QUERY - DATA SCIENTIST WORKFLOW")
+ print("="*70)
+
+ output_dir = Path(args.output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ print(f"📁 Output directory: {output_dir.absolute()}")
+
+ start_time = datetime.fromisoformat(args.start.replace('Z', '+00:00'))
+ end_time = datetime.fromisoformat(args.end.replace('Z', '+00:00'))
+
+ client = VideoQueryClient(api_url=args.api_url)
+
+ try:
+ video_path = query_video(
+ client=client,
+ camera_id=args.camera,
+ start_time=start_time,
+ end_time=end_time,
+ output_dir=output_dir
+ )
+
+ motion_df = None
+ brightness_df = None
+ object_df = None
+
+ if args.analysis in ["motion", "all"]:
+ motion_df = analyze_motion(video_path)
+
+ if args.analysis in ["brightness", "all"]:
+ brightness_df = analyze_brightness(video_path)
+
+ if args.analysis in ["object_count", "all"]:
+ object_df = count_objects_simple(video_path)
+
+ save_analysis_results(
+ output_dir=output_dir,
+ camera_id=args.camera,
+ start_time=start_time,
+ motion_df=motion_df,
+ brightness_df=brightness_df,
+ object_df=object_df
+ )
+
+ print_summary(
+ camera_id=args.camera,
+ start_time=start_time,
+ end_time=end_time,
+ video_path=video_path,
+ motion_df=motion_df,
+ brightness_df=brightness_df,
+ object_df=object_df
+ )
+
+ print("\n✅ Workflow complete!")
+
+ except KeyboardInterrupt:
+ print("\n⚠️ Workflow interrupted by user")
+ return 1
+ except Exception as e:
+ print(f"\n❌ Workflow failed: {e}")
+ import traceback
+ traceback.print_exc()
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ exit(main())
diff --git a/blueprints/video-capture-query/terraform/main.tf b/blueprints/video-capture-query/terraform/main.tf
new file mode 100644
index 00000000..3ffdb5af
--- /dev/null
+++ b/blueprints/video-capture-query/terraform/main.tf
@@ -0,0 +1,78 @@
+/**
+ * # Video Capture Query Blueprint
+ *
+ * Deploys cloud infrastructure for continuous video recording and time-based query capabilities.
+ * This blueprint orchestrates storage account, lifecycle policies, and Azure Functions for the Video Query API.
+ */
+
+module "cloud_resource_group" {
+ source = "../../../src/000-cloud/000-resource-group/terraform"
+
+ environment = var.environment
+ resource_prefix = var.resource_prefix
+ location = var.location
+ instance = var.instance
+ tags = var.tags
+}
+
+module "cloud_data" {
+ source = "../../../src/000-cloud/030-data/terraform"
+
+ depends_on = [module.cloud_resource_group]
+
+ environment = var.environment
+ resource_prefix = var.resource_prefix
+ location = var.location
+ instance = var.instance
+ resource_group = module.cloud_resource_group.resource_group
+
+ // Azure Functions requires HNS disabled
+ storage_account_is_hns_enabled = false
+
+ // Keep public network access disabled for local/managed-identity scenarios.
+ should_enable_public_network_access = false
+
+ // Disable schema registry and ADR namespace for video-only blueprint
+ should_create_schema_registry = false
+ should_create_adr_namespace = false
+
+ // Disable data lake for video-only blueprint
+ should_create_data_lake = false
+}
+
+module "cloud_messaging" {
+ source = "../../../src/000-cloud/040-messaging/terraform"
+
+ depends_on = [module.cloud_data]
+
+ environment = var.environment
+ resource_prefix = var.resource_prefix
+ instance = var.instance
+ resource_group = module.cloud_resource_group.resource_group
+ aio_identity = null
+
+ // Enable Azure Functions for Video Query API
+ should_create_azure_functions = true
+
+ // Disable Event Grid and Event Hubs for video-only blueprint
+ should_create_eventgrid = false
+ should_create_eventhub = false
+
+ // Use Python for the Video Query Azure Function
+ function_node_version = null
+ function_python_version = "3.11"
+
+ function_app_settings = {
+ FUNCTIONS_WORKER_RUNTIME = "python"
+ STORAGE_ACCOUNT_NAME = module.cloud_data.storage_account.name
+ TEMP_VIDEOS_CONTAINER = "temp-videos"
+ VIDEO_RECORDINGS_CONTAINER = "video-recordings"
+ SAS_EXPIRY_HOURS = "24"
+ }
+}
+
+resource "azurerm_role_assignment" "video_query_storage_blob_data_contributor" {
+ scope = module.cloud_data.storage_account.id
+ role_definition_name = "Storage Blob Data Contributor"
+ principal_id = module.cloud_messaging.function_app.principal_id
+}
diff --git a/blueprints/video-capture-query/terraform/outputs.tf b/blueprints/video-capture-query/terraform/outputs.tf
new file mode 100644
index 00000000..fceba36e
--- /dev/null
+++ b/blueprints/video-capture-query/terraform/outputs.tf
@@ -0,0 +1,68 @@
+/**
+ * Video Capture Query Blueprint Outputs
+ *
+ * Exports storage connection information and Azure Functions URL for video query access.
+ */
+
+/*
+ * Storage Account Outputs
+ */
+
+output "storage_account" {
+ description = "Storage Account resource for video recordings."
+ value = module.cloud_data.storage_account
+ sensitive = true
+}
+
+output "storage_account_connection_string" {
+ description = "Storage Account primary connection string for edge device configuration."
+ value = module.cloud_data.storage_account.primary_connection_string
+ sensitive = true
+}
+
+/*
+ * Azure Functions Outputs
+ */
+
+output "function_app" {
+ description = "Azure Function App resource for Video Query API."
+ value = module.cloud_messaging.function_app
+}
+
+output "function_app_url" {
+ description = "Azure Function App default hostname URL."
+ value = try(module.cloud_messaging.function_app.default_hostname, null)
+}
+
+output "function_storage_account" {
+ description = "Storage Account used by the Function App for internal state."
+ value = module.cloud_messaging.function_storage_account
+ sensitive = true
+}
+
+/*
+ * Resource Group Outputs
+ */
+
+output "resource_group" {
+ description = "Resource group for all video capture query resources."
+ value = module.cloud_resource_group.resource_group
+}
+
+/*
+ * Configuration Outputs
+ */
+
+output "video_recording_config" {
+ description = "Video recording configuration parameters for edge deployment."
+ value = {
+ recording_mode = var.recording_mode
+ segment_duration_seconds = var.segment_duration_seconds
+ video_retention_days = var.video_retention_days
+ storage_account_name = module.cloud_data.storage_account.name
+ storage_container_video = "video-recordings"
+ storage_container_temp = "temp-videos"
+ function_app_default_hostname = try(module.cloud_messaging.function_app.default_hostname, null)
+ }
+ sensitive = true
+}
diff --git a/blueprints/video-capture-query/terraform/terraform.tfvars.example b/blueprints/video-capture-query/terraform/terraform.tfvars.example
new file mode 100644
index 00000000..50a87e8c
--- /dev/null
+++ b/blueprints/video-capture-query/terraform/terraform.tfvars.example
@@ -0,0 +1,20 @@
+# Example Terraform variables configuration for video-capture-query blueprint
+
+# Core parameters - REQUIRED
+environment = "dev"
+location = "eastus"
+resource_prefix = "video-demo"
+
+# Core parameters - Optional
+instance = "001"
+
+tags = {
+ project = "video-capture-query"
+ environment = "dev"
+ managed_by = "terraform"
+}
+
+# Video recording parameters - Optional (defaults provided)
+# recording_mode = "continuous"
+# segment_duration_seconds = 300
+# video_retention_days = 365
diff --git a/blueprints/video-capture-query/terraform/variables.tf b/blueprints/video-capture-query/terraform/variables.tf
new file mode 100644
index 00000000..c6ac8542
--- /dev/null
+++ b/blueprints/video-capture-query/terraform/variables.tf
@@ -0,0 +1,81 @@
+/*
+ * Core Parameters - Required
+ */
+
+variable "environment" {
+ type = string
+ description = "Environment for all resources in this module: dev, test, or prod"
+
+ validation {
+ condition = contains(["dev", "test", "prod"], var.environment)
+ error_message = "Environment must be dev, test, or prod."
+ }
+}
+
+variable "location" {
+ type = string
+ description = "Location for all resources in this module"
+}
+
+variable "resource_prefix" {
+ type = string
+ description = "Prefix for all resources in this module"
+
+ validation {
+ condition = length(var.resource_prefix) > 0 && can(regex("^[a-zA-Z](?:-?[a-zA-Z0-9])*$", var.resource_prefix))
+ error_message = "Resource prefix must not be empty, must only contain alphanumeric characters and dashes. Must start with an alphabetic character."
+ }
+}
+
+/*
+ * Core Parameters - Optional
+ */
+
+variable "instance" {
+ type = string
+ description = "Instance identifier for naming resources: 001, 002, etc"
+ default = "001"
+}
+
+variable "tags" {
+ type = map(string)
+ description = "Tags to apply to all resources in this blueprint"
+ default = {}
+}
+
+/*
+ * Video Recording Parameters - Optional
+ */
+
+variable "recording_mode" {
+ type = string
+ description = "Video recording mode: continuous, hybrid, or ring_buffer_only"
+ default = "continuous"
+
+ validation {
+ condition = contains(["continuous", "hybrid", "ring_buffer_only"], var.recording_mode)
+ error_message = "Recording mode must be continuous, hybrid, or ring_buffer_only."
+ }
+}
+
+variable "segment_duration_seconds" {
+ type = number
+ description = "Duration of each video segment in seconds"
+ default = 300
+
+ validation {
+ condition = var.segment_duration_seconds > 0 && var.segment_duration_seconds <= 3600
+ error_message = "Segment duration must be between 1 and 3600 seconds."
+ }
+}
+
+variable "video_retention_days" {
+ type = number
+ description = "Number of days to retain video files before deletion"
+ default = 365
+
+ validation {
+ condition = var.video_retention_days > 0 && var.video_retention_days <= 3650
+ error_message = "Video retention days must be between 1 and 3650."
+ }
+}
diff --git a/blueprints/video-capture-query/terraform/versions.tf b/blueprints/video-capture-query/terraform/versions.tf
new file mode 100644
index 00000000..16d9b16b
--- /dev/null
+++ b/blueprints/video-capture-query/terraform/versions.tf
@@ -0,0 +1,22 @@
+terraform {
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = ">= 4.51.0"
+ }
+ azapi = {
+ source = "Azure/azapi"
+ version = ">= 2.3.0"
+ }
+ }
+ required_version = ">= 1.9.8, < 2.0"
+}
+
+provider "azurerm" {
+ storage_use_azuread = true
+ features {
+ resource_group {
+ prevent_deletion_if_contains_resources = false
+ }
+ }
+}
diff --git a/docs/getting-started/onvif-camera-quickstart.md b/docs/getting-started/onvif-camera-quickstart.md
new file mode 100644
index 00000000..031bb1cd
--- /dev/null
+++ b/docs/getting-started/onvif-camera-quickstart.md
@@ -0,0 +1,382 @@
+---
+title: ONVIF Camera Deployment Guide
+description: Deploy any ONVIF-compatible camera to Azure IoT Operations using the Device Registry component with Bicep, Terraform, or automated scripts
+author: Edge AI Team
+ms.date: 2026-03-13
+ms.topic: quickstart
+estimated_reading_time: 15
+keywords:
+ - onvif
+ - camera
+ - ptz
+ - device-registry
+ - azure-iot-operations
+ - quickstart
+---
+
+## ONVIF Camera Deployment Guide
+
+Deploy any ONVIF-compatible camera to Azure IoT Operations using the 111-assets component. This guide covers device registration, credential management, and PTZ control configuration.
+
+## Prerequisites
+
+- Azure IoT Operations deployed and running on your cluster
+- ONVIF Connector installed (part of Azure IoT Operations)
+- Camera accessible on your network with known IP address
+- Camera credentials (username and password)
+- Azure CLI installed with `az` command available
+- `kubectl` access to your Kubernetes cluster
+
+## Step 1: Enable and Verify ONVIF on Your Camera
+
+Many cameras ship with ONVIF disabled. Enable it before proceeding.
+
+1. Access your camera's web interface at `http://`
+2. Navigate to ONVIF settings (typically under Settings > Network > Advanced > ONVIF)
+3. Enable ONVIF service, set authentication to **Digest**, and save
+4. Reboot the camera if required and wait 60-90 seconds
+
+Verify the ONVIF endpoint responds:
+
+```bash
+curl -X POST http:///onvif/device_service \
+ -H "Content-Type: application/soap+xml" \
+ --max-time 10 \
+ -d ''
+```
+
+A successful response includes `GetSystemDateAndTimeResponse`. Common errors:
+
+| Error | Meaning | Solution |
+|-------------------------------|------------------------------------|---------------------------------|
+| `Data required for operation` | ONVIF disabled | Enable ONVIF in camera settings |
+| `Connection refused` | Wrong port or service not running | Try ports 80, 8000, 8080 |
+| `Connection timeout` | Firewall or wrong IP | Check network connectivity |
+| `401 Unauthorized` | Auth required (service IS enabled) | Proceed to Step 2 |
+
+## Step 2: Gather Camera Information
+
+| Item | Description | Example |
+|-------------|-----------------------|-------------------------|
+| Camera IP | Network IP address | `` |
+| ONVIF Port | Usually 80 or 8000 | `80` |
+| ONVIF Path | Service endpoint path | `/onvif/device_service` |
+| Username | Camera admin username | `admin` |
+| Password | Camera admin password | (your password) |
+| Camera Name | Unique identifier | `camera-01` |
+
+### Determine PTZ Capabilities
+
+Check your camera's web interface or documentation for PTZ support:
+
+| Camera Type | Capabilities | Notes |
+|------------------|------------------------|--------------------------|
+| Fixed | None | Basic IP cameras |
+| Pan/Tilt (PT) | Pan, Tilt, Stop | Many indoor cameras |
+| PTZ | Pan, Tilt, Zoom, Home | PTZ-capable models |
+| PTZ with Presets | PTZ + Preset positions | Professional PTZ cameras |
+
+## Step 3: Create Kubernetes Secrets
+
+Encode your credentials and create a Kubernetes secret:
+
+```bash
+kubectl create secret generic -credentials \
+ -n azure-iot-operations \
+ --from-literal=username='' \
+ --from-literal=password=''
+```
+
+Verify:
+
+```bash
+kubectl get secret -credentials -n azure-iot-operations
+```
+
+## Step 4: Deploy
+
+Choose one of three deployment methods.
+
+### Option A: Automated Scripts (Recommended)
+
+Interactive scripts that handle secrets, configuration, and deployment:
+
+```bash
+cd src/100-edge/111-assets/scripts
+
+# Terraform deployment
+./deploy-onvif-camera-terraform.sh
+
+# Bicep deployment
+./deploy-onvif-camera-bicep.sh
+```
+
+The scripts prompt for camera details, test ONVIF connectivity, discover Azure resources, generate configuration, and deploy with verification.
+
+See [scripts/README.md](../../src/100-edge/111-assets/scripts/README.md) for usage details.
+
+### Option B: Bicep
+
+Create `camera-deployment.bicepparam`:
+
+```bicep
+using './main.bicep'
+
+param common = {
+ environment: 'dev'
+ location: 'eastus2'
+ resource: 'camera'
+ instance: '001'
+}
+
+param customLocationId = ''
+param adrNamespaceName = ''
+
+param namespacedDevices = [
+ {
+ name: ''
+ isEnabled: true
+ endpoints: {
+ outbound: { assigned: {} }
+ inbound: {
+ '-endpoint': {
+ endpointType: 'Microsoft.Onvif'
+ address: 'http:///onvif/device_service'
+ version: '1.0'
+ authentication: {
+ method: 'UsernamePassword'
+ usernamePasswordCredentials: {
+ usernameSecretName: '-credentials/username'
+ passwordSecretName: '-credentials/password'
+ }
+ }
+ }
+ }
+ }
+ }
+]
+```
+
+Deploy:
+
+```bash
+cd src/100-edge/111-assets/bicep
+az deployment group create \
+ --resource-group \
+ --template-file main.bicep \
+ --parameters camera-deployment.bicepparam
+```
+
+### Option C: Terraform
+
+Create `camera-deployment.tfvars`:
+
+```hcl
+location = "eastus2"
+
+resource_group = {
+ name = ""
+ id = "/subscriptions//resourceGroups/"
+}
+
+adr_namespace = {
+ id = "/subscriptions//resourceGroups//providers/Microsoft.DeviceRegistry/namespaces/"
+}
+
+custom_location_id = ""
+
+namespaced_devices = [
+ {
+ name = ""
+ enabled = true
+ endpoints = {
+ outbound = { assigned = {} }
+ inbound = {
+ "-endpoint" = {
+ endpoint_type = "Microsoft.Onvif"
+ address = "http:///onvif/device_service"
+ version = "1.0"
+ authentication = {
+ method = "UsernamePassword"
+ usernamePasswordCredentials = {
+ usernameSecretName = "-credentials/username"
+ passwordSecretName = "-credentials/password"
+ }
+ }
+ }
+ }
+ }
+ }
+]
+```
+
+Deploy:
+
+```bash
+cd src/100-edge/111-assets/terraform
+terraform init
+terraform plan -var-file="camera-deployment.tfvars" -out=tfplan
+terraform apply tfplan
+```
+
+### Get Required Azure Resource IDs
+
+```bash
+az account show --query id -o tsv
+az group show --name --query id -o tsv
+az customlocation show --name --resource-group --query id -o tsv
+az resource list --resource-type Microsoft.DeviceRegistry/namespaces --query "[0].name" -o tsv
+```
+
+## Step 5: Verify Deployment
+
+```bash
+# Devices in Azure
+az resource list --resource-group \
+ --resource-type Microsoft.DeviceRegistry/namespaces/devices \
+ --query "[].{name:name, provisioning:properties.provisioningState}" -o table
+
+# Devices in Kubernetes
+kubectl get devices.namespaces.deviceregistry.microsoft.com -n azure-iot-operations
+
+# Assets in Kubernetes
+kubectl get assets.namespaces.deviceregistry.microsoft.com -n azure-iot-operations
+
+# ONVIF connector logs
+kubectl logs -n azure-iot-operations -l app.kubernetes.io/component=connector --tail=100 -f
+```
+
+## Step 6: Configure PTZ Control
+
+PTZ control uses **managementGroups** with the MRPC protocol. Add this to your asset configuration:
+
+### Bicep managementGroups
+
+```bicep
+param namespacedAssets = [
+ {
+ name: '-ptz'
+ isEnabled: true
+ deviceRef: {
+ deviceName: ''
+ endpointName: '-endpoint'
+ }
+ managementGroups: [
+ {
+ name: 'ptz'
+ actions: [
+ { name: 'RelativeMove', actionType: 'Call', targetUri: 'dtmi:onvif:ptz:RelativeMove;1' }
+ { name: 'ContinuousMove', actionType: 'Call', targetUri: 'dtmi:onvif:ptz:ContinuousMove;1' }
+ { name: 'Stop', actionType: 'Call', targetUri: 'dtmi:onvif:ptz:Stop;1' }
+ { name: 'GotoHomePosition', actionType: 'Call', targetUri: 'dtmi:onvif:ptz:GotoHomePosition;1' }
+ { name: 'GotoPreset', actionType: 'Call', targetUri: 'dtmi:onvif:ptz:GotoPreset;1' }
+ ]
+ }
+ ]
+ }
+]
+```
+
+### Terraform management_groups
+
+```hcl
+namespaced_assets = [
+ {
+ name = "-ptz"
+ enabled = true
+ device_ref = {
+ device_name = ""
+ endpoint_name = "-endpoint"
+ }
+ management_groups = [
+ {
+ name = "ptz"
+ actions = [
+ { name = "RelativeMove", action_type = "Call", target_uri = "dtmi:onvif:ptz:RelativeMove;1" },
+ { name = "ContinuousMove", action_type = "Call", target_uri = "dtmi:onvif:ptz:ContinuousMove;1" },
+ { name = "Stop", action_type = "Call", target_uri = "dtmi:onvif:ptz:Stop;1" },
+ { name = "GotoHomePosition", action_type = "Call", target_uri = "dtmi:onvif:ptz:GotoHomePosition;1" },
+ { name = "GotoPreset", action_type = "Call", target_uri = "dtmi:onvif:ptz:GotoPreset;1" }
+ ]
+ }
+ ]
+ }
+]
+```
+
+### MRPC Protocol
+
+PTZ commands use the MRPC (Message-based RPC) protocol over MQTT:
+
+- **Topic pattern**: `{namespace}/mrpc/{asset}/{commandName}`
+- **Example**: `azure-iot-operations/mrpc/camera-01-ptz/RelativeMove`
+
+The connector logs `Asset Endpoint is not being observed` for PTZ-only assets (with only `management_groups`). This is expected behavior.
+
+For PTZ testing, use the [Azure Samples PTZ Demo](https://github.com/Azure-Samples/explore-iot-operations/tree/main/samples/aio-onvif-connector-ptz-demo) or direct ONVIF SOAP commands.
+
+### Direct ONVIF PTZ Testing
+
+Test PTZ independently of Azure IoT Operations:
+
+```bash
+curl -X POST "http:///onvif/ptz_service" \
+ --anyauth --user ":" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ 000
+
+
+
+
+
+
+'
+```
+
+## Troubleshooting
+
+### Camera Not Responding
+
+1. Verify network: `ping `
+2. Test ONVIF endpoint with curl (see Step 1)
+3. Check credentials: `kubectl get secret -credentials -n azure-iot-operations -o jsonpath='{.data.username}' | base64 -d`
+
+### ONVIF Service Disabled
+
+Error: `Data required for operation`
+
+Enable ONVIF in camera web interface under Settings > Network > Advanced > ONVIF. Save and reboot.
+
+### Authentication Errors
+
+- Verify secret exists in `azure-iot-operations` namespace
+- Check credential encoding has no trailing newlines
+- Confirm the camera user has ONVIF permissions
+- Use secret reference format: `/`
+
+### PTZ Commands Not Working
+
+- Verify camera supports PTZ (check specs)
+- Use Operations Experience UI for PTZ configuration and testing
+- Test direct ONVIF SOAP commands to confirm camera PTZ works
+- Check connector logs: `kubectl logs -n azure-iot-operations -l app.kubernetes.io/component=connector --tail=500`
+
+### Device Not Showing in Kubernetes
+
+Use the correct API group:
+
+```bash
+kubectl get devices.namespaces.deviceregistry.microsoft.com -n azure-iot-operations
+kubectl get assets.namespaces.deviceregistry.microsoft.com -n azure-iot-operations
+```
+
+## Additional Resources
+
+- [ONVIF Specifications](https://www.onvif.org/specs/core/ONVIF-Core-Specification.pdf)
+- [Azure IoT Operations Documentation](https://learn.microsoft.com/azure/iot-operations/)
+- [Manage Assets in Azure IoT Operations](https://learn.microsoft.com/azure/iot-operations/discover-manage-assets/overview-manage-assets)
+- [PTZ Demo Sample](https://github.com/Azure-Samples/explore-iot-operations/tree/main/samples/aio-onvif-connector-ptz-demo)
diff --git a/docs/solution-adr-library/continuous-video-capture-acsa-sync.md b/docs/solution-adr-library/continuous-video-capture-acsa-sync.md
new file mode 100644
index 00000000..d2dce347
--- /dev/null
+++ b/docs/solution-adr-library/continuous-video-capture-acsa-sync.md
@@ -0,0 +1,708 @@
+---
+title: Continuous Video Capture with ACSA-Based Cloud Synchronization
+description: Architecture Decision Record for implementing continuous video recording from ONVIF cameras with automatic edge-to-cloud synchronization using Azure Container Storage Accelerator (ACSA), enabling time-based video query capabilities for Data Scientists
+author: Edge AI Team
+ms.date: 2026-01-09
+ms.topic: architecture-decision-record
+estimated_reading_time: 15
+keywords:
+ - video-capture
+ - continuous-recording
+ - acsa
+ - azure-iot-operations
+ - onvif-cameras
+ - edge-to-cloud-sync
+ - blob-storage
+ - lifecycle-management
+ - video-query
+ - time-based-retrieval
+ - ffmpeg
+ - rust
+ - azure-functions
+ - architecture-decision-record
+ - adr
+---
+
+## Status
+
+- [ ] Draft
+- [x] Proposed
+- [ ] Accepted
+- [ ] Deprecated
+
+**Date:** 2026-01-08
+**Proposed:** 2026-01-09
+
+## Context
+
+### Business Need
+
+Industrial and manufacturing environments require the ability to capture continuous video from surveillance cameras for compliance, quality assurance, incident investigation, and operational analysis. Data Scientists need programmatic access to retrieve historical video segments based on time ranges to perform:
+
+- **Incident Investigation**: Retrieve video from specific time periods when events occurred
+- **Quality Analysis**: Extract video segments for visual inspection and defect detection
+- **Compliance Auditing**: Access historical footage for regulatory compliance verification
+- **Operational Analytics**: Analyze video patterns across time periods for process optimization
+- **AI Model Training**: Collect video data for training computer vision models
+
+### Technical Requirements
+
+The continuous video capture solution must meet the following requirements:
+
+**Recording Requirements:**
+
+- 24/7 continuous recording from ONVIF-compliant cameras
+- RTSP stream ingestion with H.264 codec support
+- Configurable segment duration (default 5 minutes) for manageable file sizes
+- Support for multiple cameras with independent recording streams
+- Automatic reconnection and error recovery for camera streams
+
+**Storage Requirements:**
+
+- Edge buffering to handle network disruptions to cloud
+- Automatic synchronization to Azure Blob Storage
+- Cost-optimized storage with lifecycle management (Hot → Cool → Archive → Delete)
+- Efficient path organization for time-based queries (year/month/day/hour structure)
+- Metadata files accompanying each video segment for quick filtering
+
+**Query Requirements:**
+
+- Time-based video retrieval (specify camera ID, start time, end time)
+- Two query paths:
+ - Real-time MQTT-based queries returning segment URLs (< 1 second response)
+ - Historical REST API queries with FFmpeg-stitched continuous video (10-60 second response)
+- Python SDK for Data Scientists to simplify video queries from Jupyter notebooks
+- SAS token generation for secure, time-limited access to video segments
+
+**Integration Requirements:**
+
+- Integration with Azure IoT Operations for edge orchestration
+- MQTT broker for real-time query/response messaging
+- Azure Container Storage Accelerator (ACSA) for automatic cloud sync
+- Support for existing edge infrastructure (K3s Kubernetes)
+- Azure Functions for video query and stitching API
+
+**Operational Requirements:**
+
+- Minimal operational overhead (no custom upload code)
+- Automatic retry and offline buffering during network failures
+- Health monitoring and metrics exposure
+- Configurable retention policies aligned with compliance requirements
+- Container-based deployment for portability
+
+### Existing Architecture Context
+
+The video capture system operates within the Azure IoT Operations ecosystem:
+
+- **MQTT Broker**: Azure IoT Operations MQTT broker for request/response messaging
+- **Device Registry (111-assets)**: Camera asset management and configuration
+- **ACSA**: Azure Container Storage Accelerator for edge-to-cloud synchronization
+- **Edge Storage**: Local K3s persistent volumes for ACSA mount points
+- **Cloud Storage**: Azure Blob Storage with multi-tier lifecycle policies
+- **Observability**: Prometheus/Grafana for monitoring recording health
+
+### Problem Statement
+
+Data Scientists currently have no systematic way to retrieve historical video from edge cameras:
+
+1. **No Continuous Recording**: Existing media capture service only supports event-triggered recording (alert-based snapshots)
+2. **Manual Video Retrieval**: No programmatic interface for time-based video queries
+3. **Cloud Upload Complexity**: No automated mechanism for edge-to-cloud video synchronization
+4. **Storage Cost Concerns**: Lack of lifecycle management leads to expensive long-term storage
+5. **Fragmented Segments**: No ability to stitch multiple 5-minute segments into continuous video
+
+Early implementations explored several approaches:
+
+1. **Direct Azure SDK Upload**: Custom Rust code using Azure Storage SDK
+ - ❌ Requires extensive error handling for network failures
+ - ❌ Manual retry logic and offline buffering
+ - ❌ Additional code maintenance burden
+ - ❌ Bypasses ACSA's purpose-built edge-to-cloud sync
+
+2. **Blob Trigger Functions**: Azure Functions triggered by blob creation
+ - ❌ Higher latency (5-15 seconds)
+ - ❌ Cold start issues for consumption plan
+ - ❌ Additional cost per blob operation
+
+3. **Custom Sync Service**: Separate service monitoring local storage
+ - ❌ Duplicate functionality with ACSA
+ - ❌ Additional service to manage and monitor
+
+## Decision
+
+Implement a **Rust-based continuous video recorder** that writes to **ACSA-mounted volumes**, with **dual query paths** (MQTT for real-time, REST API for historical), and **FFmpeg-based video stitching** for continuous playback:
+
+1. **Continuous Recording Engine**: Rust service capturing RTSP streams continuously
+2. **ACSA-Based Sync**: Write MP4 segments + JSON metadata to ACSA volume; ACSA handles automatic cloud sync
+3. **Time-Based Organization**: Hash-prefixed hierarchical paths (year/month/day/hour) for efficient queries
+4. **Lifecycle Management**: Automated tier transitions (Hot → Cool → Archive → Delete) based on age
+5. **Dual Query Architecture**:
+ - **MQTT Path**: Real-time queries returning segment URLs (< 1s response)
+ - **REST API Path**: Azure Function stitching segments into continuous video (10-60s response)
+6. **Python SDK**: Simple interface for Data Scientists to query and download video
+
+### Architecture Pattern: ACSA as the Sync Abstraction
+
+Instead of implementing custom Azure Storage SDK code in the media capture service, we leverage **ACSA as the edge-to-cloud synchronization layer**:
+
+```rust
+// Simple write to ACSA volume - no Azure SDK code needed
+pub struct AcsaWriter {
+ acsa_mount_path: PathBuf,
+}
+
+impl AcsaWriter {
+ pub async fn write_segment_with_metadata(
+ &self,
+ video_file: &Path,
+ camera_id: &str,
+ camera_location: &str,
+ segment_start: DateTime,
+ segment_end: DateTime,
+ ) -> Result<(), Box> {
+ // Write MP4 file to ACSA volume
+ let video_path = self.generate_acsa_path(camera_id, segment_start);
+ fs::copy(video_file, &video_path).await?;
+
+ // Write companion JSON metadata
+ let metadata = json!({
+ "camera_id": camera_id,
+ "location": camera_location,
+ "segment_start": segment_start.to_rfc3339(),
+ "segment_end": segment_end.to_rfc3339(),
+ "duration_seconds": (segment_end - segment_start).num_seconds(),
+ "file_path": video_path.to_str(),
+ });
+ let metadata_path = video_path.with_extension("json");
+ fs::write(&metadata_path, serde_json::to_string_pretty(&metadata)?).await?;
+
+ // ACSA automatically syncs to Azure Blob Storage
+ info!("ACSA will automatically sync {} to cloud", video_path.display());
+ Ok(())
+ }
+}
+```
+
+**Rationale:**
+
+- **Operational Simplicity**: No custom sync code, retry logic, or offline buffering
+- **Built-in Reliability**: ACSA provides retry, offline buffer, and network failure handling
+- **Azure IoT Operations Native**: ACSA is purpose-built for Azure IoT Operations edge-to-cloud scenarios
+- **Reduced Maintenance**: No need to maintain Azure Storage SDK integration
+- **Clear Separation**: Recording service focuses on video capture; ACSA handles sync
+
+### Storage Organization Pattern: Hash-Prefixed Hierarchical Paths
+
+Videos are organized using a two-level structure for optimal query performance:
+
+```text
+media-capture-data/
+├── a1/ # MD5 hash prefix (first 2 chars of camera ID hash)
+│ └── camera-01/
+│ └── 2026/01/09/10/
+│ ├── video-2026-01-09T10-00-00Z.mp4
+│ ├── video-2026-01-09T10-00-00Z.json
+│ ├── video-2026-01-09T10-05-00Z.mp4
+│ └── video-2026-01-09T10-05-00Z.json
+└── b2/
+ └── camera-02/
+ └── ...
+```
+
+**Rationale:**
+
+- **Load Balancing**: Hash prefixes distribute camera data across storage partitions
+- **Time-Based Queries**: Year/month/day/hour structure enables fast prefix-based blob listing
+- **Blob Index Tags**: Each video tagged with `camera_id`, `start_time`, `duration_seconds` for efficient filtering (future enhancement)
+- **Metadata Co-location**: JSON files alongside MP4 files enable the Video Query API to enrich responses with precise timing and location data without parsing video files
+
+### Dual Query Pattern: MQTT + REST API
+
+Two query paths serve different use cases:
+
+**MQTT Path (Real-Time)**:
+
+```python
+# Fast query returning segment URLs (< 1 second)
+client = VideoClient(mqtt_broker="192.168.102.100:1883")
+result = client.get_video_mqtt(
+ camera_id="camera-01",
+ start_time="2026-01-09T10:00:00Z",
+ end_time="2026-01-09T10:30:00Z"
+)
+# Returns: List of segment URLs with SAS tokens
+```
+
+**REST API Path (Historical) - Default Mode**:
+
+```python
+# Fast query returning segment URLs (< 2 seconds)
+result = client.get_video_api(
+ camera_id="camera-01",
+ start_time="2026-01-09T10:00:00Z",
+ end_time="2026-01-09T10:30:00Z"
+)
+# Returns: List of segments with URLs, timestamps, and metadata
+```
+
+**REST API Path (Historical) - Stitched Mode**:
+
+```python
+# Slower query returning stitched video (2-10 seconds)
+result = client.get_video_api(
+ camera_id="camera-01",
+ start_time="2026-01-09T10:00:00Z",
+ end_time="2026-01-09T10:30:00Z",
+ stitch=True
+)
+# Returns: Single continuous 30-minute MP4 URL
+```
+
+**Rationale:**
+
+- **MQTT**: Low latency for real-time queries, returns multiple segments
+- **REST API (default)**: Fast response with segment array, flexible for programmatic access
+- **REST API (stitch=true)**: Higher latency but returns continuous video (FFmpeg-stitched), user-friendly
+- **Use Case Alignment**: MQTT for responsive UIs, REST API default for data science, REST API stitched for business users
+- **Flexibility**: Data Scientists choose based on their workflow needs
+
+## Decision Drivers
+
+### Primary Drivers (High Priority)
+
+1. **ACSA Alignment**
+ - **Description**: Leverage Azure IoT Operations' purpose-built edge-to-cloud sync
+ - **Impact**: Eliminates 400+ lines of custom Azure SDK code, automatic retry/buffer
+ - **Weight**: Critical - reduces maintenance burden and aligns with Azure IoT Ops architecture
+
+2. **Cost Optimization**
+ - **Description**: Multi-tier storage with lifecycle policies
+ - **Impact**: 91% cost reduction (Hot $657/month → Mixed $58/month for 100GB/day/camera)
+ - **Weight**: Critical - enables long-term retention without prohibitive costs
+
+3. **Data Scientist Accessibility**
+ - **Description**: Simple Python SDK for video queries from Jupyter notebooks
+ - **Impact**: Reduces video retrieval from hours (manual) to minutes (programmatic)
+ - **Weight**: High - directly impacts data scientist productivity
+
+4. **Query Performance**
+ - **Description**: Dual query paths optimized for different use cases
+ - **Impact**: MQTT < 1s for quick checks, REST API 10-60s for continuous video
+ - **Weight**: High - affects user experience for different workflows
+
+### Secondary Drivers (Medium Priority)
+
+1. **Operational Simplicity**
+ - **Description**: Single Rust service, Helm chart deployment, minimal configuration
+ - **Impact**: Reduces operational complexity and deployment time
+ - **Weight**: Medium - improves maintainability
+
+2. **Metadata Co-location**
+ - **Description**: JSON files alongside MP4 for quick attribute queries
+ - **Impact**: Enables filtering without video parsing
+ - **Weight**: Medium - improves query efficiency
+
+## Considered Options
+
+### Option 1: ACSA-Based Sync with Rust Recorder (Selected)
+
+**Description**: Rust service writes MP4 + JSON to ACSA volume; ACSA handles cloud sync automatically.
+
+**Technical Details**:
+
+- Rust continuous recorder using FFmpeg
+- ACSA PersistentVolume mounted at `/cloud-sync/video-recordings`
+- Write-only interface (no Azure SDK code)
+- Dual query: MQTT (real-time) + Azure Function (historical stitching)
+
+**Pros**:
+
+- ✅ No custom sync code (ACSA handles everything)
+- ✅ Built-in retry logic and offline buffering
+- ✅ Aligns with Azure IoT Operations architecture
+- ✅ Reduced maintenance burden (no Azure SDK updates)
+- ✅ Rust performance for recording (< 100MB container, 2-3s cold start)
+- ✅ Dual query paths serve different use cases
+
+**Cons**:
+
+- ⚠️ Requires ACSA configuration (PersistentVolume setup)
+- ⚠️ ACSA sync lag (1-2 minutes typical, but acceptable for historical queries)
+- ⚠️ Requires Rust expertise for recorder maintenance
+
+**Risks**:
+
+- **Risk**: ACSA connectivity issues causing data loss
+ - **Probability**: Low (ACSA has offline buffering)
+ - **Impact**: Medium (delayed cloud availability, but data not lost)
+ - **Mitigation**: Monitor ACSA sync lag; alert if > 10 minutes; local volume size sufficient for 24hr buffer
+
+- **Risk**: FFmpeg stitching performance degradation for long time ranges
+ - **Probability**: Medium (large queries could be slow)
+ - **Impact**: Low (REST API query path, not real-time)
+ - **Mitigation**: Limit query range (max 2 hours), implement pagination, use MQTT path for quick queries
+
+**Dependencies**:
+
+- Azure IoT Operations with ACSA enabled
+- K3s cluster with persistent volume support
+- Azure Blob Storage account
+- Azure Functions for video query API
+
+**Costs**:
+
+- **Initial**: ~40 hours development (Rust recorder + ACSA integration + Azure Function)
+- **Ongoing**: Storage costs (Hot $18/TB/month, Cool $10, Archive $1.50)
+- **Effort**: Low maintenance (ACSA handles sync, lifecycle policies automated)
+
+### Option 2: Custom Azure SDK Upload in Rust
+
+**Description**: Rust service uses Azure Storage SDK directly to upload segments to Blob Storage.
+
+**Technical Details**:
+
+- Azure Storage SDK Rust crate for blob uploads
+- Custom retry logic and error handling
+- Offline buffering with local queue
+- Same dual query architecture
+
+**Pros**:
+
+- ✅ No ACSA dependency
+- ✅ Direct control over upload process
+- ✅ Potentially lower sync latency
+
+**Cons**:
+
+- ❌ 400+ lines of custom sync code
+- ❌ Manual retry logic and exponential backoff
+- ❌ Manual offline buffering implementation
+- ❌ Azure SDK updates require maintenance
+- ❌ Bypasses ACSA's purpose-built sync
+- ❌ More complex error handling
+
+**Risks**:
+
+- **Risk**: Network failure handling complexity
+ - **Probability**: High (edge environments have intermittent connectivity)
+ - **Impact**: High (could lose video data)
+ - **Mitigation**: Implement robust offline buffer, but this adds significant code
+
+- **Risk**: Azure SDK breaking changes
+ - **Probability**: Medium (SDK evolves over time)
+ - **Impact**: Medium (requires maintenance and testing)
+ - **Mitigation**: Pin SDK versions, but delays security updates
+
+**Dependencies**:
+
+- Azure Storage SDK for Rust
+- Local persistent volume for offline buffer
+- Azure Blob Storage account
+
+**Costs**:
+
+- **Initial**: ~60 hours development (SDK integration + retry logic + offline buffer)
+- **Ongoing**: Same storage costs as Option 1
+- **Effort**: High maintenance (SDK updates, bug fixes in sync logic)
+
+**Why Not Chosen**: Bypasses ACSA's purpose-built functionality, significantly higher development and maintenance cost.
+
+### Option 3: Python-Based Recorder with OpenCV
+
+**Description**: Python service using OpenCV for RTSP capture, Azure SDK for upload.
+
+**Technical Details**:
+
+- Python with OpenCV for video capture
+- Azure Storage SDK for Python
+- Similar architecture to Option 2
+
+**Pros**:
+
+- ✅ More Python developers available
+- ✅ Rich ecosystem for video processing
+
+**Cons**:
+
+- ❌ Higher memory footprint (1.2GB+ container vs 100MB Rust)
+- ❌ Slower cold start (6-8 seconds vs 2-3 seconds)
+- ❌ Lower throughput per CPU core
+- ❌ Still requires custom sync code (same as Option 2)
+
+**Risks**:
+
+- **Risk**: Resource constraints on edge devices
+ - **Probability**: High (multiple camera streams)
+ - **Impact**: High (container OOM kills)
+ - **Mitigation**: Limit cameras per instance, but increases deployment complexity
+
+**Dependencies**:
+
+- Python runtime, OpenCV, Azure SDK
+- Larger container images (> 500MB)
+
+**Costs**:
+
+- **Initial**: ~50 hours development
+- **Ongoing**: Higher compute costs due to resource usage
+- **Effort**: Medium-high maintenance
+
+**Why Not Chosen**: Resource footprint too high for edge deployment, especially with multiple camera streams.
+
+## Comparison Matrix
+
+| Criteria | Weight | Option 1: ACSA-Based (Selected) | Option 2: Custom SDK | Option 3: Python |
+|----------------------------|----------|---------------------------------|-------------------------|-------------------------|
+| **Development Effort** | High | 40 hours | 60 hours | 50 hours |
+| **Maintenance Burden** | High | Low (ACSA handles sync) | High (custom sync code) | Medium-High |
+| **Resource Efficiency** | High | Excellent (<100MB, low CPU) | Good | Poor (1.2GB+, high CPU) |
+| **Sync Reliability** | Critical | Excellent (ACSA built-in) | Manual implementation | Manual implementation |
+| **Architecture Alignment** | High | Perfect (Azure IoT Ops native) | Bypasses ACSA | Bypasses ACSA |
+| **Offline Resilience** | High | Excellent (ACSA buffer) | Manual implementation | Manual implementation |
+| **Query Performance** | Medium | MQTT < 1s, API 10-60s | Same | Same |
+| **Cost Optimization** | High | 91% reduction (lifecycle) | Same | Higher (compute) |
+| **Developer Availability** | Medium | Rust (moderate) | Rust (moderate) | Python (high) |
+
+**Recommended**: Option 1 (ACSA-Based) is expected to excel in maintenance burden, sync reliability, architecture alignment, and offline resilience.
+
+## Consequences
+
+### Positive
+
+- **Reduced Maintenance**: Will eliminate 400+ lines of custom sync code; ACSA will handle retry, buffering, network failures
+- **Cost Optimization**: Projects 91% storage cost reduction through lifecycle policies ($657/month → $58/month for 100GB/day)
+- **Data Scientist Productivity**: Python SDK will reduce video retrieval from hours (manual) to minutes (programmatic)
+- **Architectural Alignment**: Will leverage Azure IoT Operations ACSA as designed
+- **Operational Simplicity**: Single Rust service with Helm chart deployment
+- **Query Flexibility**: Dual paths (MQTT + REST API) will serve different use cases
+- **Offline Resilience**: ACSA's offline buffer will prevent data loss during connectivity issues
+
+### Negative
+
+- **ACSA Dependency**: Will require ACSA configuration and monitoring
+- **Sync Latency**: Expected 1-2 minute lag to cloud (acceptable for historical queries, not real-time)
+- **Rust Expertise**: Will require Rust developers for recorder maintenance
+- **FFmpeg Complexity**: Video stitching will add Azure Function dependency
+
+### Neutral
+
+- **Dual Query Paths**: Will add complexity but serve different use cases
+- **Metadata Files**: JSON co-location adds minimal storage overhead (~300 bytes per segment) but enables the Video Query API to return enriched responses with precise timestamps, duration, and location data without parsing video files
+- **Hash Prefixing**: Will add path complexity but optimize load distribution
+
+### Risks and Monitoring
+
+**Ongoing Risks**:
+
+- **ACSA Sync Lag**: Monitor sync lag; alert if > 10 minutes
+- **Storage Capacity**: Monitor ACSA volume usage; ensure sufficient capacity for 24hr buffer
+- **FFmpeg Performance**: Monitor stitching times; implement query range limits if needed
+- **Camera Connectivity**: Monitor stream health; auto-reconnect on failures
+
+**Target Success Metrics**:
+
+- ACSA sync lag: < 2 minutes (p95)
+- MQTT query response: < 1 second
+- REST API query response: < 60 seconds for 30-minute videos
+- Recording uptime: > 99.5% per camera
+- Storage cost: < $60/month per camera (100GB/day)
+
+## Implementation
+
+### Phase 1: Core Recording and ACSA Sync (Week 1-2)
+
+**Tasks**:
+
+1. Implement Rust continuous recorder with FFmpeg
+2. Configure ACSA PersistentVolume with Azure Blob Storage binding
+3. Implement continuous recording with configurable segment duration
+4. Create video segments with hierarchical timestamp paths
+5. Test ACSA sync with network failure scenarios
+
+**Deliverables**:
+
+- Rust continuous recorder service
+- ACSA PersistentVolume configuration
+- Hierarchical path structure (year/month/day/hour)
+- Network failure test results
+
+### Phase 2: Blob Storage and Lifecycle Management (Week 2)
+
+**Tasks**:
+
+1. Configure blob index tags for efficient queries
+2. Implement lifecycle management policies (Hot/Cool/Archive/Delete)
+3. Create storage account with multi-tier configuration
+4. Test tier transitions and access patterns
+
+**Deliverables**:
+
+- Blob Storage account with lifecycle policies
+- Blob index tag schema
+- Tier transition test results
+
+### Phase 3: MQTT Query Path (Week 3)
+
+**Tasks**:
+
+1. Implement MQTT request/response handlers in recorder
+2. Generate SAS tokens for segment URLs
+3. Test MQTT query performance and reliability
+
+**Deliverables**:
+
+- MQTT query implementation
+- SAS token generation
+- Query performance benchmarks (< 1s response)
+
+### Phase 4: REST API Query Path (Week 3-4)
+
+**Tasks**:
+
+1. Develop Azure Function for video query API
+2. Implement FFmpeg-based video stitching
+3. Create temporary blob storage for stitched videos
+4. Generate SAS URLs for stitched video access
+
+**Deliverables**:
+
+- Azure Function deployment
+- FFmpeg stitching logic
+- Stitched video SAS generation
+- Query performance benchmarks (10-60s response)
+
+### Phase 5: Python SDK and Documentation (Week 4)
+
+**Tasks**:
+
+1. Develop Python SDK with simple query interface
+2. Create Jupyter notebook examples
+3. Write deployment documentation
+4. Create architecture diagrams
+
+**Deliverables**:
+
+- Python SDK package
+- Jupyter notebook examples
+- Deployment guide
+- Architecture diagrams (6 draw.io + markdown docs)
+
+### Phase 6: Local File Retention Management (Post-Deployment Enhancement)
+
+**Tasks**:
+
+1. Implement configurable local file retention with automatic cleanup
+2. Add background cleanup task with configurable interval
+3. Implement recursive directory traversal for old file deletion
+4. Remove empty directories after file cleanup
+5. Make retention parameters configurable via Helm values
+
+**Deliverables**:
+
+- Background cleanup task using tokio interval timer
+- Configurable `localRetentionHours` parameter (default: 24 hours)
+- Configurable `cleanupIntervalMinutes` parameter (default: 60 minutes)
+- Recursive async cleanup with Send-safe futures for tokio compatibility
+- Automatic empty directory removal to prevent path accumulation
+- Updated Helm chart with retention configuration options
+
+**Implementation Details**:
+
+- Cleanup task spawned on service startup, runs independently from recording loop
+- Calculates cutoff time based on current timestamp minus retention hours
+- Recursively traverses `/cloud-sync/video-recordings/{camera_id}/` directory structure
+- Deletes MP4 files with modification time older than cutoff
+- Removes empty year/month/day/hour directories after file deletion
+- Uses idempotent directory creation (`create_dir_all`) to prevent race conditions
+- Logs cleanup operations for observability (files deleted, directories removed)
+
+**Configuration Example**:
+
+```bash
+helm install media-capture ./charts/media-capture-service \
+ --set mediaCapture.continuousRecording.segmentDurationSeconds=300 \
+ --set mediaCapture.continuousRecording.localRetentionHours=24 \
+ --set mediaCapture.continuousRecording.cleanupIntervalMinutes=60
+```
+
+**Rationale**:
+
+- Prevents disk space exhaustion on edge nodes with limited storage
+- Maintains buffer for network interruptions (ACSA handles cloud sync)
+- Reduces Azure egress costs by limiting local storage footprint
+- Configurable to accommodate different customer storage constraints
+- ACSA syncs files to cloud before local retention period expires
+
+### Timeline
+
+- **Total Duration**: 4 weeks
+- **Team**: 2 developers (1 Rust, 1 Python/Azure Functions)
+- **Deployment Target**: Production-ready by end of Week 4
+- **Phase 6 Enhancement**: 2 days (post-deployment operational improvement)
+
+### Resources Required
+
+- **Development**: 2 developers (160 hours total)
+- **Infrastructure**: Azure subscription with IoT Operations, Blob Storage, Functions
+- **Edge Hardware**: K3s cluster with ACSA support
+- **Testing**: ONVIF camera (real or simulated), network failure testing tools
+
+## Future Considerations
+
+### Monitoring Requirements
+
+- **ACSA Sync Health**: Monitor sync lag, offline buffer size, failed uploads
+- **Recording Health**: Monitor camera connectivity, stream errors, disk usage
+- **Local Retention Cleanup**: Monitor cleanup task execution, files deleted, disk space freed, empty directories removed
+- **Query Performance**: Monitor MQTT response times, API latency, FFmpeg stitching duration
+- **Storage Costs**: Track blob tier distribution, lifecycle policy effectiveness
+- **Alert Thresholds**: Sync lag > 10 min, recording uptime < 99.5%, query timeout > 60s, disk usage > 80%
+
+### Evolution Opportunities
+
+- **Multi-Camera Aggregation**: Support queries across multiple cameras
+- **Video Analytics Integration**: Add AI-based event detection and indexing
+- **Archive Rehydration**: Implement automatic rehydration for Archive tier queries
+- **Blob Index Optimization**: Explore additional tags for advanced filtering
+- **Edge Caching**: Implement local cache for frequently accessed segments
+- **Compression Optimization**: Evaluate H.265 codec for storage savings
+
+### Triggers for Review
+
+- **ACSA Performance Issues**: If sync lag consistently exceeds SLA (> 10 minutes)
+- **Storage Cost Overruns**: If lifecycle policies don't achieve target cost savings
+- **Query Performance Degradation**: If FFmpeg stitching becomes bottleneck
+- **New Azure Features**: If Azure Video Analyzer or similar services become available
+- **Architecture Changes**: If Azure IoT Operations introduces new edge storage patterns
+
+**Review Schedule**: Quarterly review (next review: April 2026)
+
+## References
+
+### Internal Documentation
+
+- [Media Capture Service README](../../../src/500-application/503-media-capture-service/README.md)
+- [Video Capture Query Blueprint](../../../blueprints/video-capture-query/README.md)
+- [Component Integration Flow](../../../blueprints/video-capture-query/diagrams/02-component-integration-flow.md)
+- [Deployment Sequence](../../../blueprints/video-capture-query/diagrams/06-deployment-sequence.md)
+
+### External References
+
+- [Azure Container Storage Accelerator Documentation](https://learn.microsoft.com/en-us/azure/azure-arc/container-storage/)
+- [Azure Blob Storage Lifecycle Management](https://learn.microsoft.com/en-us/azure/storage/blobs/lifecycle-management-overview)
+- [ONVIF Specification](https://www.onvif.org/specs/)
+- [FFmpeg Documentation](https://ffmpeg.org/documentation.html)
+
+### Architecture Decision Context
+
+This ADR documents the proposed architectural approach for continuous video capture query implementation. The proposed solution includes:
+
+- Rust-based continuous recorder
+- ACSA-based edge-to-cloud sync
+- Dual query paths (MQTT + REST API)
+- Python SDK for Data Scientists
+- Comprehensive deployment automation (Terraform + Helm)
+
+This architecture is currently being validated through the Customer PoC deployment and will inform future customer implementations.
+
+**Related ADRs**: None (first ADR for video capture domain in this repository)
diff --git a/docs/solution-adr-library/edge-video-streaming-and-image-capture.md b/docs/solution-adr-library/edge-video-streaming-and-image-capture.md
index 252e5f74..98c95c2d 100644
--- a/docs/solution-adr-library/edge-video-streaming-and-image-capture.md
+++ b/docs/solution-adr-library/edge-video-streaming-and-image-capture.md
@@ -1,6 +1,6 @@
---
title: Video and Image Capture from Edge-Attached Cameras
-description: Architecture Decision Record for implementing secure video streaming and image capture from edge-attached IP cameras using Azure IoT Operations Media Connector. Covers live RTSP streaming, snapshot/clip storage workflows, MQTT integration, Azure Container Storage enabled by Azure Arc (ACSA), and media synchronization with Azure Blob Storage for anomaly detection scenarios.
+description: Architecture Decision Record for implementing secure video streaming and image capture from edge-attached IP cameras using Azure IoT Operations Media Connector. Covers live RTSP streaming, snapshot/clip storage workflows, MQTT integration, Azure Container Storage enabled by Azure Arc (ACSA), and media synchronization with Azure Blob Storage for anomaly detection scenarios. Amended to reference the Dual-Component Video Architecture ADR which supersedes the Media Sync Service approach.
author: Alain Uyidi
ms.date: 2025-11-12
ms.topic: architecture-decision-record
@@ -86,17 +86,27 @@ Data flow:
### 2.2 Move files from unbacked to backed ACSA for confirmed events
-
+
Data flow:
(1) - The Deterministic Logic Service publishes to AIO MQTT Broker to the event topic with the timestamp of when the anomaly was detected.
-(2) - The Media Sync Service subscribed to the event topic, retrieves the message.
+> **Architecture Update**: The Media Sync Service described in this section was superseded by the [Media Capture Service (503)](../../src/500-application/503-media-capture-service/) which writes directly to cloud-backed ACSA PVCs, eliminating the need for an intermediate sync service. See [Dual-Component Video Architecture](./dual-component-video-architecture.md) for the updated architecture decision.
-(3) - The Media Sync Service finds and retrieves the clips/snapshot files stored in unbacked persisted volume within the time range configured for the event.
+**Updated Architecture (Mermaid)**:
-(4) - The Media Sync Service copies the clips and files to the cloud backed persisted volume.
+```mermaid
+graph LR
+ DLS[Deterministic Logic Service] -->|Publish event| MQTT[AIO MQTT Broker]
+ MQTT -->|Event topic| MCS[503 Media Capture Service]
+ MCS -->|Write segments| ACSA[ACSA Cloud-Backed PVC]
+ ACSA -->|Auto-sync| BLOB[Azure Blob Storage]
+```
+
+(2) - The Media Capture Service records continuously to cloud-backed ACSA PVC with automatic Azure Blob Storage sync.
+
+(3) - Event timestamps from the Deterministic Logic Service are used by the Video Query API to retrieve relevant segments from Azure Blob Storage.
(5) - The files in ACSA Cloud backed persisted volume is then synced to Azure Blob Storage.
@@ -110,11 +120,19 @@ Data flow:
(2) - Mosquitto MQTT Client publishes message to AIO MQTT Broker
-(3) - The Media Sync subscribes and retrieves message from the topic
+> **Architecture Update**: The Media Sync Service described in this section was superseded by the [Media Capture Service (503)](../../src/500-application/503-media-capture-service/). See [Dual-Component Video Architecture](./dual-component-video-architecture.md).
+
+**Updated Architecture (Mermaid)**:
-(4) - The Media Sync finds and retrieves the clips/snapshot files stored in unbacked persisted volume within the time range specified by Operations Team
+```mermaid
+graph LR
+ OT[Operations Team] -->|Time range query| QUERY[Video Query API
503]
+ QUERY -->|Retrieve segments| BLOB[Azure Blob Storage]
+ BLOB -->|SAS URLs| QUERY
+ QUERY -->|Return SAS URLs| OT
+```
-(5) - The Media Sync copies the clips and files to the cloud backed persisted volume.
+(3) - The Video Query API retrieves clips and snapshots from Azure Blob Storage using the time range specified by the Operations Team.
(6) - The files in ACSA Cloud backed persisted volume is then synced to Azure Blob Storage.
@@ -126,6 +144,8 @@ Based on the features available to securely interact and operate edge-attached c
**Local Development**: A Docker Compose development environment is available in `src/500-application/508-media-connector` for testing without requiring a full Kubernetes cluster.
+> **Note**: This ADR has been supplemented by [Dual-Component Video Architecture](./dual-component-video-architecture.md), which documents the decision to use the Media Capture Service (503) for recording and cloud archival alongside the Media Connector for live streaming and snapshots.
+
## Decision Drivers (optional)
The main purpose of the media connector for the solution's use case is to interact with edge-attached cameras through Asset task configurations:
@@ -159,4 +179,6 @@ In the current ADR, the following is out-of-scope:
- Automated camera discovery and registration workflows
- Multi-site media synchronization and federated storage
+> **Update**: Continuous video recording and cloud synchronization, previously out of scope for the Media Connector, are now provided by the [Media Capture Service (503)](../../src/500-application/503-media-capture-service/). See [Dual-Component Video Architecture](./dual-component-video-architecture.md).
+
*AI and automation capabilities described in this scenario should be implemented following responsible AI principles, including fairness, reliability, safety, privacy, inclusiveness, transparency, and accountability. Organizations should ensure appropriate governance, monitoring, and human oversight are in place for all AI-powered solutions.*
diff --git a/docs/solution-adr-library/onvif-connector-camera-integration.md b/docs/solution-adr-library/onvif-connector-camera-integration.md
index ecb94703..7f77e170 100644
--- a/docs/solution-adr-library/onvif-connector-camera-integration.md
+++ b/docs/solution-adr-library/onvif-connector-camera-integration.md
@@ -349,14 +349,16 @@ Events are published to MQTT:
### PTZ Control
-PTZ commands are received via MQTT and translated to ONVIF SOAP:
+PTZ commands use the **MRPC (Message-based RPC) protocol** over MQTT. The connector subscribes to MRPC topics and translates commands to ONVIF SOAP:
```plaintext
-MQTT Command:
- Topic: onvif-camera/ptz/command/pan
- Payload: {"direction": "right", "speed": 0.5}
+MRPC Topic Pattern:
+ {namespace}/mrpc/{asset}/{commandName}
-ONVIF SOAP Request:
+Example Topic:
+ azure-iot-operations/mrpc/camera-01-ptz/RelativeMove
+
+ONVIF SOAP Request (generated by connector):
profile_s_h264
@@ -369,12 +371,15 @@ ONVIF SOAP Request:
```
-Supported PTZ commands:
+**Important**: MRPC requires proper CloudEvents format with correlation IDs and response topic handling. Simple `mosquitto_pub` commands will NOT work. Use the [Azure Samples PTZ Demo](https://github.com/Azure-Samples/explore-iot-operations/tree/main/samples/aio-onvif-connector-ptz-demo) or direct ONVIF SOAP commands.
+
+Supported PTZ commands via MRPC:
-- **Pan**: `onvif-camera/ptz/command/pan` → Left/right movement
-- **Tilt**: `onvif-camera/ptz/command/tilt` → Up/down movement
-- **Zoom**: `onvif-camera/ptz/command/zoom` → In/out zoom
-- **Home**: `onvif-camera/ptz/command/home` → Return to preset position
+- **RelativeMove**: `{ns}/mrpc/{asset}/RelativeMove` → Incremental movement
+- **ContinuousMove**: `{ns}/mrpc/{asset}/ContinuousMove` → Continuous movement
+- **Stop**: `{ns}/mrpc/{asset}/Stop` → Stop movement
+- **GotoHomePosition**: `{ns}/mrpc/{asset}/GotoHomePosition` → Return to home
+- **GotoPreset**: `{ns}/mrpc/{asset}/GotoPreset` → Go to saved preset
### Deployment Options
@@ -395,13 +400,19 @@ Provides:
- MQTT broker
- MQTT monitor
-Test PTZ commands:
+Test PTZ via direct ONVIF SOAP (recommended for testing):
```bash
-docker exec -it onvif-mqtt-monitor mosquitto_pub \
- -h onvif-mosquitto-broker -p 11883 \
- -t 'onvif-camera/ptz/command/pan' \
- -m '{"direction": "right", "speed": 0.5}'
+curl -X POST "http://camera-ip/onvif/ptz_service" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+ 000
+
+
+
+'
```
#### 2. Production Deployment (Terraform)
diff --git a/project-adrs/Accepted/006-dual-component-video-architecture.md b/project-adrs/Accepted/006-dual-component-video-architecture.md
new file mode 100644
index 00000000..a9104c20
--- /dev/null
+++ b/project-adrs/Accepted/006-dual-component-video-architecture.md
@@ -0,0 +1,43 @@
+# Dual-Component Video Architecture for Recording and Live Streaming
+
+Date: **2026-03-02** [Format=YYYY-MM-DD]
+
+## Status
+
+* [ ] Draft
+* [ ] Proposed
+* [x] Accepted
+* [ ] Deprecated
+* [ ] Superseded by NNNN
+
+## Decision
+
+Adopt a dual-component video architecture where the Media Capture Service (503) handles continuous recording, event-driven capture, and cloud archival while the Media Connector (508) handles live RTSP streaming, snapshots, and ONVIF camera management.
+
+## Context
+
+The industrial video surveillance pilot requires continuous 24/7 recording with cloud sync, event-driven capture with pre-event buffering, live video redistribution, and time-based video queries. Six limitations in the Media Connector prevent it from serving as the sole video capture component. A detailed analysis is documented in the solution ADR: [Dual-Component Video Architecture](../../docs/solution-adr-library/edge-video-streaming-and-image-capture.md).
+
+## Decision drivers
+
+* Media Connector lacks continuous recording, ring buffer, and cloud sync capabilities
+* Media Capture Service lacks live RTSP proxying and ONVIF camera management
+* Each component excels in its domain without runtime dependency on the other
+* Reduces operational surface area by using purpose-built components
+
+## Considered options
+
+* Single-component architecture using Media Connector only (rejected — 6 limitations)
+* Single-component architecture using Media Capture Service only (rejected — no live streaming)
+* Dual-component architecture with complementary responsibilities (selected)
+
+## Decision Conclusion
+
+Adopt the dual-component architecture. The Media Capture Service (503) handles all recording and cloud archival. The Media Connector (508) handles live RTSP streaming and ONVIF management.
+
+## Consequences
+
+* Two components must be deployed and configured independently
+* Clear separation of concerns simplifies debugging and scaling
+* Future video features route to the appropriate component based on capability
+* Architecture pattern is documented and reusable via the solution ADR library
diff --git a/src/000-cloud/010-security-identity/terraform/README.md b/src/000-cloud/010-security-identity/terraform/README.md
index 280756a3..989c8d4a 100644
--- a/src/000-cloud/010-security-identity/terraform/README.md
+++ b/src/000-cloud/010-security-identity/terraform/README.md
@@ -46,6 +46,7 @@ access to resources.
| key\_vault\_name | The name of the Key Vault to store secrets. If not provided, defaults to 'kv-{resource\_prefix}-{environment}-{instance}' | `string` | `null` | no |
| key\_vault\_private\_endpoint\_subnet\_id | The ID of the subnet where the Key Vault private endpoint will be created. Required if should\_create\_key\_vault\_private\_endpoint is true. | `string` | `null` | no |
| key\_vault\_virtual\_network\_id | The ID of the virtual network to link to the Key Vault private DNS zone. Required if should\_create\_key\_vault\_private\_endpoint is true. | `string` | `null` | no |
+| log\_analytics\_workspace\_id | The ID of the Log Analytics workspace for diagnostic settings. If null, diagnostics are not enabled | `string` | `null` | no |
| onboard\_identity\_type | Identity type to use for onboarding the cluster to Azure Arc. Allowed values: - id - sp - skip | `string` | `"id"` | no |
| should\_create\_aio\_identity | Whether to create a user-assigned identity for Azure IoT Operations. | `bool` | `true` | no |
| should\_create\_aks\_identity | Whether to create a user-assigned identity for AKS cluster when using custom private DNS zones. | `bool` | `false` | no |
@@ -54,6 +55,7 @@ access to resources.
| should\_create\_key\_vault\_private\_endpoint | Whether to create a private endpoint for the Key Vault. | `bool` | `false` | no |
| should\_create\_ml\_workload\_identity | Whether to create a user-assigned identity for AzureML workloads. | `bool` | `false` | no |
| should\_create\_secret\_sync\_identity | Whether to create a user-assigned identity for Secret Sync Extension. | `bool` | `true` | no |
+| should\_enable\_diagnostic\_settings | Whether to enable diagnostic settings for Key Vault | `bool` | `false` | no |
| should\_enable\_public\_network\_access | Whether to enable public network access for the Key Vault | `bool` | `true` | no |
| should\_enable\_purge\_protection | Whether to enable purge protection for the Key Vault. Enable for production to prevent accidental or malicious secret deletion | `bool` | `false` | no |
| should\_use\_current\_user\_key\_vault\_admin | Whether to give the current user the Key Vault Secrets Officer Role. | `bool` | `true` | no |
diff --git a/src/000-cloud/010-security-identity/terraform/main.tf b/src/000-cloud/010-security-identity/terraform/main.tf
index d3533669..21be803a 100644
--- a/src/000-cloud/010-security-identity/terraform/main.tf
+++ b/src/000-cloud/010-security-identity/terraform/main.tf
@@ -31,6 +31,8 @@ module "key_vault" {
should_enable_public_network_access = var.should_enable_public_network_access
should_enable_purge_protection = var.should_enable_purge_protection
should_add_key_vault_role_assignment = local.should_add_key_vault_role_assignment
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+ should_enable_diagnostic_settings = var.should_enable_diagnostic_settings
}
module "identity" {
diff --git a/src/000-cloud/010-security-identity/terraform/modules/key-vault/README.md b/src/000-cloud/010-security-identity/terraform/modules/key-vault/README.md
index 86a9fb2e..f50120e7 100644
--- a/src/000-cloud/010-security-identity/terraform/modules/key-vault/README.md
+++ b/src/000-cloud/010-security-identity/terraform/modules/key-vault/README.md
@@ -21,6 +21,7 @@ Create or use and existing a Key Vault for Secret Sync Extension
| Name | Type |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
| [azurerm_key_vault.new](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault) | resource |
+| [azurerm_monitor_diagnostic_setting.key_vault](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_diagnostic_setting) | resource |
| [azurerm_private_dns_a_record.a_record](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_a_record) | resource |
| [azurerm_private_dns_zone.dns_zone](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_zone) | resource |
| [azurerm_private_dns_zone_virtual_network_link.vnet_link](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_zone_virtual_network_link) | resource |
@@ -37,11 +38,13 @@ Create or use and existing a Key Vault for Secret Sync Extension
| key\_vault\_admin\_principal\_id | The Principal ID or Object ID for the admin that will have access to update secrets on the Key Vault. | `string` | n/a | yes |
| key\_vault\_name | The name of the Key Vault to store secrets. If not provided, defaults to 'kv-{resource\_prefix}-{environment}-{instance}' | `string` | n/a | yes |
| location | Azure region where all resources will be deployed | `string` | n/a | yes |
+| log\_analytics\_workspace\_id | The ID of the Log Analytics workspace for diagnostic settings | `string` | n/a | yes |
| private\_endpoint\_subnet\_id | The ID of the subnet where the private endpoint will be created | `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\_add\_key\_vault\_role\_assignment | Whether to add role assignment to the Key Vault | `bool` | n/a | yes |
| should\_create\_private\_endpoint | Whether to create a private endpoint for the Key Vault | `bool` | n/a | yes |
+| should\_enable\_diagnostic\_settings | Whether to enable diagnostic settings for the Key Vault | `bool` | n/a | yes |
| should\_enable\_public\_network\_access | Whether to enable public network access for the Key Vault | `bool` | n/a | yes |
| should\_enable\_purge\_protection | Whether to enable purge protection for the Key Vault | `bool` | n/a | yes |
| virtual\_network\_id | The ID of the virtual network to link to the private DNS zone | `string` | n/a | yes |
diff --git a/src/000-cloud/010-security-identity/terraform/modules/key-vault/main.tf b/src/000-cloud/010-security-identity/terraform/modules/key-vault/main.tf
index fa015402..6ad8e684 100644
--- a/src/000-cloud/010-security-identity/terraform/modules/key-vault/main.tf
+++ b/src/000-cloud/010-security-identity/terraform/modules/key-vault/main.tf
@@ -46,6 +46,26 @@ resource "terraform_data" "defer" {
depends_on = [azurerm_role_assignment.user_key_vault_secrets_officer]
}
+/*
+ * Diagnostic Settings
+ */
+
+resource "azurerm_monitor_diagnostic_setting" "key_vault" {
+ count = var.should_enable_diagnostic_settings ? 1 : 0
+
+ name = "diag-${azurerm_key_vault.new.name}"
+ target_resource_id = azurerm_key_vault.new.id
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+
+ enabled_log {
+ category = "AuditEvent"
+ }
+
+ enabled_metric {
+ category = "AllMetrics"
+ }
+}
+
/*
* Private Endpoint
*/
diff --git a/src/000-cloud/010-security-identity/terraform/modules/key-vault/variables.tf b/src/000-cloud/010-security-identity/terraform/modules/key-vault/variables.tf
index 1c31d9e3..54831f75 100644
--- a/src/000-cloud/010-security-identity/terraform/modules/key-vault/variables.tf
+++ b/src/000-cloud/010-security-identity/terraform/modules/key-vault/variables.tf
@@ -37,3 +37,13 @@ variable "should_enable_purge_protection" {
type = bool
description = "Whether to enable purge protection for the Key Vault"
}
+
+variable "log_analytics_workspace_id" {
+ type = string
+ description = "The ID of the Log Analytics workspace for diagnostic settings"
+}
+
+variable "should_enable_diagnostic_settings" {
+ type = bool
+ description = "Whether to enable diagnostic settings for the Key Vault"
+}
diff --git a/src/000-cloud/010-security-identity/terraform/variables.tf b/src/000-cloud/010-security-identity/terraform/variables.tf
index 2f936b8f..5ab975b6 100644
--- a/src/000-cloud/010-security-identity/terraform/variables.tf
+++ b/src/000-cloud/010-security-identity/terraform/variables.tf
@@ -38,6 +38,22 @@ variable "should_enable_purge_protection" {
default = false
}
+/*
+ * Key Vault Diagnostic Settings - Optional
+ */
+
+variable "log_analytics_workspace_id" {
+ description = "The ID of the Log Analytics workspace for diagnostic settings. If null, diagnostics are not enabled"
+ type = string
+ default = null
+}
+
+variable "should_enable_diagnostic_settings" {
+ description = "Whether to enable diagnostic settings for Key Vault"
+ type = bool
+ default = false
+}
+
/*
* Key Vault Private Endpoint - Optional
*/
diff --git a/src/000-cloud/020-observability/scripts/import-grafana-dashboards.sh b/src/000-cloud/020-observability/scripts/import-grafana-dashboards.sh
index 7e10aede..bc6352fb 100755
--- a/src/000-cloud/020-observability/scripts/import-grafana-dashboards.sh
+++ b/src/000-cloud/020-observability/scripts/import-grafana-dashboards.sh
@@ -10,8 +10,8 @@ GRAFANA_NAME="${GRAFANA_NAME:-}"
RESOURCE_GROUP_NAME="${RESOURCE_GROUP_NAME:-}"
if [[ -z "$GRAFANA_NAME" || -z "$RESOURCE_GROUP_NAME" ]]; then
- echo "Error: GRAFANA_NAME and RESOURCE_GROUP_NAME environment variables must be set"
- exit 1
+ echo "Error: GRAFANA_NAME and RESOURCE_GROUP_NAME environment variables must be set"
+ exit 1
fi
echo "Importing Grafana dashboards for ${GRAFANA_NAME} in resource group ${RESOURCE_GROUP_NAME}"
@@ -19,23 +19,44 @@ echo "Importing Grafana dashboards for ${GRAFANA_NAME} in resource group ${RESOU
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# Retry wrapper for Grafana API calls (SSL cert may not be ready immediately)
+retry() {
+ local max_attempts=10
+ local delay=30
+ local attempt=1
+ while true; do
+ if "$@"; then
+ return 0
+ fi
+ if ((attempt >= max_attempts)); then
+ echo "Failed after ${max_attempts} attempts"
+ return 1
+ fi
+ echo "Attempt ${attempt}/${max_attempts} failed, retrying in ${delay}s..."
+ sleep "$delay"
+ ((attempt++))
+ done
+}
+
# Import dashboards from local files
echo "Importing local dashboard files..."
for dashboard in "${SCRIPT_DIR}"/*.json; do
- if [[ -f "$dashboard" ]]; then
- echo "Importing dashboard: $(basename "$dashboard")"
- az grafana dashboard import \
- -g "$RESOURCE_GROUP_NAME" \
- -n "$GRAFANA_NAME" \
- --definition "$dashboard"
- fi
+ if [[ -f "$dashboard" ]]; then
+ echo "Importing dashboard: $(basename "$dashboard")"
+ retry az grafana dashboard import \
+ -g "$RESOURCE_GROUP_NAME" \
+ -n "$GRAFANA_NAME" \
+ --overwrite \
+ --definition "$dashboard"
+ fi
done
# Import dashboard from GitHub
echo "Importing AIO sample dashboard from GitHub..."
-az grafana dashboard import \
- -g "$RESOURCE_GROUP_NAME" \
- -n "$GRAFANA_NAME" \
- --definition "https://raw.githubusercontent.com/Azure/azure-iot-operations/refs/heads/main/samples/grafana-dashboard/aio.sample.json"
+retry az grafana dashboard import \
+ -g "$RESOURCE_GROUP_NAME" \
+ -n "$GRAFANA_NAME" \
+ --overwrite \
+ --definition "https://raw.githubusercontent.com/Azure/azure-iot-operations/refs/heads/main/samples/grafana-dashboard/aio.sample.json"
echo "Dashboard import completed successfully"
diff --git a/src/000-cloud/030-data/terraform/modules/storage-account/outputs.tf b/src/000-cloud/030-data/terraform/modules/storage-account/outputs.tf
index b32df9a5..dd1a02c7 100644
--- a/src/000-cloud/030-data/terraform/modules/storage-account/outputs.tf
+++ b/src/000-cloud/030-data/terraform/modules/storage-account/outputs.tf
@@ -1,13 +1,15 @@
output "storage_account" {
description = "The newly created Storage Account."
value = {
- id = azurerm_storage_account.storage_account.id
- name = azurerm_storage_account.storage_account.name
- primary_blob_endpoint = azurerm_storage_account.storage_account.primary_blob_endpoint
- primary_file_endpoint = azurerm_storage_account.storage_account.primary_file_endpoint
- primary_queue_endpoint = azurerm_storage_account.storage_account.primary_queue_endpoint
- primary_table_endpoint = azurerm_storage_account.storage_account.primary_table_endpoint
+ id = azurerm_storage_account.storage_account.id
+ name = azurerm_storage_account.storage_account.name
+ primary_blob_endpoint = azurerm_storage_account.storage_account.primary_blob_endpoint
+ primary_file_endpoint = azurerm_storage_account.storage_account.primary_file_endpoint
+ primary_queue_endpoint = azurerm_storage_account.storage_account.primary_queue_endpoint
+ primary_table_endpoint = azurerm_storage_account.storage_account.primary_table_endpoint
+ primary_connection_string = azurerm_storage_account.storage_account.primary_connection_string
}
+ sensitive = true
}
output "private_endpoints" {
diff --git a/src/000-cloud/040-messaging/terraform/README.md b/src/000-cloud/040-messaging/terraform/README.md
index 93761aaa..5d1ec728 100644
--- a/src/000-cloud/040-messaging/terraform/README.md
+++ b/src/000-cloud/040-messaging/terraform/README.md
@@ -54,9 +54,11 @@ Azure IoT Operations Dataflow to send and receive data from edge to cloud.
| function\_node\_version | The version of Node.js to use | `string` | `"20"` | no |
| function\_python\_version | The version of Python to use. | `string` | `null` | no |
| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
+| log\_analytics\_workspace\_id | The ID of the Log Analytics workspace for diagnostic settings. If null, diagnostics are not enabled | `string` | `null` | no |
| should\_create\_azure\_functions | Whether to create the Azure Functions resources including App Service Plan | `bool` | `false` | no |
| should\_create\_eventgrid | Whether to create the Event Grid resources. | `bool` | `true` | no |
| should\_create\_eventhub | Whether to create the Event Hubs resources. | `bool` | `true` | no |
+| should\_enable\_diagnostic\_settings | Whether to enable diagnostic settings for Event Grid and Event Hubs | `bool` | `false` | no |
| tags | Tags to apply to all resources | `map(string)` | `{}` | no |
## Outputs
diff --git a/src/000-cloud/040-messaging/terraform/main.tf b/src/000-cloud/040-messaging/terraform/main.tf
index 018662be..ed2bc020 100644
--- a/src/000-cloud/040-messaging/terraform/main.tf
+++ b/src/000-cloud/040-messaging/terraform/main.tf
@@ -10,14 +10,16 @@ module "eventhub" {
source = "./modules/eventhub"
- environment = var.environment
- resource_prefix = var.resource_prefix
- instance = var.instance
- resource_group_name = var.resource_group.name
- location = var.resource_group.location
- aio_uami_principal_id = var.aio_identity.principal_id
- capacity = var.eventhub_capacity
- eventhubs = var.eventhubs
+ environment = var.environment
+ resource_prefix = var.resource_prefix
+ instance = var.instance
+ resource_group_name = var.resource_group.name
+ location = var.resource_group.location
+ aio_uami_principal_id = var.aio_identity.principal_id
+ capacity = var.eventhub_capacity
+ eventhubs = var.eventhubs
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+ should_enable_diagnostic_settings = var.should_enable_diagnostic_settings
}
module "eventgrid" {
@@ -36,6 +38,8 @@ module "eventgrid" {
capacity = var.eventgrid_capacity
eventgrid_max_client_sessions_per_auth_name = var.eventgrid_max_client_sessions
topic_name = var.eventgrid_topic_name
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+ should_enable_diagnostic_settings = var.should_enable_diagnostic_settings
}
module "app_service_plan" {
diff --git a/src/000-cloud/040-messaging/terraform/modules/eventgrid/README.md b/src/000-cloud/040-messaging/terraform/modules/eventgrid/README.md
index 3e1b7725..39c8d6d4 100644
--- a/src/000-cloud/040-messaging/terraform/modules/eventgrid/README.md
+++ b/src/000-cloud/040-messaging/terraform/modules/eventgrid/README.md
@@ -18,11 +18,12 @@ Create a new Event Grid namespace and namespace topic and assign the AIO instanc
## Resources
-| Name | Type |
-|----------------------------------------------------------------------------------------------------------------------------------------------|----------|
-| [azapi_resource.eventgrid_namespace_topic_space](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) | resource |
-| [azurerm_eventgrid_namespace.aio_eg_ns](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/eventgrid_namespace) | resource |
-| [azurerm_role_assignment.data_sender](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
+| Name | Type |
+|------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
+| [azapi_resource.eventgrid_namespace_topic_space](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) | resource |
+| [azurerm_eventgrid_namespace.aio_eg_ns](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/eventgrid_namespace) | resource |
+| [azurerm_monitor_diagnostic_setting.eventgrid](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_diagnostic_setting) | resource |
+| [azurerm_role_assignment.data_sender](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
## Inputs
@@ -32,8 +33,10 @@ Create a new Event Grid namespace and namespace topic and assign the AIO instanc
| 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 |
| location | Azure region where all resources will be deployed | `string` | n/a | yes |
+| log\_analytics\_workspace\_id | The ID of the Log Analytics workspace for diagnostic settings | `string` | n/a | yes |
| resource\_group\_name | Name of the resource group | `string` | n/a | yes |
| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| should\_enable\_diagnostic\_settings | Whether to enable diagnostic settings for the Event Grid namespace | `bool` | n/a | yes |
| capacity | Specifies the Capacity / Throughput Units for a Standard SKU namespace. | `number` | `1` | no |
| eventgrid\_max\_client\_sessions\_per\_auth\_name | Specifies the maximum number of client sessions per authentication name. Valid values are from 3 to 100. This parameter should be greater than the number of dataflows | `number` | `8` | no |
| topic\_name | Topic template name to create in the Event Grid namespace | `string` | `"default"` | no |
diff --git a/src/000-cloud/040-messaging/terraform/modules/eventgrid/main.tf b/src/000-cloud/040-messaging/terraform/modules/eventgrid/main.tf
index 4b6d5c8c..34e21526 100644
--- a/src/000-cloud/040-messaging/terraform/modules/eventgrid/main.tf
+++ b/src/000-cloud/040-messaging/terraform/modules/eventgrid/main.tf
@@ -29,6 +29,26 @@ resource "azapi_resource" "eventgrid_namespace_topic_space" {
}
}
+/*
+ * Diagnostic Settings
+ */
+
+resource "azurerm_monitor_diagnostic_setting" "eventgrid" {
+ count = var.should_enable_diagnostic_settings ? 1 : 0
+
+ name = "diag-${azurerm_eventgrid_namespace.aio_eg_ns.name}"
+ target_resource_id = azurerm_eventgrid_namespace.aio_eg_ns.id
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+
+ enabled_log {
+ category_group = "allLogs"
+ }
+
+ enabled_metric {
+ category = "AllMetrics"
+ }
+}
+
resource "azurerm_role_assignment" "data_sender" {
scope = azapi_resource.eventgrid_namespace_topic_space.id
role_definition_name = "EventGrid TopicSpaces Publisher"
diff --git a/src/000-cloud/040-messaging/terraform/modules/eventgrid/variables.tf b/src/000-cloud/040-messaging/terraform/modules/eventgrid/variables.tf
index 9409a367..3a2b5f86 100644
--- a/src/000-cloud/040-messaging/terraform/modules/eventgrid/variables.tf
+++ b/src/000-cloud/040-messaging/terraform/modules/eventgrid/variables.tf
@@ -53,3 +53,13 @@ variable "topic_name" {
type = string
default = "default"
}
+
+variable "log_analytics_workspace_id" {
+ type = string
+ description = "The ID of the Log Analytics workspace for diagnostic settings"
+}
+
+variable "should_enable_diagnostic_settings" {
+ type = bool
+ description = "Whether to enable diagnostic settings for the Event Grid namespace"
+}
diff --git a/src/000-cloud/040-messaging/terraform/modules/eventhub/README.md b/src/000-cloud/040-messaging/terraform/modules/eventhub/README.md
index 1220af99..8248982d 100644
--- a/src/000-cloud/040-messaging/terraform/modules/eventhub/README.md
+++ b/src/000-cloud/040-messaging/terraform/modules/eventhub/README.md
@@ -22,20 +22,23 @@ Create a new Event Hub namespace and Event Hub and assign the AIO instance UAMI
| [azurerm_eventhub.destination_eh](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/eventhub) | resource |
| [azurerm_eventhub_consumer_group.destination_eh_cg](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/eventhub_consumer_group) | resource |
| [azurerm_eventhub_namespace.destination_eventhub_namespace](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/eventhub_namespace) | resource |
+| [azurerm_monitor_diagnostic_setting.eventhub](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_diagnostic_setting) | resource |
| [azurerm_role_assignment.data_sender](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
## Inputs
-| Name | Description | Type | Default | Required |
-|--------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|:--------:|
-| aio\_uami\_principal\_id | Principal ID of the User Assigned Managed Identity for the Azure IoT Operations instance | `string` | n/a | yes |
-| capacity | Specifies the Capacity / Throughput Units for a Standard SKU namespace. | `number` | n/a | yes |
-| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
-| eventhubs | Per-Event Hub configuration. Keys are Event Hub names. - **Message retention**: Specifies the number of days to retain events for this Event Hub, from 1 to 7. - **Partition count**: Specifies the number of partitions for the Event Hub. Valid values are from 1 to 32. - **Consumer group user metadata**: A placeholder to store user-defined string data with maximum length 1024. It can be used to store descriptive data, such as list of teams and their contact information, or user-defined configuration settings. | ```map(object({ message_retention = optional(number, 1) partition_count = optional(number, 1) consumer_groups = optional(map(object({ user_metadata = optional(string, null) })), {}) }))``` | n/a | yes |
-| instance | Instance identifier for naming resources: 001, 002, etc | `string` | n/a | yes |
-| location | Azure region where all resources will be deployed | `string` | n/a | yes |
-| resource\_group\_name | Name of the resource group | `string` | n/a | yes |
-| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| Name | Description | Type | Default | Required |
+|--------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|:--------:|
+| aio\_uami\_principal\_id | Principal ID of the User Assigned Managed Identity for the Azure IoT Operations instance | `string` | n/a | yes |
+| capacity | Specifies the Capacity / Throughput Units for a Standard SKU namespace. | `number` | n/a | yes |
+| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
+| eventhubs | Per-Event Hub configuration. Keys are Event Hub names. - **Message retention**: Specifies the number of days to retain events for this Event Hub, from 1 to 7. - **Partition count**: Specifies the number of partitions for the Event Hub. Valid values are from 1 to 32. - **Consumer group user metadata**: A placeholder to store user-defined string data with maximum length 1024. It can be used to store descriptive data, such as list of teams and their contact information, or user-defined configuration settings. | ```map(object({ message_retention = optional(number, 1) partition_count = optional(number, 1) consumer_groups = optional(map(object({ user_metadata = optional(string, null) })), {}) }))``` | n/a | yes |
+| instance | Instance identifier for naming resources: 001, 002, etc | `string` | n/a | yes |
+| location | Azure region where all resources will be deployed | `string` | n/a | yes |
+| log\_analytics\_workspace\_id | The ID of the Log Analytics workspace for diagnostic settings | `string` | n/a | yes |
+| resource\_group\_name | Name of the resource group | `string` | n/a | yes |
+| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| should\_enable\_diagnostic\_settings | Whether to enable diagnostic settings for the Event Hubs namespace | `bool` | n/a | yes |
## Outputs
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 3936f9e4..0999af1f 100644
--- a/src/000-cloud/040-messaging/terraform/modules/eventhub/main.tf
+++ b/src/000-cloud/040-messaging/terraform/modules/eventhub/main.tf
@@ -45,6 +45,26 @@ resource "azurerm_eventhub_consumer_group" "destination_eh_cg" {
depends_on = [azurerm_eventhub.destination_eh]
}
+/*
+ * Diagnostic Settings
+ */
+
+resource "azurerm_monitor_diagnostic_setting" "eventhub" {
+ count = var.should_enable_diagnostic_settings ? 1 : 0
+
+ name = "diag-${azurerm_eventhub_namespace.destination_eventhub_namespace.name}"
+ target_resource_id = azurerm_eventhub_namespace.destination_eventhub_namespace.id
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+
+ enabled_log {
+ category_group = "allLogs"
+ }
+
+ enabled_metric {
+ category = "AllMetrics"
+ }
+}
+
resource "azurerm_role_assignment" "data_sender" {
scope = azurerm_eventhub_namespace.destination_eventhub_namespace.id
role_definition_name = "Azure Event Hubs Data Sender"
diff --git a/src/000-cloud/040-messaging/terraform/modules/eventhub/variables.tf b/src/000-cloud/040-messaging/terraform/modules/eventhub/variables.tf
index ab28c52a..5087c4bc 100644
--- a/src/000-cloud/040-messaging/terraform/modules/eventhub/variables.tf
+++ b/src/000-cloud/040-messaging/terraform/modules/eventhub/variables.tf
@@ -37,6 +37,16 @@ variable "capacity" {
}
}
+variable "log_analytics_workspace_id" {
+ type = string
+ description = "The ID of the Log Analytics workspace for diagnostic settings"
+}
+
+variable "should_enable_diagnostic_settings" {
+ type = bool
+ description = "Whether to enable diagnostic settings for the Event Hubs namespace"
+}
+
variable "eventhubs" {
description = <<-EOF
Per-Event Hub configuration. Keys are Event Hub names.
diff --git a/src/000-cloud/040-messaging/terraform/variables.tf b/src/000-cloud/040-messaging/terraform/variables.tf
index 15ae5048..e0a5dc13 100644
--- a/src/000-cloud/040-messaging/terraform/variables.tf
+++ b/src/000-cloud/040-messaging/terraform/variables.tf
@@ -115,3 +115,19 @@ variable "tags" {
description = "Tags to apply to all resources"
default = {}
}
+
+/*
+ * Diagnostic Settings - Optional
+ */
+
+variable "log_analytics_workspace_id" {
+ type = string
+ description = "The ID of the Log Analytics workspace for diagnostic settings. If null, diagnostics are not enabled"
+ default = null
+}
+
+variable "should_enable_diagnostic_settings" {
+ type = bool
+ description = "Whether to enable diagnostic settings for Event Grid and Event Hubs"
+ default = false
+}
diff --git a/src/000-cloud/051-vm-host/terraform/README.md b/src/000-cloud/051-vm-host/terraform/README.md
index 6f9ce91a..dee89132 100644
--- a/src/000-cloud/051-vm-host/terraform/README.md
+++ b/src/000-cloud/051-vm-host/terraform/README.md
@@ -62,7 +62,7 @@ Deploys one or more Linux VMs for Arc-connected K3s cluster
| vm\_eviction\_policy | Eviction policy for Spot VMs: Deallocate (VM stopped, disk retained, can restart) or Delete (VM and disks removed, no storage charges). Only used when vm\_priority is Spot | `string` | `"Delete"` | no |
| vm\_max\_bid\_price | Maximum price per hour in USD for Spot VM. Set to -1 (default) for no price-based eviction - VM will not be evicted for price reasons. Custom values support up to 5 decimal places (e.g., 0.98765). Only used when vm\_priority is Spot | `number` | `-1` | no |
| vm\_priority | VM priority: Regular (production, guaranteed capacity) or Spot (cost-optimized, can be evicted with 30s notice). Spot VMs offer up to 90% cost savings | `string` | `"Regular"` | no |
-| vm\_sku\_size | Size of the VM | `string` | `"Standard_D8s_v3"` | no |
+| vm\_sku\_size | Size of the VM | `string` | `"Standard_D8s_v6"` | no |
| vm\_user\_principals | Map of Azure AD principals for Virtual Machine User Login role (standard access). Keys are descriptive identifiers (e.g., `user@company.com`), values are principal object IDs. | `map(string)` | `{}` | no |
| vm\_username | Username for the VM admin account | `string` | `null` | no |
diff --git a/src/000-cloud/051-vm-host/terraform/tests/setup/main.tf b/src/000-cloud/051-vm-host/terraform/tests/setup/main.tf
index dffc8ad1..0c585e7e 100644
--- a/src/000-cloud/051-vm-host/terraform/tests/setup/main.tf
+++ b/src/000-cloud/051-vm-host/terraform/tests/setup/main.tf
@@ -49,7 +49,7 @@ output "arc_onboarding_user_assigned_identity" {
output "vm_expected_values" {
value = {
- default_vm_size = "Standard_D8s_v3"
+ default_vm_size = "Standard_D8s_v6"
default_admin_username = local.resource_prefix
os_disk_type = "Standard_LRS"
vm_publisher = "Canonical"
diff --git a/src/000-cloud/051-vm-host/terraform/variables.tf b/src/000-cloud/051-vm-host/terraform/variables.tf
index a6cc18eb..fb5ac719 100644
--- a/src/000-cloud/051-vm-host/terraform/variables.tf
+++ b/src/000-cloud/051-vm-host/terraform/variables.tf
@@ -11,7 +11,7 @@ variable "host_machine_count" {
variable "vm_sku_size" {
type = string
description = "Size of the VM"
- default = "Standard_D8s_v3"
+ default = "Standard_D8s_v6"
}
variable "vm_username" {
diff --git a/src/000-cloud/060-acr/terraform/README.md b/src/000-cloud/060-acr/terraform/README.md
index bf9ffc3b..a10d63fe 100644
--- a/src/000-cloud/060-acr/terraform/README.md
+++ b/src/000-cloud/060-acr/terraform/README.md
@@ -31,10 +31,12 @@ Deploys Azure Container Registry resources
| allowed\_public\_ip\_ranges | CIDR ranges permitted to reach the registry public endpoint | `list(string)` | `[]` | no |
| default\_outbound\_access\_enabled | Whether to enable default outbound internet access for the ACR subnet | `bool` | `false` | no |
| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
+| log\_analytics\_workspace\_id | The ID of the Log Analytics workspace for diagnostic settings. If null, diagnostics are not enabled | `string` | `null` | no |
| nat\_gateway | NAT gateway object from the networking component for managed outbound access | ```object({ id = string name = string })``` | `null` | no |
| public\_network\_access\_enabled | Whether to enable the registry public endpoint alongside private connectivity | `bool` | `false` | no |
| should\_create\_acr\_private\_endpoint | Whether to create a private endpoint for the Azure Container Registry (default false) | `bool` | `false` | no |
| should\_enable\_data\_endpoints | Whether to enable dedicated data endpoints for the registry | `bool` | `true` | no |
+| should\_enable\_diagnostic\_settings | Whether to enable diagnostic settings for ACR | `bool` | `false` | no |
| should\_enable\_export\_policy | Whether to allow container image export from the registry. Requires public\_network\_access\_enabled to be true when enabled | `bool` | `false` | no |
| should\_enable\_nat\_gateway | Whether to associate the ACR subnet with a NAT gateway for managed outbound egress | `bool` | `false` | no |
| sku | SKU name for the resource | `string` | `"Premium"` | no |
diff --git a/src/000-cloud/060-acr/terraform/main.tf b/src/000-cloud/060-acr/terraform/main.tf
index d42604fa..4ed61911 100644
--- a/src/000-cloud/060-acr/terraform/main.tf
+++ b/src/000-cloud/060-acr/terraform/main.tf
@@ -47,4 +47,6 @@ module "container_registry" {
sku = var.sku
should_enable_data_endpoints = var.should_enable_data_endpoints
should_enable_export_policy = var.should_enable_export_policy
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+ should_enable_diagnostic_settings = var.should_enable_diagnostic_settings
}
diff --git a/src/000-cloud/060-acr/terraform/modules/container-registry/README.md b/src/000-cloud/060-acr/terraform/modules/container-registry/README.md
index 96da2bb0..4902b8d3 100644
--- a/src/000-cloud/060-acr/terraform/modules/container-registry/README.md
+++ b/src/000-cloud/060-acr/terraform/modules/container-registry/README.md
@@ -20,6 +20,7 @@ Deploys Azure Container Registry with a private endpoint and private DNS zone.
| Name | Type |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|
| [azurerm_container_registry.acr](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_registry) | resource |
+| [azurerm_monitor_diagnostic_setting.acr](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_diagnostic_setting) | resource |
| [azurerm_private_dns_a_record.a_record](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_a_record) | resource |
| [azurerm_private_dns_a_record.data_endpoint](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_a_record) | resource |
| [azurerm_private_dns_zone.dns_zone](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_dns_zone) | resource |
@@ -35,11 +36,13 @@ Deploys Azure Container Registry with a private endpoint and private DNS zone.
| 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 |
| location | Azure region where all resources will be deployed | `string` | n/a | yes |
+| log\_analytics\_workspace\_id | The ID of the Log Analytics workspace for diagnostic settings | `string` | n/a | yes |
| public\_network\_access\_enabled | Whether to enable the registry public endpoint alongside private connectivity | `bool` | 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\_create\_acr\_private\_endpoint | Should create a private endpoint for the Azure Container Registry. Default is false. | `bool` | n/a | yes |
| should\_enable\_data\_endpoints | Whether to enable dedicated data endpoints for the registry | `bool` | n/a | yes |
+| should\_enable\_diagnostic\_settings | Whether to enable diagnostic settings for the container registry | `bool` | n/a | yes |
| should\_enable\_export\_policy | Whether to allow container image export from the registry | `bool` | n/a | yes |
| sku | SKU name for the resource | `string` | n/a | yes |
| snet\_acr | Subnet for the Azure Container Registry private endpoint. | ```object({ id = string })``` | n/a | yes |
diff --git a/src/000-cloud/060-acr/terraform/modules/container-registry/main.tf b/src/000-cloud/060-acr/terraform/modules/container-registry/main.tf
index ca60850c..f6e51c13 100644
--- a/src/000-cloud/060-acr/terraform/modules/container-registry/main.tf
+++ b/src/000-cloud/060-acr/terraform/modules/container-registry/main.tf
@@ -38,6 +38,30 @@ resource "azurerm_container_registry" "acr" {
}
}
+/*
+ * Diagnostic Settings
+ */
+
+resource "azurerm_monitor_diagnostic_setting" "acr" {
+ count = var.should_enable_diagnostic_settings ? 1 : 0
+
+ name = "diag-${azurerm_container_registry.acr.name}"
+ target_resource_id = azurerm_container_registry.acr.id
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+
+ enabled_log {
+ category = "ContainerRegistryRepositoryEvents"
+ }
+
+ enabled_log {
+ category = "ContainerRegistryLoginEvents"
+ }
+
+ enabled_metric {
+ category = "AllMetrics"
+ }
+}
+
resource "azurerm_private_endpoint" "pep" {
count = var.should_create_acr_private_endpoint ? 1 : 0
diff --git a/src/000-cloud/060-acr/terraform/modules/container-registry/variables.tf b/src/000-cloud/060-acr/terraform/modules/container-registry/variables.tf
index e46df9eb..04ca9e29 100644
--- a/src/000-cloud/060-acr/terraform/modules/container-registry/variables.tf
+++ b/src/000-cloud/060-acr/terraform/modules/container-registry/variables.tf
@@ -39,3 +39,13 @@ variable "should_enable_export_policy" {
type = bool
description = "Whether to allow container image export from the registry"
}
+
+variable "log_analytics_workspace_id" {
+ type = string
+ description = "The ID of the Log Analytics workspace for diagnostic settings"
+}
+
+variable "should_enable_diagnostic_settings" {
+ type = bool
+ description = "Whether to enable diagnostic settings for the container registry"
+}
diff --git a/src/000-cloud/060-acr/terraform/variables.tf b/src/000-cloud/060-acr/terraform/variables.tf
index d553e46a..b4bf1268 100644
--- a/src/000-cloud/060-acr/terraform/variables.tf
+++ b/src/000-cloud/060-acr/terraform/variables.tf
@@ -62,6 +62,22 @@ variable "should_enable_export_policy" {
default = false
}
+/*
+ * Diagnostic Settings - Optional
+ */
+
+variable "log_analytics_workspace_id" {
+ type = string
+ description = "The ID of the Log Analytics workspace for diagnostic settings. If null, diagnostics are not enabled"
+ default = null
+}
+
+variable "should_enable_diagnostic_settings" {
+ type = bool
+ description = "Whether to enable diagnostic settings for ACR"
+ default = false
+}
+
/*
* Outbound Access Controls - Optional
*/
diff --git a/src/000-cloud/070-kubernetes/terraform/README.md b/src/000-cloud/070-kubernetes/terraform/README.md
index 6a0e59a8..5f5b88d6 100644
--- a/src/000-cloud/070-kubernetes/terraform/README.md
+++ b/src/000-cloud/070-kubernetes/terraform/README.md
@@ -62,7 +62,7 @@ Deploys Azure Kubernetes Service resources
| nat\_gateway | NAT gateway object from networking component for managed outbound access | ```object({ id = string name = string })``` | `null` | 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 = optional(number, null) 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) priority = optional(string, "Regular") zones = optional(list(string), null) eviction_policy = optional(string, "Deallocate") gpu_driver = optional(string, null) }))``` | `{}` | no |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v5. | `string` | `"Standard_D8ds_v5"` | no |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v6. | `string` | `"Standard_D8ds_v6"` | no |
| private\_dns\_zone\_id | ID of the private DNS zone for the private cluster. Use 'system' to have AKS manage it, 'none' for no private DNS zone, or a resource ID for custom zone | `string` | `null` | no |
| private\_endpoint\_subnet\_id | The ID of the subnet where the private endpoint will be created | `string` | `null` | no |
| should\_add\_current\_user\_cluster\_admin | Whether to assign the current logged in user Azure Kubernetes Cluster Admin Role permissions on the cluster when 'cluster\_admin\_oid' is not provided. | `bool` | `true` | no |
diff --git a/src/000-cloud/070-kubernetes/terraform/modules/aks-cluster/README.md b/src/000-cloud/070-kubernetes/terraform/modules/aks-cluster/README.md
index 4ae789cc..5b409d1c 100644
--- a/src/000-cloud/070-kubernetes/terraform/modules/aks-cluster/README.md
+++ b/src/000-cloud/070-kubernetes/terraform/modules/aks-cluster/README.md
@@ -50,7 +50,7 @@ Supports private clusters with optional private endpoints and DNS zone managemen
| min\_count | The minimum number of nodes which should exist in the default node pool. | `number` | n/a | yes |
| node\_count | Number of nodes for the agent pool in the AKS cluster. | `number` | n/a | yes |
| node\_pools | Additional node pools for the AKS cluster. Map key is used as the node pool name. | ```map(object({ node_count = optional(number, null) vm_size = string vnet_subnet_id = string pod_subnet_id = string node_taints = optional(list(string), []) enable_auto_scaling = optional(bool, false) min_count = optional(number, null) max_count = optional(number, null) priority = optional(string, "Regular") zones = optional(list(string), null) eviction_policy = optional(string) gpu_driver = optional(string, null) }))``` | n/a | yes |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v5. | `string` | n/a | yes |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v6. | `string` | n/a | yes |
| private\_dns\_zone\_id | ID of the private DNS zone for the private cluster. Use 'system' to have AKS manage it, 'none' for no private DNS zone, or a resource ID for custom zone | `string` | n/a | yes |
| private\_endpoint\_subnet\_id | The ID of the subnet where the private endpoint will be created | `string` | n/a | yes |
| resource\_group | Resource group object containing name and id where resources will be deployed | ```object({ name = string })``` | n/a | yes |
diff --git a/src/000-cloud/070-kubernetes/terraform/modules/aks-cluster/variables.tf b/src/000-cloud/070-kubernetes/terraform/modules/aks-cluster/variables.tf
index 0632d09e..6a00705f 100644
--- a/src/000-cloud/070-kubernetes/terraform/modules/aks-cluster/variables.tf
+++ b/src/000-cloud/070-kubernetes/terraform/modules/aks-cluster/variables.tf
@@ -44,7 +44,7 @@ variable "node_count" {
variable "node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v5."
+ description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v6."
}
variable "dns_prefix" {
diff --git a/src/000-cloud/070-kubernetes/terraform/variables.tf b/src/000-cloud/070-kubernetes/terraform/variables.tf
index 1db7dfa0..9742d510 100644
--- a/src/000-cloud/070-kubernetes/terraform/variables.tf
+++ b/src/000-cloud/070-kubernetes/terraform/variables.tf
@@ -40,8 +40,8 @@ variable "node_count" {
variable "node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v5."
- default = "Standard_D8ds_v5"
+ description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v6."
+ default = "Standard_D8ds_v6"
}
variable "enable_auto_scaling" {
diff --git a/src/000-cloud/071-aks-host/terraform/README.md b/src/000-cloud/071-aks-host/terraform/README.md
index 6a0e59a8..5f5b88d6 100644
--- a/src/000-cloud/071-aks-host/terraform/README.md
+++ b/src/000-cloud/071-aks-host/terraform/README.md
@@ -62,7 +62,7 @@ Deploys Azure Kubernetes Service resources
| nat\_gateway | NAT gateway object from networking component for managed outbound access | ```object({ id = string name = string })``` | `null` | 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 = optional(number, null) 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) priority = optional(string, "Regular") zones = optional(list(string), null) eviction_policy = optional(string, "Deallocate") gpu_driver = optional(string, null) }))``` | `{}` | no |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v5. | `string` | `"Standard_D8ds_v5"` | no |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v6. | `string` | `"Standard_D8ds_v6"` | no |
| private\_dns\_zone\_id | ID of the private DNS zone for the private cluster. Use 'system' to have AKS manage it, 'none' for no private DNS zone, or a resource ID for custom zone | `string` | `null` | no |
| private\_endpoint\_subnet\_id | The ID of the subnet where the private endpoint will be created | `string` | `null` | no |
| should\_add\_current\_user\_cluster\_admin | Whether to assign the current logged in user Azure Kubernetes Cluster Admin Role permissions on the cluster when 'cluster\_admin\_oid' is not provided. | `bool` | `true` | no |
diff --git a/src/000-cloud/071-aks-host/terraform/modules/aks-cluster/README.md b/src/000-cloud/071-aks-host/terraform/modules/aks-cluster/README.md
index 00f556c7..0e809022 100644
--- a/src/000-cloud/071-aks-host/terraform/modules/aks-cluster/README.md
+++ b/src/000-cloud/071-aks-host/terraform/modules/aks-cluster/README.md
@@ -47,7 +47,7 @@ Supports private clusters with optional private endpoints and DNS zone managemen
| min\_count | The minimum number of nodes which should exist in the default node pool. | `number` | n/a | yes |
| node\_count | Number of nodes for the agent pool in the AKS cluster. | `number` | n/a | yes |
| node\_pools | Additional node pools for the AKS cluster. Map key is used as the node pool name. | ```map(object({ node_count = optional(number, null) vm_size = string vnet_subnet_id = string pod_subnet_id = string node_taints = optional(list(string), []) enable_auto_scaling = optional(bool, false) min_count = optional(number, null) max_count = optional(number, null) priority = optional(string, "Regular") zones = optional(list(string), null) eviction_policy = optional(string) gpu_driver = optional(string, null) }))``` | n/a | yes |
-| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v5. | `string` | n/a | yes |
+| node\_vm\_size | VM size for the agent pool in the AKS cluster. Default is Standard\_D8ds\_v6. | `string` | n/a | yes |
| private\_dns\_zone\_id | ID of the private DNS zone for the private cluster. Use 'system' to have AKS manage it, 'none' for no private DNS zone, or a resource ID for custom zone | `string` | n/a | yes |
| private\_endpoint\_subnet\_id | The ID of the subnet where the private endpoint will be created | `string` | n/a | yes |
| resource\_group | Resource group object containing name and id where resources will be deployed | ```object({ name = string })``` | n/a | yes |
diff --git a/src/000-cloud/071-aks-host/terraform/modules/aks-cluster/variables.tf b/src/000-cloud/071-aks-host/terraform/modules/aks-cluster/variables.tf
index 0632d09e..6a00705f 100644
--- a/src/000-cloud/071-aks-host/terraform/modules/aks-cluster/variables.tf
+++ b/src/000-cloud/071-aks-host/terraform/modules/aks-cluster/variables.tf
@@ -44,7 +44,7 @@ variable "node_count" {
variable "node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v5."
+ description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v6."
}
variable "dns_prefix" {
diff --git a/src/000-cloud/071-aks-host/terraform/variables.tf b/src/000-cloud/071-aks-host/terraform/variables.tf
index 1db7dfa0..9742d510 100644
--- a/src/000-cloud/071-aks-host/terraform/variables.tf
+++ b/src/000-cloud/071-aks-host/terraform/variables.tf
@@ -40,8 +40,8 @@ variable "node_count" {
variable "node_vm_size" {
type = string
- description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v5."
- default = "Standard_D8ds_v5"
+ description = "VM size for the agent pool in the AKS cluster. Default is Standard_D8ds_v6."
+ default = "Standard_D8ds_v6"
}
variable "enable_auto_scaling" {
diff --git a/src/000-cloud/072-azure-local-host/terraform/README.md b/src/000-cloud/072-azure-local-host/terraform/README.md
index 1a930e6f..cf79b3e1 100644
--- a/src/000-cloud/072-azure-local-host/terraform/README.md
+++ b/src/000-cloud/072-azure-local-host/terraform/README.md
@@ -52,7 +52,7 @@ Creates Azure Stack HCI (Azure Local) cluster resources.
| load\_balancer\_count | Number of load balancers for the cluster (Otherwise, 0). | `number` | `0` | no |
| nfs\_csi\_driver\_enabled | Enable NFS CSI driver for persistent storage (Otherwise, false). | `bool` | `false` | no |
| node\_pool\_count | Number of worker nodes in the default node pool (Otherwise, 1). | `number` | `1` | no |
-| node\_pool\_vm\_size | VM size for worker nodes (Otherwise, 'Standard\_D8s\_v3'). | `string` | `"Standard_D8s_v3"` | no |
+| node\_pool\_vm\_size | VM size for worker nodes (Otherwise, 'Standard\_D8s\_v6'). | `string` | `"Standard_D8s_v6"` | no |
| pod\_cidr | CIDR range for Kubernetes pods (Otherwise, '10.244.0.0/16'). | `string` | `"10.244.0.0/16"` | no |
| smb\_csi\_driver\_enabled | Enable SMB CSI driver for persistent storage (Otherwise, false). | `bool` | `false` | no |
| ssh\_public\_key | SSH public key for Linux nodes (Otherwise, generated). | `string` | `null` | no |
diff --git a/src/000-cloud/072-azure-local-host/terraform/variables.tf b/src/000-cloud/072-azure-local-host/terraform/variables.tf
index 6fdc34b3..449e81bd 100644
--- a/src/000-cloud/072-azure-local-host/terraform/variables.tf
+++ b/src/000-cloud/072-azure-local-host/terraform/variables.tf
@@ -71,8 +71,8 @@ variable "node_pool_count" {
variable "node_pool_vm_size" {
type = string
- description = "VM size for worker nodes (Otherwise, 'Standard_D8s_v3')."
- default = "Standard_D8s_v3"
+ description = "VM size for worker nodes (Otherwise, 'Standard_D8s_v6')."
+ default = "Standard_D8s_v6"
}
variable "kubernetes_version" {
diff --git a/src/000-cloud/073-vm-host/terraform/README.md b/src/000-cloud/073-vm-host/terraform/README.md
index 11b6f9b8..736f2c45 100644
--- a/src/000-cloud/073-vm-host/terraform/README.md
+++ b/src/000-cloud/073-vm-host/terraform/README.md
@@ -60,7 +60,7 @@ Deploys one or more Linux VMs for Arc-connected K3s cluster
| vm\_eviction\_policy | Eviction policy for Spot VMs: Deallocate (VM stopped, disk retained, can restart) or Delete (VM and disks removed, no storage charges). Only used when vm\_priority is Spot | `string` | `"Delete"` | no |
| vm\_max\_bid\_price | Maximum price per hour in USD for Spot VM. Set to -1 (default) for no price-based eviction - VM will not be evicted for price reasons. Custom values support up to 5 decimal places (e.g., 0.98765). Only used when vm\_priority is Spot | `number` | `-1` | no |
| vm\_priority | VM priority: Regular (production, guaranteed capacity) or Spot (cost-optimized, can be evicted with 30s notice). Spot VMs offer up to 90% cost savings | `string` | `"Regular"` | no |
-| vm\_sku\_size | Size of the VM | `string` | `"Standard_D8s_v3"` | no |
+| vm\_sku\_size | Size of the VM | `string` | `"Standard_D8s_v6"` | no |
| vm\_user\_principals | Map of Azure AD principals for Virtual Machine User Login role (standard access). Keys are descriptive identifiers (e.g., `user@company.com`), values are principal object IDs. | `map(string)` | `{}` | no |
| vm\_username | Username for the VM admin account | `string` | `null` | no |
diff --git a/src/000-cloud/073-vm-host/terraform/tests/setup/main.tf b/src/000-cloud/073-vm-host/terraform/tests/setup/main.tf
index dffc8ad1..0c585e7e 100644
--- a/src/000-cloud/073-vm-host/terraform/tests/setup/main.tf
+++ b/src/000-cloud/073-vm-host/terraform/tests/setup/main.tf
@@ -49,7 +49,7 @@ output "arc_onboarding_user_assigned_identity" {
output "vm_expected_values" {
value = {
- default_vm_size = "Standard_D8s_v3"
+ default_vm_size = "Standard_D8s_v6"
default_admin_username = local.resource_prefix
os_disk_type = "Standard_LRS"
vm_publisher = "Canonical"
diff --git a/src/000-cloud/073-vm-host/terraform/variables.tf b/src/000-cloud/073-vm-host/terraform/variables.tf
index 498a18b7..bbe5a51d 100644
--- a/src/000-cloud/073-vm-host/terraform/variables.tf
+++ b/src/000-cloud/073-vm-host/terraform/variables.tf
@@ -11,7 +11,7 @@ variable "host_machine_count" {
variable "vm_sku_size" {
type = string
description = "Size of the VM"
- default = "Standard_D8s_v3"
+ default = "Standard_D8s_v6"
}
variable "vm_username" {
diff --git a/src/100-edge/100-cncf-cluster/README.md b/src/100-edge/100-cncf-cluster/README.md
index ec4f096a..f542840d 100644
--- a/src/100-edge/100-cncf-cluster/README.md
+++ b/src/100-edge/100-cncf-cluster/README.md
@@ -112,7 +112,7 @@ The script performs the following steps:
- Install K3s, Azure CLI, kubectl
- Login to Azure CLI (Service Principal or Managed Identity)
- Connect to Azure Arc and enable features: `custom-locations`, `oidc-issuer`, `workload-identity`, `cluster-connect` and optionally `auto-upgrade`
-- Optionally add the provided Azure AD user as a cluster admin to enable `kubectl` access via `connectedk8s proxy`
+- Optionally add the provided Entra ID user or group as a cluster admin and assign Azure Arc RBAC roles (`Azure Arc Kubernetes Viewer`, `Azure Arc Enabled Kubernetes Cluster User Role`) to enable `az connectedk8s proxy`
- Configure OIDC issuer url for Azure Arc within K3s
- Increase limits for Azure container storage within the host machine
- In non production environments will install k9s and configure `.bashrc` with auto complete and aliases for development
@@ -142,6 +142,25 @@ ENVIRONMENT=dev \
./k3s-device-setup.sh
```
+## Cluster Admin Access
+
+By default, the deploying user receives cluster-admin permissions. To grant access to an entire Entra ID group (enabling `az connectedk8s proxy` for all group members), set the following in your Terraform configuration (e.g. `terraform.tfvars`):
+
+```hcl
+cluster_admin_group_oid = ""
+```
+
+This creates:
+
+- A Kubernetes `ClusterRoleBinding` with `--group` for in-cluster access
+- Azure RBAC role assignments (`Azure Arc Kubernetes Viewer` and `Azure Arc Enabled Kubernetes Cluster User Role`) on the Arc connected cluster resource for `az connectedk8s proxy` access
+
+Group members can then connect via:
+
+```sh
+az connectedk8s proxy -n -g
+```
+
---
diff --git a/src/100-edge/100-cncf-cluster/scripts/k3s-device-setup.sh b/src/100-edge/100-cncf-cluster/scripts/k3s-device-setup.sh
index e76ab605..6c4cafe1 100755
--- a/src/100-edge/100-cncf-cluster/scripts/k3s-device-setup.sh
+++ b/src/100-edge/100-cncf-cluster/scripts/k3s-device-setup.sh
@@ -9,30 +9,31 @@ ARC_RESOURCE_NAME="${ARC_RESOURCE_NAME}" # The name of the Azure Arc
## Optional Environment Variables:
-K3S_URL="${K3S_URL}" # The url for the k3s server if creating an 'agent' node (ex. 'https://:6443')
-K3S_NODE_TYPE="${K3S_NODE_TYPE}" # Type of k3s node to create (ex. 'server' or 'agent', defaults to 'server')
-K3S_TOKEN="${K3S_TOKEN}" # The token used to secure k3s agent nodes joining a k3s cluster (refer https://docs.k3s.io/cli/token)
-K3S_VERSION="${K3S_VERSION}" # Version of k3s to install (ex. 'v1.31.2+k3s1') leave blank to install latest
-CLUSTER_ADMIN_UPN="${CLUSTER_ADMIN_UPN}" # The user principal name that would be given the cluster-admin permission in the cluster (ex. 'az ad signed-in-user show --query userPrincipalName -o tsv')
-CLUSTER_ADMIN_OID="${CLUSTER_ADMIN_OID}" # The object ID that would be given the cluster-admin permission in the cluster (ex. 'az ad signed-in-user show --query id -o tsv')
-AKV_NAME="${AKV_NAME}" # Azure Key Vault name to store secrets
-AKV_K3S_TOKEN_SECRET="${AKV_K3S_TOKEN_SECRET}" # Azure Key Vault secret name for k3s token
-AKV_DEPLOY_SAT_SECRET="${AKV_DEPLOY_SAT_SECRET}" # Azure Key Vault secret name for cluster admin token
-ARC_AUTO_UPGRADE="${ARC_AUTO_UPGRADE}" # Enable/disable auto upgrade for Azure Arc cluster components (ex. 'false' to disable)
-ARC_SP_CLIENT_ID="${ARC_SP_CLIENT_ID}" # Service Principal Client ID used to connect the new cluster to Azure Arc
-ARC_SP_SECRET="${ARC_SP_SECRET}" # Service Principal Client Secret used to connect the new cluster to Azure Arc
-ARC_TENANT_ID="${ARC_TENANT_ID}" # Tenant where the new cluster will be connected to Azure Arc
-AZ_CLI_VER="${AZ_CLI_VER}" # The Azure CLI version to install (ex. '2.51.0')
-AZ_CONNECTEDK8S_VER="${AZ_CONNECTEDK8S_VER}" # The Azure CLI extension connectedk8s version to install (ex. '1.10.0')
-CLIENT_ID="${CLIENT_ID}" # Client ID for the managed identity used with Azure CLI `az login --identity`
-CUSTOM_LOCATIONS_OID="${CUSTOM_LOCATIONS_OID}" # Custom Locations Object ID needed if permissions are not allowed
-DEVICE_USERNAME="${DEVICE_USERNAME}" # Username for this device that will also need access to the k3s cluster
-SKIP_INSTALL_AZ_CLI="${SKIP_INSTALL_AZ_CLI}" # Skips downloading and installing Azure CLI (Ubuntu, Debian) from https://aka.ms/InstallAzureCLIDeb
-SKIP_AZ_LOGIN="${SKIP_AZ_LOGIN}" # Skips calling 'az login' and instead expects this to have been done previously
-SKIP_INSTALL_K3S="${SKIP_INSTALL_K3S}" # Skips downloading and installing k3s from https://get.k3s.io
-SKIP_INSTALL_KUBECTL="${SKIP_INSTALL_KUBECTL}" # Skips downloading and installing kubectl if it is missing
-SKIP_ARC_CONNECT="${SKIP_ARC_CONNECT}" # Skips connecting the cluster Azure Arc
-SKIP_DEPLOY_SAT="${SKIP_DEPLOY_SAT}" # Skips adding a 'cluster-admin' ServiceAccount and token, required for ARM DeploymentScripts
+K3S_URL="${K3S_URL}" # The url for the k3s server if creating an 'agent' node (ex. 'https://:6443')
+K3S_NODE_TYPE="${K3S_NODE_TYPE}" # Type of k3s node to create (ex. 'server' or 'agent', defaults to 'server')
+K3S_TOKEN="${K3S_TOKEN}" # The token used to secure k3s agent nodes joining a k3s cluster (refer https://docs.k3s.io/cli/token)
+K3S_VERSION="${K3S_VERSION}" # Version of k3s to install (ex. 'v1.31.2+k3s1') leave blank to install latest
+CLUSTER_ADMIN_UPN="${CLUSTER_ADMIN_UPN}" # The user principal name that would be given the cluster-admin permission in the cluster (ex. 'az ad signed-in-user show --query userPrincipalName -o tsv')
+CLUSTER_ADMIN_OID="${CLUSTER_ADMIN_OID}" # The object ID that would be given the cluster-admin permission in the cluster (ex. 'az ad signed-in-user show --query id -o tsv')
+CLUSTER_ADMIN_GROUP_OID="${CLUSTER_ADMIN_GROUP_OID}" # The Entra ID group Object ID that will be given cluster-admin permissions for 'az connectedk8s proxy'
+AKV_NAME="${AKV_NAME}" # Azure Key Vault name to store secrets
+AKV_K3S_TOKEN_SECRET="${AKV_K3S_TOKEN_SECRET}" # Azure Key Vault secret name for k3s token
+AKV_DEPLOY_SAT_SECRET="${AKV_DEPLOY_SAT_SECRET}" # Azure Key Vault secret name for cluster admin token
+ARC_AUTO_UPGRADE="${ARC_AUTO_UPGRADE}" # Enable/disable auto upgrade for Azure Arc cluster components (ex. 'false' to disable)
+ARC_SP_CLIENT_ID="${ARC_SP_CLIENT_ID}" # Service Principal Client ID used to connect the new cluster to Azure Arc
+ARC_SP_SECRET="${ARC_SP_SECRET}" # Service Principal Client Secret used to connect the new cluster to Azure Arc
+ARC_TENANT_ID="${ARC_TENANT_ID}" # Tenant where the new cluster will be connected to Azure Arc
+AZ_CLI_VER="${AZ_CLI_VER}" # The Azure CLI version to install (ex. '2.51.0')
+AZ_CONNECTEDK8S_VER="${AZ_CONNECTEDK8S_VER}" # The Azure CLI extension connectedk8s version to install (ex. '1.10.0')
+CLIENT_ID="${CLIENT_ID}" # Client ID for the managed identity used with Azure CLI `az login --identity`
+CUSTOM_LOCATIONS_OID="${CUSTOM_LOCATIONS_OID}" # Custom Locations Object ID needed if permissions are not allowed
+DEVICE_USERNAME="${DEVICE_USERNAME}" # Username for this device that will also need access to the k3s cluster
+SKIP_INSTALL_AZ_CLI="${SKIP_INSTALL_AZ_CLI}" # Skips downloading and installing Azure CLI (Ubuntu, Debian) from https://aka.ms/InstallAzureCLIDeb
+SKIP_AZ_LOGIN="${SKIP_AZ_LOGIN}" # Skips calling 'az login' and instead expects this to have been done previously
+SKIP_INSTALL_K3S="${SKIP_INSTALL_K3S}" # Skips downloading and installing k3s from https://get.k3s.io
+SKIP_INSTALL_KUBECTL="${SKIP_INSTALL_KUBECTL}" # Skips downloading and installing kubectl if it is missing
+SKIP_ARC_CONNECT="${SKIP_ARC_CONNECT}" # Skips connecting the cluster Azure Arc
+SKIP_DEPLOY_SAT="${SKIP_DEPLOY_SAT}" # Skips adding a 'cluster-admin' ServiceAccount and token, required for ARM DeploymentScripts
## Examples
## ENVIRONMENT=dev ARC_RESOURCE_GROUP_NAME=rg-sample-eastu2-001 ARC_RESOURCE_NAME=arc-sample ./k3s-device-setup.sh
@@ -40,37 +41,58 @@ SKIP_DEPLOY_SAT="${SKIP_DEPLOY_SAT}" # Skips adding a 'cluster-admin
###
usage() {
- echo "usage: ${0##*./}"
- grep -x -B99 -m 1 "^###" "$0" \
- | sed -E -e '/^[^#]+=/ {s/^([^ ])/ \1/ ; s/#/ / ; s/=[^ ]*$// ;}' \
- | sed -E -e ':x' -e '/^[^#]+=/ {s/^( [^ ]+)[^ ] /\1 / ;}' -e 'tx' \
- | sed -e 's/^## //' -e '/^#/d' -e '/^$/d'
- exit 1
+ echo "usage: ${0##*./}"
+ grep -x -B99 -m 1 "^###" "$0" |
+ sed -E -e '/^[^#]+=/ {s/^([^ ])/ \1/ ; s/#/ / ; s/=[^ ]*$// ;}' |
+ sed -E -e ':x' -e '/^[^#]+=/ {s/^( [^ ]+)[^ ] /\1 / ;}' -e 'tx' |
+ sed -e 's/^## //' -e '/^#/d' -e '/^$/d'
+ exit 1
}
log() {
- printf "========== %s ==========\n" "$1"
+ printf "========== %s ==========\n" "$1"
}
err() {
- printf "[ ERROR ]: %s" "$1" >&2
- exit 1
+ printf "[ ERROR ]: %s" "$1" >&2
+ exit 1
+}
+
+install_azure_cli() {
+ log "Installing Azure CLI"
+ export DEBIAN_FRONTEND=noninteractive
+ sudo apt-get -o DPkg::Lock::Timeout=300 update
+ sudo apt-get -o DPkg::Lock::Timeout=300 install --assume-yes --no-install-recommends apt-transport-https ca-certificates curl gnupg lsb-release
+ sudo mkdir -p /etc/apt/keyrings
+ curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | sudo gpg --dearmor -o /etc/apt/keyrings/microsoft.gpg
+ sudo chmod go+r /etc/apt/keyrings/microsoft.gpg
+ local cli_repo architecture
+ cli_repo=$(lsb_release -cs)
+ architecture=$(dpkg --print-architecture)
+ echo "Types: deb
+URIs: https://packages.microsoft.com/repos/azure-cli/
+Suites: ${cli_repo}
+Components: main
+Architectures: ${architecture}
+Signed-by: /etc/apt/keyrings/microsoft.gpg" | sudo tee /etc/apt/sources.list.d/azure-cli.sources >/dev/null
+ sudo apt-get -o DPkg::Lock::Timeout=300 update
+ sudo apt-get -o DPkg::Lock::Timeout=300 install --assume-yes azure-cli
}
enable_debug() {
- echo "[ DEBUG ]: Enabling writing out all commands being executed"
- set -x
+ echo "[ DEBUG ]: Enabling writing out all commands being executed"
+ set -x
}
if [[ $# -gt 0 ]]; then
- case "$1" in
+ case "$1" in
-d | --debug)
- enable_debug
- ;;
+ enable_debug
+ ;;
*)
- usage
- ;;
- esac
+ usage
+ ;;
+ esac
fi
set -e
@@ -85,66 +107,52 @@ log "Setting up AZ CLI..."
# Install Azure CLI.
if ! command -v "az" &>/dev/null; then
- if [[ ! $SKIP_INSTALL_AZ_CLI ]]; then
- log "Installing Azure CLI"
- # Pin Azure CLI install via Microsoft apt keyring/repo and explicit version (OSSF Scorecard pinned-dependencies)
- AZ_CLI_INSTALL_VER="${AZ_CLI_VER:-2.67.0}"
- sudo apt-get update
- sudo apt-get install -y ca-certificates curl apt-transport-https lsb-release gnupg
- sudo mkdir -p /etc/apt/keyrings
- curl -sLS https://packages.microsoft.com/keys/microsoft.asc \
- | gpg --dearmor \
- | sudo tee /etc/apt/keyrings/microsoft.gpg >/dev/null
- sudo chmod go+r /etc/apt/keyrings/microsoft.gpg
- AZ_REPO=$(lsb_release -cs)
- echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/microsoft.gpg] https://packages.microsoft.com/repos/azure-cli/ ${AZ_REPO} main" \
- | sudo tee /etc/apt/sources.list.d/azure-cli.list >/dev/null
- sudo apt-get update
- sudo apt-get install -y "azure-cli=${AZ_CLI_INSTALL_VER}-1~${AZ_REPO}"
- else
- err "'az' is missing and required"
- fi
+ if [[ ! $SKIP_INSTALL_AZ_CLI ]]; then
+ install_azure_cli
+ else
+ err "'az' is missing and required"
+ fi
fi
# Verify correct version of Azure CLI and install if needed.
if [[ $AZ_CLI_VER && ! $SKIP_INSTALL_AZ_CLI ]]; then
- if ! az version | grep "\"azure-cli\"" | grep -Fq "$AZ_CLI_VER"; then
- log "Installing specified version of Azure CLI $AZ_CLI_VER"
- sudo apt-get remove -y azure-cli && log "Removed Azure CLI to install specific version"
- sudo apt-get install -y "azure-cli=$AZ_CLI_VER-1~$(lsb_release -cs)"
- fi
+ if ! az version | grep "\"azure-cli\"" | grep -Fq "$AZ_CLI_VER"; then
+ log "Installing specified version of Azure CLI $AZ_CLI_VER"
+ sudo apt-get -o DPkg::Lock::Timeout=300 remove -y azure-cli && log "Removed Azure CLI to install specific version"
+ sudo apt-get -o DPkg::Lock::Timeout=300 install --assume-yes azure-cli="$AZ_CLI_VER-1~$(lsb_release -cs)"
+ fi
fi
# Enable Azure CLI extension connectedk8s.
if [[ $AZ_CONNECTEDK8S_VER ]]; then
- if ! az version | grep "\"connectedk8s\"" | grep -Fq "$AZ_CONNECTEDK8S_VER"; then
- az extension remove --name connectedk8s 2>/dev/null && log "Removed Azure CLI extension [connectedk8s]"
- log "Enabling Azure CLI extension [connectedk8s] with version $AZ_CONNECTEDK8S_VER"
- az extension add --name connectedk8s --version "$AZ_CONNECTEDK8S_VER" -y
- fi
+ if ! az version | grep "\"connectedk8s\"" | grep -Fq "$AZ_CONNECTEDK8S_VER"; then
+ az extension remove --name connectedk8s 2>/dev/null && log "Removed Azure CLI extension [connectedk8s]"
+ log "Enabling Azure CLI extension [connectedk8s] with version $AZ_CONNECTEDK8S_VER"
+ az extension add --name connectedk8s --version "$AZ_CONNECTEDK8S_VER" -y
+ fi
else
- log "Enabling and upgrading Azure CLI extension [connectedk8s]"
- az extension add --upgrade --name connectedk8s -y
+ log "Enabling and upgrading Azure CLI extension [connectedk8s]"
+ az extension add --upgrade --name connectedk8s -y
fi
# Log in to the tenant with Azure CLI.
if [[ ! $SKIP_AZ_LOGIN ]]; then
- if [[ $ARC_SP_CLIENT_ID && $ARC_SP_SECRET && $ARC_TENANT_ID ]]; then
- az login --service-principal -u "$ARC_SP_CLIENT_ID" -p "$ARC_SP_SECRET" --tenant "$ARC_TENANT_ID"
- else
- if [[ $CLIENT_ID ]]; then
- log "Logging into Azure CLI using managed identity client ID $CLIENT_ID"
- if ! az login --identity --client-id "$CLIENT_ID" --allow-no-subscriptions; then
- err "Azure CLI login failed for managed identity client ID $CLIENT_ID"
- fi
+ if [[ $ARC_SP_CLIENT_ID && $ARC_SP_SECRET && $ARC_TENANT_ID ]]; then
+ az login --service-principal -u "$ARC_SP_CLIENT_ID" -p "$ARC_SP_SECRET" --tenant "$ARC_TENANT_ID"
else
- log "Logging in with default managed identity"
- az login --identity --allow-no-subscriptions
+ if [[ $CLIENT_ID ]]; then
+ log "Logging into Azure CLI using managed identity client ID $CLIENT_ID"
+ if ! az login --identity --client-id "$CLIENT_ID" --allow-no-subscriptions; then
+ err "Azure CLI login failed for managed identity client ID $CLIENT_ID"
+ fi
+ else
+ log "Logging in with default managed identity"
+ az login --identity --allow-no-subscriptions
+ fi
fi
- fi
fi
log "Finished setting up AZ CLI..."
@@ -163,13 +171,13 @@ max_user_watches=524288
file_max=100000
if [[ $(sudo cat /proc/sys/fs/inotify/max_user_instances 2>/dev/null || echo 0) -lt "$max_user_instances" ]]; then
- echo "fs.inotify.max_user_instances=$max_user_instances" | sudo tee -a /etc/sysctl.conf
+ echo "fs.inotify.max_user_instances=$max_user_instances" | sudo tee -a /etc/sysctl.conf
fi
if [[ $(sudo cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null || echo 0) -lt "$max_user_watches" ]]; then
- echo "fs.inotify.max_user_watches=$max_user_watches" | sudo tee -a /etc/sysctl.conf
+ echo "fs.inotify.max_user_watches=$max_user_watches" | sudo tee -a /etc/sysctl.conf
fi
if [[ $(sudo cat /proc/sys/fs/file-max 2>/dev/null || echo 0) -lt "$file_max" ]]; then
- echo "fs.file-max=$file_max" | sudo tee -a /etc/sysctl.conf
+ echo "fs.file-max=$file_max" | sudo tee -a /etc/sysctl.conf
fi
sudo sysctl -p
@@ -178,82 +186,60 @@ sudo sysctl -p
if [[ ! $SKIP_INSTALL_K3S ]]; then
- # Install k3s agent if requested.
+ # Install k3s agent if requested.
- if [[ ${K3S_NODE_TYPE,,} == "agent" ]]; then
+ if [[ ${K3S_NODE_TYPE,,} == "agent" ]]; then
- # Validate 'agent' required parameters.
- if [[ ! $K3S_URL ]]; then
- err "'K3S_URL' env var is required for 'agent' K3S_NODE_TYPE"
- elif [[ ! $K3S_TOKEN ]]; then
- err "'K3S_TOKEN' env var is required for 'agent' K3S_NODE_TYPE"
- fi
+ # Validate 'agent' required parameters.
+ if [[ ! $K3S_URL ]]; then
+ err "'K3S_URL' env var is required for 'agent' K3S_NODE_TYPE"
+ elif [[ ! $K3S_TOKEN ]]; then
+ err "'K3S_TOKEN' env var is required for 'agent' K3S_NODE_TYPE"
+ fi
+
+ # Get k3s token from Key Vault if name and secret are provided.
+ if [[ $AKV_NAME && $AKV_K3S_TOKEN_SECRET ]]; then
+ log "Getting k3s token from key vault: $AKV_NAME (secret: $AKV_K3S_TOKEN_SECRET)"
+ if akv_k3s_token="$(az keyvault secret show --name "$AKV_K3S_TOKEN_SECRET" --vault-name "$AKV_NAME" --query "value" -o tsv)"; then
+ K3S_TOKEN="$akv_k3s_token"
+ else
+ err "'AKV_NAME' and 'AKV_K3S_TOKEN_SECRET' were provided but failed getting secret value, please verify roles are properly configured."
+ fi
+ fi
+
+ export INSTALL_K3S_EXEC="agent"
+ export INSTALL_K3S_VERSION="$K3S_VERSION"
+ export K3S_TOKEN
+ export K3S_URL
+ curl -sfL https://get.k3s.io | sh -
+
+ log "Finished installing k3s agent node... exiting successfully..."
- # Get k3s token from Key Vault if name and secret are provided.
- if [[ $AKV_NAME && $AKV_K3S_TOKEN_SECRET ]]; then
- log "Getting k3s token from key vault: $AKV_NAME (secret: $AKV_K3S_TOKEN_SECRET)"
- if akv_k3s_token="$(az keyvault secret show --name "$AKV_K3S_TOKEN_SECRET" --vault-name "$AKV_NAME" --query "value" -o tsv)"; then
- K3S_TOKEN="$akv_k3s_token"
- else
- err "'AKV_NAME' and 'AKV_K3S_TOKEN_SECRET' were provided but failed getting secret value, please verify roles are properly configured."
- fi
+ exit 0
fi
- # Pin k3s binary + installer (OSSF Scorecard pinned-dependencies)
- K3S_INSTALL_VERSION="${K3S_VERSION:-v1.31.2+k3s1}"
- K3S_TAG_URL="${K3S_INSTALL_VERSION//+/%2B}"
- curl -sfL -o /tmp/k3s "https://github.com/k3s-io/k3s/releases/download/${K3S_TAG_URL}/k3s"
- curl -sfL -o /tmp/k3s.sha256sums "https://github.com/k3s-io/k3s/releases/download/${K3S_TAG_URL}/sha256sum-amd64.txt"
- (cd /tmp && grep -E '(^|[[:space:]])k3s$' k3s.sha256sums | sha256sum -c -)
- sudo install -m 0755 /tmp/k3s /usr/local/bin/k3s
- curl -sfL -o /tmp/k3s-install.sh https://get.k3s.io
- export INSTALL_K3S_SKIP_DOWNLOAD=true
- export INSTALL_K3S_EXEC="agent"
- export INSTALL_K3S_VERSION="$K3S_INSTALL_VERSION"
- export K3S_TOKEN
- export K3S_URL
- sh /tmp/k3s-install.sh
-
- log "Finished installing k3s agent node... exiting successfully..."
-
- exit 0
- fi
-
- # Install k3s server if it is missing.
-
- if ! command -v 'k3s' &>/dev/null; then
- # Pin k3s binary + installer (OSSF Scorecard pinned-dependencies)
- K3S_INSTALL_VERSION="${K3S_VERSION:-v1.31.2+k3s1}"
- K3S_TAG_URL="${K3S_INSTALL_VERSION//+/%2B}"
- curl -sfL -o /tmp/k3s "https://github.com/k3s-io/k3s/releases/download/${K3S_TAG_URL}/k3s"
- curl -sfL -o /tmp/k3s.sha256sums "https://github.com/k3s-io/k3s/releases/download/${K3S_TAG_URL}/sha256sum-amd64.txt"
- (cd /tmp && grep -E '(^|[[:space:]])k3s$' k3s.sha256sums | sha256sum -c -)
- sudo install -m 0755 /tmp/k3s /usr/local/bin/k3s
- curl -sfL -o /tmp/k3s-install.sh https://get.k3s.io
- export INSTALL_K3S_SKIP_DOWNLOAD=true
- export INSTALL_K3S_EXEC="server"
- export INSTALL_K3S_VERSION="$K3S_INSTALL_VERSION"
- export K3S_TOKEN
- sh /tmp/k3s-install.sh
-
- log "Finished installing k3s server"
- fi
+ # Install k3s server if it is missing.
+
+ if ! command -v 'k3s' &>/dev/null; then
+ export INSTALL_K3S_EXEC="server"
+ export INSTALL_K3S_VERSION="$K3S_VERSION"
+ export K3S_TOKEN
+ curl -sfL https://get.k3s.io | sh -
+
+ log "Finished installing k3s server"
+ fi
fi
# Install kubectl if it is missing (should come with k3s).
if ! command -v 'kubectl' &>/dev/null; then
- if [[ ! $SKIP_INSTALL_KUBECTL ]]; then
- # Pin kubectl version + verify sha256 (OSSF Scorecard pinned-dependencies)
- KUBECTL_VERSION="${KUBECTL_VERSION:-v1.31.2}"
- curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl"
- curl -LO "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl.sha256"
- echo "$(cat kubectl.sha256) kubectl" | sha256sum -c -
- chmod +x ./kubectl
- sudo mv ./kubectl /usr/local/bin
- else
- err "'kubectl' is missing and required"
- fi
+ if [[ ! $SKIP_INSTALL_KUBECTL ]]; then
+ curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
+ chmod +x ./kubectl
+ sudo mv ./kubectl /usr/local/bin
+ else
+ err "'kubectl' is missing and required"
+ fi
fi
# Configure kubectl for k3s.
@@ -274,63 +260,72 @@ kubectl config use-context default
# Add ~/.kube/config to the user's .kube config folder and make it available to all users
if [[ "$DEVICE_USERNAME" && -d "/home/$DEVICE_USERNAME" && ! -f "/home/$DEVICE_USERNAME/.kube/config" ]]; then
- log "Creating /home/$DEVICE_USERNAME/.kube/config"
- mkdir -p "/home/$DEVICE_USERNAME/.kube"
- cp "$HOME/.kube/config" "/home/$DEVICE_USERNAME/.kube/config"
- chmod 666 "/home/$DEVICE_USERNAME/.kube/config"
+ log "Creating /home/$DEVICE_USERNAME/.kube/config"
+ mkdir -p "/home/$DEVICE_USERNAME/.kube"
+ cp "$HOME/.kube/config" "/home/$DEVICE_USERNAME/.kube/config"
+ chmod 666 "/home/$DEVICE_USERNAME/.kube/config"
fi
# Add utilities and settings for non-prod environments.
if [[ ${ENVIRONMENT,,} != "prod" ]]; then
- log "Configuring non-prod settings"
- bash_rc="/etc/bash.bashrc"
- {
- for line in \
- "export KUBECONFIG=~/.kube/config" \
- "source <(kubectl completion bash)" \
- "alias k=kubectl" \
- "complete -o default -F __start_kubectl k" \
- "alias kubens='kubectl config set-context --current --namespace '"; do
- sudo grep -qxF -- "$line" "$bash_rc" || echo "$line"
- done
- } | sudo tee -a "$bash_rc" >/dev/null
-
- if ! command -v 'k9s' &>/dev/null; then
- log "Downloading and installing k9s"
- curl -LO https://github.com/derailed/k9s/releases/latest/download/k9s_linux_amd64.tar.gz \
- && sudo tar -xf k9s_linux_amd64.tar.gz --directory=/usr/local/bin k9s \
- && sudo chmod +x /usr/local/bin/k9s \
- && rm k9s_linux_amd64.tar.gz
- fi
+ log "Configuring non-prod settings"
+ bash_rc="/etc/bash.bashrc"
+ {
+ for line in \
+ "export KUBECONFIG=~/.kube/config" \
+ "source <(kubectl completion bash)" \
+ "alias k=kubectl" \
+ "complete -o default -F __start_kubectl k" \
+ "alias kubens='kubectl config set-context --current --namespace '"; do
+ sudo grep -qxF -- "$line" "$bash_rc" || echo "$line"
+ done
+ } | sudo tee -a "$bash_rc" >/dev/null
+
+ if ! command -v 'k9s' &>/dev/null; then
+ log "Downloading and installing k9s"
+ curl -LO https://github.com/derailed/k9s/releases/latest/download/k9s_linux_amd64.tar.gz &&
+ sudo tar -xf k9s_linux_amd64.tar.gz --directory=/usr/local/bin k9s &&
+ sudo chmod +x /usr/local/bin/k9s &&
+ rm k9s_linux_amd64.tar.gz
+ fi
fi
# Create 'cluster-admin' role binding for the provided Admin Object ID and/or UPN, needed for additional setup.
if [[ $CLUSTER_ADMIN_OID ]]; then
- log "Adding $CLUSTER_ADMIN_OID as cluster admin by object ID"
- short_id="$(echo "$CLUSTER_ADMIN_OID" | cut -c1-7)"
- kubectl create clusterrolebinding "$short_id-user-binding" \
- --clusterrole cluster-admin \
- --user="$CLUSTER_ADMIN_OID" \
- --dry-run=client -o yaml | kubectl apply -f -
+ log "Adding $CLUSTER_ADMIN_OID as cluster admin by object ID"
+ short_id="$(echo "$CLUSTER_ADMIN_OID" | cut -c1-7)"
+ kubectl create clusterrolebinding "$short_id-user-binding" \
+ --clusterrole cluster-admin \
+ --user="$CLUSTER_ADMIN_OID" \
+ --dry-run=client -o yaml | kubectl apply -f -
fi
if [[ $CLUSTER_ADMIN_UPN ]]; then
- log "Adding $CLUSTER_ADMIN_UPN as cluster admin by user principal name"
- short_upn="$(echo "$CLUSTER_ADMIN_UPN" | sha256sum | cut -c1-7)"
- kubectl create clusterrolebinding "$short_upn-user-binding" \
- --clusterrole cluster-admin \
- --user="$CLUSTER_ADMIN_UPN" \
- --dry-run=client -o yaml | kubectl apply -f -
+ log "Adding $CLUSTER_ADMIN_UPN as cluster admin by user principal name"
+ short_upn="$(echo "$CLUSTER_ADMIN_UPN" | sha256sum | cut -c1-7)"
+ kubectl create clusterrolebinding "$short_upn-user-binding" \
+ --clusterrole cluster-admin \
+ --user="$CLUSTER_ADMIN_UPN" \
+ --dry-run=client -o yaml | kubectl apply -f -
+fi
+
+if [[ $CLUSTER_ADMIN_GROUP_OID ]]; then
+ log "Adding Entra ID group $CLUSTER_ADMIN_GROUP_OID as cluster admin"
+ short_gid="$(echo "$CLUSTER_ADMIN_GROUP_OID" | cut -c1-7)"
+ kubectl create clusterrolebinding "$short_gid-group-binding" \
+ --clusterrole cluster-admin \
+ --group="$CLUSTER_ADMIN_GROUP_OID" \
+ --dry-run=client -o yaml | kubectl apply -f -
fi
# Create SAT with 'custer-admin' for deployment scripts.
if [[ ! $SKIP_DEPLOY_SAT ]]; then
- kubectl create serviceaccount deploy-user -n default --dry-run=client -o yaml | kubectl apply -f -
- kubectl create clusterrolebinding deploy-user --clusterrole cluster-admin --serviceaccount default:deploy-user --dry-run=client -o yaml | kubectl apply -f -
- kubectl apply -f - </dev/null || echo "")"
- if [[ ! $connected_to_cluster ]]; then
- # Do the 'az connectedk8s connect' and check for error.
- if ! connect_arc; then
- log "Connecting to Azure Arc failed"
- if [[ ${ENVIRONMENT,,} == "prod" ]]; then
- err "Cluster failed Azure Arc connect to resource: $ARC_RESOURCE_NAME in resource group: $ARC_RESOURCE_GROUP_NAME, \
+ connected_to_cluster="$(kubectl get cm azure-clusterconfig -n azure-arc -o jsonpath="{.data.AZURE_RESOURCE_NAME}" 2>/dev/null || echo "")"
+ if [[ ! $connected_to_cluster ]]; then
+ # Do the 'az connectedk8s connect' and check for error.
+ if ! connect_arc; then
+ log "Connecting to Azure Arc failed"
+ if [[ ${ENVIRONMENT,,} == "prod" ]]; then
+ err "Cluster failed Azure Arc connect to resource: $ARC_RESOURCE_NAME in resource group: $ARC_RESOURCE_GROUP_NAME, \
likely resource already exists and needs to be deleted"
- fi
- log "Attempting to reconnect by deleting Azure Arc connectedCluster resource in Azure"
- if ! az connectedk8s delete --name "$ARC_RESOURCE_NAME" --resource-group "$ARC_RESOURCE_GROUP_NAME" --yes; then
- log "Error on deleting Azure Arc connectedCluster resource in Azure... Ignoring and re-attempting Azure Arc connect..."
- fi
- connect_arc
+ fi
+ log "Attempting to reconnect by deleting Azure Arc connectedCluster resource in Azure"
+ if ! az connectedk8s delete --name "$ARC_RESOURCE_NAME" --resource-group "$ARC_RESOURCE_GROUP_NAME" --yes; then
+ log "Error on deleting Azure Arc connectedCluster resource in Azure... Ignoring and re-attempting Azure Arc connect..."
+ fi
+ connect_arc
+ fi
+ elif [[ $connected_to_cluster != "$ARC_RESOURCE_NAME" ]]; then
+ err "Cluster is already connected to a different Azure Arc resource: $connected_to_cluster"
+ fi
+
+ # Enable Cluster Connect and Custom Locations, both are required for Azure IoT Operations.
+
+ log "Enabling Azure Arc feature [cluster-connect custom-locations]"
+ az_connectedk8s_enable_features=("az connectedk8s enable-features"
+ "--name $ARC_RESOURCE_NAME"
+ "--resource-group $ARC_RESOURCE_GROUP_NAME"
+ "--features cluster-connect custom-locations"
+ )
+ if [[ $CUSTOM_LOCATIONS_OID ]]; then
+ az_connectedk8s_enable_features+=("--custom-locations-oid $CUSTOM_LOCATIONS_OID")
+ fi
+ echo "Executing: ${az_connectedk8s_enable_features[*]}"
+ eval "${az_connectedk8s_enable_features[*]}"
+
+ # Update k3s config.yaml with Azure Arc Workload Identity settings to support Managed Identities.
+
+ log "Updating kube-api server settings with OIDC settings for Azure Arc workload identity"
+ issuer_url=$(az connectedk8s show -g "$ARC_RESOURCE_GROUP_NAME" -n "$ARC_RESOURCE_NAME" --query oidcIssuerProfile.issuerUrl --output tsv 2>/dev/null || echo "")
+ if [[ $issuer_url ]]; then
+ k3s_config="/etc/rancher/k3s/config.yaml"
+ {
+ for line in \
+ "kube-apiserver-arg:" \
+ "- service-account-issuer=$issuer_url" \
+ "- service-account-max-token-expiration=24h"; do
+ sudo grep -qxF -- "$line" "$k3s_config" || echo "$line"
+ done
+ } | sudo tee -a "$k3s_config" >/dev/null
+
+ # Restart the cluster to use the new settings for workload identity.
+ sudo systemctl restart k3s
fi
- elif [[ $connected_to_cluster != "$ARC_RESOURCE_NAME" ]]; then
- err "Cluster is already connected to a different Azure Arc resource: $connected_to_cluster"
- fi
-
- # Enable Cluster Connect and Custom Locations, both are required for Azure IoT Operations.
-
- log "Enabling Azure Arc feature [cluster-connect custom-locations]"
- az_connectedk8s_enable_features=("az connectedk8s enable-features"
- "--name $ARC_RESOURCE_NAME"
- "--resource-group $ARC_RESOURCE_GROUP_NAME"
- "--features cluster-connect custom-locations"
- )
- if [[ $CUSTOM_LOCATIONS_OID ]]; then
- az_connectedk8s_enable_features+=("--custom-locations-oid $CUSTOM_LOCATIONS_OID")
- fi
- echo "Executing: ${az_connectedk8s_enable_features[*]}"
- eval "${az_connectedk8s_enable_features[*]}"
-
- # Update k3s config.yaml with Azure Arc Workload Identity settings to support Managed Identities.
-
- log "Updating kube-api server settings with OIDC settings for Azure Arc workload identity"
- issuer_url=$(az connectedk8s show -g "$ARC_RESOURCE_GROUP_NAME" -n "$ARC_RESOURCE_NAME" --query oidcIssuerProfile.issuerUrl --output tsv 2>/dev/null || echo "")
- if [[ $issuer_url ]]; then
- k3s_config="/etc/rancher/k3s/config.yaml"
- {
- for line in \
- "kube-apiserver-arg:" \
- "- service-account-issuer=$issuer_url" \
- "- service-account-max-token-expiration=24h"; do
- sudo grep -qxF -- "$line" "$k3s_config" || echo "$line"
- done
- } | sudo tee -a "$k3s_config" >/dev/null
-
- # Restart the cluster to use the new settings for workload identity.
- sudo systemctl restart k3s
- fi
fi
####
@@ -452,39 +447,39 @@ fi
####
wait_for_k3s_server_ready() {
- local timeout_seconds=1800
- local start_time
- local elapsed_time
+ local timeout_seconds=1800
+ local start_time
+ local elapsed_time
- start_time=$(date +%s)
+ start_time=$(date +%s)
- log "Waiting for k3s server to be ready (timeout: ${timeout_seconds}s)..."
+ log "Waiting for k3s server to be ready (timeout: ${timeout_seconds}s)..."
- while true; do
- elapsed_time=$(($(date +%s) - start_time))
+ while true; do
+ elapsed_time=$(($(date +%s) - start_time))
- if ((elapsed_time >= timeout_seconds)); then
- err "Timeout waiting for k3s server to become ready after ${timeout_seconds} seconds. Check 'systemctl status k3s' and 'kubectl get nodes' for more information."
- fi
+ if ((elapsed_time >= timeout_seconds)); then
+ err "Timeout waiting for k3s server to become ready after ${timeout_seconds} seconds. Check 'systemctl status k3s' and 'kubectl get nodes' for more information."
+ fi
- if kubectl wait --for condition=ready node --all --timeout=60s; then
- if kubectl wait --for=jsonpath='{.status.phase}'=Running pod -l '!job-name' -n kube-system --timeout=60s \
- && kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod -l 'job-name' -n kube-system --timeout=60s; then
- if kubectl cluster-info | grep -c -E "(Kubernetes control plane|CoreDNS|Metrics-server).*running" | grep -q "3"; then
- log "k3s server is ready and responding (${elapsed_time}s elapsed)"
- return 0
+ if kubectl wait --for condition=ready node --all --timeout=60s; then
+ if kubectl wait --for=jsonpath='{.status.phase}'=Running pod -l '!job-name' -n kube-system --timeout=60s &&
+ kubectl wait --for=jsonpath='{.status.phase}'=Succeeded pod -l 'job-name' -n kube-system --timeout=60s; then
+ if kubectl cluster-info | grep -c -E "(Kubernetes control plane|CoreDNS|Metrics-server).*running" | grep -q "3"; then
+ log "k3s server is ready and responding (${elapsed_time}s elapsed)"
+ return 0
+ fi
+ fi
fi
- fi
- fi
- sleep 5
- elapsed_time=$(($(date +%s) - start_time))
- log "Still waiting for k3s server readiness... (${elapsed_time}s elapsed)"
- done
+ sleep 5
+ elapsed_time=$(($(date +%s) - start_time))
+ log "Still waiting for k3s server readiness... (${elapsed_time}s elapsed)"
+ done
}
if [[ ! $SKIP_INSTALL_K3S ]]; then
- wait_for_k3s_server_ready
+ wait_for_k3s_server_ready
fi
log "Finished setting up Azure Arc..."
diff --git a/src/100-edge/100-cncf-cluster/terraform/README.md b/src/100-edge/100-cncf-cluster/terraform/README.md
index 76cc778a..69946038 100644
--- a/src/100-edge/100-cncf-cluster/terraform/README.md
+++ b/src/100-edge/100-cncf-cluster/terraform/README.md
@@ -25,14 +25,18 @@ install extensions for cluster connect and custom locations.
## Resources
-| Name | Type |
-|----------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
-| [terraform_data.defer_azuread_user](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
-| [terraform_data.defer_custom_locations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
-| [azapi_resource.arc_connected_cluster](https://registry.terraform.io/providers/Azure/azapi/latest/docs/data-sources/resource) | data source |
-| [azuread_service_principal.custom_locations](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/data-sources/service_principal) | data source |
-| [azuread_user.current](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/data-sources/user) | data source |
-| [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) | data source |
+| Name | Type |
+|--------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
+| [azurerm_role_assignment.arc_cluster_user_group](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
+| [azurerm_role_assignment.arc_cluster_user_user](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
+| [azurerm_role_assignment.arc_kubernetes_viewer_group](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
+| [azurerm_role_assignment.arc_kubernetes_viewer_user](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/role_assignment) | resource |
+| [terraform_data.defer_azuread_user](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
+| [terraform_data.defer_custom_locations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
+| [azapi_resource.arc_connected_cluster](https://registry.terraform.io/providers/Azure/azapi/latest/docs/data-sources/resource) | data source |
+| [azuread_service_principal.custom_locations](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/data-sources/service_principal) | data source |
+| [azuread_user.current](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/data-sources/user) | data source |
+| [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) | data source |
## Modules
@@ -48,43 +52,45 @@ install extensions for cluster connect and custom locations.
## Inputs
-| Name | Description | Type | Default | Required |
-|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|---------|:--------:|
-| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
-| resource\_group | Resource group object containing name and id where resources will be deployed | ```object({ name = string id = optional(string) })``` | n/a | yes |
-| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
-| should\_get\_custom\_locations\_oid | Whether to get Custom Locations Object ID using Terraform's azuread provider. (Otherwise, provided by 'custom\_locations\_oid' or `az connectedk8s enable-features` for custom-locations on cluster setup if not provided.) | `bool` | n/a | yes |
-| arc\_onboarding\_identity | The User Assigned Managed Identity that will be used for onboarding the cluster to Arc | ```object({ id = string name = string principal_id = string client_id = string tenant_id = string })``` | `null` | no |
-| arc\_onboarding\_principal\_ids | The Principal IDs for the identity or service principal that will be used for onboarding the cluster to Arc | `list(string)` | `null` | no |
-| arc\_onboarding\_sp | n/a | ```object({ client_id = string object_id = string client_secret = string })``` | `null` | no |
-| cluster\_admin\_oid | The Object ID that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user Object ID if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | `null` | no |
-| cluster\_admin\_upn | The User Principal Name that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user UPN if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | `null` | no |
-| cluster\_node\_machine | n/a | ```list(object({ id = string location = string }))``` | `null` | no |
-| cluster\_node\_machine\_count | Number of cluster node machines referenced by cluster\_node\_machine when deploying scripts | `number` | `null` | no |
-| cluster\_server\_host\_machine\_username | 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` | `null` | no |
-| cluster\_server\_ip | The IP Address for the cluster server that the cluster nodes will use to connect. | `string` | `null` | no |
-| cluster\_server\_machine | n/a | ```object({ id = string location = string })``` | `null` | no |
-| cluster\_server\_token | The token that will be given to the server for the cluster or used by the agent nodes to connect them to the cluster. (ex. ) | `string` | `null` | no |
-| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant. If none is provided, the script will attempt to retrieve this requiring '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 |
-| http\_proxy | HTTP proxy URL | `string` | `null` | no |
-| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
-| key\_vault | The Key Vault object containing id, name, and vault\_uri properties | ```object({ id = string name = string vault_uri = string })``` | `null` | no |
-| key\_vault\_script\_secret\_prefix | Optional prefix for the Key Vault script secret name when should\_use\_script\_from\_secrets\_for\_deploy is true. | `string` | `""` | no |
-| private\_key\_pem | Private key for onboarding | `string` | `null` | no |
-| script\_output\_filepath | The location of where to write out the script file. (Otherwise, '{path.root}/out') | `string` | `null` | no |
-| should\_add\_current\_user\_cluster\_admin | Gives the current logged in user cluster-admin permissions with the new cluster. | `bool` | `true` | no |
-| should\_assign\_roles | Whether to assign Key Vault roles to identity or service principal. | `bool` | `true` | no |
-| should\_deploy\_arc\_agents | Should deploy arc agents using helm charts instead of Azure CLI. | `bool` | `false` | no |
-| should\_deploy\_arc\_machines | Should deploy to Arc-connected servers instead of Azure VMs. When true, machine\_id refers to an Arc-connected server ID. | `bool` | `false` | no |
-| should\_deploy\_script\_to\_vm | Should deploy the scripts to the provided Azure VMs. | `bool` | `true` | no |
-| should\_enable\_arc\_auto\_upgrade | Enable or disable auto-upgrades of Arc agents. (Otherwise, 'false' for 'env=prod' else 'true' for all other envs). | `bool` | `null` | no |
-| should\_generate\_cluster\_server\_token | Should generate token used by the server. ('cluster\_server\_token' must be null if this is 'true') | `bool` | `false` | no |
-| should\_output\_cluster\_node\_script | Whether to write out the script for setting up cluster node host machines. (Needed for multi-node clusters) | `bool` | `false` | no |
-| should\_output\_cluster\_server\_script | Whether to write out the script for setting up the cluster server host machine. | `bool` | `false` | no |
-| should\_skip\_az\_cli\_login | Should skip login process with Azure CLI on the server. (Skipping assumes 'az login' has been completed prior to script execution) | `bool` | `false` | no |
-| should\_skip\_installing\_az\_cli | Should skip downloading and installing Azure CLI on the server. (Skipping assumes the server will already have the Azure CLI) | `bool` | `false` | no |
-| should\_upload\_to\_key\_vault | Whether to upload the scripts to Key Vault as secrets. | `bool` | `true` | no |
-| should\_use\_script\_from\_secrets\_for\_deploy | Whether to use the deploy-script-secrets.sh script to fetch and execute deployment scripts from Key Vault | `bool` | `true` | no |
+| Name | Description | Type | Default | Required |
+|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|----------|:--------:|
+| environment | Environment for all resources in this module: dev, test, or prod | `string` | n/a | yes |
+| resource\_group | Resource group object containing name and id where resources will be deployed | ```object({ name = string id = optional(string) })``` | n/a | yes |
+| resource\_prefix | Prefix for all resources in this module | `string` | n/a | yes |
+| should\_get\_custom\_locations\_oid | Whether to get Custom Locations Object ID using Terraform's azuread provider. (Otherwise, provided by 'custom\_locations\_oid' or `az connectedk8s enable-features` for custom-locations on cluster setup if not provided.) | `bool` | n/a | yes |
+| arc\_onboarding\_identity | The User Assigned Managed Identity that will be used for onboarding the cluster to Arc | ```object({ id = string name = string principal_id = string client_id = string tenant_id = string })``` | `null` | no |
+| arc\_onboarding\_principal\_ids | The Principal IDs for the identity or service principal that will be used for onboarding the cluster to Arc | `list(string)` | `null` | no |
+| arc\_onboarding\_sp | n/a | ```object({ client_id = string object_id = string client_secret = string })``` | `null` | no |
+| cluster\_admin\_group\_oid | The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy' | `string` | `null` | no |
+| cluster\_admin\_oid | The Object ID that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user Object ID if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | `null` | no |
+| cluster\_admin\_oid\_type | The principal type of cluster\_admin\_oid for Azure RBAC assignments. Ignored when using current user (defaults to 'User') | `string` | `"User"` | no |
+| cluster\_admin\_upn | The User Principal Name that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user UPN if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | `null` | no |
+| cluster\_node\_machine | n/a | ```list(object({ id = string location = string }))``` | `null` | no |
+| cluster\_node\_machine\_count | Number of cluster node machines referenced by cluster\_node\_machine when deploying scripts | `number` | `null` | no |
+| cluster\_server\_host\_machine\_username | 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` | `null` | no |
+| cluster\_server\_ip | The IP Address for the cluster server that the cluster nodes will use to connect. | `string` | `null` | no |
+| cluster\_server\_machine | n/a | ```object({ id = string location = string })``` | `null` | no |
+| cluster\_server\_token | The token that will be given to the server for the cluster or used by the agent nodes to connect them to the cluster. (ex. ) | `string` | `null` | no |
+| custom\_locations\_oid | The object id of the Custom Locations Entra ID application for your tenant. If none is provided, the script will attempt to retrieve this requiring '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 |
+| http\_proxy | HTTP proxy URL | `string` | `null` | no |
+| instance | Instance identifier for naming resources: 001, 002, etc | `string` | `"001"` | no |
+| key\_vault | The Key Vault object containing id, name, and vault\_uri properties | ```object({ id = string name = string vault_uri = string })``` | `null` | no |
+| key\_vault\_script\_secret\_prefix | Optional prefix for the Key Vault script secret name when should\_use\_script\_from\_secrets\_for\_deploy is true. | `string` | `""` | no |
+| private\_key\_pem | Private key for onboarding | `string` | `null` | no |
+| script\_output\_filepath | The location of where to write out the script file. (Otherwise, '{path.root}/out') | `string` | `null` | no |
+| should\_add\_current\_user\_cluster\_admin | Gives the current logged in user cluster-admin permissions with the new cluster. | `bool` | `true` | no |
+| should\_assign\_roles | Whether to assign Key Vault roles to identity or service principal. | `bool` | `true` | no |
+| should\_deploy\_arc\_agents | Should deploy arc agents using helm charts instead of Azure CLI. | `bool` | `false` | no |
+| should\_deploy\_arc\_machines | Should deploy to Arc-connected servers instead of Azure VMs. When true, machine\_id refers to an Arc-connected server ID. | `bool` | `false` | no |
+| should\_deploy\_script\_to\_vm | Should deploy the scripts to the provided Azure VMs. | `bool` | `true` | no |
+| should\_enable\_arc\_auto\_upgrade | Enable or disable auto-upgrades of Arc agents. (Otherwise, 'false' for 'env=prod' else 'true' for all other envs). | `bool` | `null` | no |
+| should\_generate\_cluster\_server\_token | Should generate token used by the server. ('cluster\_server\_token' must be null if this is 'true') | `bool` | `false` | no |
+| should\_output\_cluster\_node\_script | Whether to write out the script for setting up cluster node host machines. (Needed for multi-node clusters) | `bool` | `false` | no |
+| should\_output\_cluster\_server\_script | Whether to write out the script for setting up the cluster server host machine. | `bool` | `false` | no |
+| should\_skip\_az\_cli\_login | Should skip login process with Azure CLI on the server. (Skipping assumes 'az login' has been completed prior to script execution) | `bool` | `false` | no |
+| should\_skip\_installing\_az\_cli | Should skip downloading and installing Azure CLI on the server. (Skipping assumes the server will already have the Azure CLI) | `bool` | `false` | no |
+| should\_upload\_to\_key\_vault | Whether to upload the scripts to Key Vault as secrets. | `bool` | `true` | no |
+| should\_use\_script\_from\_secrets\_for\_deploy | Whether to use the deploy-script-secrets.sh script to fetch and execute deployment scripts from Key Vault | `bool` | `true` | no |
## Outputs
diff --git a/src/100-edge/100-cncf-cluster/terraform/main.tf b/src/100-edge/100-cncf-cluster/terraform/main.tf
index 3c72e611..594b26cc 100644
--- a/src/100-edge/100-cncf-cluster/terraform/main.tf
+++ b/src/100-edge/100-cncf-cluster/terraform/main.tf
@@ -62,6 +62,55 @@ module "role_assignments" {
arc_onboarding_principal_ids = local.arc_onboarding_principal_ids
}
+/*
+ * Arc Connected Cluster RBAC - enables 'az connectedk8s proxy' access
+ */
+
+locals {
+ arc_cluster_id = try(data.azapi_resource.arc_connected_cluster[0].id, null)
+ cluster_admin_oid = try(coalesce(var.cluster_admin_oid, local.current_user_oid), null)
+ has_arc_cluster = var.should_deploy_script_to_vm && !var.should_deploy_arc_agents
+ has_cluster_admin = var.cluster_admin_oid != null || var.should_add_current_user_cluster_admin
+ should_assign_arc_rbac_user = var.should_assign_roles && local.has_arc_cluster && local.has_cluster_admin
+ should_assign_arc_rbac_group = var.should_assign_roles && local.has_arc_cluster && var.cluster_admin_group_oid != null
+}
+
+resource "azurerm_role_assignment" "arc_kubernetes_viewer_user" {
+ count = local.should_assign_arc_rbac_user ? 1 : 0
+
+ scope = local.arc_cluster_id
+ role_definition_name = "Azure Arc Kubernetes Viewer"
+ principal_id = local.cluster_admin_oid
+ principal_type = var.cluster_admin_oid_type
+}
+
+resource "azurerm_role_assignment" "arc_cluster_user_user" {
+ count = local.should_assign_arc_rbac_user ? 1 : 0
+
+ scope = local.arc_cluster_id
+ role_definition_name = "Azure Arc Enabled Kubernetes Cluster User Role"
+ principal_id = local.cluster_admin_oid
+ principal_type = var.cluster_admin_oid_type
+}
+
+resource "azurerm_role_assignment" "arc_kubernetes_viewer_group" {
+ count = local.should_assign_arc_rbac_group ? 1 : 0
+
+ scope = local.arc_cluster_id
+ role_definition_name = "Azure Arc Kubernetes Viewer"
+ principal_id = var.cluster_admin_group_oid
+ principal_type = "Group"
+}
+
+resource "azurerm_role_assignment" "arc_cluster_user_group" {
+ count = local.should_assign_arc_rbac_group ? 1 : 0
+
+ scope = local.arc_cluster_id
+ role_definition_name = "Azure Arc Enabled Kubernetes Cluster User Role"
+ principal_id = var.cluster_admin_group_oid
+ principal_type = "Group"
+}
+
/*
* Ubuntu K3s Cluster Setup
*/
@@ -78,6 +127,7 @@ module "ubuntu_k3s" {
arc_tenant_id = data.azurerm_client_config.current.tenant_id
cluster_admin_oid = try(coalesce(var.cluster_admin_oid, local.current_user_oid), null)
cluster_admin_upn = try(coalesce(var.cluster_admin_upn, local.current_user_upn), null)
+ cluster_admin_group_oid = var.cluster_admin_group_oid
custom_locations_oid = local.custom_locations_oid
should_enable_arc_auto_upgrade = var.should_enable_arc_auto_upgrade
environment = var.environment
diff --git a/src/100-edge/100-cncf-cluster/terraform/modules/role-assignments/README.md b/src/100-edge/100-cncf-cluster/terraform/modules/role-assignments/README.md
index 27e01074..36ef7a78 100644
--- a/src/100-edge/100-cncf-cluster/terraform/modules/role-assignments/README.md
+++ b/src/100-edge/100-cncf-cluster/terraform/modules/role-assignments/README.md
@@ -1,7 +1,7 @@
-# Key Vault Role Assignment
+# Role Assignments
-Assigns Azure RBAC roles for Key Vault access
+Assigns Azure RBAC roles for Arc onboarding and Key Vault access.
## Requirements
diff --git a/src/100-edge/100-cncf-cluster/terraform/modules/role-assignments/main.tf b/src/100-edge/100-cncf-cluster/terraform/modules/role-assignments/main.tf
index 788cd597..1466a269 100644
--- a/src/100-edge/100-cncf-cluster/terraform/modules/role-assignments/main.tf
+++ b/src/100-edge/100-cncf-cluster/terraform/modules/role-assignments/main.tf
@@ -1,11 +1,11 @@
/**
- * # Key Vault Role Assignment
+ * # Role Assignments
*
- * Assigns Azure RBAC roles for Key Vault access
+ * Assigns Azure RBAC roles for Arc onboarding and Key Vault access.
*/
/*
- * Role Assignments
+ * Role Assignments - Arc Onboarding
*/
resource "azurerm_role_assignment" "connected_machine_onboarding" {
diff --git a/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/README.md b/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/README.md
index 22248261..bf394fd1 100644
--- a/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/README.md
+++ b/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/README.md
@@ -39,6 +39,7 @@ along with installing extensions for cluster connect and custom locations.
| arc\_onboarding\_sp | n/a | ```object({ client_id = string object_id = string client_secret = string })``` | n/a | yes |
| arc\_resource\_name | The name of the new Azure Arc resource. | `string` | n/a | yes |
| arc\_tenant\_id | The ID of the Tenant for the new Azure Arc resource. | `string` | n/a | yes |
+| cluster\_admin\_group\_oid | The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy' | `string` | n/a | yes |
| cluster\_admin\_oid | The Object ID that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user Object ID if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | n/a | yes |
| cluster\_admin\_upn | The User Principal Name that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user UPN if 'should\_add\_current\_user\_cluster\_admin=true') | `string` | n/a | yes |
| cluster\_server\_host\_machine\_username | 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` | n/a | yes |
diff --git a/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/main.tf b/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/main.tf
index 0b7c0f3b..908e0956 100644
--- a/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/main.tf
+++ b/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/main.tf
@@ -41,20 +41,22 @@ locals {
# Server specific environment variables for the k3s node setup.
server_env_var = {
- CLUSTER_ADMIN_OID = coalesce(var.cluster_admin_oid, "$${CLUSTER_ADMIN_OID}")
- CLUSTER_ADMIN_UPN = coalesce(var.cluster_admin_upn, "$${CLUSTER_ADMIN_UPN}")
- CLIENT_ID = "$${CLIENT_ID}"
- K3S_NODE_TYPE = "server"
- SKIP_ARC_CONNECT = "$${SKIP_ARC_CONNECT}"
+ CLUSTER_ADMIN_OID = coalesce(var.cluster_admin_oid, "$${CLUSTER_ADMIN_OID}")
+ CLUSTER_ADMIN_UPN = coalesce(var.cluster_admin_upn, "$${CLUSTER_ADMIN_UPN}")
+ CLUSTER_ADMIN_GROUP_OID = coalesce(var.cluster_admin_group_oid, "$${CLUSTER_ADMIN_GROUP_OID}")
+ CLIENT_ID = "$${CLIENT_ID}"
+ K3S_NODE_TYPE = "server"
+ SKIP_ARC_CONNECT = "$${SKIP_ARC_CONNECT}"
}
# Agent specific environment variables for the k3s node setup.
node_env_var = {
- CLUSTER_ADMIN_OID = "$${CLUSTER_ADMIN_OID}"
- CLUSTER_ADMIN_UPN = "$${CLUSTER_ADMIN_UPN}"
- CLIENT_ID = "$${CLIENT_ID}"
- K3S_NODE_TYPE = "agent"
- SKIP_ARC_CONNECT = "true"
+ CLUSTER_ADMIN_OID = "$${CLUSTER_ADMIN_OID}"
+ CLUSTER_ADMIN_UPN = "$${CLUSTER_ADMIN_UPN}"
+ CLUSTER_ADMIN_GROUP_OID = "$${CLUSTER_ADMIN_GROUP_OID}"
+ CLIENT_ID = "$${CLIENT_ID}"
+ K3S_NODE_TYPE = "agent"
+ SKIP_ARC_CONNECT = "true"
}
# Read in script file and remove any carriage returns then split on separator in file '###\n' for parameters.
diff --git a/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/variables.tf b/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/variables.tf
index 16416643..30afa6e7 100644
--- a/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/variables.tf
+++ b/src/100-edge/100-cncf-cluster/terraform/modules/ubuntu-k3s/variables.tf
@@ -80,6 +80,11 @@ variable "cluster_admin_upn" {
description = "The User Principal Name that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user UPN if 'should_add_current_user_cluster_admin=true')"
}
+variable "cluster_admin_group_oid" {
+ type = string
+ description = "The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy'"
+}
+
variable "cluster_server_ip" {
type = string
description = "The IP Address for the cluster server that the cluster nodes will use to connect."
diff --git a/src/100-edge/100-cncf-cluster/terraform/variables.tf b/src/100-edge/100-cncf-cluster/terraform/variables.tf
index dce33242..f53ceafa 100644
--- a/src/100-edge/100-cncf-cluster/terraform/variables.tf
+++ b/src/100-edge/100-cncf-cluster/terraform/variables.tf
@@ -117,12 +117,28 @@ variable "cluster_admin_oid" {
default = null
}
+variable "cluster_admin_oid_type" {
+ type = string
+ description = "The principal type of cluster_admin_oid for Azure RBAC assignments. Ignored when using current user (defaults to 'User')"
+ default = "User"
+ validation {
+ condition = contains(["User", "Group", "ServicePrincipal"], var.cluster_admin_oid_type)
+ error_message = "Must be one of: User, Group, ServicePrincipal"
+ }
+}
+
variable "cluster_admin_upn" {
type = string
description = "The User Principal Name that will be given cluster-admin permissions with the new cluster. (Otherwise, current logged in user UPN if 'should_add_current_user_cluster_admin=true')"
default = null
}
+variable "cluster_admin_group_oid" {
+ type = string
+ description = "The Entra ID group Object ID that will be given cluster-admin permissions and Azure Arc RBAC access for 'az connectedk8s proxy'"
+ default = null
+}
+
variable "cluster_server_ip" {
type = string
description = "The IP Address for the cluster server that the cluster nodes will use to connect."
diff --git a/src/100-edge/110-iot-ops/scripts/deploy-cluster-admin-oid.sh b/src/100-edge/110-iot-ops/scripts/deploy-cluster-admin-oid.sh
deleted file mode 100755
index c1b617a1..00000000
--- a/src/100-edge/110-iot-ops/scripts/deploy-cluster-admin-oid.sh
+++ /dev/null
@@ -1,29 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-
-# Refer to: https://learn.microsoft.com/azure/azure-arc/kubernetes/cluster-connect?tabs=azure-cli
-
-# ARC_RESOURCE_GROUP_NAME=
-# ARC_RESOURCE_NAME=
-
-if [[ -n $SHOULD_USE_CURRENT_USER ]]; then
- DEPLOY_ADMIN_OID=$(az ad signed-in-user show --query id -o tsv)
- echo "DEPLOY_ADMIN_OID=$DEPLOY_ADMIN_OID"
- echo ""
-fi
-
-# From a place that has role assignment privs:
-
-if [[ -n $SHOULD_ASSIGN_ROLES ]]; then
- CONNECTED_CLUSTER_ID=$(az resource show -g "$ARC_RESOURCE_GROUP_NAME" -n "$ARC_RESOURCE_NAME" --resource-type "microsoft.kubernetes/connectedclusters" --query id --output tsv)
- az role assignment create --role "Azure Arc Kubernetes Viewer" --assignee "$DEPLOY_ADMIN_OID" --scope "$CONNECTED_CLUSTER_ID"
- az role assignment create --role "Azure Arc Enabled Kubernetes Cluster User Role" --assignee "$DEPLOY_ADMIN_OID" --scope "$CONNECTED_CLUSTER_ID"
-fi
-
-echo "Adding $DEPLOY_ADMIN_OID as deployment admin"
-
-kubectl create clusterrolebinding "$(echo "$DEPLOY_ADMIN_OID" | cut -c1-7)-deploy-binding" --clusterrole cluster-admin --user="$DEPLOY_ADMIN_OID" --dry-run=client -o yaml | kubectl apply -f -
-
-echo ""
-echo "az connectedk8s proxy -n $ARC_RESOURCE_NAME -g $ARC_RESOURCE_GROUP_NAME"
diff --git a/src/100-edge/111-assets/scripts/README.md b/src/100-edge/111-assets/scripts/README.md
new file mode 100644
index 00000000..f37f3c1c
--- /dev/null
+++ b/src/100-edge/111-assets/scripts/README.md
@@ -0,0 +1,440 @@
+# ONVIF Camera Deployment Scripts
+
+Automated deployment scripts for deploying ONVIF cameras to Azure IoT Operations.
+
+## Available Scripts
+
+### Terraform Deployment Script
+
+**File:** `deploy-onvif-camera-terraform.sh`
+
+Automates ONVIF camera deployment using Terraform with interactive prompts.
+
+#### Features
+
+- ✅ Interactive camera information gathering
+- ✅ ONVIF endpoint connectivity testing
+- ✅ Azure resource discovery and validation
+- ✅ Automatic Kubernetes secret creation
+- ✅ PTZ capability configuration (Fixed/PT/PTZ/PTZ+Home)
+- ✅ Terraform configuration generation
+- ✅ Automated deployment with plan review
+- ✅ Deployment verification
+- ✅ Comprehensive summary with next steps
+
+#### Prerequisites
+
+- Azure CLI installed and authenticated (`az login`)
+- kubectl configured for your cluster
+- Terraform installed (v1.9.8+)
+- Camera accessible on network with known credentials
+- Azure IoT Operations deployed with ONVIF Connector
+
+#### Usage
+
+```bash
+cd /workspaces/edge-ai/src/100-edge/111-assets/scripts
+./deploy-onvif-camera-terraform.sh
+```
+
+The script will prompt you for:
+
+1. **Camera Information**
+ - Camera name (unique identifier)
+ - Camera IP address
+ - ONVIF port (default: 80)
+ - ONVIF path (default: /onvif/device_service)
+ - Camera credentials (username/password)
+
+2. **Azure Resources**
+ - Subscription (validates current subscription)
+ - Resource group (lists available)
+ - Custom location (lists in resource group)
+ - ADR namespace (lists available)
+
+3. **PTZ Capabilities**
+ - Fixed (no PTZ)
+ - Pan/Tilt only
+ - Pan/Tilt/Zoom
+ - PTZ with Home position
+
+#### Generated Files
+
+- `{component}/terraform/{camera-name}-deployment.tfvars` - Terraform variables file
+- `{component}/terraform/{camera-name}.tfplan` - Terraform execution plan
+
+---
+
+### Bicep Deployment Script
+
+**File:** `deploy-onvif-camera-bicep.sh`
+
+Automates ONVIF camera deployment using Bicep with interactive prompts.
+
+#### Bicep Script Features
+
+- ✅ Interactive camera information gathering
+- ✅ ONVIF endpoint connectivity testing
+- ✅ Azure resource discovery and validation
+- ✅ Automatic Kubernetes secret creation
+- ✅ PTZ capability configuration (Fixed/PT/PTZ/PTZ+Home)
+- ✅ Bicep configuration generation
+- ✅ Bicep validation before deployment
+- ✅ Automated deployment with confirmation
+- ✅ Deployment verification
+- ✅ Comprehensive summary with next steps
+
+#### Bicep Script Prerequisites
+
+- Azure CLI installed and authenticated (`az login`)
+- kubectl configured for your cluster
+- Camera accessible on network with known credentials
+- Azure IoT Operations deployed with ONVIF Connector
+
+#### Bicep Script Usage
+
+```bash
+cd /workspaces/edge-ai/src/100-edge/111-assets/scripts
+./deploy-onvif-camera-bicep.sh
+```
+
+The script will prompt you for:
+
+1. **Camera Information**
+ - Camera name (unique identifier)
+ - Camera IP address
+ - ONVIF port (default: 80)
+ - ONVIF path (default: /onvif/device_service)
+ - Camera credentials (username/password)
+
+2. **Azure Resources**
+ - Subscription (validates current subscription)
+ - Resource group (lists available)
+ - Custom location (lists in resource group)
+ - ADR namespace (lists available)
+ - Environment name (default: dev)
+ - Instance identifier (default: 001)
+
+3. **PTZ Capabilities**
+ - Fixed (no PTZ)
+ - Pan/Tilt only
+ - Pan/Tilt/Zoom
+ - PTZ with Home position
+
+#### Bicep Generated Files
+
+- `{component}/bicep/{camera-name}-deployment.bicepparam` - Bicep parameters file
+
+---
+
+## Script Workflow
+
+Both scripts follow the same workflow:
+
+1. **Prerequisite Checks**
+ - Verify required tools installed (az, kubectl, base64, terraform/bicep)
+ - Verify Azure CLI authentication
+ - Verify kubectl cluster access
+
+2. **Camera Information Gathering**
+ - Prompt for camera details
+ - Construct ONVIF URL
+ - Display configuration summary
+
+3. **ONVIF Connection Testing**
+ - Test ONVIF endpoint with GetSystemDateAndTime call
+ - Warn if test fails but continue (camera may still work)
+
+4. **Azure Resource Discovery**
+ - List and validate subscription
+ - List and validate resource group
+ - List and validate custom location
+ - List and validate ADR namespace
+ - Get resource IDs for deployment
+
+5. **Kubernetes Secret Creation**
+ - Check if secret exists
+ - Base64 encode credentials
+ - Create secret in azure-iot-operations namespace
+ - Verify secret creation
+
+6. **PTZ Capability Configuration**
+ - Prompt for camera PTZ type
+ - Configure appropriate data points
+
+7. **Configuration Generation**
+ - Generate deployment configuration file
+ - Include all required parameters
+ - Add PTZ data points based on capabilities
+
+8. **Deployment**
+ - **Terraform**: Initialize, plan, review, apply
+ - **Bicep**: Validate, review, deploy
+
+9. **Verification**
+ - Check device in Azure Device Registry
+ - Check asset in Azure Device Registry (if PTZ)
+ - Check ONVIF connector logs for activity
+
+10. **Summary Display**
+ - Show deployed resources
+ - Provide next steps
+ - Display helpful commands and links
+
+## Common Issues and Troubleshooting
+
+### Script Fails at Prerequisites Check
+
+**Problem:** Missing required tools
+
+**Solution:** Install missing tools:
+
+```bash
+# Azure CLI
+curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
+
+# kubectl
+curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
+sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
+
+# Terraform (for Terraform script)
+wget https://releases.hashicorp.com/terraform/1.10.3/terraform_1.10.3_linux_amd64.zip
+unzip terraform_1.10.3_linux_amd64.zip
+sudo mv terraform /usr/local/bin/
+```
+
+### ONVIF Connection Test Fails
+
+**Problem:** Cannot connect to camera endpoint
+
+**Solutions:**
+
+1. Verify camera IP address is correct
+2. Check camera is powered on and accessible on network
+3. Verify ONVIF port (try 80, 8000, 8080)
+4. Check camera ONVIF settings are enabled
+5. Try different ONVIF path (/onvif/device_service, /onvif/services)
+
+**Note:** Script will continue even if test fails - camera may still work if credentials are correct.
+
+### Secret Creation Fails
+
+**Problem:** kubectl cannot create secret
+
+**Solutions:**
+
+1. Verify kubectl is configured for correct cluster
+2. Check azure-iot-operations namespace exists
+3. Verify you have permissions to create secrets
+
+### Deployment Fails
+
+#### Terraform Deployment Failures
+
+**Problem:** Terraform plan or apply fails
+
+**Solutions:**
+
+1. Check Terraform is initialized (`cd terraform && terraform init`)
+2. Verify Azure resource IDs are correct
+3. Check ADR namespace exists
+4. Review Terraform error messages for specific issues
+
+#### Bicep Deployment Failures
+
+**Problem:** Bicep validation or deployment fails
+
+**Solutions:**
+
+1. Check Bicep file exists at expected path
+2. Verify Azure resource IDs are correct
+3. Check parameter file syntax
+4. Review deployment error messages in Azure portal
+
+### Device Not Appearing in Azure
+
+**Problem:** Device deployed but not showing in Device Registry
+
+**Solutions:**
+
+1. Wait 1-2 minutes for provisioning to complete
+2. Check deployment logs in Azure portal
+3. Verify custom location ID is correct
+4. Check ONVIF connector is running: `kubectl get pods -n azure-iot-operations`
+
+### PTZ Commands Not Working
+
+**Problem:** Device deployed but PTZ commands don't work
+
+**Solutions:**
+
+1. Verify camera actually supports PTZ (check specifications)
+2. Check ONVIF connector logs for errors
+3. Verify camera is not in manual control mode
+4. Test PTZ directly via camera web interface first
+
+## Examples
+
+### Example: Deploying a PT Camera with Terraform
+
+```bash
+# Run script
+./deploy-onvif-camera-terraform.sh
+
+# Respond to prompts:
+Camera name: camera-01
+Camera IP address:
+ONVIF port: 80
+ONVIF path: /onvif/device_service
+Camera username: admin
+Camera password: [your-password]
+
+# Use current subscription: y
+Resource group name:
+Custom location name:
+ADR namespace name:
+
+# PTZ capabilities: 2 (Pan/Tilt only)
+
+# Review plan and confirm deployment
+```
+
+### Example: Deploying a PTZ Camera with Bicep
+
+```bash
+# Run script
+./deploy-onvif-camera-bicep.sh
+
+# Respond to prompts:
+Camera name: camera-02
+Camera IP address:
+ONVIF port: 80
+ONVIF path: /onvif/device_service
+Camera username: admin
+Camera password: [your-password]
+
+# Use current subscription: y
+Resource group name:
+Custom location name:
+ADR namespace name:
+Environment name: dev
+Instance identifier: 001
+
+# PTZ capabilities: 3 (Pan/Tilt/Zoom)
+
+# Confirm deployment
+```
+
+### Example: Deploying Multiple Cameras
+
+Run the script multiple times with different camera names and IPs:
+
+```bash
+# Camera 1
+./deploy-onvif-camera-terraform.sh
+# Name: camera-01, IP:
+
+# Camera 2
+./deploy-onvif-camera-terraform.sh
+# Name: camera-02, IP:
+
+# Camera 3
+./deploy-onvif-camera-terraform.sh
+# Name: camera-03, IP:
+```
+
+Each run creates a separate configuration file and deploys independently.
+
+## Script Output Files
+
+### Terraform Script Outputs
+
+- `../terraform/{camera-name}-deployment.tfvars` - Deployment configuration
+- `../terraform/{camera-name}.tfplan` - Terraform execution plan
+- Kubernetes secret: `{camera-name}-credentials` in `azure-iot-operations` namespace
+
+### Bicep Script Outputs
+
+- `../bicep/{camera-name}-deployment.bicepparam` - Deployment configuration
+- Kubernetes secret: `{camera-name}-credentials` in `azure-iot-operations` namespace
+- Azure deployment: `onvif-camera-{camera-name}-{timestamp}` in resource group
+
+## Next Steps After Deployment
+
+1. **Monitor Connector Logs**
+
+ ```bash
+ kubectl logs -n azure-iot-operations -l app.kubernetes.io/component=connector -f
+ ```
+
+2. **Verify Device in Azure Portal**
+
+ Navigate to your Device Registry namespace → Devices → {camera-name}
+
+3. **Test PTZ Control** (if PTZ camera)
+
+ ```bash
+ # Get MQTT broker service
+ kubectl get service -n azure-iot-operations | grep mqtt
+
+ # Test pan command
+ mosquitto_pub -h -t "cameras/{camera-name}/ptz/pan_left" -m "1"
+
+ # Stop movement
+ mosquitto_pub -h -t "cameras/{camera-name}/ptz/stop" -m "1"
+ ```
+
+4. **Review Documentation**
+ - [ONVIF-CAMERA-QUICKSTART.md](../ONVIF-CAMERA-QUICKSTART.md) - Generic deployment guide
+ - [ONVIF-CAMERA-DEPLOYMENT.md](../terraform/ONVIF-CAMERA-DEPLOYMENT.md) - Detailed technical documentation
+ - [ONVIF-CAMERA-QUICK-REFERENCE.md](../terraform/ONVIF-CAMERA-QUICK-REFERENCE.md) - Command reference
+
+## Cleaning Up
+
+### Remove Deployed Camera
+
+#### Using Terraform
+
+```bash
+cd ../terraform
+terraform destroy -var-file="{camera-name}-deployment.tfvars"
+```
+
+#### Using Bicep (Manual)
+
+```bash
+# Delete device
+az resource delete --ids "${ADR_NAMESPACE_ID}/devices/${CAMERA_NAME}"
+
+# Delete asset (if exists)
+az resource delete --ids "${ADR_NAMESPACE_ID}/assets/${CAMERA_NAME}-ptz"
+
+# Delete secret
+kubectl delete secret ${CAMERA_NAME}-credentials -n azure-iot-operations
+```
+
+### Remove Configuration Files
+
+```bash
+# Terraform files
+rm ../terraform/{camera-name}-deployment.tfvars
+rm ../terraform/{camera-name}.tfplan
+
+# Bicep files
+rm ../bicep/{camera-name}-deployment.bicepparam
+```
+
+## Support
+
+For issues with:
+
+- **Scripts**: Check troubleshooting section above
+- **Azure IoT Operations**: Create Azure support ticket
+- **Camera compatibility**: Consult camera manufacturer documentation
+- **ONVIF protocol**: Visit [ONVIF community forums](https://www.onvif.org/community/)
+
+---
+
+**Script Version:** 1.0.0
+**Last Updated:** December 19, 2025
+**Tested With:** Azure IoT Operations v1.2.112, Terraform v1.10.3, Bicep latest
diff --git a/src/100-edge/111-assets/scripts/deploy-onvif-camera-bicep.sh b/src/100-edge/111-assets/scripts/deploy-onvif-camera-bicep.sh
new file mode 100755
index 00000000..497197ca
--- /dev/null
+++ b/src/100-edge/111-assets/scripts/deploy-onvif-camera-bicep.sh
@@ -0,0 +1,671 @@
+#!/bin/bash
+################################################################################
+# ONVIF Camera Deployment Script - Bicep
+#
+# This script automates the deployment of ONVIF cameras to Azure IoT Operations
+# using Bicep and the 111-assets component.
+#
+# Prerequisites:
+# - Azure IoT Operations deployed and running
+# - ONVIF Connector installed
+# - Camera accessible on network
+# - Azure CLI installed and authenticated
+# - kubectl configured for your cluster
+#
+# Usage:
+# ./deploy-onvif-camera-bicep.sh
+################################################################################
+
+set -e
+
+# Color codes for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Script directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+COMPONENT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+BICEP_DIR="${COMPONENT_DIR}/bicep"
+
+################################################################################
+# Helper Functions
+################################################################################
+
+print_header() {
+ echo ""
+ echo -e "${BLUE}========================================${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}========================================${NC}"
+ echo ""
+}
+
+print_success() {
+ echo -e "${GREEN}✓ $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}✗ $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}⚠ $1${NC}"
+}
+
+print_info() {
+ echo -e "${BLUE}ℹ $1${NC}"
+}
+
+prompt_input() {
+ local prompt="$1"
+ local default="$2"
+ local var_name="$3"
+
+ if [ -n "$default" ]; then
+ read -r -p "$(echo -e "${YELLOW}${prompt}${NC} [${default}]: ")" value
+ value="${value:-$default}"
+ else
+ read -r -p "$(echo -e "${YELLOW}${prompt}${NC}: ")" value
+ fi
+
+ eval "$var_name='$value'"
+}
+
+prompt_confirm() {
+ local prompt="$1"
+ read -r -p "$(echo -e "${YELLOW}${prompt}${NC} (y/n): ")" -n 1 -r
+ echo
+ [[ $REPLY =~ ^[Yy]$ ]]
+}
+
+################################################################################
+# Prerequisite Checks
+################################################################################
+
+check_prerequisites() {
+ print_header "Checking Prerequisites"
+
+ local missing_tools=()
+
+ # Check Azure CLI
+ if ! command -v az &>/dev/null; then
+ missing_tools+=("Azure CLI (az)")
+ else
+ print_success "Azure CLI installed"
+ fi
+
+ # Check kubectl
+ if ! command -v kubectl &>/dev/null; then
+ missing_tools+=("kubectl")
+ else
+ print_success "kubectl installed"
+ fi
+
+ # Check base64
+ if ! command -v base64 &>/dev/null; then
+ missing_tools+=("base64")
+ else
+ print_success "base64 installed"
+ fi
+
+ if [ ${#missing_tools[@]} -gt 0 ]; then
+ print_error "Missing required tools:"
+ for tool in "${missing_tools[@]}"; do
+ echo " - $tool"
+ done
+ exit 1
+ fi
+
+ # Check Azure CLI authentication
+ if ! az account show &>/dev/null; then
+ print_error "Azure CLI not authenticated. Run 'az login' first."
+ exit 1
+ else
+ print_success "Azure CLI authenticated"
+ fi
+
+ # Check kubectl cluster access
+ if ! kubectl cluster-info &>/dev/null; then
+ print_error "kubectl not configured for cluster access"
+ exit 1
+ else
+ print_success "kubectl cluster access confirmed"
+ fi
+}
+
+################################################################################
+# Gather Camera Information
+################################################################################
+
+gather_camera_info() {
+ print_header "Camera Information"
+
+ print_info "Provide information about your ONVIF camera."
+ echo ""
+
+ prompt_input "Camera name (unique identifier)" "camera-01" CAMERA_NAME
+ prompt_input "Camera IP address" "192.168.1.100" CAMERA_IP
+ prompt_input "ONVIF port" "80" CAMERA_PORT
+ prompt_input "ONVIF path" "/onvif/device_service" CAMERA_PATH
+ prompt_input "Camera username" "admin" CAMERA_USERNAME
+ prompt_input "Camera password" "" CAMERA_PASSWORD
+
+ # Construct full ONVIF URL
+ CAMERA_URL="http://${CAMERA_IP}:${CAMERA_PORT}${CAMERA_PATH}"
+
+ echo ""
+ print_info "Camera Configuration Summary:"
+ echo " Name: ${CAMERA_NAME}"
+ echo " ONVIF URL: ${CAMERA_URL}"
+ echo " Username: ${CAMERA_USERNAME}"
+ echo " Password: ********"
+ echo ""
+}
+
+################################################################################
+# Test ONVIF Connectivity
+################################################################################
+
+test_onvif_connection() {
+ print_header "Testing ONVIF Connection"
+
+ print_info "Testing connection to ${CAMERA_URL}..."
+
+ local soap_request=''
+
+ if curl -s -X POST "${CAMERA_URL}" \
+ -H "Content-Type: application/soap+xml" \
+ -d "${soap_request}" \
+ --max-time 10 \
+ -o /dev/null -w "%{http_code}" | grep -q "200"; then
+ print_success "ONVIF endpoint responding"
+ else
+ print_warning "ONVIF endpoint test failed - continuing anyway"
+ print_info "Camera may still work if credentials are correct"
+ fi
+}
+
+################################################################################
+# Gather Azure Information
+################################################################################
+
+gather_azure_info() {
+ print_header "Azure Resource Information"
+
+ # Get current subscription
+ SUBSCRIPTION_ID=$(az account show --query id -o tsv)
+ print_info "Current subscription: ${SUBSCRIPTION_ID}"
+
+ if ! prompt_confirm "Use this subscription?"; then
+ prompt_input "Enter subscription ID" "" SUBSCRIPTION_ID
+ az account set --subscription "${SUBSCRIPTION_ID}"
+ fi
+
+ # Resource Group
+ print_info "Listing resource groups..."
+ az group list --query "[].name" -o table
+ echo ""
+ prompt_input "Resource group name" "" RESOURCE_GROUP
+
+ # Verify resource group exists
+ if ! az group show --name "${RESOURCE_GROUP}" &>/dev/null; then
+ print_error "Resource group '${RESOURCE_GROUP}' not found"
+ exit 1
+ fi
+ print_success "Resource group verified"
+
+ # Custom Location
+ print_info "Listing custom locations in ${RESOURCE_GROUP}..."
+ az customlocation list --resource-group "${RESOURCE_GROUP}" --query "[].name" -o table
+ echo ""
+ prompt_input "Custom location name" "" CUSTOM_LOCATION
+
+ # Verify custom location exists
+ if ! az customlocation show --name "${CUSTOM_LOCATION}" --resource-group "${RESOURCE_GROUP}" &>/dev/null; then
+ print_error "Custom location '${CUSTOM_LOCATION}' not found"
+ exit 1
+ fi
+ print_success "Custom location verified"
+
+ # Get custom location ID
+ CUSTOM_LOCATION_ID=$(az customlocation show --name "${CUSTOM_LOCATION}" --resource-group "${RESOURCE_GROUP}" --query id -o tsv)
+
+ # ADR Namespace
+ print_info "Listing Device Registry namespaces..."
+ az resource list --resource-type Microsoft.DeviceRegistry/namespaces --query "[].name" -o table
+ echo ""
+ prompt_input "ADR namespace name" "" ADR_NAMESPACE
+
+ # Verify ADR namespace exists
+ local namespace_id
+ namespace_id=$(az resource list --resource-type Microsoft.DeviceRegistry/namespaces --query "[?name=='${ADR_NAMESPACE}'].id" -o tsv)
+
+ if [ -z "${namespace_id}" ]; then
+ print_error "ADR namespace '${ADR_NAMESPACE}' not found"
+ exit 1
+ fi
+ print_success "ADR namespace verified"
+
+ ADR_NAMESPACE_ID="${namespace_id}"
+
+ # Location
+ LOCATION=$(az group show --name "${RESOURCE_GROUP}" --query location -o tsv)
+ print_info "Using location: ${LOCATION}"
+
+ # Environment
+ prompt_input "Environment name" "dev" ENVIRONMENT
+ prompt_input "Instance identifier" "001" INSTANCE
+}
+
+################################################################################
+# Create Kubernetes Secret
+################################################################################
+
+create_kubernetes_secret() {
+ print_header "Creating Kubernetes Secret"
+
+ local secret_name="${CAMERA_NAME}-credentials"
+
+ # Check if secret already exists
+ if kubectl get secret "${secret_name}" -n azure-iot-operations &>/dev/null; then
+ print_warning "Secret '${secret_name}' already exists"
+ if prompt_confirm "Delete and recreate?"; then
+ kubectl delete secret "${secret_name}" -n azure-iot-operations
+ print_success "Existing secret deleted"
+ else
+ print_info "Using existing secret"
+ return 0
+ fi
+ fi
+
+ # Encode credentials
+ local username_b64
+ local password_b64
+ username_b64=$(echo -n "${CAMERA_USERNAME}" | base64 -w 0)
+ password_b64=$(echo -n "${CAMERA_PASSWORD}" | base64 -w 0)
+
+ # Create secret YAML
+ local secret_yaml
+ secret_yaml=$(
+ cat <"${bicepparam_file}" <>"${bicepparam_file}" <<'EOF'
+// PTZ control asset
+param namespacedAssets = [
+ {
+EOF
+ cat >>"${bicepparam_file}" <>"${bicepparam_file}" <<'EOF'
+ datasets: [
+ {
+ name: 'ptz_commands'
+ dataPoints: [
+ {
+ name: 'pan_left'
+ dataSource: 'pan_left'
+ dataPointConfiguration: '{"capability_id":"http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove","pan_tilt":{"x":-0.5,"y":0.0},"zoom":{"x":0.0}}'
+ }
+ {
+ name: 'pan_right'
+ dataSource: 'pan_right'
+ dataPointConfiguration: '{"capability_id":"http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove","pan_tilt":{"x":0.5,"y":0.0},"zoom":{"x":0.0}}'
+ }
+ {
+ name: 'tilt_up'
+ dataSource: 'tilt_up'
+ dataPointConfiguration: '{"capability_id":"http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove","pan_tilt":{"x":0.0,"y":0.5},"zoom":{"x":0.0}}'
+ }
+ {
+ name: 'tilt_down'
+ dataSource: 'tilt_down'
+ dataPointConfiguration: '{"capability_id":"http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove","pan_tilt":{"x":0.0,"y":-0.5},"zoom":{"x":0.0}}'
+ }
+ {
+ name: 'stop'
+ dataSource: 'stop'
+ dataPointConfiguration: '{"capability_id":"http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove","pan_tilt":{"x":0.0,"y":0.0},"zoom":{"x":0.0}}'
+ }
+EOF
+
+ # Add zoom controls if PTZ
+ if [ "${PTZ_TYPE}" = "ptz" ] || [ "${PTZ_TYPE}" = "ptz_home" ]; then
+ cat >>"${bicepparam_file}" <<'EOF'
+ {
+ name: 'zoom_in'
+ dataSource: 'zoom_in'
+ dataPointConfiguration: '{"capability_id":"http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove","pan_tilt":{"x":0.0,"y":0.0},"zoom":{"x":0.5}}'
+ }
+ {
+ name: 'zoom_out'
+ dataSource: 'zoom_out'
+ dataPointConfiguration: '{"capability_id":"http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove","pan_tilt":{"x":0.0,"y":0.0},"zoom":{"x":-0.5}}'
+ }
+EOF
+ fi
+
+ # Add home position if PTZ with home
+ if [ "${PTZ_TYPE}" = "ptz_home" ]; then
+ cat >>"${bicepparam_file}" <<'EOF'
+ {
+ name: 'go_home'
+ dataSource: 'go_home'
+ dataPointConfiguration: '{"capability_id":"http://www.onvif.org/ver20/ptz/wsdl/GotoHomePosition"}'
+ }
+EOF
+ fi
+
+ # Close data points and asset
+ cat >>"${bicepparam_file}" <<'EOF'
+ ]
+ }
+ ]
+ }
+]
+EOF
+ else
+ # No PTZ - empty assets array
+ cat >>"${bicepparam_file}" <<'EOF'
+// No PTZ assets (fixed camera)
+param namespacedAssets = []
+EOF
+ fi
+
+ print_success "Bicep configuration generated: ${bicepparam_file}"
+}
+
+################################################################################
+# Deploy with Bicep
+################################################################################
+
+deploy_bicep() {
+ print_header "Deploying with Bicep"
+
+ cd "${BICEP_DIR}"
+
+ local bicepparam_file="${CAMERA_NAME}-deployment.bicepparam"
+ local deployment_name
+ deployment_name="onvif-camera-${CAMERA_NAME}-$(date +%Y%m%d-%H%M%S)"
+
+ # Validate Bicep
+ print_info "Validating Bicep configuration..."
+ if ! az deployment group validate \
+ --resource-group "${RESOURCE_GROUP}" \
+ --template-file main.bicep \
+ --parameters "${bicepparam_file}" \
+ --no-prompt; then
+ print_error "Bicep validation failed"
+ exit 1
+ fi
+ print_success "Bicep validation passed"
+
+ echo ""
+ if prompt_confirm "Deploy to Azure?"; then
+ print_info "Deploying Bicep configuration..."
+ if az deployment group create \
+ --resource-group "${RESOURCE_GROUP}" \
+ --name "${deployment_name}" \
+ --template-file main.bicep \
+ --parameters "${bicepparam_file}" \
+ --no-prompt; then
+ print_success "Bicep deployment completed successfully!"
+ else
+ print_error "Bicep deployment failed"
+ exit 1
+ fi
+ else
+ print_info "Deployment cancelled"
+ exit 0
+ fi
+}
+
+################################################################################
+# Verify Deployment
+################################################################################
+
+verify_deployment() {
+ print_header "Verifying Deployment"
+
+ # Check device in Azure
+ print_info "Checking device in Azure..."
+ local device_id="${ADR_NAMESPACE_ID}/devices/${CAMERA_NAME}"
+ if az resource show --ids "${device_id}" &>/dev/null; then
+ print_success "Device '${CAMERA_NAME}' found in Azure"
+
+ local provisioning_state
+ provisioning_state=$(az resource show --ids "${device_id}" --query "properties.provisioningState" -o tsv)
+ print_info "Provisioning state: ${provisioning_state}"
+ else
+ print_warning "Device not found in Azure (may take a moment to appear)"
+ fi
+
+ # Check asset in Azure if PTZ
+ if [ "${PTZ_TYPE}" != "none" ]; then
+ print_info "Checking PTZ asset in Azure..."
+ local asset_id="${ADR_NAMESPACE_ID}/assets/${CAMERA_NAME}-ptz"
+ if az resource show --ids "${asset_id}" &>/dev/null; then
+ print_success "Asset '${CAMERA_NAME}-ptz' found in Azure"
+
+ local asset_state
+ asset_state=$(az resource show --ids "${asset_id}" --query "properties.provisioningState" -o tsv)
+ print_info "Asset provisioning state: ${asset_state}"
+ else
+ print_warning "Asset not found in Azure (may take a moment to appear)"
+ fi
+ fi
+
+ # Check ONVIF connector logs
+ print_info "Checking ONVIF connector logs..."
+ if kubectl logs -n azure-iot-operations -l app.kubernetes.io/component=connector --tail=20 2>/dev/null | grep -i "${CAMERA_NAME}"; then
+ print_success "Camera activity found in ONVIF connector logs"
+ else
+ print_warning "No recent camera activity in logs (this may be normal)"
+ fi
+}
+
+################################################################################
+# Display Summary
+################################################################################
+
+display_summary() {
+ print_header "Deployment Summary"
+
+ echo "Camera Deployment Completed!"
+ echo ""
+ echo "Camera Information:"
+ echo " Name: ${CAMERA_NAME}"
+ echo " URL: ${CAMERA_URL}"
+ echo " PTZ Type: ${PTZ_TYPE}"
+ echo ""
+ echo "Azure Resources:"
+ echo " Subscription: ${SUBSCRIPTION_ID}"
+ echo " Resource Group: ${RESOURCE_GROUP}"
+ echo " Custom Location: ${CUSTOM_LOCATION}"
+ echo " ADR Namespace: ${ADR_NAMESPACE}"
+ echo ""
+ echo "Kubernetes:"
+ echo " Secret: ${CAMERA_NAME}-credentials (azure-iot-operations namespace)"
+ echo ""
+ echo "Bicep:"
+ echo " Config: ${BICEP_DIR}/${CAMERA_NAME}-deployment.bicepparam"
+ echo ""
+ echo "Next Steps:"
+ echo " 1. Monitor ONVIF connector logs:"
+ echo " kubectl logs -n azure-iot-operations -l app.kubernetes.io/component=connector -f"
+ echo ""
+ echo " 2. View device in Azure Portal:"
+ echo " https://portal.azure.com/#@/resource${ADR_NAMESPACE_ID}/devices/${CAMERA_NAME}"
+ echo ""
+ if [ "${PTZ_TYPE}" != "none" ]; then
+ echo " 3. Test PTZ control via MQTT (example):"
+ echo " mosquitto_pub -h -t \"cameras/${CAMERA_NAME}/ptz/pan_left\" -m \"1\""
+ echo ""
+ fi
+ echo "For more information, see:"
+ echo " - ONVIF-CAMERA-QUICKSTART.md"
+ echo " - ONVIF-CAMERA-DEPLOYMENT.md"
+ echo ""
+}
+
+################################################################################
+# Main Execution
+################################################################################
+
+main() {
+ print_header "ONVIF Camera Deployment - Bicep"
+
+ echo "This script will deploy an ONVIF camera to Azure IoT Operations"
+ echo "using Bicep and the 111-assets component."
+ echo ""
+
+ if ! prompt_confirm "Continue?"; then
+ echo "Deployment cancelled."
+ exit 0
+ fi
+
+ check_prerequisites
+ gather_camera_info
+ test_onvif_connection
+ gather_azure_info
+ create_kubernetes_secret
+ determine_ptz_capabilities
+ generate_bicep_config
+ deploy_bicep
+ verify_deployment
+ display_summary
+
+ print_success "All done! 🎉"
+}
+
+# Run main function
+main "$@"
diff --git a/src/100-edge/111-assets/scripts/deploy-onvif-camera-terraform.sh b/src/100-edge/111-assets/scripts/deploy-onvif-camera-terraform.sh
new file mode 100755
index 00000000..16b70808
--- /dev/null
+++ b/src/100-edge/111-assets/scripts/deploy-onvif-camera-terraform.sh
@@ -0,0 +1,703 @@
+#!/bin/bash
+################################################################################
+# ONVIF Camera Deployment Script - Terraform
+#
+# This script automates the deployment of ONVIF cameras to Azure IoT Operations
+# using Terraform and the 111-assets component.
+#
+# Prerequisites:
+# - Azure IoT Operations deployed and running
+# - ONVIF Connector installed
+# - Camera accessible on network
+# - Azure CLI installed and authenticated
+# - kubectl configured for your cluster
+# - Terraform installed
+#
+# Usage:
+# ./deploy-onvif-camera-terraform.sh
+################################################################################
+
+set -e
+
+# Color codes for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Script directory
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+COMPONENT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+TERRAFORM_DIR="${COMPONENT_DIR}/terraform"
+
+################################################################################
+# Helper Functions
+################################################################################
+
+print_header() {
+ echo ""
+ echo -e "${BLUE}========================================${NC}"
+ echo -e "${BLUE}$1${NC}"
+ echo -e "${BLUE}========================================${NC}"
+ echo ""
+}
+
+print_success() {
+ echo -e "${GREEN}✓ $1${NC}"
+}
+
+print_error() {
+ echo -e "${RED}✗ $1${NC}"
+}
+
+print_warning() {
+ echo -e "${YELLOW}⚠ $1${NC}"
+}
+
+print_info() {
+ echo -e "${BLUE}ℹ $1${NC}"
+}
+
+prompt_input() {
+ local prompt="$1"
+ local default="$2"
+ local var_name="$3"
+
+ if [ -n "$default" ]; then
+ read -r -p "$(echo -e "${YELLOW}${prompt}${NC} [${default}]: ")" value
+ value="${value:-$default}"
+ else
+ read -r -p "$(echo -e "${YELLOW}${prompt}${NC}: ")" value
+ fi
+
+ eval "$var_name='$value'"
+}
+
+prompt_confirm() {
+ local prompt="$1"
+ read -r -p "$(echo -e "${YELLOW}${prompt}${NC} (y/n): ")" -n 1 -r
+ echo
+ [[ $REPLY =~ ^[Yy]$ ]]
+}
+
+################################################################################
+# Prerequisite Checks
+################################################################################
+
+check_prerequisites() {
+ print_header "Checking Prerequisites"
+
+ local missing_tools=()
+
+ # Check Azure CLI
+ if ! command -v az &>/dev/null; then
+ missing_tools+=("Azure CLI (az)")
+ else
+ print_success "Azure CLI installed"
+ fi
+
+ # Check kubectl
+ if ! command -v kubectl &>/dev/null; then
+ missing_tools+=("kubectl")
+ else
+ print_success "kubectl installed"
+ fi
+
+ # Check Terraform
+ if ! command -v terraform &>/dev/null; then
+ missing_tools+=("Terraform")
+ else
+ print_success "Terraform installed ($(terraform version -json | jq -r '.terraform_version'))"
+ fi
+
+ # Check base64
+ if ! command -v base64 &>/dev/null; then
+ missing_tools+=("base64")
+ else
+ print_success "base64 installed"
+ fi
+
+ if [ ${#missing_tools[@]} -gt 0 ]; then
+ print_error "Missing required tools:"
+ for tool in "${missing_tools[@]}"; do
+ echo " - $tool"
+ done
+ exit 1
+ fi
+
+ # Check Azure CLI authentication
+ if ! az account show &>/dev/null; then
+ print_error "Azure CLI not authenticated. Run 'az login' first."
+ exit 1
+ else
+ print_success "Azure CLI authenticated"
+ fi
+
+ # Check kubectl cluster access
+ if ! kubectl cluster-info &>/dev/null; then
+ print_error "kubectl not configured for cluster access"
+ exit 1
+ else
+ print_success "kubectl cluster access confirmed"
+ fi
+}
+
+################################################################################
+# Gather Camera Information
+################################################################################
+
+gather_camera_info() {
+ print_header "Camera Information"
+
+ print_info "Provide information about your ONVIF camera."
+ echo ""
+
+ prompt_input "Camera name (unique identifier)" "camera-01" CAMERA_NAME
+ prompt_input "Camera IP address" "192.168.1.100" CAMERA_IP
+ prompt_input "ONVIF port" "80" CAMERA_PORT
+ prompt_input "ONVIF path" "/onvif/device_service" CAMERA_PATH
+ prompt_input "Camera username" "admin" CAMERA_USERNAME
+ prompt_input "Camera password" "" CAMERA_PASSWORD
+
+ # Construct full ONVIF URL
+ CAMERA_URL="http://${CAMERA_IP}:${CAMERA_PORT}${CAMERA_PATH}"
+
+ echo ""
+ print_info "Camera Configuration Summary:"
+ echo " Name: ${CAMERA_NAME}"
+ echo " ONVIF URL: ${CAMERA_URL}"
+ echo " Username: ${CAMERA_USERNAME}"
+ echo " Password: ********"
+ echo ""
+}
+
+################################################################################
+# Test ONVIF Connectivity
+################################################################################
+
+test_onvif_connection() {
+ print_header "Testing ONVIF Connection"
+
+ print_info "Testing connection to ${CAMERA_URL}..."
+
+ local soap_request=''
+
+ if curl -s -X POST "${CAMERA_URL}" \
+ -H "Content-Type: application/soap+xml" \
+ -d "${soap_request}" \
+ --max-time 10 \
+ -o /dev/null -w "%{http_code}" | grep -q "200"; then
+ print_success "ONVIF endpoint responding"
+ else
+ print_warning "ONVIF endpoint test failed - continuing anyway"
+ print_info "Camera may still work if credentials are correct"
+ fi
+}
+
+################################################################################
+# Gather Azure Information
+################################################################################
+
+gather_azure_info() {
+ print_header "Azure Resource Information"
+
+ # Get current subscription
+ SUBSCRIPTION_ID=$(az account show --query id -o tsv)
+ print_info "Current subscription: ${SUBSCRIPTION_ID}"
+
+ if ! prompt_confirm "Use this subscription?"; then
+ prompt_input "Enter subscription ID" "" SUBSCRIPTION_ID
+ az account set --subscription "${SUBSCRIPTION_ID}"
+ fi
+
+ # Resource Group
+ print_info "Listing resource groups..."
+ az group list --query "[].name" -o table
+ echo ""
+ prompt_input "Resource group name" "" RESOURCE_GROUP
+
+ # Verify resource group exists
+ if ! az group show --name "${RESOURCE_GROUP}" &>/dev/null; then
+ print_error "Resource group '${RESOURCE_GROUP}' not found"
+ exit 1
+ fi
+ print_success "Resource group verified"
+
+ # Get resource group ID
+ RESOURCE_GROUP_ID=$(az group show --name "${RESOURCE_GROUP}" --query id -o tsv)
+
+ # Custom Location
+ print_info "Listing custom locations in ${RESOURCE_GROUP}..."
+ az customlocation list --resource-group "${RESOURCE_GROUP}" --query "[].name" -o table
+ echo ""
+ prompt_input "Custom location name" "" CUSTOM_LOCATION
+
+ # Verify custom location exists
+ if ! az customlocation show --name "${CUSTOM_LOCATION}" --resource-group "${RESOURCE_GROUP}" &>/dev/null; then
+ print_error "Custom location '${CUSTOM_LOCATION}' not found"
+ exit 1
+ fi
+ print_success "Custom location verified"
+
+ # Get custom location ID
+ CUSTOM_LOCATION_ID=$(az customlocation show --name "${CUSTOM_LOCATION}" --resource-group "${RESOURCE_GROUP}" --query id -o tsv)
+
+ # ADR Namespace
+ print_info "Listing Device Registry namespaces..."
+ az resource list --resource-type Microsoft.DeviceRegistry/namespaces --query "[].name" -o table
+ echo ""
+ prompt_input "ADR namespace name" "" ADR_NAMESPACE
+
+ # Get ADR namespace ID
+ ADR_NAMESPACE_ID=$(az resource list --resource-type Microsoft.DeviceRegistry/namespaces --query "[?name=='${ADR_NAMESPACE}'].id" -o tsv)
+
+ if [ -z "${ADR_NAMESPACE_ID}" ]; then
+ print_error "ADR namespace '${ADR_NAMESPACE}' not found"
+ exit 1
+ fi
+ print_success "ADR namespace verified"
+
+ # Location
+ LOCATION=$(az group show --name "${RESOURCE_GROUP}" --query location -o tsv)
+ print_info "Using location: ${LOCATION}"
+}
+
+################################################################################
+# Create Kubernetes Secret
+################################################################################
+
+create_kubernetes_secret() {
+ print_header "Creating Kubernetes Secret"
+
+ local secret_name="${CAMERA_NAME}-credentials"
+
+ # Check if secret already exists
+ if kubectl get secret "${secret_name}" -n azure-iot-operations &>/dev/null; then
+ print_warning "Secret '${secret_name}' already exists"
+ if prompt_confirm "Delete and recreate?"; then
+ kubectl delete secret "${secret_name}" -n azure-iot-operations
+ print_success "Existing secret deleted"
+ else
+ print_info "Using existing secret"
+ return 0
+ fi
+ fi
+
+ # Encode credentials
+ local username_b64
+ username_b64=$(echo -n "${CAMERA_USERNAME}" | base64 -w 0)
+ local password_b64
+ password_b64=$(echo -n "${CAMERA_PASSWORD}" | base64 -w 0)
+
+ # Create secret YAML
+ local secret_yaml
+ secret_yaml=$(
+ cat <"${tfvars_file}" <>"${tfvars_file}" <>"${tfvars_file}" <>"${tfvars_file}" <>"${tfvars_file}" <>"${tfvars_file}" </dev/null; then
+ print_success "Device '${CAMERA_NAME}' found in Azure"
+
+ local provisioning_state
+ provisioning_state=$(az resource show --ids "${device_id}" --query "properties.provisioningState" -o tsv)
+ print_info "Provisioning state: ${provisioning_state}"
+ else
+ print_warning "Device not found in Azure (may take a moment to appear)"
+ fi
+
+ # Check asset in Azure if PTZ
+ if [ "${PTZ_TYPE}" != "none" ]; then
+ print_info "Checking PTZ asset in Azure..."
+ local asset_id="${ADR_NAMESPACE_ID}/assets/${CAMERA_NAME}-ptz"
+ if az resource show --ids "${asset_id}" &>/dev/null; then
+ print_success "Asset '${CAMERA_NAME}-ptz' found in Azure"
+
+ local asset_state
+ asset_state=$(az resource show --ids "${asset_id}" --query "properties.provisioningState" -o tsv)
+ print_info "Asset provisioning state: ${asset_state}"
+ else
+ print_warning "Asset not found in Azure (may take a moment to appear)"
+ fi
+ fi
+
+ # Check ONVIF connector logs
+ print_info "Checking ONVIF connector logs..."
+ if kubectl logs -n azure-iot-operations -l app.kubernetes.io/component=connector --tail=20 2>/dev/null | grep -i "${CAMERA_NAME}"; then
+ print_success "Camera activity found in ONVIF connector logs"
+ else
+ print_warning "No recent camera activity in logs (this may be normal)"
+ fi
+}
+
+################################################################################
+# Display Summary
+################################################################################
+
+display_summary() {
+ print_header "Deployment Summary"
+
+ echo "Camera Deployment Completed!"
+ echo ""
+ echo "Camera Information:"
+ echo " Name: ${CAMERA_NAME}"
+ echo " URL: ${CAMERA_URL}"
+ echo " PTZ Type: ${PTZ_TYPE}"
+ echo ""
+ echo "Azure Resources:"
+ echo " Subscription: ${SUBSCRIPTION_ID}"
+ echo " Resource Group: ${RESOURCE_GROUP}"
+ echo " Custom Location: ${CUSTOM_LOCATION}"
+ echo " ADR Namespace: ${ADR_NAMESPACE}"
+ echo ""
+ echo "Kubernetes:"
+ echo " Secret: ${CAMERA_NAME}-credentials (azure-iot-operations namespace)"
+ echo ""
+ echo "Terraform:"
+ echo " Config: ${TERRAFORM_DIR}/${CAMERA_NAME}-deployment.tfvars"
+ echo ""
+ echo "Next Steps:"
+ echo " 1. Monitor ONVIF connector logs:"
+ echo " kubectl logs -n azure-iot-operations -l app.kubernetes.io/component=connector -f"
+ echo ""
+ echo " 2. View device in Azure Portal:"
+ echo " https://portal.azure.com/#@/resource${ADR_NAMESPACE_ID}/devices/${CAMERA_NAME}"
+ echo ""
+ if [ "${PTZ_TYPE}" != "none" ]; then
+ echo " 3. Test PTZ control via MQTT (example):"
+ echo " mosquitto_pub -h -t \"cameras/${CAMERA_NAME}/ptz/pan_left\" -m \"1\""
+ echo ""
+ fi
+ echo "For more information, see:"
+ echo " - ONVIF-CAMERA-QUICKSTART.md"
+ echo " - ONVIF-CAMERA-DEPLOYMENT.md"
+ echo ""
+}
+
+################################################################################
+# Main Execution
+################################################################################
+
+main() {
+ print_header "ONVIF Camera Deployment - Terraform"
+
+ echo "This script will deploy an ONVIF camera to Azure IoT Operations"
+ echo "using Terraform and the 111-assets component."
+ echo ""
+
+ if ! prompt_confirm "Continue?"; then
+ echo "Deployment cancelled."
+ exit 0
+ fi
+
+ check_prerequisites
+ gather_camera_info
+ test_onvif_connection
+ gather_azure_info
+ create_kubernetes_secret
+ determine_ptz_capabilities
+ generate_terraform_config
+ deploy_terraform
+ verify_deployment
+ display_summary
+
+ print_success "All done! 🎉"
+}
+
+# Run main function
+main "$@"
diff --git a/src/100-edge/111-assets/scripts/discover-ptz-profile.sh b/src/100-edge/111-assets/scripts/discover-ptz-profile.sh
new file mode 100755
index 00000000..87c7e71c
--- /dev/null
+++ b/src/100-edge/111-assets/scripts/discover-ptz-profile.sh
@@ -0,0 +1,147 @@
+#!/usr/bin/env bash
+
+##
+## Discovers PTZ profile token for an ONVIF camera
+##
+## This script queries the camera's GetProfiles endpoint and extracts
+## the profile token(s) that can be used for PTZ commands.
+##
+## Examples:
+## CAMERA_IP=192.168.1.100 CAMERA_USERNAME=admin CAMERA_PASSWORD="your_password" ./discover-ptz-profile.sh
+##
+###
+
+set -e
+
+# Configuration
+CAMERA_IP="${CAMERA_IP:-}"
+CAMERA_PORT="${CAMERA_PORT:-80}"
+CAMERA_USERNAME="${CAMERA_USERNAME:-}"
+CAMERA_PASSWORD="${CAMERA_PASSWORD:-}"
+
+# Check required parameters
+if [[ -z "$CAMERA_IP" ]] || [[ -z "$CAMERA_USERNAME" ]] || [[ -z "$CAMERA_PASSWORD" ]]; then
+ echo "❌ Required environment variables missing"
+ echo ""
+ echo "Usage:"
+ echo " CAMERA_IP=192.168.1.100 CAMERA_USERNAME=admin CAMERA_PASSWORD='your_password' ./discover-ptz-profile.sh"
+ echo ""
+ echo "Required environment variables:"
+ echo " CAMERA_IP - Camera IP address (e.g., 192.168.1.100)"
+ echo " CAMERA_USERNAME - Camera username (e.g., admin)"
+ echo " CAMERA_PASSWORD - Camera password"
+ echo ""
+ echo "Optional environment variables:"
+ echo " CAMERA_PORT (default: 80)"
+ exit 1
+fi
+
+CAMERA_URL="http://${CAMERA_IP}:${CAMERA_PORT}/onvif/device_service"
+
+echo "=========================================="
+echo "ONVIF PTZ Profile Discovery Tool"
+echo "=========================================="
+echo ""
+echo "Camera: ${CAMERA_IP}:${CAMERA_PORT}"
+echo "User: ${CAMERA_USERNAME}"
+echo ""
+
+echo "📡 Querying camera for media profiles..."
+echo ""
+
+# Query GetProfiles
+response=$(curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+
+')
+
+# Save raw response
+echo "$response" >/tmp/onvif-profiles-response.xml
+echo "✅ Raw response saved to: /tmp/onvif-profiles-response.xml"
+echo ""
+
+# Check for SOAP fault
+if echo "$response" | grep -q "SOAP-ENV:Fault"; then
+ echo "❌ Camera returned an error:"
+ echo "$response" | grep -A 5 "SOAP-ENV:Text" || echo "$response"
+ exit 1
+fi
+
+# Try to extract profile tokens (multiple patterns for different cameras)
+echo "🔍 Extracting profile tokens..."
+echo ""
+
+# Pattern 1: token attribute
+tokens=$(echo "$response" | grep -oP 'token="[^"]*"' | cut -d'"' -f2)
+
+# Pattern 2: Profile name or token in elements
+if [[ -z "$tokens" ]]; then
+ tokens=$(echo "$response" | grep -oP '<[^>]*token>[^<]*' | sed 's/<[^>]*>//')
+fi
+
+# Pattern 3: ProfileToken element
+if [[ -z "$tokens" ]]; then
+ tokens=$(echo "$response" | grep -oP '[^<]*' | sed 's///')
+fi
+
+if [[ -n "$tokens" ]]; then
+ echo "✅ Found profile token(s):"
+ echo ""
+ while IFS= read -r token; do
+ if [[ -n "$token" ]]; then
+ echo " 🎯 ProfileToken: $token"
+ fi
+ done <<<"$tokens"
+ echo ""
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo "Next Steps:"
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ echo ""
+ echo "1. Copy one of the profile tokens above"
+ echo "2. Update test-camera-ptz.sh with the correct token"
+ echo "3. Run the PTZ test script"
+ echo ""
+ echo "Example test command:"
+ first_token=$(echo "$tokens" | head -n 1 | xargs)
+ if [[ -n "$first_token" ]]; then
+ echo ""
+ echo " curl --digest -s -X POST \"${CAMERA_URL}\" \\"
+ echo " -u \"${CAMERA_USERNAME}:${CAMERA_PASSWORD}\" \\"
+ echo " -H \"Content-Type: application/soap+xml\" \\"
+ echo " -d '"
+ echo ""
+ echo " "
+ echo " "
+ echo " ${first_token}"
+ echo " "
+ echo " "
+ echo " "
+ echo " "
+ echo " "
+ echo "'"
+ echo ""
+ fi
+else
+ echo "⚠️ Could not extract profile tokens automatically"
+ echo ""
+ echo "Please check /tmp/onvif-profiles-response.xml manually"
+ echo ""
+ echo "Look for patterns like:"
+ echo " - token=\"...\" attribute"
+ echo " - ... element"
+ echo " - elements with PTZ configuration"
+ echo ""
+ echo "Common search commands:"
+ echo " cat /tmp/onvif-profiles-response.xml | grep -i profile"
+ echo " cat /tmp/onvif-profiles-response.xml | grep -i token"
+ echo " cat /tmp/onvif-profiles-response.xml | grep -i ptz"
+ echo ""
+fi
+
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
diff --git a/src/100-edge/111-assets/scripts/quick-ptz-test.sh b/src/100-edge/111-assets/scripts/quick-ptz-test.sh
new file mode 100755
index 00000000..aa4467b9
--- /dev/null
+++ b/src/100-edge/111-assets/scripts/quick-ptz-test.sh
@@ -0,0 +1,265 @@
+#!/bin/bash
+# shellcheck disable=SC2016
+################################################################################
+# Quick PTZ Test for ONVIF Camera
+#
+# This script performs a quick PTZ test on your ONVIF camera.
+# You can provide credentials via environment variables or Kubernetes secret.
+#
+# Usage:
+# CAMERA_IP=192.168.1.100 CAMERA_USERNAME=admin CAMERA_PASSWORD='pass' ./quick-ptz-test.sh
+# Or set K8S_SECRET_NAME to load from Kubernetes
+################################################################################
+
+set -e
+
+# Camera details - all required
+CAMERA_IP="${CAMERA_IP:-}"
+CAMERA_PORT="${CAMERA_PORT:-80}"
+CAMERA_USERNAME="${CAMERA_USERNAME:-}"
+CAMERA_PASSWORD="${CAMERA_PASSWORD:-}"
+PROFILE_TOKEN="${PROFILE_TOKEN:-MainStream}"
+K8S_SECRET_NAME="${K8S_SECRET_NAME:-}"
+K8S_NAMESPACE="${K8S_NAMESPACE:-azure-iot-operations}"
+
+# Try to load from Kubernetes secret if specified
+if [[ -n "$K8S_SECRET_NAME" ]]; then
+ echo "🔑 Loading credentials from Kubernetes secret: ${K8S_SECRET_NAME}..."
+ CAMERA_USERNAME=$(kubectl get secret "${K8S_SECRET_NAME}" -n "${K8S_NAMESPACE}" -o jsonpath='{.data.username}' 2>/dev/null | base64 -d || echo "")
+ CAMERA_PASSWORD=$(kubectl get secret "${K8S_SECRET_NAME}" -n "${K8S_NAMESPACE}" -o jsonpath='{.data.password}' 2>/dev/null | base64 -d || echo "")
+
+ if [[ -n "$CAMERA_USERNAME" ]] && [[ -n "$CAMERA_PASSWORD" ]]; then
+ echo "✅ Credentials loaded from secret"
+ else
+ echo "⚠️ Warning: Could not load credentials from secret"
+ fi
+fi
+
+# Validate required parameters
+if [[ -z "$CAMERA_IP" ]] || [[ -z "$CAMERA_USERNAME" ]] || [[ -z "$CAMERA_PASSWORD" ]]; then
+ echo "❌ Required parameters missing"
+ echo ""
+ echo "Usage:"
+ echo " CAMERA_IP=192.168.1.100 CAMERA_USERNAME=admin CAMERA_PASSWORD='pass' ./quick-ptz-test.sh"
+ echo ""
+ echo "Or load from Kubernetes secret:"
+ echo " CAMERA_IP=192.168.1.100 K8S_SECRET_NAME=camera-credentials ./quick-ptz-test.sh"
+ echo ""
+ echo "Required environment variables:"
+ echo " CAMERA_IP - Camera IP address"
+ echo " CAMERA_USERNAME - Camera username"
+ echo " CAMERA_PASSWORD - Camera password"
+ echo ""
+ echo "Optional environment variables:"
+ echo " CAMERA_PORT - ONVIF port (default: 80)"
+ echo " PROFILE_TOKEN - PTZ profile token (default: MainStream)"
+ echo " K8S_SECRET_NAME - Kubernetes secret name to load credentials from"
+ echo " K8S_NAMESPACE - Kubernetes namespace (default: azure-iot-operations)"
+ exit 1
+fi
+
+CAMERA_URL="http://${CAMERA_IP}:${CAMERA_PORT}/onvif/device_service"
+
+echo ""
+echo "🎥 Testing ONVIF Camera PTZ at ${CAMERA_IP}:${CAMERA_PORT}"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo ""
+
+# Test 1: Pan Right
+echo "📹 Test 1: Panning Right (2 seconds)..."
+curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ ${PROFILE_TOKEN}
+
+
+
+
+
+' \
+ --max-time 5 >/tmp/ptz-response.xml 2>&1
+
+if grep -q "ContinuousMoveResponse\|HTTP" /tmp/ptz-response.xml; then
+ echo " ✅ Command sent successfully"
+else
+ echo " ⚠️ Response: $(grep -o "SOAP-ENV:Text[^<]*" /tmp/ptz-response.xml | head -1)"
+fi
+
+sleep 2
+
+# Stop
+echo "🛑 Stopping movement..."
+curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ ${PROFILE_TOKEN}
+ true
+ true
+
+
+' \
+ --max-time 5 >/dev/null 2>&1
+
+sleep 1
+
+# Test 2: Pan Left
+echo "📹 Test 2: Panning Left (2 seconds)..."
+curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ ${PROFILE_TOKEN}
+
+
+
+
+
+' \
+ --max-time 5 >/tmp/ptz-response.xml 2>&1
+
+if grep -q "ContinuousMoveResponse\|HTTP" /tmp/ptz-response.xml; then
+ echo " ✅ Command sent successfully"
+else
+ echo " ⚠️ Response: $(grep -o "SOAP-ENV:Text[^<]*" /tmp/ptz-response.xml | head -1)"
+fi
+
+sleep 2
+
+# Stop
+curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ ${PROFILE_TOKEN}
+ true
+ true
+
+
+' \
+ --max-time 5 >/dev/null 2>&1
+
+sleep 1
+
+# Test 3: Tilt Up
+echo "📹 Test 3: Tilting Up (2 seconds)..."
+curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ ${PROFILE_TOKEN}
+
+
+
+
+
+' \
+ --max-time 5 >/tmp/ptz-response.xml 2>&1
+
+if grep -q "ContinuousMoveResponse\|HTTP" /tmp/ptz-response.xml; then
+ echo " ✅ Command sent successfully"
+else
+ echo " ⚠️ Response: $(grep -o "SOAP-ENV:Text[^<]*" /tmp/ptz-response.xml | head -1)"
+fi
+
+sleep 2
+
+# Stop
+curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ ${PROFILE_TOKEN}
+ true
+ true
+
+
+' \
+ --max-time 5 >/dev/null 2>&1
+
+sleep 1
+
+# Test 4: Tilt Down
+echo "📹 Test 4: Tilting Down (2 seconds)..."
+curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ ${PROFILE_TOKEN}
+
+
+
+
+
+' \
+ --max-time 5 >/tmp/ptz-response.xml 2>&1
+
+if grep -q "ContinuousMoveResponse\|HTTP" /tmp/ptz-response.xml; then
+ echo " ✅ Command sent successfully"
+else
+ echo " ⚠️ Response: $(grep -o "SOAP-ENV:Text[^<]*" /tmp/ptz-response.xml | head -1)"
+fi
+
+sleep 2
+
+# Final Stop
+curl --digest -s -X POST "${CAMERA_URL}" \
+ -u "${CAMERA_USERNAME}:${CAMERA_PASSWORD}" \
+ -H "Content-Type: application/soap+xml" \
+ -d '
+
+
+
+ ${PROFILE_TOKEN}
+ true
+ true
+
+
+' \
+ --max-time 5 >/dev/null 2>&1
+
+echo ""
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo "✅ PTZ Test Complete!"
+echo ""
+echo "Did your camera move during the tests?"
+echo " - Pan Right (first test)"
+echo " - Pan Left (second test)"
+echo " - Tilt Up (third test)"
+echo " - Tilt Down (fourth test)"
+echo ""
+echo "If the camera didn't move, check:"
+echo " 1. Camera password is correct in the Kubernetes secret"
+echo " 2. Camera PTZ is enabled in camera settings"
+echo " 3. Response messages above for errors"
+echo ""
diff --git a/src/500-application/503-media-capture-service/README.md b/src/500-application/503-media-capture-service/README.md
index d381da5a..7edb1ebc 100644
--- a/src/500-application/503-media-capture-service/README.md
+++ b/src/500-application/503-media-capture-service/README.md
@@ -297,6 +297,91 @@ helm install media-capture-service . --dry-run --debug
helm lint .
```
+#### Multi-Camera Deployments
+
+**Important**: Each media-capture-service deployment is configured for a **single camera**. To record from multiple cameras, deploy **separate instances** (not replicas).
+
+**Why Not Use Replicas?**
+
+Increasing replicas (e.g., `--set replicaCount=2`) for the same deployment will:
+
+- ❌ Create duplicate recordings of the same camera
+- ❌ Waste storage with identical video streams
+- ❌ Potentially cause race conditions when writing to the same paths
+- ❌ Not distribute load across multiple cameras
+
+### Correct Approach: Separate Helm Releases
+
+Deploy one Helm release per camera with unique configurations:
+
+```bash
+# Camera 1 - Front Entrance
+helm install media-capture-camera-01 ./charts/media-capture-service \
+ --namespace azure-iot-operations \
+ --set mediaCapture.continuousRecording.cameraId=camera-01 \
+ --set mediaCapture.video.rtspUrl=rtsp://camera-01.local:8554/live \
+ --set mediaCapture.continuousRecording.cameraLocation=front-entrance
+
+# Camera 2 - Loading Dock
+helm install media-capture-camera-02 ./charts/media-capture-service \
+ --namespace azure-iot-operations \
+ --set mediaCapture.continuousRecording.cameraId=camera-02 \
+ --set mediaCapture.video.rtspUrl=rtsp://camera-02.local:8554/live \
+ --set mediaCapture.continuousRecording.cameraLocation=loading-dock
+
+# Camera 3 - Warehouse
+helm install media-capture-camera-03 ./charts/media-capture-service \
+ --namespace azure-iot-operations \
+ --set mediaCapture.continuousRecording.cameraId=camera-03 \
+ --set mediaCapture.video.rtspUrl=rtsp://camera-03.local:8554/live \
+ --set mediaCapture.continuousRecording.cameraLocation=warehouse
+```
+
+**Benefits of Separate Deployments:**
+
+- ✅ Each camera records independently to unique storage paths
+- ✅ Different camera configurations (resolution, FPS, retention)
+- ✅ Independent scaling and resource allocation
+- ✅ Isolated failures (one camera failure doesn't affect others)
+- ✅ Camera-specific monitoring and troubleshooting
+
+**Managing Multiple Deployments:**
+
+```bash
+# List all media capture deployments
+helm list -n azure-iot-operations | grep media-capture
+
+# Update specific camera configuration
+helm upgrade media-capture-camera-01 ./charts/media-capture-service \
+ --namespace azure-iot-operations \
+ --reuse-values \
+ --set mediaCapture.continuousRecording.segmentDurationSeconds=600
+
+# Remove specific camera deployment
+helm uninstall media-capture-camera-02 -n azure-iot-operations
+
+# View logs for specific camera
+kubectl logs -n azure-iot-operations -l app.kubernetes.io/instance=media-capture-camera-01
+```
+
+**Storage Organization:**
+
+Each camera automatically organizes recordings by camera ID:
+
+```text
+/cloud-sync/video-recordings/
+├── camera-01/
+│ └── 2026/01/13/21/
+│ ├── segment_2026-01-13T21:00:00Z_camera-01.mp4
+│ └── segment_2026-01-13T21:05:00Z_camera-01.mp4
+├── camera-02/
+│ └── 2026/01/13/21/
+│ └── segment_2026-01-13T21:00:00Z_camera-02.mp4
+└── camera-03/
+ └── 2026/01/13/21/
+ └── segment_2026-01-13T21:00:00Z_camera-03.mp4
+```
+
### Manual Deployment (Advanced)
For advanced users requiring custom configuration or legacy environments:
@@ -584,13 +669,13 @@ The service uses sophisticated timing to extract relevant video segments:
2. **Check Storage Account Access**:
```bash
- az storage container show --account-name $STORAGE_ACCOUNT_NAME --name media --auth-mode login
+ az storage container show --account-name $STORAGE_ACCOUNT_NAME --name video-recordings --auth-mode login
```
3. **Monitor File Sync**:
```bash
- kubectl exec -it deployment/media-capture-service -n azure-iot-operations -- ls -la /cloud-sync/media/
+ kubectl exec -it deployment/media-capture-service -n azure-iot-operations -- ls -la /cloud-sync/video-recordings/
```
4. **Verify Cloud Storage Integration**:
@@ -600,9 +685,198 @@ The service uses sophisticated timing to extract relevant video segments:
kubectl logs -l app.kubernetes.io/name=media-capture-service -n azure-iot-operations
# Verify files in Azure Storage (using Azure CLI)
- az storage blob list --account-name $STORAGE_ACCOUNT_NAME --container-name media --auth-mode login
+ az storage blob list --account-name $STORAGE_ACCOUNT_NAME --container-name video-recordings --auth-mode login
```
+### Cloud Storage Sync Troubleshooting
+
+When video recordings are not appearing in Azure Blob Storage, follow this diagnostic workflow:
+
+#### 1. Verify Service is Running and Recording
+
+Check if the media-capture-service pod is running and actively recording:
+
+```bash
+# Check pod status
+kubectl get pods -n azure-iot-operations -l app.kubernetes.io/name=media-capture-service
+
+# Check if service is scaled up (should show 1/1 replicas)
+kubectl get deployment media-capture-service -n azure-iot-operations
+
+# If scaled down (0/0), scale up to enable recording
+kubectl scale deployment media-capture-service -n azure-iot-operations --replicas=1
+```
+
+#### 2. Check Local Recording on Pod
+
+Verify files are being created locally before checking cloud sync:
+
+```bash
+# Get pod name
+POD_NAME=$(kubectl get pods -n azure-iot-operations -l app.kubernetes.io/name=media-capture-service -o jsonpath='{.items[0].metadata.name}')
+
+# Check if files are being written to local storage
+kubectl exec -n azure-iot-operations $POD_NAME -- ls -lh /cloud-sync/video-recordings/
+
+# Check for recent files (adjust camera ID as needed)
+kubectl exec -n azure-iot-operations $POD_NAME -- find /cloud-sync/video-recordings/ -name "*.mp4" -o -name "*.mkv" -mmin -10
+
+# Check total storage usage
+kubectl exec -n azure-iot-operations $POD_NAME -- du -sh /cloud-sync/video-recordings/
+```
+
+#### 3. Review Service Logs
+
+Check for recording activity and errors:
+
+```bash
+# View recent logs
+kubectl logs -n azure-iot-operations $POD_NAME --tail=100
+
+# Follow logs in real-time
+kubectl logs -n azure-iot-operations $POD_NAME --follow
+
+# Look for specific events
+kubectl logs -n azure-iot-operations $POD_NAME | grep -E "(Recording started|segment saved|error|failed)"
+```
+
+#### 4. Understand ACSA Sync Behavior
+
+**Important**: Files written to the ACSA-backed PVC are **not synced instantly** to Azure Blob Storage.
+
+- **Sync Delay**: Can take several minutes depending on file size and network conditions
+- **Local First**: Files are written to local PVC first, then synced asynchronously by ACSA
+- **No Immediate Visibility**: Recent files may not appear in Azure Portal or CLI immediately
+
+```bash
+# Check ACSA PVC status
+kubectl get pvc -n azure-iot-operations | grep -E "(NAME|cloud-backed)"
+
+# Verify PVC is bound and using cloud-backed storage class
+kubectl describe pvc pvc-acsa-cloud-backed -n azure-iot-operations
+```
+
+#### 5. Verify Storage Account Network Access
+
+The storage account may have network restrictions preventing access from certain locations:
+
+```bash
+# Check storage account network settings
+az storage account show --name $STORAGE_ACCOUNT_NAME \
+ --query "{publicNetworkAccess:publicNetworkAccess,allowSharedKeyAccess:allowSharedKeyAccess}" \
+ --output table
+
+# If publicNetworkAccess is Disabled and you don't have private connectivity configured,
+# ACSA uploads can fail with 403 AuthorizationFailure.
+# Use Azure CLI with auth-mode login to verify from an allowed network.
+az storage blob list --account-name $STORAGE_ACCOUNT_NAME \
+ --container-name video-recordings \
+ --auth-mode login \
+ --output table
+```
+
+**Common Network Issues**:
+
+- `publicNetworkAccess: Disabled` can block both Azure Portal browsing and ACSA edge-to-cloud uploads
+- IP allowlists may block your current location
+- Use `--auth-mode login` with Azure CLI to verify access (subject to network rules)
+
+#### 6. Check ACSA Sync Controller Logs
+
+If files exist locally but never appear in cloud storage, check ACSA sync status:
+
+```bash
+# Check Arc Container Storage pods (operator + per-PVC worker)
+kubectl get pods -n azure-arc-containerstorage
+
+# Check EdgeSubvolume health and backlog
+kubectl get edgesubvolumes -A
+kubectl get edgesubvolume media -o yaml
+
+# Tail logs from the per-PVC worker pod (datamover container)
+WORKER_POD=$(kubectl get pods -n azure-arc-containerstorage -o name | grep '^pod/w-pvc-acsa-cloud-backed' | head -n 1 | cut -d/ -f 2)
+kubectl logs -n azure-arc-containerstorage $WORKER_POD -c datamover --tail=200
+```
+
+#### 7. Verify Storage Account Credentials
+
+Ensure ACSA has proper credentials to access the storage account:
+
+```bash
+# ACSA uses the Azure Arc Container Storage extension managed identity.
+# If EdgeSubvolume status shows AuthorizationFailure, validate the extension identity has
+# Storage Blob Data Contributor on the target storage account.
+
+# Inspect EdgeSubvolume errors (look for AuthorizationFailure)
+kubectl get edgesubvolume media -o jsonpath='{.status.fileErrors[0].error}' && echo
+```
+
+#### 8. Test End-to-End Sync
+
+Force a test recording and monitor sync:
+
+```bash
+# Trigger a manual recording by publishing MQTT message (if configured)
+# Or wait for scheduled continuous recording segments
+
+# Monitor file creation locally
+watch -n 5 "kubectl exec -n azure-iot-operations $POD_NAME -- find /cloud-sync/video-recordings/ -name '*.mp4' -o -name '*.mkv' -mmin -5 | wc -l"
+
+# Wait 3-5 minutes, then check Azure storage
+az storage blob list --account-name $STORAGE_ACCOUNT_NAME \
+ --container-name video-recordings \
+ --auth-mode login \
+ --query "[?properties.lastModified >= '$(date -u -d '5 minutes ago' +%Y-%m-%dT%H:%M:%SZ)'].{name:name, size:properties.contentLength, modified:properties.lastModified}" \
+ --output table
+```
+
+#### Common Issues and Solutions
+
+| Issue | Symptoms | Solution |
+|-------------------------------------|---------------------------------------------|-----------------------------------------------------------------|
+| **Service scaled down** | No new files being created | Scale deployment to 1 replica |
+| **RTSP stream unavailable** | No recording activity in logs | Verify camera RTSP URL is accessible from pod |
+| **ACSA not configured** | PVC in Pending state | Deploy and configure Azure Container Storage |
+| **Storage network blocked** | Can't see blobs in Portal | Use Azure CLI with `--auth-mode login` |
+| **Storage public access disabled** | ACSA logs show `AuthorizationFailure` (403) | Enable public network access or configure private connectivity |
+| **Sync delay** | Local files exist, cloud empty | Wait 3-5 minutes for ACSA sync to complete |
+| **Storage credentials invalid** | ACSA controller errors | Verify storage account connection in extension config |
+| **Retention policy deleting files** | Files disappear quickly | Adjust `localRetentionHours` in values.yaml (default: 24 hours) |
+
+#### Quick Diagnostic Script
+
+Run this script to get a comprehensive status check:
+
+```bash
+#!/bin/bash
+echo "=== Media Capture Service Status ==="
+kubectl get deployment media-capture-service -n azure-iot-operations
+
+echo -e "\n=== Pod Status ==="
+kubectl get pods -n azure-iot-operations -l app=media-capture-service
+
+POD_NAME=$(kubectl get pods -n azure-iot-operations -l app.kubernetes.io/name=media-capture-service -o jsonpath='{.items[0].metadata.name}')
+
+if [ -n "$POD_NAME" ]; then
+ echo -e "\n=== Local Storage Usage ==="
+ kubectl exec -n azure-iot-operations $POD_NAME -- du -sh /cloud-sync/video-recordings/ 2>/dev/null || echo "Pod not ready or path not accessible"
+
+ echo -e "\n=== Recent Files (last 10 minutes) ==="
+ kubectl exec -n azure-iot-operations $POD_NAME -- find /cloud-sync/video-recordings/ \( -name "*.mp4" -o -name "*.mkv" \) -mmin -10 2>/dev/null | wc -l
+
+ echo -e "\n=== Recent Logs ==="
+ kubectl logs -n azure-iot-operations $POD_NAME --tail=20 2>/dev/null
+else
+ echo "No running pod found"
+fi
+
+echo -e "\n=== ACSA PVC Status ==="
+kubectl get pvc -n azure-iot-operations | grep -E "(NAME|cloud-backed)"
+
+echo -e "\n=== ACSA Controller Status ==="
+kubectl get pods -n azure-arc-containerstorage -l app=acsa-controller
+```
+
### Docker Compose Issues
#### Common Docker Compose Problems
diff --git a/src/500-application/503-media-capture-service/charts/media-capture-service/templates/deployment.yaml b/src/500-application/503-media-capture-service/charts/media-capture-service/templates/deployment.yaml
index fd761f04..bdf1560e 100644
--- a/src/500-application/503-media-capture-service/charts/media-capture-service/templates/deployment.yaml
+++ b/src/500-application/503-media-capture-service/charts/media-capture-service/templates/deployment.yaml
@@ -32,6 +32,10 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always
+ {{- with .Values.initContainers }}
+ initContainers:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
containers:
- name: {{ .Chart.Name }}
{{- with .Values.securityContext }}
@@ -84,11 +88,40 @@ spec:
value: {{ .Values.mediaCapture.video.captureDurationSeconds | quote }}
- name: VIDEO_FEED_DELAY_SECONDS
value: {{ .Values.mediaCapture.video.feedDelaySeconds | quote }}
+ - name: OUTPUT_FORMAT
+ value: {{ .Values.mediaCapture.video.outputFormat | default "mp4" | quote }}
# Logging
- name: RUST_LOG
value: {{ .Values.logging.level | quote }}
+ # Continuous Recording Configuration
+ {{- if .Values.mediaCapture.continuousRecording.enabled }}
+ - name: CONTINUOUS_RECORDING_ENABLED
+ value: "true"
+ - name: CAMERA_ID
+ value: {{ .Values.mediaCapture.continuousRecording.cameraId | quote }}
+ - name: CAMERA_LOCATION
+ value: {{ .Values.mediaCapture.continuousRecording.cameraLocation | quote }}
+ - name: CONTINUOUS_SEGMENT_DURATION_SECONDS
+ value: {{ .Values.mediaCapture.continuousRecording.segmentDurationSeconds | quote }}
+ - name: LOCAL_RETENTION_HOURS
+ value: {{ .Values.mediaCapture.continuousRecording.localRetentionHours | default "24" | quote }}
+ - name: CLEANUP_INTERVAL_MINUTES
+ value: {{ .Values.mediaCapture.continuousRecording.cleanupIntervalMinutes | default "60" | quote }}
+ - name: AZURE_STORAGE_CONNECTION_STRING
+ {{- if .Values.mediaCapture.continuousRecording.azureStorage.connectionStringSecret }}
+ valueFrom:
+ secretKeyRef:
+ name: {{ .Values.mediaCapture.continuousRecording.azureStorage.connectionStringSecret.name }}
+ key: {{ .Values.mediaCapture.continuousRecording.azureStorage.connectionStringSecret.key }}
+ {{- else }}
+ value: {{ .Values.mediaCapture.continuousRecording.azureStorage.connectionString | quote }}
+ {{- end }}
+ - name: AZURE_STORAGE_CONTAINER_NAME
+ value: {{ .Values.mediaCapture.continuousRecording.azureStorage.containerName | quote }}
+ {{- end }}
+
volumeMounts:
{{- if .Values.volumes.mqSat.enabled }}
- name: mq-sat
diff --git a/src/500-application/503-media-capture-service/charts/media-capture-service/values.yaml b/src/500-application/503-media-capture-service/charts/media-capture-service/values.yaml
index 0d608b78..499fbcb0 100644
--- a/src/500-application/503-media-capture-service/charts/media-capture-service/values.yaml
+++ b/src/500-application/503-media-capture-service/charts/media-capture-service/values.yaml
@@ -37,7 +37,7 @@ mediaCapture:
# Media storage configuration
storage:
- cloudSyncDir: "/cloud-sync/media"
+ cloudSyncDir: "/cloud-sync/video-recordings"
pvcName: "pvc-acsa-cloud-backed"
# Video configuration
@@ -48,7 +48,21 @@ mediaCapture:
frameHeight: "512"
bufferSeconds: "60"
captureDurationSeconds: "10"
+ outputFormat: "mp4"
+
+ # Continuous recording configuration
+ continuousRecording:
+ enabled: true
+ segmentDurationSeconds: "300" # 5 minutes
+ cameraId: "camera-01"
+ cameraLocation: "default"
feedDelaySeconds: "5"
+ # Local file retention (cleanup old files after upload)
+ localRetentionHours: "24" # 1 day
+ cleanupIntervalMinutes: "60" # Check every hour
+ azureStorage:
+ containerName: "video-recordings"
+ connectionString: ""
# Logging configuration
logging:
diff --git a/src/500-application/503-media-capture-service/docker-compose.yml b/src/500-application/503-media-capture-service/docker-compose.yml
index 6621dcf7..12cf06c6 100644
--- a/src/500-application/503-media-capture-service/docker-compose.yml
+++ b/src/500-application/503-media-capture-service/docker-compose.yml
@@ -17,7 +17,7 @@ services:
# Local Mosquitto MQTT Broker
mosquitto-broker:
- image: eclipse-mosquitto@sha256:7b77b81b6d25b1fc6cc5ed1eb8ae48c247d4fd6f9aef1f7ee88b4a8e0b7f2b3e@sha256:7b77b81b6d25b1fc6cc5ed1eb8ae48c247d4fd6f9aef1f7ee88b4a8e0b7f2b3e
+ image: eclipse-mosquitto:latest
container_name: mosquitto-broker
ports:
- "1883:1883" # MQTT non-TLS port
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/Cargo.lock b/src/500-application/503-media-capture-service/services/media-capture-service/Cargo.lock
index ebfeea32..ecc4ece7 100644
--- a/src/500-application/503-media-capture-service/services/media-capture-service/Cargo.lock
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/Cargo.lock
@@ -754,6 +754,12 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "md5"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
+
[[package]]
name = "media-capture-service"
version = "0.1.0"
@@ -764,6 +770,7 @@ dependencies = [
"filetime",
"futures-util",
"lazy_static",
+ "md5",
"opencv",
"serde",
"serde_json",
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/Cargo.toml b/src/500-application/503-media-capture-service/services/media-capture-service/Cargo.toml
index c7e55d0b..2db4458f 100644
--- a/src/500-application/503-media-capture-service/services/media-capture-service/Cargo.toml
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/Cargo.toml
@@ -23,6 +23,7 @@ futures-util = "0.3.31"
opencv = { version = "0.94.4", features = ["videoio", "imgproc", "highgui", "clang-runtime"] }
ffmpeg-next = "7.1.0"
uuid = { version = "1.17.0", features = ["v4"] }
+md5 = "0.7"
[dev-dependencies]
tempfile = "3.2"
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/acsa_writer.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/acsa_writer.rs
new file mode 100644
index 00000000..281ae065
--- /dev/null
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/acsa_writer.rs
@@ -0,0 +1,88 @@
+use std::{error::Error, path::{Path, PathBuf}};
+use chrono::{DateTime, Utc};
+use tokio::fs;
+use tracing::{info, debug};
+use serde_json::json;
+
+pub struct AcsaWriter {
+ acsa_mount_path: PathBuf,
+}
+
+impl AcsaWriter {
+ pub fn new(acsa_mount_path: PathBuf) -> Self {
+ Self { acsa_mount_path }
+ }
+
+ pub fn from_environment() -> Result> {
+ let acsa_mount_path = std::env::var("MEDIA_CLOUD_SYNC_DIR")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from("/media-capture-backed-acsa"));
+
+ Ok(Self::new(acsa_mount_path))
+ }
+
+ pub async fn write_segment_with_metadata(
+ &self,
+ video_file: &Path,
+ camera_id: &str,
+ camera_location: &str,
+ segment_start: DateTime,
+ segment_end: DateTime,
+ ) -> Result<(), Box> {
+ info!(
+ "Writing video segment to ACSA volume: {}",
+ video_file.display()
+ );
+
+ let metadata = json!({
+ "camera_id": camera_id,
+ "location": camera_location,
+ "segment_start": segment_start.to_rfc3339(),
+ "segment_end": segment_end.to_rfc3339(),
+ "duration_seconds": (segment_end - segment_start).num_seconds(),
+ "file_path": video_file.to_str(),
+ });
+
+ let metadata_path = video_file.with_extension("json");
+ fs::write(&metadata_path, serde_json::to_string_pretty(&metadata)?).await?;
+
+ debug!(
+ "Metadata written for segment: {}",
+ metadata_path.display()
+ );
+
+ info!(
+ "ACSA will automatically sync {} to Azure Blob Storage",
+ video_file.display()
+ );
+
+ Ok(())
+ }
+
+ pub fn generate_acsa_path(
+ &self,
+ camera_id: &str,
+ timestamp: &DateTime,
+ ) -> PathBuf {
+ let hash_prefix = Self::calculate_hash_prefix(camera_id);
+
+ self.acsa_mount_path
+ .join(hash_prefix)
+ .join(camera_id)
+ .join(timestamp.format("%Y").to_string())
+ .join(timestamp.format("%m").to_string())
+ .join(timestamp.format("%d").to_string())
+ .join(timestamp.format("%H").to_string())
+ .join(format!(
+ "segment_{}_{}.mp4",
+ timestamp.format("%Y-%m-%dT%H:%M:%SZ"),
+ camera_id
+ ))
+ }
+
+ fn calculate_hash_prefix(camera_id: &str) -> String {
+ use md5::Digest;
+ let digest = md5::compute(camera_id.as_bytes());
+ format!("{:03}", digest[0] as u32 % 1000)
+ }
+}
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/continuous_recorder.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/continuous_recorder.rs
new file mode 100644
index 00000000..3d7e3a81
--- /dev/null
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/continuous_recorder.rs
@@ -0,0 +1,343 @@
+use std::{env, error::Error, path::{Path, PathBuf}, process::{Command, Stdio}, time::Duration};
+use chrono::{DateTime, Utc};
+use tokio::{fs, time::interval};
+use tracing::{info, error, debug, warn};
+use std::pin::Pin;
+use std::future::Future;
+use crate::acsa_writer::AcsaWriter;
+
+pub struct ContinuousRecorder {
+ camera_id: String,
+ rtsp_url: String,
+ segment_duration: Duration,
+ output_base_path: PathBuf,
+ location: String,
+ retention_hours: u64,
+ cleanup_interval_minutes: u64,
+ output_format: String,
+ acsa_writer: AcsaWriter,
+}
+
+impl ContinuousRecorder {
+ pub fn new(
+ camera_id: String,
+ rtsp_url: String,
+ segment_duration: Duration,
+ output_base_path: PathBuf,
+ location: String,
+ retention_hours: u64,
+ cleanup_interval_minutes: u64,
+ output_format: String,
+ ) -> Result> {
+ let acsa_writer = AcsaWriter::from_environment()?;
+ Ok(Self {
+ camera_id,
+ rtsp_url,
+ segment_duration,
+ output_base_path,
+ location,
+ retention_hours,
+ cleanup_interval_minutes,
+ output_format,
+ acsa_writer,
+ })
+ }
+
+ pub fn from_environment() -> Result> {
+ let camera_id = env::var("CAMERA_ID")
+ .unwrap_or_else(|_| "camera-01".to_string());
+ let rtsp_url = env::var("RTSP_URL")
+ .expect("RTSP_URL not set");
+ let segment_duration_secs: u64 = env::var("CONTINUOUS_SEGMENT_DURATION_SECONDS")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(300);
+ let output_base_path = PathBuf::from(
+ env::var("MEDIA_CLOUD_SYNC_DIR").expect("MEDIA_CLOUD_SYNC_DIR not set")
+ );
+ let location = env::var("CAMERA_LOCATION")
+ .unwrap_or_else(|_| "unknown".to_string());
+ let retention_hours: u64 = env::var("LOCAL_RETENTION_HOURS")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(24);
+ let cleanup_interval_minutes: u64 = env::var("CLEANUP_INTERVAL_MINUTES")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(60);
+ let output_format = env::var("OUTPUT_FORMAT")
+ .unwrap_or_else(|_| "mp4".to_string())
+ .to_lowercase();
+
+ let recorder = Self::new(
+ camera_id,
+ rtsp_url,
+ Duration::from_secs(segment_duration_secs),
+ output_base_path,
+ location,
+ retention_hours,
+ cleanup_interval_minutes,
+ output_format,
+ )?;
+
+ info!("Recording locally to ACSA-mounted volume for automatic Azure upload");
+ info!("Local retention: {} hours, cleanup interval: {} minutes",
+ retention_hours, cleanup_interval_minutes);
+
+ Ok(recorder)
+ }
+
+ pub async fn record_loop(&self) -> Result<(), Box> {
+ info!(
+ "Starting continuous recording loop for camera {} with {}s segments",
+ self.camera_id,
+ self.segment_duration.as_secs()
+ );
+
+ // Start cleanup task in background
+ self.start_cleanup_task();
+
+ loop {
+ let segment_start = Utc::now();
+
+ match self.record_and_upload_segment(segment_start).await {
+ Ok(_) => {
+ debug!("Successfully recorded and uploaded segment for {}", self.camera_id);
+ }
+ Err(e) => {
+ error!("Failed to record/upload segment: {}", e);
+ tokio::time::sleep(Duration::from_secs(5)).await;
+ }
+ }
+ }
+ }
+
+ async fn record_and_upload_segment(&self, segment_start: DateTime) -> Result<(), Box> {
+ let local_file = self.generate_local_filename(&segment_start);
+
+ self.ensure_local_directory(&local_file).await?;
+
+ self.record_segment_with_keyframes(&local_file, self.segment_duration).await?;
+
+ let segment_end = segment_start + chrono::Duration::from_std(self.segment_duration)?;
+
+ // Write companion JSON metadata
+ self.acsa_writer.write_segment_with_metadata(
+ &local_file,
+ &self.camera_id,
+ &self.location,
+ segment_start,
+ segment_end,
+ ).await?;
+
+ info!("Segment recorded to ACSA-mounted path: {} ({:.2} MB) - ACSA will automatically upload to Azure",
+ local_file.display(),
+ fs::metadata(&local_file).await?.len() as f64 / 1_048_576.0
+ );
+
+ Ok(())
+ }
+
+ async fn record_segment_with_keyframes(
+ &self,
+ output: &Path,
+ duration: Duration,
+ ) -> Result<(), Box> {
+ info!(
+ "Recording {}s segment with keyframe alignment to {}",
+ duration.as_secs(),
+ output.display()
+ );
+
+ let duration_str = duration.as_secs().to_string();
+
+ // Map file extension to FFmpeg format name
+ let ffmpeg_format = match self.output_format.as_str() {
+ "mkv" => "matroska",
+ "mp4" => "mp4",
+ _ => &self.output_format, // Use as-is for other formats
+ };
+
+ let mut ffmpeg_args = vec![
+ "-rtsp_transport", "tcp",
+ "-timeout", "10000000", // 10 seconds in microseconds
+ "-i", &self.rtsp_url,
+ "-t", &duration_str,
+ "-vf", "scale=-1:360", // Scale down to 360p to reduce memory
+ "-c:v", "libx264",
+ "-preset", "ultrafast", // Faster encoding, less memory
+ "-crf", "28", // Higher CRF for smaller file size
+ "-g", "30",
+ "-sc_threshold", "0",
+ "-c:a", "aac",
+ "-b:a", "64k", // Lower audio bitrate
+ "-f", ffmpeg_format,
+ ];
+
+ // Add movflags only for mp4 format
+ if self.output_format == "mp4" {
+ ffmpeg_args.push("-movflags");
+ ffmpeg_args.push("+faststart");
+ }
+
+ ffmpeg_args.push("-y");
+ ffmpeg_args.push(output.to_str().ok_or("Invalid path")?);
+
+ let output_result = Command::new("ffmpeg")
+ .args(&ffmpeg_args)
+ .stdout(Stdio::null())
+ .stderr(Stdio::piped())
+ .output()?;
+
+ if !output_result.status.success() {
+ let stderr = String::from_utf8_lossy(&output_result.stderr);
+ error!("FFmpeg stderr: {}", stderr);
+ return Err(format!("FFmpeg failed with status: {} - {}", output_result.status,
+ stderr.lines().last().unwrap_or("no error message")).into());
+ }
+
+ Ok(())
+ }
+
+ fn generate_local_filename(&self, timestamp: &DateTime) -> PathBuf {
+ let filename = format!(
+ "segment_{}_{}.{}",
+ timestamp.format("%Y-%m-%dT%H:%M:%SZ"),
+ self.camera_id,
+ self.output_format
+ );
+
+ self.output_base_path
+ .join(&self.camera_id)
+ .join(timestamp.format("%Y").to_string())
+ .join(timestamp.format("%m").to_string())
+ .join(timestamp.format("%d").to_string())
+ .join(timestamp.format("%H").to_string())
+ .join(filename)
+ }
+
+ async fn ensure_local_directory(&self, file_path: &Path) -> Result<(), Box> {
+ if let Some(parent) = file_path.parent() {
+ // Always try to create the directory - create_dir_all is idempotent
+ debug!("Ensuring directory exists: {}", parent.display());
+ fs::create_dir_all(parent).await?;
+ info!("Directory ready: {}", parent.display());
+ }
+ Ok(())
+ }
+
+ pub fn get_camera_id(&self) -> &str {
+ &self.camera_id
+ }
+
+ pub fn get_location(&self) -> &str {
+ &self.location
+ }
+
+ fn start_cleanup_task(&self) {
+ let base_path = self.output_base_path.clone();
+ let camera_id = self.camera_id.clone();
+ let retention_hours = self.retention_hours;
+ let cleanup_interval = Duration::from_secs(self.cleanup_interval_minutes * 60);
+ let format = self.output_format.clone();
+
+ tokio::spawn(async move {
+ let mut interval_timer = interval(cleanup_interval);
+ info!(
+ "Starting file cleanup task (retention: {}h, interval: {}m) for camera {}",
+ retention_hours,
+ cleanup_interval.as_secs() / 60,
+ camera_id
+ );
+
+ loop {
+ interval_timer.tick().await;
+
+ match Self::cleanup_old_files(&base_path, &camera_id, retention_hours, &format).await {
+ Ok(count) => {
+ if count > 0 {
+ info!("Cleaned up {} old files for camera {}", count, camera_id);
+ } else {
+ debug!("No old files to clean up for camera {}", camera_id);
+ }
+ }
+ Err(e) => {
+ warn!("Failed to clean up old files: {}", e);
+ }
+ }
+ }
+ });
+ }
+
+ async fn cleanup_old_files(
+ base_path: &Path,
+ camera_id: &str,
+ retention_hours: u64,
+ output_format: &str,
+ ) -> Result {
+ let camera_path = base_path.join(camera_id);
+
+ if !camera_path.exists() {
+ return Ok(0);
+ }
+
+ let now = Utc::now();
+ let retention_duration = chrono::Duration::hours(retention_hours as i64);
+ let cutoff_time = now - retention_duration;
+ let mut deleted_count = 0;
+
+ Self::cleanup_directory_recursive(&camera_path, cutoff_time, &mut deleted_count, output_format).await?;
+
+ Ok(deleted_count)
+ }
+
+ fn cleanup_directory_recursive<'a>(
+ dir_path: &'a Path,
+ cutoff_time: DateTime,
+ deleted_count: &'a mut usize,
+ output_format: &'a str,
+ ) -> Pin> + Send + 'a>> {
+ Box::pin(async move {
+ let mut entries = fs::read_dir(dir_path).await?;
+
+ while let Some(entry) = entries.next_entry().await? {
+ let path = entry.path();
+
+ if path.is_dir() {
+ Self::cleanup_directory_recursive(&path, cutoff_time, deleted_count, output_format).await?;
+
+ // Remove empty directories
+ if let Ok(mut dir_entries) = fs::read_dir(&path).await {
+ if dir_entries.next_entry().await?.is_none() {
+ if let Err(e) = fs::remove_dir(&path).await {
+ warn!("Failed to remove empty directory {}: {}", path.display(), e);
+ } else {
+ debug!("Removed empty directory: {}", path.display());
+ }
+ }
+ }
+ } else if path.extension().and_then(|s| s.to_str()) == Some(output_format) {
+ if let Ok(metadata) = fs::metadata(&path).await {
+ if let Ok(modified) = metadata.modified() {
+ let modified_datetime: DateTime = modified.into();
+
+ if modified_datetime < cutoff_time {
+ match fs::remove_file(&path).await {
+ Ok(_) => {
+ debug!("Deleted old file: {}", path.display());
+ *deleted_count += 1;
+ }
+ Err(e) => {
+ warn!("Failed to delete {}: {}", path.display(), e);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Ok(())
+ })
+ }
+}
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/lib.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/lib.rs
index 6ae98919..51cecace 100644
--- a/src/500-application/503-media-capture-service/services/media-capture-service/src/lib.rs
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/lib.rs
@@ -3,6 +3,10 @@ pub mod video_processor;
pub mod mqtt_handler;
pub mod video_writer;
pub mod multi_trigger;
+pub mod continuous_recorder;
+pub mod acsa_writer;
pub use video_processor::{TimeParams, VideoSegmentParams, TimeParamWorker, process_video_stream};
pub use multi_trigger::MultiTriggerWorker;
+pub use continuous_recorder::ContinuousRecorder;
+pub use acsa_writer::AcsaWriter;
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/mqtt_handler.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/mqtt_handler.rs
index def3564e..887aff8e 100644
--- a/src/500-application/503-media-capture-service/services/media-capture-service/src/mqtt_handler.rs
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/mqtt_handler.rs
@@ -49,15 +49,15 @@ pub async fn receive_messages(
continue;
}
};
-
+
// Create a new JSON value with the topic included
let mut payload_with_topic = payload.clone();
if let serde_json::Value::Object(ref mut map) = payload_with_topic {
map.insert("__mqtt_topic".to_string(), serde_json::Value::String(input_topic.clone()));
}
-
+
// Process the message with the enhanced payload
- process_message(&payload_with_topic, worker.clone(), buffer.clone(), dest_path.clone(),
+ process_message(&payload_with_topic, worker.clone(), buffer.clone(), dest_path.clone(),
&filename_format, &video_format, fps, frame_size).await;
}
}
@@ -80,10 +80,11 @@ async fn process_message(
return;
}
};
-
+
let time_param = params_with_id.time_param;
let event_id = params_with_id.event_id.clone();
let event_type = params_with_id.event_type.clone();
+ let camera_id = params_with_id.camera_id.clone();
let event_id_str = event_id.map(|id| id.to_string()).unwrap_or_else(|| "unknown".to_string());
let start_range = time_param.start_time.with_timezone(&Local);
@@ -111,6 +112,7 @@ async fn process_message(
wait_seconds,
event_id.clone(),
event_type.as_deref(),
+ camera_id.as_deref(),
)
.await;
@@ -125,4 +127,4 @@ async fn process_message(
info!("event_id={}, Buffered video segment written successfully.", event_id_str);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger.rs
index a5cf57f4..f51a683d 100644
--- a/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger.rs
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger.rs
@@ -196,6 +196,9 @@ fn calculate_video_segment_params(
capture_duration: f64,
video_feed_delay_secs: i64
) -> Result> {
+ // Get camera_id from environment variable (same as continuous recorder)
+ let camera_id = std::env::var("CAMERA_ID").ok();
+
// Calculate total capture duration including the delay compensation
// For example, with 10s capture_duration and 5s delay:
// Total duration becomes 15s (5s for delay + 10s for capture around the event)
@@ -229,7 +232,7 @@ fn calculate_video_segment_params(
// - End: (timestamp + 5s) = 5s after the event timestamp
// - Total duration: 15s (5s for delay + 5s before event + 5s after event)
- Ok(VideoSegmentParams { time_param, event_id, event_type })
+ Ok(VideoSegmentParams { time_param, event_id, event_type, camera_id })
}
impl TimeParamWorker for MultiTriggerWorker {
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger_binary.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger_binary.rs
index 43ad1d1d..9ffb8af5 100644
--- a/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger_binary.rs
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/multi_trigger_binary.rs
@@ -1,6 +1,7 @@
+use std::env;
use tracing_subscriber::{EnvFilter};
use tracing::{info, span, Level};
-use media_capture_service::{MultiTriggerWorker, process_video_stream};
+use media_capture_service::{MultiTriggerWorker, ContinuousRecorder, process_video_stream};
#[tokio::main]
async fn main() -> Result<(), Box> {
@@ -14,23 +15,46 @@ async fn main() -> Result<(), Box> {
let main_span = span!(Level::INFO, "multi_trigger_main");
let _enter = main_span.enter();
- info!("Starting multi-trigger service");
- info!("This binary supports multiple message formats based on topic patterns");
+ // Check if continuous recording mode is enabled
+ let continuous_recording_enabled = env::var("CONTINUOUS_RECORDING_ENABLED")
+ .unwrap_or_else(|_| "false".to_string())
+ .to_lowercase() == "true";
- // Create a new MultiTriggerWorker (it no longer needs the topic at construction time)
- let worker = MultiTriggerWorker::new();
+ if continuous_recording_enabled {
+ info!("Starting in CONTINUOUS RECORDING mode");
- // Pass an empty input_topic so that process_video_stream will use the TRIGGER_TOPICS env var
- let result = process_video_stream("multi-trigger", worker, "".to_string()).await;
+ // Create continuous recorder from environment variables
+ let recorder = ContinuousRecorder::from_environment()?;
- match result {
- Ok(_) => {
- info!("multi-trigger service completed successfully");
- Ok(())
- }
- Err(e) => {
- tracing::error!("multi-trigger service failed: {:?}", e);
- Err(e)
+ info!(
+ "Continuous recording configured for camera: {}, location: {}",
+ recorder.get_camera_id(),
+ recorder.get_location()
+ );
+
+ // Start the continuous recording loop
+ recorder.record_loop().await?;
+
+ Ok(())
+ } else {
+ info!("Starting in MQTT-TRIGGERED mode");
+ info!("This binary supports multiple message formats based on topic patterns");
+
+ // Create a new MultiTriggerWorker (it no longer needs the topic at construction time)
+ let worker = MultiTriggerWorker::new();
+
+ // Pass an empty input_topic so that process_video_stream will use the TRIGGER_TOPICS env var
+ let result = process_video_stream("multi-trigger", worker, "".to_string()).await;
+
+ match result {
+ Ok(_) => {
+ info!("multi-trigger service completed successfully");
+ Ok(())
+ }
+ Err(e) => {
+ tracing::error!("multi-trigger service failed: {:?}", e);
+ Err(e)
+ }
}
}
}
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/video_processor.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/video_processor.rs
index 9da286e4..1f84f2aa 100644
--- a/src/500-application/503-media-capture-service/services/media-capture-service/src/video_processor.rs
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/video_processor.rs
@@ -68,6 +68,7 @@ pub struct VideoSegmentParams {
pub time_param: TimeParams,
pub event_id: Option,
pub event_type: Option,
+ pub camera_id: Option,
}
const DEFAULT_BUFFER_SECONDS: usize = 30;
@@ -246,6 +247,7 @@ mod tests {
},
event_id: Some(42),
event_type: Some("test".to_string()),
+ camera_id: Some("test-camera".to_string()),
})
}
}
diff --git a/src/500-application/503-media-capture-service/services/media-capture-service/src/video_writer.rs b/src/500-application/503-media-capture-service/services/media-capture-service/src/video_writer.rs
index 4dcefe71..347feaf0 100644
--- a/src/500-application/503-media-capture-service/services/media-capture-service/src/video_writer.rs
+++ b/src/500-application/503-media-capture-service/services/media-capture-service/src/video_writer.rs
@@ -74,6 +74,19 @@ fn sample_frames<'a>(
}
}
+/// Generates the hierarchical output path for video files.
+/// Format: {base_path}/{camera_id}/{YYYY}/{MM}/{DD}/{HH}/
+/// This matches the continuous recorder path structure for unified querying.
+fn generate_hierarchical_path(base_path: &PathBuf, camera_id: Option<&str>, timestamp: &chrono::DateTime) -> PathBuf {
+ let camera = camera_id.unwrap_or("unknown-camera");
+ base_path
+ .join(camera)
+ .join(timestamp.format("%Y").to_string())
+ .join(timestamp.format("%m").to_string())
+ .join(timestamp.format("%d").to_string())
+ .join(timestamp.format("%H").to_string())
+}
+
pub async fn write_buffered_video(
buffer: Arc>,
dest_path: PathBuf,
@@ -85,20 +98,20 @@ pub async fn write_buffered_video(
wait_seconds: usize,
event_id: Option,
event_type: Option<&str>,
+ camera_id: Option<&str>,
) -> Result> {
let event_id_str = event_id.map(|id| id.to_string()).unwrap_or_else(|| "unknown".to_string());
let formatted_timestamp = Utc::now().format(filename_format).to_string();
- info!("Creating filename with event_id={}, event_type={:?}", event_id_str, event_type);
+ info!("Creating filename with event_id={}, event_type={:?}, camera_id={:?}", event_id_str, event_type, camera_id);
let file_name = create_video_filename(formatted_timestamp.clone(), event_id, event_type);
- // Get current date in UTC for folder naming
- let date_folder = Utc::now().format("%Y-%m-%d").to_string();
- let dated_dest_path = dest_path.join(date_folder);
-
- let file_path = dated_dest_path.join(&file_name);
- if !dated_dest_path.exists() {
- warn!("event_id={}, Directory does not exist: {:?}. Attempting to create it.", event_id_str, dated_dest_path);
- fs::create_dir_all(dated_dest_path.clone()).await.map_err(|e| Box::new(e) as Box)?;
+ // Use hierarchical path structure: {camera}/{YYYY}/{MM}/{DD}/{HH}/
+ let hierarchical_path = generate_hierarchical_path(&dest_path, camera_id, &Utc::now());
+
+ let file_path = hierarchical_path.join(&file_name);
+ if !hierarchical_path.exists() {
+ warn!("event_id={}, Directory does not exist: {:?}. Attempting to create it.", event_id_str, hierarchical_path);
+ fs::create_dir_all(hierarchical_path.clone()).await.map_err(|e| Box::new(e) as Box)?;
}
let fourcc_chars: Vec = video_format.chars().collect();
@@ -336,6 +349,7 @@ mod tests {
1,
Some(1),
Some("test"),
+ Some("test-camera"),
).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), false); // test for false when no frames
@@ -362,6 +376,7 @@ mod tests {
1,
Some(2),
Some("test"),
+ Some("test-camera"),
).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), true); // test for true when frames exist
diff --git a/src/500-application/503-media-capture-service/yaml/media-capture-mqtt-triggered.yaml b/src/500-application/503-media-capture-service/yaml/media-capture-mqtt-triggered.yaml
new file mode 100644
index 00000000..10ff8404
--- /dev/null
+++ b/src/500-application/503-media-capture-service/yaml/media-capture-mqtt-triggered.yaml
@@ -0,0 +1,98 @@
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: media-capture-mqtt-triggered
+ namespace: azure-iot-operations
+ labels:
+ app: media-capture-mqtt-triggered
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: media-capture-mqtt-triggered
+ template:
+ metadata:
+ labels:
+ app: media-capture-mqtt-triggered
+ spec:
+ serviceAccountName: default
+ containers:
+ - name: media-capture-service
+ image: acrmediacapturepoc.azurecr.io/media-capture-service:latest
+ imagePullPolicy: Always
+ env:
+ - name: CONTINUOUS_RECORDING_ENABLED
+ value: 'false'
+ - name: TRIGGER_TOPICS
+ value: '["alerts/trigger", "camera/+/+/+/+/request", "aio/from-pdmz/#"]'
+ - name: RTSP_URL
+ value: rtsp://10.10.0.52:8554/camera01
+ - name: VIDEO_FPS
+ value: '30'
+ - name: FRAME_WIDTH
+ value: '1920'
+ - name: FRAME_HEIGHT
+ value: '1080'
+ - name: BUFFER_SECONDS
+ value: '120'
+ - name: CAPTURE_DURATION_SECONDS
+ value: '30'
+ - name: VIDEO_FEED_DELAY_SECONDS
+ value: '5'
+ - name: OUTPUT_FORMAT
+ value: mp4
+ - name: MEDIA_CLOUD_SYNC_DIR
+ value: /cloud-sync/video-recordings
+ - name: CAMERA_ID
+ value: pmn-camera-01-triggered
+ - name: CAMERA_LOCATION
+ value: pmn-line1-cell1
+ - name: AIO_BROKER_HOSTNAME
+ value: aio-broker
+ - name: AIO_BROKER_TCP_PORT
+ value: '18883'
+ - name: AIO_MQTT_CLIENT_ID
+ value: media-capture-mqtt-triggered
+ - name: AIO_TLS_CA_FILE
+ value: /var/run/certs/ca.crt
+ - name: AIO_SAT_FILE
+ value: /var/run/secrets/tokens/mq-sat
+ - name: RUST_LOG
+ value: info,media_capture_service=debug
+ volumeMounts:
+ - name: mq-sat
+ mountPath: /var/run/secrets/tokens
+ readOnly: true
+ - name: trust-bundle
+ mountPath: /var/run/certs
+ readOnly: true
+ - name: media-storage
+ mountPath: /cloud-sync/video-recordings
+ resources:
+ limits:
+ cpu: '2'
+ memory: 4Gi
+ requests:
+ cpu: 500m
+ memory: 1Gi
+ securityContext:
+ allowPrivilegeEscalation: false
+ runAsNonRoot: true
+ runAsUser: 1000
+ volumes:
+ - name: mq-sat
+ projected:
+ sources:
+ - serviceAccountToken:
+ path: mq-sat
+ audience: aio-internal
+ expirationSeconds: 86400
+ - name: trust-bundle
+ configMap:
+ name: azure-iot-operations-aio-ca-trust-bundle
+ - name: media-storage
+ persistentVolumeClaim:
+ claimName: pvc-acsa-video-cloud
+ imagePullSecrets:
+ - name: acr-pull-secret
diff --git a/src/500-application/508-media-connector/README.md b/src/500-application/508-media-connector/README.md
index 81cbe0b6..0db05249 100644
--- a/src/500-application/508-media-connector/README.md
+++ b/src/500-application/508-media-connector/README.md
@@ -144,7 +144,7 @@ For **customer production environments**:
- MediaMTX (open source, RTSP/HLS/WebRTC support)
- NGINX with RTMP module
- Wowza Streaming Engine
- - Azure Media Services
+ - Third-party cloud services (AWS MediaConvert, Mux)
2. **Reference the media server** in media connector assets:
- Configure `stream-to-rtsp` tasks with media server endpoint
@@ -258,15 +258,15 @@ After deploying the connector template, configure your cameras (devices) and cap
```bash
cd blueprints/full-single-node-cluster/terraform
-# Create or edit media-connector-assets.tfvars
-# Copy example configuration (when available)
-# cp media-connector-assets.tfvars.example media-connector-assets.tfvars
+# Create or edit terraform.tfvars
+# Copy example configuration
+# cp alert-dataflow.tfvars.example terraform.tfvars
```
Add device and asset configurations using `namespaced_devices` and `namespaced_assets` variables:
```hcl
-# In media-connector-assets.tfvars
+# In terraform.tfvars
# Define camera devices
namespaced_devices = [
@@ -297,9 +297,9 @@ namespaced_assets = [
device_name = "warehouse-camera-01"
endpoint_name = "warehouse-camera-endpoint"
}
- datasets = [{
- name = "snapshots"
- dataset_configuration = "{\"taskType\":\"snapshot-to-mqtt\",\"intervalSeconds\":5,\"quality\":85}"
+ streams = [{
+ name = "snapshots"
+ stream_configuration = "{\"taskType\":\"snapshot-to-mqtt\",\"autostart\":true,\"snapshotsPerSecond\":0.2,\"format\":\"jpeg\"}"
destinations = [{ target = "Mqtt", configuration = { topic = "warehouse/camera-01/snapshots" } }]
}]
}
@@ -311,7 +311,7 @@ See the [Configuring Media Connector Assets](#configuring-media-connector-assets
Apply the device and asset configuration:
```bash
-terraform apply -var-file="media-connector-assets.tfvars"
+terraform apply
```
#### Verify
@@ -333,12 +333,12 @@ kubectl exec -it mqtt-client -n azure-iot-operations -- \
--cafile /var/run/certs/ca.crt --topic 'media/#' -v"
```
-**Configuration Reference**: See `blueprints/full-single-node-cluster/terraform/media-connector-assets.tfvars.example`
+**Configuration Reference**: See `blueprints/full-single-node-cluster/terraform/alert-dataflow.tfvars.example`
for a complete example of all available configuration options including device and asset definitions.
## Configuring Media Connector Assets
-When configuring media connector assets in your `terraform.tfvars` or `media-connector-assets.tfvars`, define devices for your cameras/media sources and assets for capture tasks.
+When configuring media connector assets in your `terraform.tfvars`, define devices for your cameras/media sources and assets for capture tasks.
### Device Configuration Example
@@ -397,12 +397,10 @@ namespaced_assets = [
assetType = "media-snapshots"
location = "Warehouse Main Entrance"
}
- datasets = [
+ streams = [
{
- name = "snapshots"
- data_source = "" # Media connector uses device endpoint
- dataset_configuration = "{\"taskType\":\"snapshot-to-mqtt\",\"intervalSeconds\":5,\"quality\":85}"
- data_points = []
+ name = "snapshots"
+ stream_configuration = "{\"taskType\":\"snapshot-to-mqtt\",\"autostart\":true,\"snapshotsPerSecond\":0.2,\"format\":\"jpeg\"}"
destinations = [
{
target = "Mqtt"
@@ -427,32 +425,30 @@ namespaced_assets = [
assetType = "media-clips"
location = "Warehouse Main Entrance"
}
- datasets = [
+ streams = [
{
- name = "clips"
- data_source = "" # Media connector uses device endpoint
- dataset_configuration = "{\"taskType\":\"clip-to-fs\",\"durationSeconds\":30,\"storagePath\":\"/clips\"}"
- data_points = []
- destinations = [] # Clips stored to filesystem, not MQTT
+ name = "clips"
+ stream_configuration = "{\"taskType\":\"clip-to-fs\",\"autostart\":true,\"durationSeconds\":30,\"storagePath\":\"/clips\"}"
+ destinations = []
}
]
}
]
```
-### Task Types in Dataset Configuration
+### Task Types in Stream Configuration
-Configure different media connector tasks via `dataset_configuration` JSON:
+Configure different media connector tasks via `stream_configuration` JSON. All tasks require `autostart: true` to start automatically:
-| Task Type | Configuration Example |
-|----------------------|---------------------------------------------------------------------------------------------------------|
-| **snapshot-to-mqtt** | `{"taskType":"snapshot-to-mqtt","intervalSeconds":5,"quality":85}` |
-| **clip-to-fs** | `{"taskType":"clip-to-fs","durationSeconds":30,"storagePath":"/clips"}` |
-| **snapshot-to-fs** | `{"taskType":"snapshot-to-fs","intervalSeconds":10,"quality":90,"storagePath":"/snapshots"}` |
-| **stream-to-rtsp** | `{"taskType":"stream-to-rtsp","mediaServerEndpoint":"rtsp://mediamtx:8554/stream"}` |
-| **stream-to-rtsps** | `{"taskType":"stream-to-rtsps","mediaServerEndpoint":"rtsps://mediamtx:8555/stream","tlsEnabled":true}` |
+| Task Type | Configuration Example |
+|----------------------|--------------------------------------------------------------------------------------------------------------------------|
+| **snapshot-to-mqtt** | `{"taskType":"snapshot-to-mqtt","autostart":true,"snapshotsPerSecond":0.2,"format":"jpeg"}` |
+| **clip-to-fs** | `{"taskType":"clip-to-fs","autostart":true,"durationSeconds":30,"storagePath":"/clips"}` |
+| **snapshot-to-fs** | `{"taskType":"snapshot-to-fs","autostart":true,"snapshotsPerSecond":0.1,"format":"jpeg","storagePath":"/snapshots"}` |
+| **stream-to-rtsp** | `{"taskType":"stream-to-rtsp","autostart":true,"mediaServerEndpoint":"rtsp://mediamtx:8554/stream"}` |
+| **stream-to-rtsps** | `{"taskType":"stream-to-rtsps","autostart":true,"mediaServerEndpoint":"rtsps://mediamtx:8555/stream","tlsEnabled":true}` |
-**Complete Configuration Example**: See `blueprints/full-single-node-cluster/terraform/media-connector-assets.tfvars.example` for a production-ready configuration file with multiple cameras, authentication, and various task types.
+**Complete Configuration Example**: See `blueprints/full-single-node-cluster/terraform/alert-dataflow.tfvars.example` for a production-ready configuration file with multiple cameras, authentication, and various task types.
## Local Development and Testing
@@ -483,6 +479,24 @@ Configure different media connector tasks via `dataset_configuration` JSON:
mosquitto_pub -h localhost -t "media/test" -m '{"test": "message"}'
```
+### Testing with Mock RTSP Cameras on Kubernetes
+
+For cluster-based testing without real cameras, deploy mock RTSP camera pods:
+
+```bash
+kubectl apply -f src/500-application/508-media-connector/kubernetes/mock-rtsp-cameras.yaml
+```
+
+This deploys three mock cameras in the `azure-iot-operations` namespace using `ullaakut/rtspatt`:
+
+| Camera | Resolution | FPS | Service Address |
+|-------------|------------|-----|----------------------------------|
+| `pattern` | 1920x1080 | 30 | `rtsp://mock-rtsp-pattern:554` |
+| `colorbars` | 1280x720 | 15 | `rtsp://mock-rtsp-colorbars:554` |
+| `ball` | 640x480 | 25 | `rtsp://mock-rtsp-ball:554` |
+
+Device endpoint addresses should use the service name with the RTSP path (e.g., `rtsp://mock-rtsp-pattern:554/live.sdp/pattern`).
+
### Testing RTSP Streams
> **Security Note**: The examples below include credentials in the command line for **local development convenience only**. Never use inline credentials in production environments.
@@ -605,7 +619,7 @@ The media connector can be deployed using either:
- **Simple enablement**: Set `should_enable_akri_media_connector = true` for default configuration
- **Advanced configuration**: Use `custom_akri_connectors` list for custom images, MQTT settings, or multiple instances
-Specific camera and asset configuration is managed through **Device** and **Asset** resources defined in `media-connector-assets.tfvars`.
+Specific camera and asset configuration is managed through **Device** and **Asset** resources defined in `terraform.tfvars`.
### Scenario 1: Basic Snapshot Capture
@@ -619,10 +633,10 @@ cat >> terraform.tfvars <"${GRAPH_TEMP}"
-
- echo "Pushing graph definition v${VERSION}"
- oras push \
- "${ACR_NAME}.azurecr.io/msg-to-dss-key-graph:${VERSION}" \
- --config \
- /dev/null:application/vnd.microsoft.aio.graph.v1+yaml \
- "${GRAPH_TEMP}:application/yaml" \
- --disable-path-validation
+ GRAPH_TEMP=$(mktemp)
+ trap 'rm -f "${GRAPH_TEMP}"' EXIT
+ export VERSION
+ # shellcheck disable=SC2016 # Single quotes intentional - passing literal to envsubst
+ envsubst '${VERSION}' <"${GRAPH_FILE}" >"${GRAPH_TEMP}"
+
+ echo "Pushing graph definition v${VERSION}"
+ oras push \
+ "${ACR_NAME}.azurecr.io/msg-to-dss-key-graph:${VERSION}" \
+ --config \
+ /dev/null:application/vnd.microsoft.aio.graph.v1+yaml \
+ "${GRAPH_TEMP}:application/yaml" \
+ --disable-path-validation
fi
echo "ACR push complete"
diff --git a/src/500-application/520-video-query-api/.funcignore b/src/500-application/520-video-query-api/.funcignore
new file mode 100644
index 00000000..54772797
--- /dev/null
+++ b/src/500-application/520-video-query-api/.funcignore
@@ -0,0 +1,20 @@
+.git*
+.vscode
+local.settings.json
+test
+.python_packages
+__pycache__
+*.pyc
+*.pyo
+.pytest_cache
+.coverage
+htmlcov
+*.tmp
+tmp/
+temp/
+
+.venv
+
+# Ensure bin/ffmpeg is included in deployment
+!bin/
+!bin/ffmpeg
\ No newline at end of file
diff --git a/src/500-application/520-video-query-api/.gitignore b/src/500-application/520-video-query-api/.gitignore
new file mode 100644
index 00000000..30a5106e
--- /dev/null
+++ b/src/500-application/520-video-query-api/.gitignore
@@ -0,0 +1,62 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual environments
+venv/
+env/
+ENV/
+.venv
+
+# Azure Functions
+local.settings.json
+.vscode/
+.python_packages/
+__blobstorage__/
+__queuestorage__/
+__azurite_db*__.json
+
+# Testing
+.pytest_cache/
+.coverage
+htmlcov/
+*.cover
+
+# IDEs
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Temporary files
+*.tmp
+tmp/
+temp/
+bin/ffmpeg
+*.zip
diff --git a/src/500-application/520-video-query-api/README.md b/src/500-application/520-video-query-api/README.md
new file mode 100644
index 00000000..0853a110
--- /dev/null
+++ b/src/500-application/520-video-query-api/README.md
@@ -0,0 +1,674 @@
+---
+title: Video Query API
+description: Azure Function for querying and retrieving time-based video segments from continuous camera recordings
+author: Edge AI Team
+ms.date: 2026-03-03
+ms.topic: reference
+keywords:
+ - video-query
+ - azure-function
+ - blob-storage
+ - time-based-query
+ - sas-urls
+estimated_reading_time: 10
+---
+
+## Video Query API
+
+Azure Function for querying and retrieving time-based video segments from continuous camera recordings. Enables data scientists and analysts to request video from specific cameras for specific timeframes, with secure SAS URL generation for direct segment access.
+
+## Overview
+
+The Video Query API provides a REST endpoint for querying video recordings stored in Azure Blob Storage. It supports efficient time-based queries using optimized blob filtering strategies and generates secure SAS URLs for direct segment downloads.
+
+## Features
+
+* **Time-Based Video Queries**: Query videos by camera ID and timestamp range (start/end)
+* **Optimized Blob Filtering**: Automatic query optimization based on duration
+ * < 1 hour: Prefix-based list queries (fastest)
+ * 1-24 hours: Blob index tag queries (efficient)
+* **Individual Segment Access**: Returns array of video segments with metadata
+* **MQTT-Triggered Capture**: Trigger on-demand video capture via Event Grid MQTT
+* **Health Monitoring**: Anonymous health endpoint for storage connectivity checks
+* **Secure Access**: SAS URL generation with configurable expiry (default: 24 hours)
+* **Managed Identity Auth**: Uses Azure Managed Identity for storage access and MQTT publishing
+
+## Architecture
+
+```text
+┌──────────────┐ HTTP GET ┌─────────────────────┐
+│ Data │ ─────────────────> │ Video Query API │
+│ Scientist │ /api/video? │ (Azure Function) │
+│ │ camera=xxx& │ │
+│ │ start=2026-01& │ Endpoints: │
+│ │ end=2026-01 │ GET /api/health │
+└──────────────┘ │ GET /api/video │
+ ^ │ POST /api/trigger │
+ │ └─────────────────────┘
+ │ Array of │ │
+ │ Segment URLs │ Query │ MQTT Publish
+ │ (24h expiry) │ │ (alerts/trigger)
+ └─────────────────────────────────┘ │
+ │ │
+ ┌────────▼───┐ ┌─▼──────────────┐
+ │ Azure Blob │ │ Event Grid │
+ │ Storage │ │ Namespace │
+ │ │ │ (MQTT :8883) │
+ │ • video- │ └────────────────┘
+ │ recordings│
+ └────────────┘
+```
+
+## Prerequisites
+
+* Azure subscription with appropriate permissions
+* Azure Blob Storage account with containers:
+ * `video-recordings`: Continuous recording segments
+ * `temp-videos`: Temporary merged video storage (required only for stitch=true)
+* Azure Functions Core Tools 4.x
+* Python 3.11 or later
+* Managed Identity with Storage Blob Data Contributor role
+* FFmpeg 4.4 or later (required only for stitch=true)
+
+## API Reference
+
+### GET /api/health
+
+Anonymous health check that tests blob storage connectivity.
+
+**Auth Level:** Anonymous (no key required)
+
+**Response (healthy):**
+
+```json
+{
+ "status": "healthy",
+ "storage_account": "stvideoqueryapipoc001",
+ "container": "video-recordings",
+ "blob_prefix": "",
+ "blobs_found": 5,
+ "sample_blobs": ["..."],
+ "auth_method": "managed_identity"
+}
+```
+
+**Response (unhealthy, HTTP 500):**
+
+```json
+{
+ "status": "unhealthy",
+ "error": "...",
+ "storage_account": "stvideoqueryapipoc001",
+ "container": "video-recordings"
+}
+```
+
+### GET /api/video
+
+Query and retrieve video for a specific camera and timeframe.
+
+**Query Parameters:**
+
+* `camera` (required): Camera ID (e.g., "pmn-camera-01")
+* `start` (required): Start timestamp in ISO 8601 UTC format (e.g., "2026-01-20T10:00:00Z")
+* `end` (required): End timestamp in ISO 8601 UTC format (e.g., "2026-01-20T10:30:00Z")
+* `event_type` (optional): Filter by recording type — "continuous", "triggered", or specific event (e.g., "alert")
+* `stitch` (optional): Set to "true" to concatenate segments on server (default: "false")
+
+> **Note:** Always use UTC timestamps with the `Z` suffix for consistent results.
+
+**Response (with stitch=false or omitted, default):**
+
+```json
+{
+ "segments": [
+ {
+ "url": "https://storage.blob.core.windows.net/video-recordings/...",
+ "name": "reolink-01/2026/01/13/21/segment_2026-01-13T21:42:09Z_reolink-01.mp4",
+ "timestamp": "2026-01-13T21:42:09+00:00",
+ "size_bytes": 36175872,
+ "recording_type": "continuous",
+ "event_type": null,
+ "duration_seconds": 300,
+ "location": "PMN-Plant",
+ "segment_start": "2026-01-13T21:42:09.123456+00:00",
+ "segment_end": "2026-01-13T21:47:09.123456+00:00"
+ }
+ ],
+ "total_segments": 1,
+ "camera_id": "reolink-01",
+ "start_time": "2026-01-13T21:40:00Z",
+ "end_time": "2026-01-13T21:45:00Z",
+ "expires_at": "2026-01-15T06:10:04.068612",
+ "stitched": false
+}
+```
+
+> **Note**: The `duration_seconds`, `location`, `segment_start`, and `segment_end` fields are populated from companion JSON metadata files when available. These fields provide precise timing information from the recording service.
+
+**Response (with stitch=true):**
+
+```json
+{
+ "video_url": "https://storage.blob.core.windows.net/temp-videos/...",
+ "query_duration_seconds": 300,
+ "actual_duration_seconds": 298.5,
+ "segment_count": 6,
+ "camera_id": "reolink-01",
+ "start_time": "2026-01-13T21:40:00Z",
+ "end_time": "2026-01-13T21:45:00Z",
+ "earliest_segment_start": "2026-01-13T21:40:02.123456+00:00",
+ "latest_segment_end": "2026-01-13T21:44:58.789012+00:00",
+ "locations": ["PMN-Plant"],
+ "metadata_coverage": 100.0,
+ "expires_at": "2026-01-15T06:10:04.068612",
+ "stitched": true
+}
+```
+
+**Stitch Response with Gaps Detected:**
+
+When gaps between video segments exceed 5 seconds, the response includes gap details:
+
+```json
+{
+ "video_url": "https://storage.blob.core.windows.net/temp-videos/...",
+ "query_duration_seconds": 600,
+ "actual_duration_seconds": 580.5,
+ "segment_count": 12,
+ "camera_id": "reolink-01",
+ "start_time": "2026-01-13T21:40:00Z",
+ "end_time": "2026-01-13T21:50:00Z",
+ "gaps": [
+ {
+ "after_segment": "reolink-01/2026/01/13/21/segment_003.mp4",
+ "before_segment": "reolink-01/2026/01/13/21/segment_004.mp4",
+ "gap_start": "2026-01-13T21:42:30.000000+00:00",
+ "gap_end": "2026-01-13T21:42:45.500000+00:00",
+ "gap_seconds": 15.5
+ }
+ ],
+ "gap_count": 1,
+ "total_gap_seconds": 15.5,
+ "metadata_coverage": 100.0,
+ "stitched": true
+}
+```
+
+> **Note**: Gap detection uses precise `segment_start` and `segment_end` timestamps from JSON metadata. Segments are ordered by metadata timestamps for accurate concatenation. The `metadata_coverage` field indicates what percentage of segments had companion JSON metadata available.
+
+### HTTP Response Codes
+
+| Status | Description |
+|--------|------------------------------------------------------------------|
+| 200 | Success — segments found or empty result with message |
+| 202 | Accepted — trigger capture request accepted (POST /api/trigger) |
+| 400 | Bad Request — missing/invalid parameters or disallowed camera |
+| 404 | Not Found — stitch requested but no video files found |
+| 429 | Too Many Requests — trigger rate limited (30-second per-camera) |
+| 500 | Internal Server Error — storage connection or processing failure |
+| 502 | Bad Gateway — MQTT trigger delivery failed |
+| 503 | Service Unavailable — Event Grid not configured |
+
+**Empty Results Response (HTTP 200):**
+
+```json
+{
+ "segments": [],
+ "total_segments": 0,
+ "message": "No video segments found for camera 'camera-01' between 2026-01-20T10:00:00Z and 2026-01-20T10:30:00Z",
+ "camera_id": "camera-01",
+ "start_time": "2026-01-20T10:00:00Z",
+ "end_time": "2026-01-20T10:30:00Z"
+}
+```
+
+**Bad Request Response (HTTP 400):**
+
+```json
+{
+ "error": "Missing required parameter: camera"
+}
+```
+
+**Example Requests:**
+
+```bash
+# Get individual segments (default, fast)
+curl "https://func-video-query-poc-001.azurewebsites.net/api/video?camera=pmn-camera-01&start=2026-01-20T10:00:00Z&end=2026-01-20T10:30:00Z&code="
+
+# Get stitched video (requires ffmpeg)
+curl "https://func-video-query-poc-001.azurewebsites.net/api/video?camera=pmn-camera-01&start=2026-01-20T10:00:00Z&end=2026-01-20T10:30:00Z&stitch=true&code="
+```
+
+### POST /api/trigger
+
+Trigger an on-demand video capture event via Event Grid MQTT.
+
+**Auth Level:** Function (API key required via `code` parameter)
+
+**Query Parameters:**
+
+* `camera` (optional): Camera ID (default: "pmn-camera-01-triggered"). Must be one of:
+ * `pmn-camera-01-triggered`
+ * `pmn-camera-02-triggered`
+ * `pmn-camera-03-triggered`
+ * `pmn-camera-04-triggered`
+
+**Behavior:** Publishes an `ALERT_DLQC` event to the Event Grid Namespace MQTT broker (port 8883, MQTTv5, TLS) on topic `alerts/trigger`. Uses managed identity OAuth authentication. Enforces a 30-second per-camera rate limit.
+
+**Success Response (HTTP 202):**
+
+```json
+{
+ "status": "accepted",
+ "camera": "pmn-camera-01-triggered",
+ "event_id": 123456,
+ "timestamp": 1738368000000,
+ "estimated_ready_seconds": 120,
+ "message": "Trigger sent for pmn-camera-01-triggered. Video should be queryable in ~2 minutes."
+}
+```
+
+**Error Responses:**
+
+```json
+// 400 — invalid camera
+{"error": "Invalid camera: bad-cam", "allowed": ["pmn-camera-01-triggered", ...]}
+
+// 429 — rate limited
+{"error": "Rate limited", "retry_after_seconds": 25, "camera": "pmn-camera-01-triggered"}
+
+// 502 — MQTT publish failed
+{"error": "Trigger delivery failed"}
+
+// 503 — Event Grid not configured
+{"error": "Event Grid not configured", "detail": "EVENT_GRID_HOSTNAME not set"}
+```
+
+**Example Request:**
+
+```bash
+curl -X POST "https://func-video-query-poc-001.azurewebsites.net/api/trigger?camera=pmn-camera-01-triggered&code="
+```
+
+## Local Development
+
+### Quick Start
+
+1. Clone and navigate to the component:
+
+ ```bash
+ cd src/500-application/520-video-query-api
+ ```
+
+2. Copy and configure local settings:
+
+ ```bash
+ cp local.settings.json.example local.settings.json
+ nano local.settings.json
+ ```
+
+3. Install dependencies:
+
+ ```bash
+ pip install -r requirements.txt
+ ```
+
+4. Start the function locally:
+
+ ```bash
+ func start
+ ```
+
+5. Test the endpoint:
+
+ ```bash
+ curl "http://localhost:7071/api/video?camera=camera-01&start=2026-01-20T10:00:00Z&end=2026-01-20T10:30:00Z"
+ ```
+
+### Environment Configuration
+
+Required environment variables:
+
+* `STORAGE_ACCOUNT_NAME`: Azure Storage account name
+* `VIDEO_RECORDINGS_CONTAINER`: Container name for video segments (default: "video-recordings")
+* `TEMP_VIDEOS_CONTAINER`: Container name for merged videos (default: "temp-videos", required only for stitch=true)
+* `SAS_EXPIRY_HOURS`: SAS token expiry in hours (default: "24")
+* `FFMPEG_PATH`: Path to ffmpeg binary (default: "ffmpeg", required only for stitch=true)
+* `EVENT_GRID_HOSTNAME`: Event Grid Namespace MQTT hostname (required for trigger endpoint)
+* `AZURE_CLIENT_ID`: Managed identity client ID for OAuth (default: "video-query-trigger")
+
+## Production Deployment
+
+### Deploy to Azure Functions
+
+1. Create Azure Function App:
+
+ ```bash
+ az functionapp create \
+ --name video-query-func \
+ --resource-group rg-edge-ai \
+ --consumption-plan-location eastus \
+ --runtime python \
+ --runtime-version 3.11 \
+ --functions-version 4 \
+ --storage-account edgeaistorage
+ ```
+
+2. Enable managed identity and grant storage access:
+
+ ```bash
+ # Enable system-assigned managed identity
+ az functionapp identity assign \
+ --name video-query-func \
+ --resource-group rg-edge-ai
+
+ # Grant Storage Blob Data Contributor role
+ az role assignment create \
+ --assignee \
+ --role "Storage Blob Data Contributor" \
+ --scope /subscriptions//resourceGroups//providers/Microsoft.Storage/storageAccounts/
+ ```
+
+3. Configure application settings:
+
+ ```bash
+ az functionapp config appsettings set \
+ --name video-query-func \
+ --resource-group rg-edge-ai \
+ --settings \
+ STORAGE_ACCOUNT_NAME="" \
+ VIDEO_RECORDINGS_CONTAINER="video-recordings" \
+ TEMP_VIDEOS_CONTAINER="temp-videos" \
+ SAS_EXPIRY_HOURS="24" \
+ FFMPEG_PATH="$HOME/bin/ffmpeg"
+ ```
+
+4. Install ffmpeg in the Function App:
+
+ ```bash
+ # Option 1: Run install script during deployment
+ az functionapp deployment source config-zip \
+ --name video-query-func \
+ --resource-group rg-edge-ai \
+ --src
+
+ # The install-ffmpeg.sh script will run automatically
+
+ # Option 2: Use custom container with ffmpeg pre-installed
+ # See docs/custom-container-deployment.md for details
+ ```
+
+5. Deploy the function:
+
+ ```bash
+ func azure functionapp publish video-query-func
+ ```
+
+6. (Optional) Test stitching functionality:
+
+ ```bash
+ func azure functionapp publish video-query-func
+
+ # Test without stitching (fast)
+ curl "https://video-query-func.azurewebsites.net/api/video?camera=reolink-01&start=2026-01-20T10:00:00Z&end=2026-01-20T10:30:00Z"
+
+ # Test with stitching (requires ffmpeg)
+ curl "https://video-query-func.azurewebsites.net/api/video?camera=reolink-01&start=2026-01-20T10:00:00Z&end=2026-01-20T10:30:00Z&stitch=true"
+ ```
+
+## Performance
+
+* **Query Time**: < 1 second for queries up to 1 hour
+* **SAS Generation**: < 100ms per segment
+* **Response Time (stitch=false)**: < 2 seconds for typical queries (up to 100 segments)
+* **Response Time (stitch=true)**: 2-10 seconds depending on segment count and duration
+* **Stitching Time**: ~500ms for 30-minute video (no re-encoding)
+* **Storage Efficiency**: Hierarchical blob paths enable optimal distribution
+
+## Query Optimization
+
+The API automatically selects the optimal query strategy based on duration:
+
+* **< 1 hour**: Prefix-based list queries
+ * Fastest method for short timeframes
+ * Uses hierarchical path structure
+ * Example: `{camera_id}/{YYYY}/{MM}/{DD}/{HH}/`
+
+* **1-24 hours**: Blob index tag queries
+ * Efficient for longer timeframes
+ * Filters by `camera_id`, `start_time`, `end_time` tags
+ * Avoids full container scans
+
+## Stitching vs Segments
+
+Choose the appropriate response format based on your use case:
+
+### Use stitch=false (default, recommended)
+
+**Best for:**
+
+* Fast response times (< 2 seconds)
+* Programmatic access to individual segments
+* Parallel downloads
+* Analyzing specific time ranges
+* Maximum flexibility
+
+**Example:**
+
+```bash
+curl "https://func.azurewebsites.net/api/video?camera=reolink-01&start=2026-01-20T10:00:00Z&end=2026-01-20T10:30:00Z"
+```
+
+**Response:** Array of segment URLs with metadata
+
+**Client-side concatenation (if needed):**
+
+```bash
+# Download segments
+for url in $(cat response.json | jq -r '.segments[].url'); do
+ wget "$url"
+done
+
+# Concatenate with ffmpeg
+ffmpeg -f concat -safe 0 -i filelist.txt -c copy merged.mp4
+```
+
+### Use stitch=true
+
+**Best for:**
+
+* Single video file output
+* Users without ffmpeg/technical tools
+* Direct playback in simple video players
+* Simplified downstream processing
+
+**Requirements:**
+
+* FFmpeg installed in Function App environment
+* `temp-videos` container for temporary storage
+* Slower response time (2-10 seconds)
+
+**Example:**
+
+```bash
+curl "https://func.azurewebsites.net/api/video?camera=reolink-01&start=2026-01-20T10:00:00Z&end=2026-01-20T10:30:00Z&stitch=true"
+```
+
+**Response:** Single `video_url` pointing to merged MP4 file
+
+## Troubleshooting
+
+### "Video not available for requested timeframe"
+
+* Verify continuous recording is enabled on edge device
+* Check if timeframe is within ring buffer window (if using ring buffer only mode)
+* Verify blob storage connection and container exists
+
+### "Blob storage connection failed"
+
+* Verify connection string is correct
+* Check firewall rules allow Function App to access Storage
+* Verify container names match configuration
+
+### "Authentication failed"
+
+* Verify Azure credentials are configured
+* Check RBAC permissions for Storage account
+* Ensure Function App has Storage Blob Data Contributor role
+
+## Cost Considerations
+
+* **Function Execution**: Consumption plan charges per execution (minimal, < 1 second)
+* **Storage**: Cool tier recommended for segments older than 30 days
+* **Egress**: SAS URLs enable direct client downloads (no Function egress)
+* **No Temporary Storage**: API returns direct segment URLs
+
+## Security
+
+* Function-level authentication required by default
+* SAS URLs use read-only permissions
+* Temporary videos expire after 24 hours
+* Connection strings stored in Key Vault (recommended)
+
+## Contributing
+
+Follow repository contribution guidelines when modifying this component.
+
+## Related Components
+
+* **503-media-capture-service**: Continuous recording service that produces segments
+* **920-video-query-sdk**: Python SDK for data scientists (needs updating for new response format)
+
+## Recent Changes
+
+### March 2026 - Trigger Endpoint and Error Sanitization
+
+**New Endpoint**: `POST /api/trigger` — on-demand video capture via Event Grid MQTT.
+
+* Publishes `ALERT_DLQC` events to Event Grid Namespace (MQTTv5, TLS, port 8883)
+* OAuth authentication via managed identity
+* 30-second per-camera rate limiting
+* Returns 202 Accepted with `event_id` and `estimated_ready_seconds`
+
+**Security Hardening**: Error responses no longer expose internal details.
+
+* 500 returns `{"error": "Internal server error"}` (no stack trace or exception details)
+* 502 returns `{"error": "Trigger delivery failed"}` (no MQTT internals)
+* MQTT client uses `try/finally` for reliable `loop_stop()`/`disconnect()` cleanup
+
+### February 2026 - JSON Metadata Enrichment
+
+**Enhancement**: API now reads companion JSON metadata files to enrich segment responses.
+
+**New response fields** (when metadata available):
+
+* `duration_seconds`: Exact segment duration from recorder
+* `location`: Camera location/UNS hierarchy
+* `segment_start`: Precise start timestamp with nanoseconds
+* `segment_end`: Precise end timestamp with nanoseconds
+
+**Example enriched response:**
+
+```json
+{
+ "segments": [{
+ "url": "https://...",
+ "name": "camera-01/2026/02/04/18/segment_xxx.mp4",
+ "timestamp": "2026-02-04T18:49:42+00:00",
+ "size_bytes": 133169152,
+ "recording_type": "continuous",
+ "event_type": null,
+ "duration_seconds": 300,
+ "location": "PMN-Plant",
+ "segment_start": "2026-02-04T18:49:42.452942021+00:00",
+ "segment_end": "2026-02-04T18:54:42.452942021+00:00"
+ }]
+}
+```
+
+**Note**: Metadata fields are populated from sidecar `.json` files produced by the media capture service. Fields will be `null` if metadata is unavailable.
+
+### February 2026 - Enhanced Stitching with Metadata
+
+**Enhancement**: Server-side stitching now leverages JSON metadata for improved accuracy.
+
+**Improvements:**
+
+* **Precise segment ordering**: Segments sorted by `segment_start` metadata timestamps instead of filename parsing
+* **Gap detection**: Identifies and reports gaps > 5 seconds between consecutive segments
+* **Actual duration**: Reports `actual_duration_seconds` summed from metadata (vs. `query_duration_seconds` from request)
+* **Location tracking**: Reports unique locations across stitched segments
+* **Metadata coverage**: Indicates percentage of segments with available metadata
+
+**New stitch response fields:**
+
+* `actual_duration_seconds`: Total duration from summed segment metadata
+* `earliest_segment_start`: Precise timestamp of first segment start
+* `latest_segment_end`: Precise timestamp of last segment end
+* `locations`: Array of unique locations in the stitched video
+* `metadata_coverage`: Percentage of segments with JSON metadata (0-100)
+* `gaps`: Array of detected gaps with timing details (when present)
+* `gap_count`: Number of gaps detected
+* `total_gap_seconds`: Sum of all gap durations
+
+### January 2026 - Empty Results Response Fix
+
+**Bug Fix**: Empty query results now correctly return HTTP 200 instead of HTTP 404.
+
+**Previous behavior**: Queries with no matching segments returned 404 Not Found
+**New behavior**: Queries with no matching segments return 200 OK with empty segments array
+
+**Response format:**
+
+```json
+{
+ "segments": [],
+ "total_segments": 0,
+ "message": "No video segments found for camera '...' between ... and ..."
+}
+```
+
+**Rationale**: HTTP 404 implies the API endpoint doesn't exist, which is incorrect. Empty results are a valid response to a valid query.
+
+### January 2026 - Optional Server-Side Stitching
+
+**Enhancement**: Added optional `stitch=true` parameter for server-side video concatenation.
+
+**Default behavior (stitch=false)**: Returns array of individual segment URLs (fast, flexible)
+**Optional behavior (stitch=true)**: Returns single merged video URL (user-friendly, requires ffmpeg)
+
+**Use cases:**
+
+* **stitch=false**: Data scientists, programmatic access, parallel downloads, fast response
+* **stitch=true**: Business users, simple playback, no local tools required
+
+### January 2026 - API Response Format Update
+
+**Breaking Change**: The API now returns an array of individual segment URLs by default instead of a single merged video URL.
+
+**Reason**: Removed mandatory server-side video concatenation to simplify deployment and improve response times. Stitching is now optional.
+
+**Migration Guide**:
+
+* Old response: `{"video_url": "...", "duration": 1800, "segments": 6}`
+* New response: `{"segments": [{"url": "...", "name": "...", "timestamp": "...", "size_bytes": 36175872}], "total_segments": 1}`
+* Clients should iterate through `segments` array and download each URL
+* Video players that support playlists (e.g., VLC, ffmpeg concat) can play segments sequentially
+* Client-side concatenation example:
+
+ ```bash
+ # Download all segments
+ for url in $(cat segments.json | jq -r '.segments[].url'); do
+ wget "$url"
+ done
+
+ # Concatenate with ffmpeg (if needed)
+ ffmpeg -f concat -safe 0 -i filelist.txt -c copy merged.mp4
+ ```
+
+**Components Requiring Updates**:
+
+* `920-video-query-sdk`: Python SDK client wrapper needs to handle new response format
+* Jupyter notebooks using the SDK need response format updates
diff --git a/src/500-application/520-video-query-api/function_app.py b/src/500-application/520-video-query-api/function_app.py
new file mode 100644
index 00000000..0761596e
--- /dev/null
+++ b/src/500-application/520-video-query-api/function_app.py
@@ -0,0 +1,1198 @@
+"""
+Video Query API - Azure Function for time-based video queries.
+
+Provides REST endpoint for querying and retrieving video segments from
+continuous camera recordings stored in Azure Blob Storage.
+
+Supports filtering by event_type:
+- continuous: Regular continuous recording segments
+- triggered: MQTT-triggered capture segments (alerts, analytics events)
+- : Filter by specific event type (alert, analytics_disabled, etc.)
+"""
+
+import hashlib
+import json
+import logging
+import os
+import re
+import ssl
+import subprocess
+import tempfile
+import time
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Literal
+
+import azure.functions as func
+import paho.mqtt.client as mqtt
+from azure.identity import ManagedIdentityCredential
+from azure.storage.blob import BlobSasPermissions, BlobServiceClient, ContainerClient, generate_blob_sas
+
+app = func.FunctionApp()
+
+# Configure logging
+logger = logging.getLogger(__name__)
+logging.basicConfig(level=logging.DEBUG)
+
+# Trigger endpoint configuration
+_trigger_rate_limits: dict[str, float] = {}
+TRIGGER_RATE_LIMIT_SECONDS = 30
+ALLOWED_CAMERAS = {
+ "pmn-camera-01-triggered",
+ "pmn-camera-02-triggered",
+ "pmn-camera-03-triggered",
+ "pmn-camera-04-triggered",
+}
+EVENT_GRID_MQTT_SCOPE = "https://eventgrid.azure.net/.default"
+
+
+def _publish_mqtt_trigger(hostname: str, topic: str, payload: str) -> None:
+ """Publish a message to Event Grid namespace via MQTT with managed identity auth."""
+ client_id = os.environ.get("AZURE_CLIENT_ID", "video-query-trigger")
+ credential = ManagedIdentityCredential(client_id=client_id)
+ token = credential.get_token(EVENT_GRID_MQTT_SCOPE).token
+
+ client = mqtt.Client(
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+ client_id=client_id,
+ protocol=mqtt.MQTTv5,
+ )
+ client.username_pw_set(username=client_id, password=token)
+ client.tls_set(tls_version=ssl.PROTOCOL_TLS_CLIENT)
+
+ client.connect(hostname, port=8883)
+ client.loop_start()
+ try:
+ result = client.publish(topic, payload, qos=1)
+ result.wait_for_publish(timeout=10)
+ finally:
+ client.loop_stop()
+ client.disconnect()
+
+
+@app.route(route="health", auth_level=func.AuthLevel.ANONYMOUS)
+def health_check(req: func.HttpRequest) -> func.HttpResponse:
+ """Health check endpoint that tests blob storage connectivity."""
+ storage_account_name = os.getenv("STORAGE_ACCOUNT_NAME")
+ container_name = os.getenv(
+ "VIDEO_RECORDINGS_CONTAINER", "video-recordings")
+ blob_prefix = os.getenv("VIDEO_BLOB_PREFIX", "")
+ connection_string = os.getenv("STORAGE_CONNECTION_STRING")
+
+ try:
+ if connection_string:
+ # Use connection string if available
+ blob_service_client = BlobServiceClient.from_connection_string(
+ connection_string)
+ else:
+ # Fall back to managed identity
+ client_id = os.environ.get("AZURE_CLIENT_ID", "video-query-trigger")
+ credential = ManagedIdentityCredential(client_id=client_id)
+ account_url = f"https://{storage_account_name}.blob.core.windows.net"
+ blob_service_client = BlobServiceClient(
+ account_url=account_url,
+ credential=credential
+ )
+ container = blob_service_client.get_container_client(container_name)
+
+ # Try to list a few blobs
+ blob_count = 0
+ blob_names = []
+ for blob in container.list_blobs(name_starts_with=blob_prefix, results_per_page=5):
+ blob_count += 1
+ blob_names.append(blob.name)
+ if blob_count >= 5:
+ break
+
+ return func.HttpResponse(
+ json.dumps({
+ "status": "healthy",
+ "storage_account": storage_account_name,
+ "container": container_name,
+ "blob_prefix": blob_prefix,
+ "blobs_found": blob_count,
+ "sample_blobs": blob_names,
+ "auth_method": "connection_string" if connection_string else "managed_identity"
+ }),
+ status_code=200,
+ mimetype="application/json"
+ )
+ except Exception as e:
+ return func.HttpResponse(
+ json.dumps({
+ "status": "unhealthy",
+ "error": str(e),
+ "storage_account": storage_account_name,
+ "container": container_name
+ }),
+ status_code=500,
+ mimetype="application/json"
+ )
+
+
+def calculate_hash_prefix(camera_id: str) -> str:
+ """
+ Calculate hash prefix for camera ID matching Phase 1 implementation.
+
+ Args:
+ camera_id: Camera identifier
+
+ Returns:
+ Three-digit hash prefix (000-999)
+ """
+ hash_digest = hashlib.md5(camera_id.encode()).digest() # noqa: S324
+ prefix = hash_digest[0] % 1000
+ return f"{prefix:03d}"
+
+
+def parse_timestamp_from_blob_name(blob_name: str) -> datetime | None:
+ """
+ Parse timestamp from blob name for both continuous and triggered recordings.
+
+ Supported formats:
+ Continuous: {camera}/{YYYY}/{MM}/{DD}/{HH}/segment_{timestamp}_{camera}.mp4
+ Example: segment_2026-01-30T19:05:44Z_pmn-camera-01.mp4
+
+ Triggered: {camera}/{YYYY}/{MM}/{DD}/{HH}/{timestamp}_{event_type}_id_{id}.mkv
+ Example: 2026-01-30_190544_alert_event_id_12345.mkv
+
+ Args:
+ blob_name: Blob path
+
+ Returns:
+ Datetime object (timezone-naive UTC) or None if parsing fails
+ """
+ try:
+ parts = blob_name.split('/')
+ if len(parts) < 6:
+ return None
+
+ filename = parts[-1]
+
+ # Try continuous format: segment_{ISO8601_timestamp}_{camera}.mp4
+ if filename.startswith("segment_"):
+ timestamp_str = filename.split('_')[1]
+ if timestamp_str.endswith('Z'):
+ timestamp_str = timestamp_str[:-1]
+ dt = datetime.fromisoformat(timestamp_str)
+ if dt.tzinfo is not None:
+ dt = dt.replace(tzinfo=None)
+ return dt
+
+ # Try triggered format: {YYYY-MM-DD}_{HHMMSS}_...
+ # Example: 2026-01-30_190544_alert_event_id_12345.mkv
+ match = re.match(r'^(\d{4}-\d{2}-\d{2})_(\d{6})_', filename)
+ if match:
+ date_str = match.group(1)
+ time_str = match.group(2)
+ timestamp_str = f"{date_str}T{time_str[:2]}:{time_str[2:4]}:{time_str[4:6]}"
+ return datetime.fromisoformat(timestamp_str)
+
+ # Fallback: try to extract timestamp from path hierarchy
+ # Path format: {camera}/{YYYY}/{MM}/{DD}/{HH}/...
+ if len(parts) >= 5:
+ try:
+ year = int(parts[-5])
+ month = int(parts[-4])
+ day = int(parts[-3])
+ hour = int(parts[-2])
+ return datetime(year, month, day, hour, 0, 0)
+ except (ValueError, IndexError):
+ pass
+
+ return None
+ except (IndexError, ValueError) as e:
+ logger.warning(
+ f"Failed to parse timestamp from blob name {blob_name}: {e}")
+ return None
+
+
+# Event type detection patterns for triggered recordings
+TRIGGERED_PATTERNS = [
+ re.compile(r'_alert_event_id_\d+'),
+ re.compile(r'_analytics_disabled_\w*_timestamp_\d+'),
+ re.compile(r'_\w+_id_\d+'),
+]
+
+
+def fetch_segment_metadata(
+ container: ContainerClient,
+ video_blob_name: str
+) -> dict | None:
+ """
+ Fetch companion JSON metadata for a video segment.
+
+ Args:
+ container: Blob container client
+ video_blob_name: Path to the video blob (e.g., "camera/2026/02/04/18/segment_xxx.mp4")
+
+ Returns:
+ Dictionary with metadata fields or None if not found
+ """
+ # Derive JSON metadata path from video path
+ json_blob_name = video_blob_name.rsplit('.', 1)[0] + '.json'
+
+ try:
+ blob_client = container.get_blob_client(json_blob_name)
+ blob_data = blob_client.download_blob()
+ metadata = json.loads(blob_data.readall().decode('utf-8'))
+ logger.debug(f"Fetched metadata for {video_blob_name}: {metadata}")
+ return metadata
+ except Exception as e:
+ logger.debug(f"No metadata found for {video_blob_name}: {e}")
+ return None
+
+
+def detect_recording_type(blob_name: str) -> tuple[Literal["continuous", "triggered"], str | None]:
+ """
+ Detect whether a blob is a continuous or triggered recording.
+
+ Continuous recordings follow the pattern:
+ {camera}/{YYYY}/{MM}/{DD}/{HH}/segment_{timestamp}_{camera}.mp4
+
+ Triggered recordings contain event markers in the filename:
+ - _alert_event_id_{id}
+ - _analytics_disabled_{service}_timestamp_{ts}
+ - _{event_type}_id_{id}
+
+ Args:
+ blob_name: Full blob path
+
+ Returns:
+ Tuple of (recording_type, specific_event_type)
+ - recording_type: "continuous" or "triggered"
+ - specific_event_type: For triggered, the specific type (alert, analytics_disabled, etc.)
+ """
+ filename = blob_name.split('/')[-1]
+
+ # Check if the blob is in a triggered camera folder
+ if '-triggered/' in blob_name:
+ # Check for specific event types in the filename
+ if '_alert_event_id_' in filename:
+ return ("triggered", "alert")
+ if '_analytics_disabled_' in filename:
+ match = re.search(r'_analytics_disabled_(\w+)_timestamp_', filename)
+ if match:
+ return ("triggered", f"analytics_disabled_{match.group(1)}")
+ return ("triggered", "analytics_disabled")
+ match = re.search(r'_(\w+)_id_\d+', filename)
+ if match:
+ event_type = match.group(1)
+ if event_type not in ('segment',):
+ return ("triggered", event_type)
+ # In a triggered folder but no specific event marker
+ return ("triggered", "capture")
+
+ # Check for alert events
+ if '_alert_event_id_' in filename:
+ return ("triggered", "alert")
+
+ # Check for analytics disabled events
+ if '_analytics_disabled_' in filename:
+ match = re.search(r'_analytics_disabled_(\w+)_timestamp_', filename)
+ if match:
+ return ("triggered", f"analytics_disabled_{match.group(1)}")
+ return ("triggered", "analytics_disabled")
+
+ # Check for generic event ID pattern
+ match = re.search(r'_(\w+)_id_\d+', filename)
+ if match:
+ event_type = match.group(1)
+ if event_type not in ('segment',): # Exclude false positives
+ return ("triggered", event_type)
+
+ # Default: continuous recording (standard segment format)
+ return ("continuous", None)
+
+
+def filter_segments_by_event_type(
+ segments: list[dict],
+ event_type_filter: str | None
+) -> list[dict]:
+ """
+ Filter segments by event type.
+
+ Args:
+ segments: List of segment metadata dictionaries
+ event_type_filter: One of:
+ - None: No filtering, return all
+ - "continuous": Only continuous recordings
+ - "triggered": Only triggered recordings (any type)
+ - "": Only specific triggered type (alert, analytics_disabled, etc.)
+
+ Returns:
+ Filtered list of segments
+ """
+ if not event_type_filter:
+ return segments
+
+ event_type_filter = event_type_filter.lower().strip()
+ filtered = []
+
+ for segment in segments:
+ recording_type, specific_type = detect_recording_type(segment['name'])
+
+ if event_type_filter == "continuous":
+ if recording_type == "continuous":
+ filtered.append(segment)
+ elif event_type_filter == "triggered":
+ if recording_type == "triggered":
+ filtered.append(segment)
+ else:
+ # Filter by specific event type
+ if specific_type and specific_type.lower() == event_type_filter:
+ filtered.append(segment)
+ elif specific_type and event_type_filter in specific_type.lower():
+ filtered.append(segment)
+
+ return filtered
+
+
+def query_blobs_by_prefix(
+ container: ContainerClient,
+ camera_id: str,
+ start_time: datetime,
+ end_time: datetime,
+ blob_prefix: str = ""
+) -> list[dict]:
+ """
+ Query blobs using prefix-based list (optimized for < 1 hour queries).
+
+ Args:
+ container: Blob container client
+ camera_id: Camera identifier
+ start_time: Query start time
+ end_time: Query end time
+ blob_prefix: Optional prefix for blob path (e.g., 'video-recordings')
+
+ Returns:
+ List of blob metadata dictionaries
+ """
+ segments = []
+
+ hours_to_check = set()
+ current = start_time.replace(minute=0, second=0, microsecond=0)
+ while current <= end_time:
+ hours_to_check.add(current)
+ current += timedelta(hours=1)
+
+ for hour in hours_to_check:
+ if blob_prefix:
+ prefix = f"{blob_prefix}/{camera_id}/{hour.strftime('%Y/%m/%d/%H')}"
+ else:
+ prefix = f"{camera_id}/{hour.strftime('%Y/%m/%d/%H')}"
+ logger.info(f"Querying prefix: {prefix}")
+
+ try:
+ blob_count = 0
+ video_extensions = ('.mp4', '.mkv', '.avi', '.mov')
+ for blob in container.list_blobs(name_starts_with=prefix):
+ blob_count += 1
+ if not blob.name.lower().endswith(video_extensions):
+ logger.debug(f"Skipping non-video blob: {blob.name}")
+ continue
+ logger.info(f"Found blob: {blob.name}")
+ blob_time = parse_timestamp_from_blob_name(blob.name)
+ logger.info(
+ f"Parsed timestamp: {blob_time}, start: {start_time}, end: {end_time}")
+ if blob_time and start_time <= blob_time < end_time:
+ segments.append({
+ 'name': blob.name,
+ 'timestamp': blob_time,
+ 'size': blob.size
+ })
+ logger.info(f"Found {blob_count} blobs with prefix {prefix}")
+ except Exception as e:
+ logger.error(
+ f"Error listing blobs with prefix {prefix}: {e}", exc_info=True)
+
+ segments.sort(key=lambda x: x['timestamp'])
+ return segments
+
+
+def query_blobs_by_tags(
+ container: ContainerClient,
+ camera_id: str,
+ start_time: datetime,
+ end_time: datetime
+) -> list[dict]:
+ """
+ Query blobs using blob index tags (optimized for 1-24 hour queries).
+
+ Args:
+ container: Blob container client
+ camera_id: Camera identifier
+ start_time: Query start time
+ end_time: Query end time
+
+ Returns:
+ List of blob metadata dictionaries
+ """
+ query = (
+ f"camera_id='{camera_id}' AND "
+ f"start_time>='{start_time.isoformat()}' AND "
+ f"end_time<='{end_time.isoformat()}'"
+ )
+
+ logger.info(f"Querying blobs by tags: {query}")
+
+ segments = []
+ video_extensions = ('.mp4', '.mkv', '.avi', '.mov')
+ try:
+ for blob in container.find_blobs_by_tags(filter_expression=query):
+ if not blob.name.lower().endswith(video_extensions):
+ logger.debug(f"Skipping non-video blob: {blob.name}")
+ continue
+ blob_time = parse_timestamp_from_blob_name(blob.name)
+ if blob_time:
+ segments.append({
+ 'name': blob.name,
+ 'timestamp': blob_time,
+ 'size': getattr(blob, 'size', 0)
+ })
+ except Exception as e:
+ logger.error(f"Error querying blobs by tags: {e}")
+
+ segments.sort(key=lambda x: x['timestamp'])
+ return segments
+
+
+def enrich_segments_with_metadata(
+ container: ContainerClient,
+ segments: list[dict]
+) -> list[dict]:
+ """
+ Fetch and attach JSON metadata to each video segment.
+
+ Args:
+ container: Blob container client
+ segments: List of segment metadata dictionaries
+
+ Returns:
+ Segments enriched with metadata fields (segment_start, segment_end, duration_seconds, location)
+ """
+ enriched = []
+ for segment in segments:
+ metadata = fetch_segment_metadata(container, segment['name'])
+ enriched_segment = segment.copy()
+ if metadata:
+ enriched_segment['metadata'] = metadata
+ enriched_segment['segment_start'] = metadata.get('segment_start')
+ enriched_segment['segment_end'] = metadata.get('segment_end')
+ enriched_segment['duration_seconds'] = metadata.get(
+ 'duration_seconds')
+ enriched_segment['location'] = metadata.get('location')
+ enriched.append(enriched_segment)
+ return enriched
+
+
+def sort_segments_by_metadata(segments: list[dict]) -> list[dict]:
+ """
+ Sort segments by precise segment_start timestamp from metadata.
+ Falls back to filename-parsed timestamp if metadata unavailable.
+
+ Args:
+ segments: List of enriched segment dictionaries
+
+ Returns:
+ Segments sorted by segment_start (most precise) or timestamp (fallback)
+ """
+ def sort_key(seg):
+ # Prefer segment_start from metadata for precise ordering
+ if seg.get('segment_start'):
+ try:
+ return datetime.fromisoformat(seg['segment_start'].replace('Z', '+00:00'))
+ except (ValueError, TypeError):
+ pass
+ # Fall back to filename-parsed timestamp (convert naive to UTC-aware for comparison)
+ ts = seg.get('timestamp')
+ if ts:
+ if ts.tzinfo is None:
+ return ts.replace(tzinfo=UTC)
+ return ts
+ return datetime.min.replace(tzinfo=UTC)
+
+ return sorted(segments, key=sort_key)
+
+
+def detect_segment_gaps(segments: list[dict], threshold_seconds: float = 5.0) -> list[dict]:
+ """
+ Detect gaps between consecutive segments using metadata timestamps.
+
+ Args:
+ segments: Sorted list of enriched segments
+ threshold_seconds: Minimum gap duration to report (default 5s)
+
+ Returns:
+ List of gap records with start, end, and duration
+ """
+ gaps = []
+ for i in range(len(segments) - 1):
+ current = segments[i]
+ next_seg = segments[i + 1]
+
+ current_end = current.get('segment_end')
+ next_start = next_seg.get('segment_start')
+
+ if current_end and next_start:
+ try:
+ end_time = datetime.fromisoformat(
+ current_end.replace('Z', '+00:00'))
+ start_time = datetime.fromisoformat(
+ next_start.replace('Z', '+00:00'))
+ gap_seconds = (start_time - end_time).total_seconds()
+
+ if gap_seconds > threshold_seconds:
+ gaps.append({
+ "after_segment": current['name'],
+ "before_segment": next_seg['name'],
+ "gap_start": current_end,
+ "gap_end": next_start,
+ "gap_seconds": round(gap_seconds, 2)
+ })
+ except (ValueError, TypeError):
+ pass
+
+ return gaps
+
+
+def calculate_stitch_metrics(segments: list[dict]) -> dict:
+ """
+ Calculate stitching metrics from segment metadata.
+
+ Args:
+ segments: List of enriched segments
+
+ Returns:
+ Dictionary with total_duration, earliest_start, latest_end, locations
+ """
+ total_duration = 0.0
+ earliest_start = None
+ latest_end = None
+ locations = set()
+
+ for seg in segments:
+ # Sum actual durations from metadata
+ if seg.get('duration_seconds'):
+ total_duration += seg['duration_seconds']
+
+ # Track time range
+ if seg.get('segment_start'):
+ try:
+ start = datetime.fromisoformat(
+ seg['segment_start'].replace('Z', '+00:00'))
+ if earliest_start is None or start < earliest_start:
+ earliest_start = start
+ except (ValueError, TypeError):
+ pass
+
+ if seg.get('segment_end'):
+ try:
+ end = datetime.fromisoformat(
+ seg['segment_end'].replace('Z', '+00:00'))
+ if latest_end is None or end > latest_end:
+ latest_end = end
+ except (ValueError, TypeError):
+ pass
+
+ # Collect locations
+ if seg.get('location'):
+ locations.add(seg['location'])
+
+ return {
+ "total_duration_seconds": round(total_duration, 2) if total_duration > 0 else None,
+ "earliest_segment_start": earliest_start.isoformat() if earliest_start else None,
+ "latest_segment_end": latest_end.isoformat() if latest_end else None,
+ "locations": list(locations) if locations else None,
+ "metadata_coverage": sum(1 for s in segments if s.get('metadata')) / len(segments) if segments else 0
+ }
+
+
+def download_segments(
+ container: ContainerClient,
+ segments: list[dict],
+ temp_dir: Path
+) -> list[Path]:
+ """
+ Download blob segments to temporary directory.
+
+ Args:
+ container: Blob container client
+ segments: List of segment metadata
+ temp_dir: Temporary directory path
+
+ Returns:
+ List of downloaded file paths
+ """
+ downloaded_files = []
+
+ for i, segment in enumerate(segments):
+ blob_name = segment['name']
+ local_path = temp_dir / f"segment_{i:03d}.mp4"
+
+ logger.info(f"Downloading segment {i+1}/{len(segments)}: {blob_name}")
+
+ try:
+ blob_client = container.get_blob_client(blob_name)
+ with open(local_path, "wb") as f:
+ blob_data = blob_client.download_blob()
+ f.write(blob_data.readall())
+
+ downloaded_files.append(local_path)
+ except Exception as e:
+ logger.error(f"Failed to download segment {blob_name}: {e}")
+ raise
+
+ return downloaded_files
+
+
+def concat_segments(input_files: list[Path], output_file: Path) -> None:
+ """
+ Concatenate video segments using FFmpeg concat demuxer with copy codec.
+
+ CRITICAL: All segments MUST have identical codec parameters.
+ Use consistent encoding during capture with keyframe alignment.
+
+ Performance: ~500ms for 30-minute video (no re-encoding)
+
+ Args:
+ input_files: List of input video file paths
+ output_file: Output merged video file path
+
+ Raises:
+ subprocess.CalledProcessError: If FFmpeg fails
+ """
+ concat_file = output_file.parent / "concat.txt"
+
+ with open(concat_file, "w") as f:
+ for file_path in input_files:
+ f.write(f"file '{file_path.absolute()}'\n")
+
+ # Use bundled ffmpeg binary if available, otherwise fall back to system ffmpeg
+ script_dir = Path(__file__).parent
+ bundled_ffmpeg = script_dir / "bin" / "ffmpeg"
+ if bundled_ffmpeg.exists():
+ ffmpeg_path = str(bundled_ffmpeg)
+ else:
+ ffmpeg_path = os.getenv("FFMPEG_PATH", "ffmpeg")
+
+ cmd = [
+ ffmpeg_path,
+ "-f", "concat",
+ "-safe", "0",
+ "-i", str(concat_file),
+ "-c", "copy",
+ "-y",
+ str(output_file)
+ ]
+
+ logger.info(f"Running FFmpeg: {' '.join(cmd)}")
+
+ try:
+ result = subprocess.run( # noqa: S603
+ cmd,
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=300
+ )
+ logger.info("FFmpeg completed successfully")
+ if result.stderr:
+ logger.debug(f"FFmpeg stderr: {result.stderr}")
+ except subprocess.CalledProcessError as e:
+ logger.error(f"FFmpeg failed with exit code {e.returncode}")
+ logger.error(f"FFmpeg stderr: {e.stderr}")
+ raise
+ except subprocess.TimeoutExpired:
+ logger.error("FFmpeg timed out after 300 seconds")
+ raise
+
+
+def generate_sas_url(
+ blob_service_client: BlobServiceClient,
+ container_name: str,
+ blob_name: str,
+ expiry_hours: int = 24,
+ account_key: str = None
+) -> str:
+ """
+ Generate SAS URL for blob with read-only permissions.
+
+ Args:
+ blob_service_client: Blob service client
+ container_name: Container name
+ blob_name: Blob name
+ expiry_hours: SAS token expiry in hours
+ account_key: Storage account key (optional, uses user delegation if None)
+
+ Returns:
+ SAS URL for blob access
+ """
+ blob_client = blob_service_client.get_blob_client(
+ container=container_name,
+ blob=blob_name
+ )
+
+ account_name = blob_service_client.account_name
+
+ if account_key:
+ # Use account key for SAS generation
+ sas_token = generate_blob_sas(
+ account_name=account_name,
+ container_name=container_name,
+ blob_name=blob_name,
+ account_key=account_key,
+ permission=BlobSasPermissions(read=True),
+ start=datetime.now(UTC) - timedelta(minutes=5),
+ expiry=datetime.now(UTC) + timedelta(hours=expiry_hours)
+ )
+ else:
+ # Use user delegation key (requires managed identity)
+ key_start_time = datetime.now(UTC) - timedelta(minutes=5)
+ key_expiry_time = datetime.now(UTC) + timedelta(hours=expiry_hours + 1)
+ user_delegation_key = blob_service_client.get_user_delegation_key(
+ key_start_time=key_start_time,
+ key_expiry_time=key_expiry_time
+ )
+ sas_token = generate_blob_sas(
+ account_name=account_name,
+ container_name=container_name,
+ blob_name=blob_name,
+ user_delegation_key=user_delegation_key,
+ permission=BlobSasPermissions(read=True),
+ start=datetime.now(UTC) - timedelta(minutes=5),
+ expiry=datetime.now(UTC) + timedelta(hours=expiry_hours)
+ )
+
+ sas_url = f"{blob_client.url}?{sas_token}"
+ return sas_url
+
+
+@app.route(route="video", methods=["GET"], auth_level=func.AuthLevel.FUNCTION)
+def get_video(req: func.HttpRequest) -> func.HttpResponse:
+ """
+ Query and retrieve video for specific camera and timeframe.
+
+ Query Parameters:
+ camera: Camera ID (required)
+ start: Start timestamp in ISO 8601 format (required)
+ end: End timestamp in ISO 8601 format (required)
+ event_type: Filter by recording type (optional)
+ - "continuous": Only continuous recordings
+ - "triggered": Only MQTT-triggered recordings
+ - "": Specific event type (alert, analytics_disabled, etc.)
+ stitch: Whether to stitch segments server-side (optional, default: false)
+
+ Returns:
+ JSON response with video_url, duration, segments count
+ """
+ logger.info("Video query request received")
+
+ try:
+ camera_id = req.params.get('camera')
+ start_str = req.params.get('start')
+ end_str = req.params.get('end')
+ stitch = req.params.get('stitch', 'false').lower() == 'true'
+ event_type_filter = req.params.get('event_type')
+
+ if not camera_id:
+ return func.HttpResponse(
+ json.dumps({"error": "Missing required parameter: camera"}),
+ status_code=400,
+ mimetype="application/json"
+ )
+
+ if not start_str or not end_str:
+ return func.HttpResponse(
+ json.dumps(
+ {"error": "Missing required parameters: start and end"}),
+ status_code=400,
+ mimetype="application/json"
+ )
+
+ try:
+ # Parse timestamps, handling Z suffix and ensuring timezone-naive UTC
+ start_str_clean = start_str.replace('Z', '')
+ end_str_clean = end_str.replace('Z', '')
+ start_time = datetime.fromisoformat(start_str_clean)
+ end_time = datetime.fromisoformat(end_str_clean)
+ # Strip timezone info if present (assume UTC)
+ if start_time.tzinfo is not None:
+ start_time = start_time.replace(tzinfo=None)
+ if end_time.tzinfo is not None:
+ end_time = end_time.replace(tzinfo=None)
+ except ValueError as e:
+ return func.HttpResponse(
+ json.dumps({"error": f"Invalid timestamp format: {e}"}),
+ status_code=400,
+ mimetype="application/json"
+ )
+
+ if end_time <= start_time:
+ return func.HttpResponse(
+ json.dumps({"error": "end time must be after start time"}),
+ status_code=400,
+ mimetype="application/json"
+ )
+
+ duration_seconds = (end_time - start_time).total_seconds()
+
+ if duration_seconds > 86400:
+ return func.HttpResponse(
+ json.dumps({"error": "Maximum query duration is 24 hours"}),
+ status_code=400,
+ mimetype="application/json"
+ )
+
+ storage_account_name = os.getenv("STORAGE_ACCOUNT_NAME")
+ if not storage_account_name:
+ logger.error("STORAGE_ACCOUNT_NAME not configured")
+ return func.HttpResponse(
+ json.dumps({"error": "Storage connection not configured"}),
+ status_code=500,
+ mimetype="application/json"
+ )
+
+ video_container_name = os.getenv(
+ "VIDEO_RECORDINGS_CONTAINER", "video-recordings")
+ temp_container_name = os.getenv("TEMP_VIDEOS_CONTAINER", "temp-videos")
+ sas_expiry_hours = int(os.getenv("SAS_EXPIRY_HOURS", "24"))
+
+ # Use connection string if available, otherwise fall back to managed identity
+ connection_string = os.getenv("STORAGE_CONNECTION_STRING")
+ account_key = None
+ if connection_string:
+ blob_service_client = BlobServiceClient.from_connection_string(
+ connection_string)
+ # Extract account key from connection string for SAS generation
+ for part in connection_string.split(';'):
+ if part.startswith('AccountKey='):
+ account_key = part.split('=', 1)[1]
+ break
+ else:
+ client_id = os.environ.get("AZURE_CLIENT_ID", "video-query-trigger")
+ credential = ManagedIdentityCredential(client_id=client_id)
+ account_url = f"https://{storage_account_name}.blob.core.windows.net"
+ blob_service_client = BlobServiceClient(
+ account_url=account_url,
+ credential=credential
+ )
+ video_container = blob_service_client.get_container_client(
+ video_container_name)
+
+ # Optional blob prefix for subvolume path (e.g., 'video-recordings')
+ blob_prefix = os.getenv("VIDEO_BLOB_PREFIX", "")
+
+ # Prefix-based query works for all durations up to 24 hours
+ # (max 25 hourly list operations). Tag-based query requires blob
+ # index tags that may not be present on all recordings.
+ logger.info(
+ f"Using prefix-based query for {duration_seconds}s duration")
+ segments = query_blobs_by_prefix(
+ video_container, camera_id, start_time, end_time, blob_prefix)
+
+ if not segments and duration_seconds > 3600:
+ logger.info(
+ "Prefix query returned no results, trying tag-based query")
+ segments = query_blobs_by_tags(
+ video_container, camera_id, start_time, end_time)
+
+ # Apply event type filtering if specified
+ if event_type_filter:
+ original_count = len(segments)
+ segments = filter_segments_by_event_type(
+ segments, event_type_filter)
+ logger.info(
+ f"Filtered segments by event_type='{event_type_filter}': "
+ f"{original_count} -> {len(segments)}"
+ )
+
+ if not segments:
+ # Return 200 with empty results - 404 should only be for missing resources
+ return func.HttpResponse(
+ json.dumps({
+ "segments": [],
+ "total_segments": 0,
+ "camera_id": camera_id,
+ "start_time": start_str,
+ "end_time": end_str,
+ "event_type_filter": event_type_filter,
+ "message": "No video segments found for requested timeframe"
+ }),
+ status_code=200,
+ mimetype="application/json"
+ )
+
+ logger.info(f"Found {len(segments)} segments for camera {camera_id}")
+
+ if stitch:
+ # Server-side stitching requested
+ logger.info("Stitching segments on server")
+
+ # Filter to only video files (exclude .json metadata files)
+ video_extensions = ('.mp4', '.mkv', '.avi', '.mov')
+ video_segments = [
+ s for s in segments
+ if s['name'].lower().endswith(video_extensions)
+ ]
+ logger.info(
+ f"Filtered to {len(video_segments)} video files "
+ f"(excluded {len(segments) - len(video_segments)} non-video files)"
+ )
+
+ if not video_segments:
+ return func.HttpResponse(
+ json.dumps({
+ "error": "No video files found for stitching",
+ "camera_id": camera_id,
+ "start_time": start_str,
+ "end_time": end_str
+ }),
+ status_code=404,
+ mimetype="application/json"
+ )
+
+ # Enrich segments with JSON metadata for precise ordering and metrics
+ logger.info("Enriching segments with JSON metadata")
+ enriched_segments = enrich_segments_with_metadata(
+ video_container, video_segments)
+
+ # Sort by precise segment_start from metadata (falls back to filename timestamp)
+ sorted_segments = sort_segments_by_metadata(enriched_segments)
+ logger.info(
+ f"Sorted {len(sorted_segments)} segments by metadata timestamps")
+
+ # Detect gaps between segments
+ gaps = detect_segment_gaps(sorted_segments)
+ if gaps:
+ logger.warning(
+ f"Detected {len(gaps)} gaps between segments: {gaps}")
+
+ # Calculate stitch metrics from metadata
+ stitch_metrics = calculate_stitch_metrics(sorted_segments)
+ logger.info(f"Stitch metrics: {stitch_metrics}")
+
+ temp_dir = Path(tempfile.mkdtemp(prefix="video_query_"))
+ try:
+ downloaded_files = download_segments(
+ video_container, sorted_segments, temp_dir)
+
+ output_file = temp_dir / "merged.mp4"
+ concat_segments(downloaded_files, output_file)
+
+ merged_blob_name = (
+ f"temp/{camera_id}/"
+ f"{start_time.strftime('%Y%m%d_%H%M%S')}_"
+ f"{end_time.strftime('%Y%m%d_%H%M%S')}.mp4"
+ )
+
+ temp_container = blob_service_client.get_container_client(
+ temp_container_name)
+
+ logger.info(f"Uploading merged video to {merged_blob_name}")
+ with open(output_file, "rb") as data:
+ temp_container.upload_blob(
+ name=merged_blob_name,
+ data=data,
+ overwrite=True
+ )
+
+ sas_url = generate_sas_url(
+ blob_service_client,
+ temp_container_name,
+ merged_blob_name,
+ sas_expiry_hours,
+ account_key
+ )
+
+ # Build enhanced response with metadata-derived metrics
+ response_data = {
+ "video_url": sas_url,
+ "query_duration_seconds": duration_seconds,
+ "segment_count": len(sorted_segments),
+ "camera_id": camera_id,
+ "start_time": start_str,
+ "end_time": end_str,
+ "event_type_filter": event_type_filter,
+ "expires_at": (datetime.now(UTC) + timedelta(hours=sas_expiry_hours)).isoformat(),
+ "stitched": True
+ }
+
+ # Add metadata-derived stitch metrics
+ if stitch_metrics.get("total_duration_seconds"):
+ response_data["actual_duration_seconds"] = stitch_metrics["total_duration_seconds"]
+ if stitch_metrics.get("earliest_segment_start"):
+ response_data["earliest_segment_start"] = stitch_metrics["earliest_segment_start"]
+ if stitch_metrics.get("latest_segment_end"):
+ response_data["latest_segment_end"] = stitch_metrics["latest_segment_end"]
+ if stitch_metrics.get("locations"):
+ response_data["locations"] = stitch_metrics["locations"]
+ response_data["metadata_coverage"] = round(
+ stitch_metrics.get("metadata_coverage", 0) * 100, 1)
+
+ # Include gap warnings if any detected
+ if gaps:
+ response_data["gaps"] = gaps
+ response_data["gap_count"] = len(gaps)
+ response_data["total_gap_seconds"] = round(
+ sum(g["gap_seconds"] for g in gaps), 2)
+
+ logger.info(
+ f"Video query completed successfully for camera {camera_id}")
+
+ return func.HttpResponse(
+ json.dumps(response_data),
+ status_code=200,
+ mimetype="application/json"
+ )
+
+ finally:
+ import shutil
+ try:
+ shutil.rmtree(temp_dir)
+ logger.info(f"Cleaned up temporary directory: {temp_dir}")
+ except Exception as e:
+ logger.warning(
+ f"Failed to cleanup temporary directory {temp_dir}: {e}")
+ else:
+ # Return individual segments (default)
+ segment_urls = []
+ for segment in segments:
+ sas_url = generate_sas_url(
+ blob_service_client,
+ video_container_name,
+ segment["name"],
+ sas_expiry_hours,
+ account_key
+ )
+ recording_type, specific_event = detect_recording_type(
+ segment["name"])
+
+ # Fetch companion JSON metadata for enriched response
+ metadata = fetch_segment_metadata(
+ video_container, segment["name"])
+
+ segment_data = {
+ "url": sas_url,
+ "name": segment["name"],
+ "timestamp": segment["timestamp"].isoformat() if segment["timestamp"] else None,
+ "size_bytes": segment.get("size"),
+ "recording_type": recording_type,
+ "event_type": specific_event
+ }
+
+ # Enrich with metadata fields if available
+ if metadata:
+ segment_data["duration_seconds"] = metadata.get(
+ "duration_seconds")
+ segment_data["location"] = metadata.get("location")
+ segment_data["segment_start"] = metadata.get(
+ "segment_start")
+ segment_data["segment_end"] = metadata.get("segment_end")
+
+ segment_urls.append(segment_data)
+
+ response_data = {
+ "segments": segment_urls,
+ "total_segments": len(segments),
+ "camera_id": camera_id,
+ "start_time": start_str,
+ "end_time": end_str,
+ "event_type_filter": event_type_filter,
+ "expires_at": (
+ datetime.now(UTC) + timedelta(hours=sas_expiry_hours)
+ ).isoformat(),
+ "stitched": False
+ }
+
+ logger.info(
+ f"Video query completed successfully for camera {camera_id}")
+
+ return func.HttpResponse(
+ json.dumps(response_data),
+ status_code=200,
+ mimetype="application/json"
+ )
+
+ except Exception:
+ logger.exception("Unexpected error processing video query")
+ return func.HttpResponse(
+ json.dumps({"error": "Internal server error"}),
+ status_code=500,
+ mimetype="application/json"
+ )
+
+
+@app.route(route="trigger", methods=["POST"], auth_level=func.AuthLevel.FUNCTION)
+def trigger_capture(req: func.HttpRequest) -> func.HttpResponse:
+ """Trigger a video capture event via Event Grid MQTT."""
+ camera = req.params.get("camera", "pmn-camera-01-triggered")
+
+ if camera not in ALLOWED_CAMERAS:
+ return func.HttpResponse(
+ json.dumps({"error": f"Invalid camera: {camera}", "allowed": sorted(ALLOWED_CAMERAS)}),
+ status_code=400,
+ mimetype="application/json",
+ )
+
+ now = time.time()
+ last_trigger = _trigger_rate_limits.get(camera, 0)
+ if now - last_trigger < TRIGGER_RATE_LIMIT_SECONDS:
+ remaining = int(TRIGGER_RATE_LIMIT_SECONDS - (now - last_trigger))
+ return func.HttpResponse(
+ json.dumps({"error": "Rate limited", "retry_after_seconds": remaining, "camera": camera}),
+ status_code=429,
+ mimetype="application/json",
+ )
+
+ timestamp_ms = int(now * 1000)
+ event_id = int(now) % 1000000
+ trigger_payload = json.dumps({
+ "Alert": True,
+ "attributes": {
+ "devices": [{
+ "device_data": {
+ "type": "ALERT_DLQC",
+ "timestamp": timestamp_ms,
+ "event_id": event_id,
+ }
+ }]
+ },
+ })
+
+ eg_hostname = os.environ.get("EVENT_GRID_HOSTNAME", "")
+ if not eg_hostname:
+ return func.HttpResponse(
+ json.dumps({"error": "Event Grid not configured", "detail": "EVENT_GRID_HOSTNAME not set"}),
+ status_code=503,
+ mimetype="application/json",
+ )
+
+ try:
+ _publish_mqtt_trigger(
+ hostname=eg_hostname,
+ topic="alerts/trigger",
+ payload=trigger_payload,
+ )
+ except Exception:
+ logging.exception("MQTT trigger publish failed")
+ return func.HttpResponse(
+ json.dumps({"error": "Trigger delivery failed"}),
+ status_code=502,
+ mimetype="application/json",
+ )
+
+ _trigger_rate_limits[camera] = now
+
+ return func.HttpResponse(
+ json.dumps({
+ "status": "accepted",
+ "camera": camera,
+ "event_id": event_id,
+ "timestamp": timestamp_ms,
+ "estimated_ready_seconds": 120,
+ "message": f"Trigger sent for {camera}. Video should be queryable in ~2 minutes.",
+ }),
+ status_code=202,
+ mimetype="application/json",
+ )
diff --git a/src/500-application/520-video-query-api/host.json b/src/500-application/520-video-query-api/host.json
new file mode 100644
index 00000000..e0b78a93
--- /dev/null
+++ b/src/500-application/520-video-query-api/host.json
@@ -0,0 +1,25 @@
+{
+ "version": "2.0",
+ "logging": {
+ "applicationInsights": {
+ "samplingSettings": {
+ "isEnabled": true,
+ "maxTelemetryItemsPerSecond": 20
+ }
+ },
+ "logLevel": {
+ "default": "Information",
+ "Function": "Information"
+ }
+ },
+ "extensionBundle": {
+ "id": "Microsoft.Azure.Functions.ExtensionBundle",
+ "version": "[4.*, 5.0.0)"
+ },
+ "functionTimeout": "00:10:00",
+ "extensions": {
+ "http": {
+ "routePrefix": "api"
+ }
+ }
+}
diff --git a/src/500-application/520-video-query-api/install-ffmpeg.sh b/src/500-application/520-video-query-api/install-ffmpeg.sh
new file mode 100755
index 00000000..313571d4
--- /dev/null
+++ b/src/500-application/520-video-query-api/install-ffmpeg.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+# Install ffmpeg in Azure Functions Linux environment
+# This script runs during function app deployment
+
+set -e
+
+echo "Installing ffmpeg..."
+
+# Check if running in Azure Functions environment
+if [ -n "$HOME" ] && [ -d "$HOME" ]; then
+ INSTALL_DIR="$HOME/bin"
+ mkdir -p "$INSTALL_DIR"
+
+ # Download static ffmpeg build
+ FFMPEG_URL="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"
+ TEMP_DIR=$(mktemp -d)
+
+ echo "Downloading ffmpeg from $FFMPEG_URL..."
+ curl -L "$FFMPEG_URL" -o "$TEMP_DIR/ffmpeg.tar.xz"
+
+ echo "Extracting ffmpeg..."
+ tar xf "$TEMP_DIR/ffmpeg.tar.xz" -C "$TEMP_DIR"
+
+ # Find and copy ffmpeg binary
+ FFMPEG_BIN=$(find "$TEMP_DIR" -name "ffmpeg" -type f | head -n 1)
+ if [ -n "$FFMPEG_BIN" ]; then
+ cp "$FFMPEG_BIN" "$INSTALL_DIR/ffmpeg"
+ chmod +x "$INSTALL_DIR/ffmpeg"
+ echo "✓ ffmpeg installed to $INSTALL_DIR/ffmpeg"
+ else
+ echo "✗ Failed to find ffmpeg binary"
+ exit 1
+ fi
+
+ # Cleanup
+ rm -rf "$TEMP_DIR"
+
+ # Add to PATH (for current session)
+ export PATH="$INSTALL_DIR:$PATH"
+
+ # Verify installation
+ if command -v ffmpeg &>/dev/null; then
+ ffmpeg -version | head -n 1
+ echo "✓ ffmpeg is ready"
+ else
+ echo "✗ ffmpeg not found in PATH"
+ exit 1
+ fi
+else
+ echo "Not in Azure Functions environment, skipping installation"
+fi
diff --git a/src/500-application/520-video-query-api/local.settings.json.example b/src/500-application/520-video-query-api/local.settings.json.example
new file mode 100644
index 00000000..41915d27
--- /dev/null
+++ b/src/500-application/520-video-query-api/local.settings.json.example
@@ -0,0 +1,12 @@
+{
+ "IsEncrypted": false,
+ "Values": {
+ "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+ "FUNCTIONS_WORKER_RUNTIME": "python",
+ "STORAGE_CONNECTION_STRING": "",
+ "VIDEO_RECORDINGS_CONTAINER": "video-recordings",
+ "TEMP_VIDEOS_CONTAINER": "temp-videos",
+ "SAS_EXPIRY_HOURS": "24",
+ "FFMPEG_PATH": "ffmpeg"
+ }
+}
diff --git a/src/500-application/520-video-query-api/pytest.ini b/src/500-application/520-video-query-api/pytest.ini
new file mode 100644
index 00000000..3fa6488e
--- /dev/null
+++ b/src/500-application/520-video-query-api/pytest.ini
@@ -0,0 +1,8 @@
+[pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+markers =
+ integration: marks tests as integration tests (require deployed API)
+addopts = -v --tb=short
diff --git a/src/500-application/520-video-query-api/requirements.txt b/src/500-application/520-video-query-api/requirements.txt
new file mode 100644
index 00000000..1d303fc2
--- /dev/null
+++ b/src/500-application/520-video-query-api/requirements.txt
@@ -0,0 +1,5 @@
+azure-functions>=1.18.0
+azure-storage-blob>=12.19.0
+azure-identity>=1.15.0
+azure-core>=1.29.0
+cryptography==43.0.3
diff --git a/src/500-application/520-video-query-api/tests/test_video_query_api.py b/src/500-application/520-video-query-api/tests/test_video_query_api.py
new file mode 100644
index 00000000..85efde7e
--- /dev/null
+++ b/src/500-application/520-video-query-api/tests/test_video_query_api.py
@@ -0,0 +1,377 @@
+#!/usr/bin/env python3
+"""
+Video Query API Integration Tests.
+
+Tests the deployed Azure Function API for:
+- Segment queries with metadata enrichment
+- Triggered recording detection
+- Event type filtering
+- Enhanced stitching with gap detection
+- Empty results handling
+- API latency requirements
+"""
+
+import os
+import time
+from datetime import UTC, datetime, timedelta
+
+import pytest
+import requests
+
+# API Configuration - must be set via environment variables
+API_ENDPOINT = os.getenv("VIDEO_QUERY_API_ENDPOINT")
+API_CODE = os.getenv("VIDEO_QUERY_API_CODE")
+
+# Test cameras
+CONTINUOUS_CAMERA = os.getenv("TEST_CONTINUOUS_CAMERA", "pmn-camera-01")
+TRIGGERED_CAMERA = os.getenv("TEST_TRIGGERED_CAMERA", "pmn-camera-01-triggered")
+
+
+def api_url(params: dict) -> str:
+ """Build API URL with query parameters."""
+ params["code"] = API_CODE
+ query = "&".join(f"{k}={v}" for k, v in params.items())
+ return f"{API_ENDPOINT}/api/video?{query}"
+
+
+def get_recent_timerange(hours: int = 1) -> tuple[str, str]:
+ """Get ISO timestamps for recent time range."""
+ end = datetime.now(UTC)
+ start = end - timedelta(hours=hours)
+ return start.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+@pytest.fixture(scope="module")
+def api_available():
+ """Check if API is available before running tests."""
+ if not API_ENDPOINT or not API_CODE:
+ pytest.skip(
+ "API credentials not configured. Set VIDEO_QUERY_API_ENDPOINT and "
+ "VIDEO_QUERY_API_CODE environment variables."
+ )
+ try:
+ response = requests.get(f"{API_ENDPOINT}/api/health", timeout=10)
+ if response.status_code != 200:
+ pytest.skip(f"API health check failed: {response.status_code}")
+ except requests.RequestException as e:
+ pytest.skip(f"API not available: {e}")
+
+
+@pytest.mark.integration
+class TestSegmentQuery:
+ """Tests for basic segment query functionality."""
+
+ def test_segment_query_returns_segments(self, api_available):
+ """Segment query must return segments array with metadata."""
+ start, end = get_recent_timerange(hours=2)
+ response = requests.get(
+ api_url({"camera": CONTINUOUS_CAMERA, "start": start, "end": end}),
+ timeout=30
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "segments" in data
+ assert "total_segments" in data
+ assert data["stitched"] is False
+
+ def test_segment_has_required_fields(self, api_available):
+ """Each segment must have required base fields."""
+ start, end = get_recent_timerange(hours=2)
+ response = requests.get(
+ api_url({"camera": CONTINUOUS_CAMERA, "start": start, "end": end}),
+ timeout=30
+ )
+
+ data = response.json()
+ if data["total_segments"] > 0:
+ segment = data["segments"][0]
+ assert "url" in segment
+ assert "name" in segment
+ assert "timestamp" in segment
+ assert "size_bytes" in segment
+ assert "recording_type" in segment
+
+
+@pytest.mark.integration
+class TestMetadataEnrichment:
+ """Tests for JSON metadata enrichment feature."""
+
+ def test_segment_has_metadata_fields(self, api_available):
+ """Segments must include metadata-enriched fields when available."""
+ start, end = get_recent_timerange(hours=2)
+ response = requests.get(
+ api_url({"camera": CONTINUOUS_CAMERA, "start": start, "end": end}),
+ timeout=30
+ )
+
+ data = response.json()
+ if data["total_segments"] > 0:
+ segment = data["segments"][0]
+ # These fields come from JSON metadata sidecar files
+ assert "duration_seconds" in segment
+ assert "location" in segment
+ assert "segment_start" in segment
+ assert "segment_end" in segment
+
+ def test_metadata_has_precise_timestamps(self, api_available):
+ """Metadata timestamps must include nanosecond precision."""
+ start, end = get_recent_timerange(hours=2)
+ response = requests.get(
+ api_url({"camera": CONTINUOUS_CAMERA, "start": start, "end": end}),
+ timeout=30
+ )
+
+ data = response.json()
+ if data["total_segments"] > 0:
+ segment = data["segments"][0]
+ if segment.get("segment_start"):
+ # Should have format like: 2026-02-04T18:03:03.170549170+00:00
+ assert "." in segment["segment_start"]
+ assert "+" in segment["segment_start"] or "Z" in segment["segment_start"]
+
+
+@pytest.mark.integration
+class TestRecordingTypeDetection:
+ """Tests for recording type detection (continuous vs triggered)."""
+
+ def test_continuous_recording_type(self, api_available):
+ """Continuous recordings must have recording_type='continuous'."""
+ start, end = get_recent_timerange(hours=2)
+ response = requests.get(
+ api_url({"camera": CONTINUOUS_CAMERA, "start": start, "end": end}),
+ timeout=30
+ )
+
+ data = response.json()
+ if data["total_segments"] > 0:
+ segment = data["segments"][0]
+ assert segment["recording_type"] == "continuous"
+ assert segment["event_type"] is None
+
+ def test_triggered_recording_type(self, api_available):
+ """Triggered recordings must have recording_type='triggered' and event_type."""
+ # Use a wider time range to find triggered recordings
+ end = datetime.now(UTC)
+ start = end - timedelta(days=1)
+ start_str = start.strftime("%Y-%m-%dT%H:%M:%SZ")
+ end_str = end.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ response = requests.get(
+ api_url({"camera": TRIGGERED_CAMERA, "start": start_str, "end": end_str}),
+ timeout=30
+ )
+
+ data = response.json()
+ if data["total_segments"] > 0:
+ segment = data["segments"][0]
+ assert segment["recording_type"] == "triggered"
+ assert segment["event_type"] is not None
+ assert segment["event_type"] in ["alert", "analytics_disabled", "motion"]
+
+
+@pytest.mark.integration
+class TestEventTypeFiltering:
+ """Tests for event_type query parameter filtering."""
+
+ def test_filter_by_event_type_alert(self, api_available):
+ """Filter by event_type=alert must return only alert segments."""
+ end = datetime.now(UTC)
+ start = end - timedelta(days=1)
+ start_str = start.strftime("%Y-%m-%dT%H:%M:%SZ")
+ end_str = end.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ response = requests.get(
+ api_url({
+ "camera": TRIGGERED_CAMERA,
+ "start": start_str,
+ "end": end_str,
+ "event_type": "alert"
+ }),
+ timeout=30
+ )
+
+ data = response.json()
+ assert data["event_type_filter"] == "alert"
+ for segment in data["segments"]:
+ assert segment["event_type"] == "alert"
+
+ def test_filter_by_nonexistent_event_type(self, api_available):
+ """Filter by non-matching event_type must return empty results."""
+ start, end = get_recent_timerange(hours=1)
+ response = requests.get(
+ api_url({
+ "camera": CONTINUOUS_CAMERA,
+ "start": start,
+ "end": end,
+ "event_type": "nonexistent_type"
+ }),
+ timeout=30
+ )
+
+ data = response.json()
+ assert data["total_segments"] == 0
+
+
+@pytest.mark.integration
+class TestEnhancedStitch:
+ """Tests for enhanced stitching with metadata."""
+
+ def test_stitch_returns_video_url(self, api_available):
+ """Stitch=true must return single video_url."""
+ start, end = get_recent_timerange(hours=1)
+ response = requests.get(
+ api_url({
+ "camera": CONTINUOUS_CAMERA,
+ "start": start,
+ "end": end,
+ "stitch": "true"
+ }),
+ timeout=180 # Stitching takes longer
+ )
+
+ data = response.json()
+ if "error" not in data:
+ assert data["stitched"] is True
+ assert "video_url" in data
+ assert "segment_count" in data
+
+ def test_stitch_includes_metadata_metrics(self, api_available):
+ """Stitched response must include metadata-derived metrics."""
+ start, end = get_recent_timerange(hours=1)
+ response = requests.get(
+ api_url({
+ "camera": CONTINUOUS_CAMERA,
+ "start": start,
+ "end": end,
+ "stitch": "true"
+ }),
+ timeout=180
+ )
+
+ data = response.json()
+ if "error" not in data and data.get("segment_count", 0) > 0:
+ # These fields come from metadata enrichment
+ assert "actual_duration_seconds" in data
+ assert "earliest_segment_start" in data
+ assert "latest_segment_end" in data
+ assert "locations" in data
+ assert "metadata_coverage" in data
+
+ def test_stitch_detects_gaps(self, api_available):
+ """Stitched response must report gaps when present."""
+ # Use longer timeframe to increase chance of gaps
+ end = datetime.now(UTC)
+ start = end - timedelta(hours=2)
+ start_str = start.strftime("%Y-%m-%dT%H:%M:%SZ")
+ end_str = end.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ response = requests.get(
+ api_url({
+ "camera": CONTINUOUS_CAMERA,
+ "start": start_str,
+ "end": end_str,
+ "stitch": "true"
+ }),
+ timeout=180
+ )
+
+ data = response.json()
+ if "error" not in data:
+ # Gap fields should be present (may be null/0 if no gaps)
+ if data.get("gap_count"):
+ assert "gaps" in data
+ assert "total_gap_seconds" in data
+ assert len(data["gaps"]) == data["gap_count"]
+ for gap in data["gaps"]:
+ assert "after_segment" in gap
+ assert "before_segment" in gap
+ assert "gap_seconds" in gap
+
+
+@pytest.mark.integration
+class TestEmptyResults:
+ """Tests for empty results handling."""
+
+ def test_empty_results_return_200(self, api_available):
+ """Empty results must return HTTP 200, not 404."""
+ response = requests.get(
+ api_url({
+ "camera": "nonexistent-camera-xyz",
+ "start": "2026-01-01T00:00:00Z",
+ "end": "2026-01-01T00:05:00Z"
+ }),
+ timeout=30
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["segments"] == []
+ assert data["total_segments"] == 0
+ assert "message" in data
+
+
+@pytest.mark.integration
+class TestAPILatency:
+ """Tests for API response time requirements."""
+
+ def test_segment_query_latency_under_2_seconds(self, api_available):
+ """Segment query must complete in under 2 seconds."""
+ start, end = get_recent_timerange(hours=1)
+
+ start_time = time.time()
+ response = requests.get(
+ api_url({"camera": CONTINUOUS_CAMERA, "start": start, "end": end}),
+ timeout=30
+ )
+ elapsed = time.time() - start_time
+
+ assert response.status_code == 200
+ assert elapsed < 2.0, f"Query took {elapsed:.2f}s, expected < 2.0s"
+
+
+@pytest.mark.integration
+class TestParameterValidation:
+ """Tests for parameter validation."""
+
+ def test_missing_camera_returns_400(self, api_available):
+ """Missing camera parameter must return 400."""
+ response = requests.get(
+ api_url({"start": "2026-01-01T00:00:00Z", "end": "2026-01-01T01:00:00Z"}),
+ timeout=30
+ )
+
+ assert response.status_code == 400
+
+ def test_missing_start_returns_400(self, api_available):
+ """Missing start parameter must return 400."""
+ response = requests.get(
+ api_url({"camera": CONTINUOUS_CAMERA, "end": "2026-01-01T01:00:00Z"}),
+ timeout=30
+ )
+
+ assert response.status_code == 400
+
+ def test_missing_end_returns_400(self, api_available):
+ """Missing end parameter must return 400."""
+ response = requests.get(
+ api_url({"camera": CONTINUOUS_CAMERA, "start": "2026-01-01T00:00:00Z"}),
+ timeout=30
+ )
+
+ assert response.status_code == 400
+
+ def test_duration_exceeds_24_hours_returns_400(self, api_available):
+ """Query duration > 24 hours must return 400."""
+ response = requests.get(
+ api_url({
+ "camera": CONTINUOUS_CAMERA,
+ "start": "2026-01-01T00:00:00Z",
+ "end": "2026-01-03T00:00:00Z" # 48 hours
+ }),
+ timeout=30
+ )
+
+ assert response.status_code == 400
+ data = response.json()
+ assert "24 hours" in data.get("error", "").lower() or "duration" in data.get("error", "").lower()
diff --git a/src/starter-kit/dataflows-acsa-egmqtt-bidirectional/scripts/create-blob-storage.sh b/src/starter-kit/dataflows-acsa-egmqtt-bidirectional/scripts/create-blob-storage.sh
index 4144efcb..760ef662 100755
--- a/src/starter-kit/dataflows-acsa-egmqtt-bidirectional/scripts/create-blob-storage.sh
+++ b/src/starter-kit/dataflows-acsa-egmqtt-bidirectional/scripts/create-blob-storage.sh
@@ -57,11 +57,11 @@ az role assignment create \
--scope /subscriptions/"$subscriptionId"/resourceGroups/"$RESOURCE_GROUP"/providers/Microsoft.Storage/storageAccounts/"$STORAGE_ACCOUNT_NAME"
# Create a container in the storage account to store total counter metric
-totalCouterContainerName=$METRIC2_TOPIC_PATH_NAME
-echo "Creating container $totalCouterContainerName in storage account $STORAGE_ACCOUNT_NAME"
+totalCounterContainerName=$METRIC2_TOPIC_PATH_NAME
+echo "Creating container $totalCounterContainerName in storage account $STORAGE_ACCOUNT_NAME"
az storage container create \
--account-name "$STORAGE_ACCOUNT_NAME" \
- --name "$totalCouterContainerName" \
+ --name "$totalCounterContainerName" \
--auth-mode login
# Create a container in the storage account to store mashine status metric
@@ -77,14 +77,14 @@ edgeVolumeAioName=$ACSA_CLOUD_BACKED_AIO_PVC_NAME
wait_for_edge_volume "$edgeVolumeAioName"
# Update the edge volume with the new subvolumes to connect to the storage account
-totalCouterPath=$METRIC2_TOPIC_PATH_NAME
-echo "Adding subvolume $totalCouterPath to edge volume $edgeVolumeAioName to sync with storage account $STORAGE_ACCOUNT_NAME"
+totalCounterPath=$METRIC2_TOPIC_PATH_NAME
+echo "Adding subvolume $totalCounterPath to edge volume $edgeVolumeAioName to sync with storage account $STORAGE_ACCOUNT_NAME"
-export SUBVOLUME_NAME=$totalCouterPath
+export SUBVOLUME_NAME=$totalCounterPath
export EDGE_VOLUME_NAME=$edgeVolumeAioName
-export PATH_VALUE=$totalCouterPath # Using PATH_VALUE instead of PATH to avoid conflicts with system PATH
+export PATH_VALUE=$totalCounterPath # Using PATH_VALUE instead of PATH to avoid conflicts with system PATH
export STORAGE_ACCOUNT_NAME=$STORAGE_ACCOUNT_NAME
-export CONTAINER_NAME=$totalCouterContainerName
+export CONTAINER_NAME=$totalCounterContainerName
apply_template_with_envsubst "../yaml/acsa/edgeSubvolume.yaml" | kubectl apply -f -