diff --git a/.gitignore b/.gitignore index 329fdbc1087..af85b4ec579 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ build/ TestResults/ test_failures.txt zone*export.txt +report.*.json # Credentials credentials_real.json diff --git a/linter_exclusions.yml b/linter_exclusions.yml index ccaa256820d..723306f2dbd 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -297,6 +297,39 @@ aks create: container_storage_version: rule_exclusions: - option_length_too_long + enable_azure_monitor_logs: + rule_exclusions: + - option_length_too_long + enable_prometheus_metrics_scraping: + rule_exclusions: + - option_length_too_long + disable_prometheus_metrics_scraping: + rule_exclusions: + - option_length_too_long + enable_opentelemetry_metrics: + rule_exclusions: + - option_length_too_long + disable_opentelemetry_metrics: + rule_exclusions: + - option_length_too_long + opentelemetry_metrics_port_http: + rule_exclusions: + - option_length_too_long + opentelemetry_metrics_port_grpc: + rule_exclusions: + - option_length_too_long + enable_opentelemetry_logs_traces: + rule_exclusions: + - option_length_too_long + disable_opentelemetry_logs_traces: + rule_exclusions: + - option_length_too_long + opentelemetry_logs_traces_port_http: + rule_exclusions: + - option_length_too_long + opentelemetry_logs_traces_port_grpc: + rule_exclusions: + - option_length_too_long aks enable-addons: parameters: workspace_resource_id: @@ -386,6 +419,48 @@ aks update: container_storage_version: rule_exclusions: - option_length_too_long + enable_azure_monitor_logs: + rule_exclusions: + - option_length_too_long + disable_azure_monitor_logs: + rule_exclusions: + - option_length_too_long + enable_msi_auth_for_monitoring: + rule_exclusions: + - option_length_too_long + data_collection_settings: + rule_exclusions: + - option_length_too_long + enable_prometheus_metrics_scraping: + rule_exclusions: + - option_length_too_long + disable_prometheus_metrics_scraping: + rule_exclusions: + - option_length_too_long + enable_opentelemetry_metrics: + rule_exclusions: + - option_length_too_long + disable_opentelemetry_metrics: + rule_exclusions: + - option_length_too_long + opentelemetry_metrics_port_http: + rule_exclusions: + - option_length_too_long + opentelemetry_metrics_port_grpc: + rule_exclusions: + - option_length_too_long + enable_opentelemetry_logs_traces: + rule_exclusions: + - option_length_too_long + disable_opentelemetry_logs_traces: + rule_exclusions: + - option_length_too_long + opentelemetry_logs_traces_port_http: + rule_exclusions: + - option_length_too_long + opentelemetry_logs_traces_port_grpc: + rule_exclusions: + - option_length_too_long aks update-credentials: parameters: aad_server_app_secret: diff --git a/src/azure-cli/HISTORY.rst b/src/azure-cli/HISTORY.rst index c222025df06..4761b1f93ec 100644 --- a/src/azure-cli/HISTORY.rst +++ b/src/azure-cli/HISTORY.rst @@ -24,6 +24,24 @@ Release History * `az aks nodepool rollback`: Show an accurate warning when only the node OS upgrade channel is enabled (#33854) * Implement enable/disable flags for user-defined scheduler configuration (#33934) * `az aks update`: Fix Azure Container Storage configuration detection for lowercase and boolean extension settings (#33938) +* `az aks create`, `az aks update`: Add `--enable-azure-monitor-logs` to onboard Container Insights through the Azure Monitor profile using managed identity authentication +* `az aks update`: Add `--disable-azure-monitor-logs` to offboard Container Insights +* `az aks create`, `az aks update`: Add `--syslog-port`, `--enable-prometheus-metrics-scraping` and `--disable-prometheus-metrics-scraping` to tune the Azure Monitor Container Insights configuration +* `az aks create`, `az aks update`: Add `--enable-opentelemetry-metrics`, `--disable-opentelemetry-metrics`, `--opentelemetry-metrics-port-http` and `--opentelemetry-metrics-port-grpc` for the OpenTelemetry metrics receiver +* `az aks create`, `az aks update`: Add `--enable-opentelemetry-logs-traces`, `--disable-opentelemetry-logs-traces`, `--opentelemetry-logs-traces-port-http` and `--opentelemetry-logs-traces-port-grpc` for the OpenTelemetry logs and traces receiver +* `az aks create`, `az aks update`: Write container network logs to `azureMonitorProfile.containerInsights.containerNetworkLogs` instead of the monitoring addon configuration, and reject `--enable-container-network-logs` on clusters using legacy shared key authentication +* `az aks create`, `az aks update`, `az aks enable-addons`: Deprecate `--enable-msi-auth-for-monitoring` in favor of `--enable-azure-monitor-logs` +* `az aks create`, `az aks update`: Reject `--enable-azure-monitor-logs` on clusters using service principal authentication, since the Azure Monitor profile onboards with managed identity only +* `az aks update`: Reject `--enable-azure-monitor-logs` when Azure Monitor logs is already enabled on the cluster, matching `az aks enable-addons -a monitoring`. Run `--disable-azure-monitor-logs` first to change the configuration +* `az aks update`: `--disable-azure-monitor-logs` now removes the data collection rule association and resets the Container Insights settings (syslog port, Prometheus scraping and container network logs) back to their defaults, and asks for confirmation when OpenTelemetry logs and traces are enabled +* `az aks update`: Fix `--enable-azure-monitor-logs` not creating the data collection rule and association unless the Log Analytics workspace changed, which left the agent running with no data collection rule attached so no logs were ingested +* `az aks update`: Create the data collection rule and association before the cluster update when enabling with `--enable-azure-monitor-logs`, matching `az aks enable-addons -a monitoring`. Provisioning them afterwards meant the agent started before the association existed and then stayed idle for several minutes before restarting once the configuration arrived +* `az aks update`: `--disable-azure-monitor-metrics` now also disables OpenTelemetry metrics, since they are collected through the managed Prometheus pipeline, and asks for confirmation first unless `--yes` is specified +* `az aks update`: Fix the OpenTelemetry port flags (`--opentelemetry-metrics-port-http`, `--opentelemetry-metrics-port-grpc`, `--opentelemetry-logs-traces-port-http` and `--opentelemetry-logs-traces-port-grpc`) being silently ignored when supplied on their own to change a port on an already enabled receiver, and report an error instead of doing nothing when the matching receiver is not enabled or is being disabled in the same command +* `az aks update`: Fix `--enable-syslog` updating only the cluster and never re-provisioning the data collection rule, which left the DCR without the syslog data source so no syslog was ingested +* `az aks update`: Fix `--data-collection-settings` and `--ampls-resource-id` being silently ignored, as neither re-provisioned the data collection rule that carries them +* `az aks update`: Collect every monitoring disable confirmation before any of them deletes collection resources. Combining `--disable-azure-monitor-metrics` with `--disable-azure-monitor-logs` used to delete the metrics collection resources before asking about logs, so declining that prompt aborted the command with metrics still enabled on the cluster but its data collection objects and recording rules already removed +* `az aks create`, `az aks update`: Fix the `--data-collection-settings` size limit being applied to the file path instead of the settings it holds, which let an oversized file through to fail the data collection rule call with `Request Header Fields Too Large` **App Config** diff --git a/src/azure-cli/azure/cli/command_modules/acs/_consts.py b/src/azure-cli/azure/cli/command_modules/acs/_consts.py index b3248a70e78..1ba967cffd9 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_consts.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_consts.py @@ -164,6 +164,15 @@ CONST_MONITORING_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID = "logAnalyticsWorkspaceResourceID" CONST_MONITORING_USING_AAD_MSI_AUTH = "useAADAuth" +# container network logs (azureMonitorProfile.containerInsights.containerNetworkLogs) +CONST_CONTAINER_NETWORK_LOGS_ENABLED = "Enabled" +CONST_CONTAINER_NETWORK_LOGS_DISABLED = "Disabled" +# legacy omsagent addon config key, superseded by containerNetworkLogs on the Azure Monitor +# profile path. Only read, to keep recognizing clusters onboarded before the switch. +CONST_MONITORING_ENABLE_RETINA_NETWORK_FLAGS = "enableRetinaNetworkFlags" +# server-side default for azureMonitorProfile.containerInsights.syslogPort +CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT = 28330 + # virtual node CONST_VIRTUAL_NODE_ADDON_NAME = "aciConnector" CONST_VIRTUAL_NODE_SUBNET_NAME = "SubnetName" diff --git a/src/azure-cli/azure/cli/command_modules/acs/_help.py b/src/azure-cli/azure/cli/command_modules/acs/_help.py index 7b049020613..4f975067ceb 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_help.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_help.py @@ -334,6 +334,22 @@ - name: --enable-high-log-scale-mode type: bool short-summary: Enable High Log Scale Mode for Container Logs. Auto-enabled when --enable-container-network-logs is specified. + - name: --enable-azure-monitor-logs + type: bool + short-summary: Enable Azure Monitor logs (Container Insights) for the cluster using managed identity authentication. + long-summary: | + Configures Container Insights through the cluster's Azure Monitor profile instead of the monitoring addon. + Cannot be combined with "--enable-addons monitoring" or with "--enable-msi-auth-for-monitoring". + Requires the cluster to use a managed identity; clusters created with service principal authentication are not supported. + - name: --syslog-port + type: int + short-summary: TCP port that the Azure Monitor agent listens on for syslog data. Requires --enable-azure-monitor-logs. + - name: --enable-prometheus-metrics-scraping + type: bool + short-summary: Enable Prometheus metrics scraping by the Azure Monitor agent. Requires --enable-azure-monitor-logs. + - name: --disable-prometheus-metrics-scraping + type: bool + short-summary: Disable Prometheus metrics scraping by the Azure Monitor agent. Requires --enable-azure-monitor-logs. - name: --sku type: string short-summary: Specify SKU name for managed clusters. Use '--sku base' enables a base managed cluster. Use '--sku automatic' enables an automatic managed cluster. @@ -568,6 +584,30 @@ - name: --enable-azure-monitor-app-monitoring type: bool short-summary: Enable Azure Monitor Application Monitoring auto-instrumentation for a Kubernetes cluster. + - name: --enable-opentelemetry-metrics + type: bool + short-summary: Enable the OpenTelemetry (OTLP) metrics receiver. Requires --enable-azure-monitor-metrics. + - name: --disable-opentelemetry-metrics + type: bool + short-summary: Disable the OpenTelemetry (OTLP) metrics receiver. + - name: --opentelemetry-metrics-port-http + type: int + short-summary: HTTP/protobuf port for the OpenTelemetry metrics receiver. + - name: --opentelemetry-metrics-port-grpc + type: int + short-summary: gRPC port for the OpenTelemetry metrics receiver. + - name: --enable-opentelemetry-logs-traces + type: bool + short-summary: Enable the OpenTelemetry (OTLP) logs and traces receiver. Requires --enable-azure-monitor-logs. + - name: --disable-opentelemetry-logs-traces + type: bool + short-summary: Disable the OpenTelemetry (OTLP) logs and traces receiver. + - name: --opentelemetry-logs-traces-port-http + type: int + short-summary: HTTP/protobuf port for the OpenTelemetry logs and traces receiver. + - name: --opentelemetry-logs-traces-port-grpc + type: int + short-summary: gRPC port for the OpenTelemetry logs and traces receiver. - name: --nodepool-taints type: string short-summary: The node taints for all node pool. @@ -756,6 +796,14 @@ text: az aks create -g MyResourceGroup -n MyManagedCluster --enable-keda - name: Create a kubernetes cluster with the Azure Monitor managed service for Prometheus integration enabled. text: az aks create -g MyResourceGroup -n MyManagedCluster --enable-azure-monitor-metrics + - name: Create a kubernetes cluster with Azure Monitor logs (Container Insights) enabled. + text: az aks create -g MyResourceGroup -n MyManagedCluster --enable-azure-monitor-logs + - name: Create a kubernetes cluster with Azure Monitor logs enabled and syslog collected on a custom port. + text: az aks create -g MyResourceGroup -n MyManagedCluster --enable-azure-monitor-logs --enable-syslog --syslog-port 28330 + - name: Create a kubernetes cluster with the OpenTelemetry logs and traces receiver enabled. + text: az aks create -g MyResourceGroup -n MyManagedCluster --enable-azure-monitor-logs --enable-opentelemetry-logs-traces --opentelemetry-logs-traces-port-grpc 4317 + - name: Create a kubernetes cluster with the OpenTelemetry metrics receiver enabled. + text: az aks create -g MyResourceGroup -n MyManagedCluster --enable-azure-monitor-metrics --enable-opentelemetry-metrics --opentelemetry-metrics-port-grpc 4319 - name: Create a kubernetes cluster with vertical pod autoscaler enaled. text: az aks create -g MyResourceGroup -n MyManagedCluster --enable-vpa - name: create a kubernetes cluster with a Capacity Reservation Group(CRG) ID. @@ -1116,6 +1164,10 @@ - name: --disable-azure-monitor-metrics type: bool short-summary: Disable Azure Monitor Metrics Profile. This will delete all DCRA's associated with the cluster, any linked DCRs with the data stream = prometheus-stream and the recording rule groups created by the addon for this AKS cluster. + long-summary: | + If OpenTelemetry metrics are enabled, they are disabled as well, since they are collected + through the managed Prometheus pipeline. Confirmation is requested first unless "--yes" is + specified. - name: --enable-control-plane-metrics --enable-cp-metrics type: bool short-summary: Enable collection of Azure Monitor managed Prometheus control plane metrics for managed cluster components (controlplane-apiserver and controlplane-etcd targets by default). Requires Azure Monitor metrics to be enabled (already enabled or via --enable-azure-monitor-metrics). @@ -1128,6 +1180,68 @@ - name: --disable-azure-monitor-app-monitoring type: bool short-summary: Disable Azure Monitor Application Monitoring auto-instrumentation for a Kubernetes cluster. + - name: --enable-azure-monitor-logs + type: bool + short-summary: Enable Azure Monitor logs (Container Insights) for the cluster using managed identity authentication. + long-summary: | + Configures Container Insights through the cluster's Azure Monitor profile instead of the monitoring addon. + Clusters still using legacy shared key authentication must first migrate to managed identity authentication. + Requires the cluster to use a managed identity; clusters using service principal authentication are not supported. + Fails if Azure Monitor logs is already enabled on the cluster. To change the configuration, run "az aks update --disable-azure-monitor-logs" first. + - name: --disable-azure-monitor-logs + type: bool + short-summary: Disable Azure Monitor logs (Container Insights) for the cluster. + long-summary: | + Disables Container Insights, removes the data collection rule association, and resets the Container Insights settings (syslog port, Prometheus scraping and container network logs) back to their defaults. The workspace is left recorded on the profile but is unused while disabled, and is replaced on the next enable. + If OpenTelemetry logs and traces are enabled they are disabled as well, and confirmation is requested first unless "--yes" is specified. + - name: --workspace-resource-id + type: string + short-summary: The resource ID of an existing Log Analytics Workspace to use for storing monitoring data. If not specified, uses the default Log Analytics Workspace if it exists, otherwise creates one. + - name: --enable-msi-auth-for-monitoring + type: bool + short-summary: Enable Managed Identity Auth for Monitoring addon. + - name: --enable-syslog + type: bool + short-summary: Enable syslog data collection for Monitoring addon. + - name: --data-collection-settings + type: string + short-summary: Path to JSON file containing data collection settings for Monitoring addon. + - name: --ampls-resource-id + type: string + short-summary: Resource ID of Azure Monitor Private Link scope for Monitoring Addon. + - name: --syslog-port + type: int + short-summary: TCP port that the Azure Monitor agent listens on for syslog data. Requires Azure Monitor logs to be enabled. + - name: --enable-prometheus-metrics-scraping + type: bool + short-summary: Enable Prometheus metrics scraping by the Azure Monitor agent. Requires Azure Monitor logs to be enabled. + - name: --disable-prometheus-metrics-scraping + type: bool + short-summary: Disable Prometheus metrics scraping by the Azure Monitor agent. Requires Azure Monitor logs to be enabled. + - name: --enable-opentelemetry-metrics + type: bool + short-summary: Enable the OpenTelemetry (OTLP) metrics receiver. Requires Azure Monitor metrics to be enabled. + - name: --disable-opentelemetry-metrics + type: bool + short-summary: Disable the OpenTelemetry (OTLP) metrics receiver. + - name: --opentelemetry-metrics-port-http + type: int + short-summary: HTTP/protobuf port for the OpenTelemetry metrics receiver. + - name: --opentelemetry-metrics-port-grpc + type: int + short-summary: gRPC port for the OpenTelemetry metrics receiver. + - name: --enable-opentelemetry-logs-traces + type: bool + short-summary: Enable the OpenTelemetry (OTLP) logs and traces receiver. Requires Azure Monitor logs to be enabled. + - name: --disable-opentelemetry-logs-traces + type: bool + short-summary: Disable the OpenTelemetry (OTLP) logs and traces receiver. + - name: --opentelemetry-logs-traces-port-http + type: int + short-summary: HTTP/protobuf port for the OpenTelemetry logs and traces receiver. + - name: --opentelemetry-logs-traces-port-grpc + type: int + short-summary: gRPC port for the OpenTelemetry logs and traces receiver. - name: --nodepool-taints type: string short-summary: The node taints for all node pool. @@ -1255,6 +1369,16 @@ examples: - name: Reconcile the cluster back to its current state. text: az aks update -g MyResourceGroup -n MyManagedCluster + - name: Enable Azure Monitor logs (Container Insights) on an existing cluster. + text: az aks update -g MyResourceGroup -n MyManagedCluster --enable-azure-monitor-logs + - name: Disable Azure Monitor logs (Container Insights) on an existing cluster. + text: az aks update -g MyResourceGroup -n MyManagedCluster --disable-azure-monitor-logs + - name: Change the syslog port used by Azure Monitor logs on an existing cluster. + text: az aks update -g MyResourceGroup -n MyManagedCluster --enable-syslog --syslog-port 28330 + - name: Enable the OpenTelemetry logs and traces receiver on an existing cluster. + text: az aks update -g MyResourceGroup -n MyManagedCluster --enable-opentelemetry-logs-traces --opentelemetry-logs-traces-port-grpc 4317 + - name: Enable the OpenTelemetry metrics receiver on an existing cluster. + text: az aks update -g MyResourceGroup -n MyManagedCluster --enable-opentelemetry-metrics --opentelemetry-metrics-port-grpc 4319 - name: Update a kubernetes cluster with standard SKU load balancer to use two AKS created IPs for the load balancer outbound connection usage. text: az aks update -g MyResourceGroup -n MyManagedCluster --load-balancer-managed-outbound-ip-count 2 - name: Update a kubernetes cluster with standard SKU load balancer to use the provided public IPs for the load balancer outbound connection usage. diff --git a/src/azure-cli/azure/cli/command_modules/acs/_params.py b/src/azure-cli/azure/cli/command_modules/acs/_params.py index 3e2435dbc3c..afcd2143e88 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_params.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_params.py @@ -145,6 +145,12 @@ validate_bootstrap_container_registry_resource_id, validate_gateway_prefix_size, validate_artifact_streaming, + validate_azure_monitor_logs_and_enable_addons, + validate_azure_monitor_logs_enable_disable, + validate_container_insights_settings_for_create, + validate_container_insights_settings_for_update, + validate_azure_monitor_and_opentelemetry_for_create, + validate_azure_monitor_and_opentelemetry_for_update, ) from azure.cli.core.commands.parameters import ( edge_zone_type, file_type, get_enum_type, @@ -523,11 +529,44 @@ def load_arguments(self, _): # addons c.argument('enable_addons', options_list=['--enable-addons', '-a']) c.argument('workspace_resource_id') - c.argument('enable_msi_auth_for_monitoring', arg_type=get_three_state_flag()) + c.argument( + 'enable_msi_auth_for_monitoring', + arg_type=get_three_state_flag(), + deprecate_info=c.deprecate( + target='--enable-msi-auth-for-monitoring', + redirect='--enable-azure-monitor-logs', + ), + ) c.argument('enable_syslog', arg_type=get_three_state_flag()) c.argument('data_collection_settings') c.argument('ampls_resource_id', validator=validate_azuremonitor_privatelinkscope_resourceid) c.argument('enable_high_log_scale_mode', arg_type=get_three_state_flag()) + # azure monitor logs (container insights on the azure monitor profile) + c.argument( + 'enable_azure_monitor_logs', + action='store_true', + validator=validate_azure_monitor_logs_and_enable_addons, + ) + c.argument( + 'syslog_port', + type=int, + validator=validate_container_insights_settings_for_create, + ) + c.argument('enable_prometheus_metrics_scraping', action='store_true') + c.argument('disable_prometheus_metrics_scraping', action='store_true') + # opentelemetry + c.argument( + 'enable_opentelemetry_metrics', + action='store_true', + validator=validate_azure_monitor_and_opentelemetry_for_create, + ) + c.argument('disable_opentelemetry_metrics', action='store_true') + c.argument('opentelemetry_metrics_port_http', type=int) + c.argument('opentelemetry_metrics_port_grpc', type=int) + c.argument('enable_opentelemetry_logs_traces', action='store_true') + c.argument('disable_opentelemetry_logs_traces', action='store_true') + c.argument('opentelemetry_logs_traces_port_http', type=int) + c.argument('opentelemetry_logs_traces_port_grpc', type=int) c.argument('aci_subnet_name') c.argument('appgw_name', arg_group='Application Gateway') c.argument('appgw_subnet_cidr', arg_group='Application Gateway') @@ -878,6 +917,45 @@ def load_arguments(self, _): ) c.argument('enable_azure_monitor_app_monitoring', action='store_true') c.argument('disable_azure_monitor_app_monitoring', action='store_true') + # azure monitor logs (container insights on the azure monitor profile) + c.argument( + 'enable_azure_monitor_logs', + action='store_true', + validator=validate_azure_monitor_logs_enable_disable, + ) + c.argument('disable_azure_monitor_logs', action='store_true') + c.argument('workspace_resource_id') + c.argument( + 'enable_msi_auth_for_monitoring', + arg_type=get_three_state_flag(), + deprecate_info=c.deprecate( + target='--enable-msi-auth-for-monitoring', + redirect='--enable-azure-monitor-logs', + ), + ) + c.argument('enable_syslog', arg_type=get_three_state_flag()) + c.argument('data_collection_settings') + c.argument('ampls_resource_id', validator=validate_azuremonitor_privatelinkscope_resourceid) + c.argument( + 'syslog_port', + type=int, + validator=validate_container_insights_settings_for_update, + ) + c.argument('enable_prometheus_metrics_scraping', action='store_true') + c.argument('disable_prometheus_metrics_scraping', action='store_true') + # opentelemetry + c.argument( + 'enable_opentelemetry_metrics', + action='store_true', + validator=validate_azure_monitor_and_opentelemetry_for_update, + ) + c.argument('disable_opentelemetry_metrics', action='store_true') + c.argument('opentelemetry_metrics_port_http', type=int) + c.argument('opentelemetry_metrics_port_grpc', type=int) + c.argument('enable_opentelemetry_logs_traces', action='store_true') + c.argument('disable_opentelemetry_logs_traces', action='store_true') + c.argument('opentelemetry_logs_traces_port_http', type=int) + c.argument('opentelemetry_logs_traces_port_grpc', type=int) # azure container storage c.argument( "enable_azure_container_storage", @@ -987,6 +1065,7 @@ def load_arguments(self, _): with self.argument_context('aks disable-addons', resource_type=ResourceType.MGMT_CONTAINERSERVICE, operation_group='managed_clusters') as c: c.argument('addons', options_list=['--addons', '-a']) + c.argument('yes', options_list=['--yes', '-y'], help='Do not prompt for confirmation.', action='store_true') with self.argument_context('aks enable-addons', resource_type=ResourceType.MGMT_CONTAINERSERVICE, operation_group='managed_clusters') as c: c.argument('addons', options_list=['--addons', '-a']) @@ -1001,7 +1080,14 @@ def load_arguments(self, _): c.argument('enable_sgxquotehelper', action='store_true') c.argument('enable_secret_rotation', action='store_true') c.argument('rotation_poll_interval') - c.argument('enable_msi_auth_for_monitoring', arg_type=get_three_state_flag()) + c.argument( + 'enable_msi_auth_for_monitoring', + arg_type=get_three_state_flag(), + deprecate_info=c.deprecate( + target='--enable-msi-auth-for-monitoring', + redirect='--enable-azure-monitor-logs', + ), + ) c.argument('enable_syslog', arg_type=get_three_state_flag()) c.argument('data_collection_settings') c.argument('ampls_resource_id', validator=validate_azuremonitor_privatelinkscope_resourceid) diff --git a/src/azure-cli/azure/cli/command_modules/acs/_validators.py b/src/azure-cli/azure/cli/command_modules/acs/_validators.py index b0ea0e21d61..762aa975fb9 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/acs/_validators.py @@ -1129,3 +1129,215 @@ def validate_artifact_streaming(namespace): raise ArgumentUsageError('--enable-artifact-streaming can only be set for Linux nodepools') if disable_artifact_streaming: raise ArgumentUsageError('--disable-artifact-streaming can only be set for Linux nodepools') + + +def _reject_msi_auth_flag_with_azure_monitor_logs(namespace, explicit_values): + """Reject --enable-msi-auth-for-monitoring alongside --enable-azure-monitor-logs. + + Onboarding through the Azure Monitor profile is managed identity only, so the auth flag has no + meaning there. ``explicit_values`` differs by command because the flag's default does: it is + ``True`` on create, where only an explicit ``false`` is distinguishable from the default, and + ``None`` on update, where any value is explicit. + """ + if not getattr(namespace, "enable_azure_monitor_logs", False): + return + if getattr(namespace, "enable_msi_auth_for_monitoring", None) in explicit_values: + raise MutuallyExclusiveArgumentError( + "Cannot specify both '--enable-azure-monitor-logs' and " + "'--enable-msi-auth-for-monitoring'. '--enable-azure-monitor-logs' onboards through " + "the Azure Monitor profile, which always uses managed identity authentication." + ) + + +def validate_azure_monitor_logs_and_enable_addons(namespace): + """Validate that enable_azure_monitor_logs and enable_addons don't conflict.""" + if getattr(namespace, "enable_azure_monitor_logs", False): + enable_addons = getattr(namespace, "enable_addons", None) + if enable_addons and "monitoring" in enable_addons: + raise ArgumentUsageError( + "Cannot specify both '--enable-azure-monitor-logs' and '--enable-addons monitoring'. " + "Use either '--enable-azure-monitor-logs' or '--enable-addons monitoring'." + ) + # On create the flag defaults to True, so only an explicit false is detectable here. + _reject_msi_auth_flag_with_azure_monitor_logs(namespace, (False,)) + + +def validate_azure_monitor_logs_enable_disable(namespace): + """Validate that enable and disable azure monitor logs parameters don't conflict.""" + if ( + getattr(namespace, "enable_azure_monitor_logs", False) and + getattr(namespace, "disable_azure_monitor_logs", False) + ): + raise MutuallyExclusiveArgumentError( + "Cannot specify both '--enable-azure-monitor-logs' and '--disable-azure-monitor-logs'. " + "Use either '--enable-azure-monitor-logs' or '--disable-azure-monitor-logs'." + ) + # On update the flag defaults to None, so any value is an explicit use. + _reject_msi_auth_flag_with_azure_monitor_logs(namespace, (True, False)) + + +def _specified_container_insights_setting_flags(namespace): + """Return the AMP containerInsights tuning flags explicitly present on the command line.""" + flags = [] + if getattr(namespace, "syslog_port", None) is not None: + flags.append("--syslog-port") + if getattr(namespace, "enable_prometheus_metrics_scraping", False): + flags.append("--enable-prometheus-metrics-scraping") + if getattr(namespace, "disable_prometheus_metrics_scraping", False): + flags.append("--disable-prometheus-metrics-scraping") + return flags + + +def _validate_container_insights_settings_common(namespace): + """Validations for the containerInsights tuning flags that do not depend on cluster state.""" + if ( + getattr(namespace, "enable_prometheus_metrics_scraping", False) and + getattr(namespace, "disable_prometheus_metrics_scraping", False) + ): + raise MutuallyExclusiveArgumentError( + "Cannot specify both --enable-prometheus-metrics-scraping and " + "--disable-prometheus-metrics-scraping at the same time." + ) + + syslog_port = getattr(namespace, "syslog_port", None) + if syslog_port is not None and not 1 <= syslog_port <= 65535: + raise InvalidArgumentValueError( + f"--syslog-port must be a valid TCP port between 1 and 65535, got {syslog_port}." + ) + + flags = _specified_container_insights_setting_flags(namespace) + if flags and getattr(namespace, "disable_azure_monitor_logs", False): + raise ArgumentUsageError( + f"{', '.join(flags)} cannot be specified with --disable-azure-monitor-logs." + ) + + +def validate_container_insights_settings_for_create(namespace): + """Validate the containerInsights tuning flags for create operations.""" + _validate_container_insights_settings_common(namespace) + + flags = _specified_container_insights_setting_flags(namespace) + if flags and not getattr(namespace, "enable_azure_monitor_logs", False): + raise ArgumentUsageError( + f"{', '.join(flags)} requires Azure Monitor logs to be enabled. " + "Please add --enable-azure-monitor-logs to your command." + ) + + +def validate_container_insights_settings_for_update(namespace): + """Validate the containerInsights tuning flags for update operations.""" + _validate_container_insights_settings_common(namespace) + # Whether Azure Monitor logs is already enabled on the cluster is only visible once the + # ManagedCluster has been fetched, so that dependency check is deferred to the decorator. + + +def validate_opentelemetry_ports(namespace): + """Validate that the OpenTelemetry HTTP and gRPC ports are in range and all distinct.""" + ports = [ + ("--opentelemetry-metrics-port-http", getattr(namespace, "opentelemetry_metrics_port_http", None)), + ("--opentelemetry-metrics-port-grpc", getattr(namespace, "opentelemetry_metrics_port_grpc", None)), + ("--opentelemetry-logs-traces-port-http", getattr(namespace, "opentelemetry_logs_traces_port_http", None)), + ("--opentelemetry-logs-traces-port-grpc", getattr(namespace, "opentelemetry_logs_traces_port_grpc", None)), + ] + + for flag, port in ports: + if port is not None and not 1 <= port <= 65535: + raise ArgumentUsageError( + f"OpenTelemetry port {flag} must be between 1 and 65535, got {port}." + ) + + # All specified OpenTelemetry ports (HTTP and gRPC, metrics and logs/traces) must be distinct + specified = [(flag, port) for flag, port in ports if port is not None] + for i, (flag_i, port_i) in enumerate(specified): + for flag_j, port_j in specified[i + 1:]: + if port_i == port_j: + raise ArgumentUsageError( + "OpenTelemetry ports must all be different. " + f"{flag_i} and {flag_j} cannot both be set to {port_i}." + ) + + +def validate_opentelemetry_metrics_dependencies(namespace): + """Validate OpenTelemetry metrics dependencies for create operations.""" + enable_otlp_metrics = getattr(namespace, "enable_opentelemetry_metrics", False) + disable_otlp_metrics = getattr(namespace, "disable_opentelemetry_metrics", False) + + if enable_otlp_metrics and disable_otlp_metrics: + raise MutuallyExclusiveArgumentError( + "Cannot specify both --enable-opentelemetry-metrics and --disable-opentelemetry-metrics " + "at the same time." + ) + + # For create operations, require explicit Azure Monitor metrics enablement + if enable_otlp_metrics and not getattr(namespace, "enable_azure_monitor_metrics", False): + raise ArgumentUsageError( + "OpenTelemetry metrics requires Azure Monitor metrics to be enabled. " + "Please add --enable-azure-monitor-metrics to your command." + ) + + +def validate_opentelemetry_metrics_dependencies_for_update(namespace): + """Validate OpenTelemetry metrics dependencies for update operations.""" + enable_otlp_metrics = getattr(namespace, "enable_opentelemetry_metrics", False) + disable_otlp_metrics = getattr(namespace, "disable_opentelemetry_metrics", False) + + if enable_otlp_metrics and disable_otlp_metrics: + raise MutuallyExclusiveArgumentError( + "Cannot specify both --enable-opentelemetry-metrics and --disable-opentelemetry-metrics " + "at the same time." + ) + # Whether Azure Monitor metrics is already enabled on the cluster is only visible once the + # ManagedCluster has been fetched, so that dependency check is deferred to the decorator. + + +def validate_opentelemetry_logs_traces_dependencies(namespace): + """Validate OpenTelemetry logs and traces dependencies for create operations.""" + enable_otlp_logs = getattr(namespace, "enable_opentelemetry_logs_traces", False) + disable_otlp_logs = getattr(namespace, "disable_opentelemetry_logs_traces", False) + + if enable_otlp_logs and disable_otlp_logs: + raise MutuallyExclusiveArgumentError( + "Cannot specify both --enable-opentelemetry-logs-traces and " + "--disable-opentelemetry-logs-traces at the same time." + ) + + # For create operations, Azure Monitor logs must be enabled by the same command, either + # through the Azure Monitor profile or the legacy monitoring addon. + enable_addons = getattr(namespace, "enable_addons", None) + azure_monitor_logs_enabled = ( + getattr(namespace, "enable_azure_monitor_logs", False) or + (enable_addons and "monitoring" in enable_addons) + ) + if enable_otlp_logs and not azure_monitor_logs_enabled: + raise ArgumentUsageError( + "OpenTelemetry logs and traces requires Azure Monitor logs to be enabled. " + "Please add --enable-azure-monitor-logs to your command." + ) + + +def validate_opentelemetry_logs_traces_dependencies_for_update(namespace): + """Validate OpenTelemetry logs and traces dependencies for update operations.""" + enable_otlp_logs = getattr(namespace, "enable_opentelemetry_logs_traces", False) + disable_otlp_logs = getattr(namespace, "disable_opentelemetry_logs_traces", False) + + if enable_otlp_logs and disable_otlp_logs: + raise MutuallyExclusiveArgumentError( + "Cannot specify both --enable-opentelemetry-logs-traces and " + "--disable-opentelemetry-logs-traces at the same time." + ) + # Whether Azure Monitor logs is already enabled on the cluster is only visible once the + # ManagedCluster has been fetched, so that dependency check is deferred to the decorator. + + +def validate_azure_monitor_and_opentelemetry_for_create(namespace): + """Main validator for Azure Monitor and OpenTelemetry configurations for create operations.""" + validate_opentelemetry_ports(namespace) + validate_opentelemetry_metrics_dependencies(namespace) + validate_opentelemetry_logs_traces_dependencies(namespace) + + +def validate_azure_monitor_and_opentelemetry_for_update(namespace): + """Main validator for Azure Monitor and OpenTelemetry configurations for update operations.""" + validate_opentelemetry_ports(namespace) + validate_opentelemetry_metrics_dependencies_for_update(namespace) + validate_opentelemetry_logs_traces_dependencies_for_update(namespace) diff --git a/src/azure-cli/azure/cli/command_modules/acs/addonconfiguration.py b/src/azure-cli/azure/cli/command_modules/acs/addonconfiguration.py index 0194aff5a8f..7244b6f07e4 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/addonconfiguration.py +++ b/src/azure-cli/azure/cli/command_modules/acs/addonconfiguration.py @@ -390,6 +390,28 @@ def get_existing_container_insights_extension_dcr_tags(cmd, dcr_url): return tags +def warn_on_legacy_monitoring_auth(enable_msi_auth_for_monitoring, addons): + """Warn when the user explicitly opts into legacy shared key authentication for monitoring. + + The argument level deprecation already fires for any explicit use of + --enable-msi-auth-for-monitoring. This adds the migration pointer for the value that actually + leaves the cluster on shared key authentication, which is also the state that later blocks + --enable-azure-monitor-logs. + """ + if enable_msi_auth_for_monitoring is not False: + return + requested_addons = {addon.strip().lower() for addon in (addons or "").split(",")} + if "monitoring" not in requested_addons: + return + logger.warning( + "--enable-msi-auth-for-monitoring false configures Container Insights with legacy shared " + "key authentication. Managed identity authentication is recommended, and is required by " + "'--enable-azure-monitor-logs'. See " + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/" + "container-insights-authentication?tabs=cli#migrate-to-managed-identity-authentication" + ) + + # pylint: disable=too-many-locals,too-many-branches,too-many-statements,line-too-long def ensure_container_insights_for_monitoring( cmd, diff --git a/src/azure-cli/azure/cli/command_modules/acs/custom.py b/src/azure-cli/azure/cli/command_modules/acs/custom.py index b6aae535dde..e7efda7a56c 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/custom.py +++ b/src/azure-cli/azure/cli/command_modules/acs/custom.py @@ -90,6 +90,7 @@ add_virtual_node_role_assignment, ensure_container_insights_for_monitoring, ensure_default_log_analytics_workspace_for_monitoring, + warn_on_legacy_monitoring_auth, ) from azure.cli.core._profile import Profile from azure.cli.core.azclierror import ( @@ -959,6 +960,20 @@ def aks_create( data_collection_settings=None, ampls_resource_id=None, enable_high_log_scale_mode=None, + # azure monitor logs (container insights on the azure monitor profile) + enable_azure_monitor_logs=False, + syslog_port=None, + enable_prometheus_metrics_scraping=False, + disable_prometheus_metrics_scraping=False, + # opentelemetry + enable_opentelemetry_metrics=False, + disable_opentelemetry_metrics=False, + opentelemetry_metrics_port_http=None, + opentelemetry_metrics_port_grpc=None, + enable_opentelemetry_logs_traces=False, + disable_opentelemetry_logs_traces=False, + opentelemetry_logs_traces_port_http=None, + opentelemetry_logs_traces_port_grpc=None, aci_subnet_name=None, appgw_name=None, appgw_subnet_cidr=None, @@ -1231,6 +1246,26 @@ def aks_update( disable_control_plane_metrics=False, enable_azure_monitor_app_monitoring=False, disable_azure_monitor_app_monitoring=False, + # azure monitor logs (container insights on the azure monitor profile) + enable_azure_monitor_logs=False, + disable_azure_monitor_logs=False, + workspace_resource_id=None, + enable_msi_auth_for_monitoring=None, + enable_syslog=None, + data_collection_settings=None, + ampls_resource_id=None, + syslog_port=None, + enable_prometheus_metrics_scraping=False, + disable_prometheus_metrics_scraping=False, + # opentelemetry + enable_opentelemetry_metrics=False, + disable_opentelemetry_metrics=False, + opentelemetry_metrics_port_http=None, + opentelemetry_metrics_port_grpc=None, + enable_opentelemetry_logs_traces=False, + disable_opentelemetry_logs_traces=False, + opentelemetry_logs_traces_port_http=None, + opentelemetry_logs_traces_port_grpc=None, # azure container storage enable_azure_container_storage=None, disable_azure_container_storage=None, @@ -1570,14 +1605,38 @@ def _remove_nulls(managed_clusters): # pylint: disable=line-too-long -def aks_disable_addons(cmd, client, resource_group_name, name, addons, no_wait=False): +def aks_disable_addons(cmd, client, resource_group_name, name, addons, no_wait=False, yes=False): + from azure.cli.command_modules.acs.managed_cluster_decorator import ( + _is_opentelemetry_logs_traces_enabled, + _reset_container_insights_to_defaults, + ) + instance = client.get(resource_group_name, name) subscription_id = get_subscription_id(cmd.cli_ctx) monitoring_addon_key = get_monitoring_addon_key( instance.addon_profiles, CONST_MONITORING_ADDON_NAME ) + disabling_monitoring = CONST_MONITORING_ADDON_NAME in [ + ADDONS.get(addon.strip()) for addon in (addons or "").split(",") + ] + + # OpenTelemetry logs and traces are collected by the Container Insights agent, so disabling the + # monitoring addon necessarily turns them off too. The confirmation is taken before any cleanup + # runs, otherwise declining the prompt would leave the DCR association already deleted. + opentelemetry_logs_traces_enabled = ( + disabling_monitoring and _is_opentelemetry_logs_traces_enabled(instance) + ) + if opentelemetry_logs_traces_enabled and not yes: + msg = ( + "OpenTelemetry logs and traces are enabled on this cluster and are collected by " + "Azure Monitor logs. Disabling the monitoring addon will also disable OpenTelemetry " + "logs and traces. Do you want to continue?" + ) + if not prompt_y_n(msg, default="n"): + return None + try: - if addons == "monitoring" and monitoring_addon_key in instance.addon_profiles and \ + if disabling_monitoring and monitoring_addon_key in instance.addon_profiles and \ instance.addon_profiles[monitoring_addon_key].enabled and \ CONST_MONITORING_USING_AAD_MSI_AUTH in instance.addon_profiles[monitoring_addon_key].config and \ str(instance.addon_profiles[monitoring_addon_key].config[CONST_MONITORING_USING_AAD_MSI_AUTH]).lower() == 'true': @@ -1613,6 +1672,26 @@ def aks_disable_addons(cmd, client, resource_group_name, name, addons, no_wait=F no_wait=no_wait ) + if disabling_monitoring: + # The legacy addon and the AMP containerInsights profile are two views of the same feature, + # and the RP only copies a containerInsights field onto the cluster when that field is + # present on the request. Disabling therefore has to reset them explicitly, otherwise a + # later --enable-azure-monitor-logs silently re-onboards with the old syslog port, scraping + # choice and container network logs setting. + if instance.azure_monitor_profile and instance.azure_monitor_profile.container_insights: + _reset_container_insights_to_defaults( + instance.azure_monitor_profile.container_insights + ) + # OpenTelemetry logs and traces ride on the Container Insights agent, so they go down with + # it. The confirmation for this was taken above. + if opentelemetry_logs_traces_enabled: + open_telemetry_logs_and_traces = ( + instance.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + ) + open_telemetry_logs_and_traces.enabled = False + open_telemetry_logs_and_traces.http_port = None + open_telemetry_logs_and_traces.grpc_port = None + # send the managed cluster representation to update the addon profiles return sdk_no_wait(no_wait, client.begin_create_or_update, resource_group_name, name, instance) @@ -1635,6 +1714,7 @@ def aks_enable_addons(cmd, client, resource_group_name, name, addons, ampls_resource_id=None, enable_high_log_scale_mode=None, no_wait=False,): + warn_on_legacy_monitoring_auth(enable_msi_auth_for_monitoring, addons) instance = client.get(resource_group_name, name) msi_auth = False if instance.service_principal_profile.client_id == "msi": diff --git a/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py b/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py index 034b91055c7..6f30c09e281 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py +++ b/src/azure-cli/azure/cli/command_modules/acs/managed_cluster_decorator.py @@ -5,6 +5,7 @@ # pylint: disable=line-too-long import copy +import json import os import re import time @@ -54,6 +55,10 @@ CONST_APP_ROUTING_ISTIO_MODE_DISABLED, CONST_MANAGED_GATEWAY_INSTALLATION_DISABLED, CONST_MANAGED_GATEWAY_INSTALLATION_STANDARD, + CONST_CONTAINER_NETWORK_LOGS_ENABLED, + CONST_CONTAINER_NETWORK_LOGS_DISABLED, + CONST_MONITORING_ENABLE_RETINA_NETWORK_FLAGS, + CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT, ) from azure.cli.command_modules.acs.azurecontainerstorage._consts import ( CONST_ACSTOR_EXT_INSTALLATION_NAME, @@ -140,6 +145,11 @@ logger = get_logger(__name__) +# Maximum size, in characters of the serialized JSON, of the --data-collection-settings payload. +# The settings are embedded in the data collection rule request, which the service rejects with +# "Request Header Fields Too Large" beyond roughly this size. +CONST_DATA_COLLECTION_SETTINGS_MAX_CHARS = 10000 + # type variables ContainerServiceClient = TypeVar("ContainerServiceClient") Identity = TypeVar("Identity") @@ -173,6 +183,209 @@ def _get_monitoring_addon_key_from_consts(addon_profiles, addon_consts): addon_consts.get("CONST_MONITORING_ADDON_NAME"), ) + +def _get_container_insights_profile(mc): + """Return the Azure Monitor Profile containerInsights object, or None.""" + azure_monitor_profile = getattr(mc, "azure_monitor_profile", None) if mc is not None else None + if azure_monitor_profile is None: + return None + return getattr(azure_monitor_profile, "container_insights", None) + + +def _is_container_insights_enabled(mc): + """Whether Azure Monitor logs are enabled through the AMP containerInsights profile.""" + container_insights = _get_container_insights_profile(mc) + return bool(container_insights and container_insights.enabled) + + +def _get_monitoring_addon_profile(cluster, addon_consts): + """Return the omsagent addon profile object for a cluster, or None.""" + addon_profiles = getattr(cluster, "addon_profiles", None) if cluster is not None else None + if not addon_profiles: + return None + addon_key = _get_monitoring_addon_key_from_consts(addon_profiles, addon_consts) + return addon_profiles.get(addon_key) + + +def _is_monitoring_enabled_on_mc(mc, addon_consts): + """Whether Azure Monitor logs are enabled through either the AMP profile or the legacy addon.""" + if _is_container_insights_enabled(mc): + return True + addon_profile = _get_monitoring_addon_profile(mc, addon_consts) + return bool(addon_profile and addon_profile.enabled) + + +def _apply_container_insights_settings(container_insights, syslog_port, disable_prometheus_scraping): + """Write the optional AMP containerInsights tuning fields, leaving unset ones untouched.""" + if syslog_port is not None: + container_insights.syslog_port = syslog_port + if disable_prometheus_scraping is not None: + container_insights.disable_prometheus_metrics_scraping = disable_prometheus_scraping + + +def _reset_container_insights_to_defaults(container_insights): + """Reset the AMP containerInsights settings back to their documented defaults. + + The RP copies a containerInsights field from the request onto the cluster only when the field + is present (see ``ApplyAzureMonitorProfileContainerInsights``), so leaving a field as ``None`` + preserves whatever the cluster already has. Disabling therefore has to write the defaults + explicitly, otherwise a later re-enable silently inherits the old syslog port, scraping choice + and container network logs setting. + + ``logAnalyticsWorkspaceResourceId`` is deliberately left alone. It is a resource-id typed + field, and blanking it makes the RP mirror the empty string into + ``addonProfiles.omsagent.config.logAnalyticsWorkspaceResourceID``; ARM then rejects every later + write of the cluster with ``LinkedInvalidPropertyId``, which would break unrelated + ``az aks update`` calls too. The stale id is inert once ``enabled`` is false, and the enable + path always overwrites it with a freshly resolved workspace, so nothing is inherited. + """ + container_insights.enabled = False + container_insights.syslog_port = CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT + container_insights.disable_prometheus_metrics_scraping = False + container_insights.container_network_logs = CONST_CONTAINER_NETWORK_LOGS_DISABLED + + +def _is_service_principal_cluster(mc): + """Whether the cluster authenticates to Azure with a service principal instead of an identity. + + Managed identity clusters report ``servicePrincipalProfile.clientId == "msi"``, so any other + non-empty client id means a real service principal. A missing profile means managed identity. + """ + service_principal_profile = getattr(mc, "service_principal_profile", None) if mc is not None else None + if service_principal_profile is None: + return False + client_id = getattr(service_principal_profile, "client_id", None) + if not client_id: + return False + return client_id.lower() != "msi" + + +def _raise_if_service_principal_cluster(mc): + """Reject --enable-azure-monitor-logs on a service principal cluster. + + The Azure Monitor profile has no shared key/``useAADAuth`` concept: the agent authenticates to + the Log Analytics workspace with the cluster's managed identity. A service principal cluster + has no such identity, so the onboarding cannot work and is rejected up front. + """ + if _is_service_principal_cluster(mc): + raise ArgumentUsageError( + "'--enable-azure-monitor-logs' cannot be used on clusters with service principal " + "authentication. Azure Monitor logs onboards through the Azure Monitor profile, " + "which requires the cluster to use a managed identity. Update the cluster to use a " + "managed identity with 'az aks update --enable-managed-identity', then retry." + ) + + +def _get_addon_config_value(config, key): + """Case-insensitive lookup of an addon config value. + + ARM echoes addon config keys back in whatever casing they were written with, and the RP + reads them case-insensitively, so match that here. + """ + if not config or not key: + return None + if key in config: + return config[key] + lowered = key.lower() + for existing_key, value in config.items(): + if existing_key.lower() == lowered: + return value + return None + + +def _is_container_network_logs_enabled_on_mc(mc, addon_consts): + """Whether container network logs are on, via the AMP profile or the legacy addon config key.""" + container_insights = _get_container_insights_profile(mc) + amp_value = container_insights.container_network_logs if container_insights else None + if amp_value: + return safe_lower(amp_value) == CONST_CONTAINER_NETWORK_LOGS_ENABLED.lower() + addon_profile = _get_monitoring_addon_profile(mc, addon_consts) + if addon_profile is None: + return False + value = _get_addon_config_value(addon_profile.config, CONST_MONITORING_ENABLE_RETINA_NETWORK_FLAGS) + return safe_lower(value) == "true" + + +def _is_azure_monitor_metrics_enabled(mc): + """Whether managed Prometheus (Azure Monitor metrics) is enabled on the cluster.""" + return bool( + mc and + mc.azure_monitor_profile and + mc.azure_monitor_profile.metrics and + mc.azure_monitor_profile.metrics.enabled + ) + + +def _is_opentelemetry_metrics_enabled(mc): + """Whether the OpenTelemetry metrics receiver is enabled on the cluster.""" + return bool( + mc and + mc.azure_monitor_profile and + mc.azure_monitor_profile.app_monitoring and + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics and + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.enabled + ) + + +def _is_opentelemetry_logs_traces_enabled(mc): + """Whether the OpenTelemetry logs and traces receiver is enabled on the cluster.""" + return bool( + mc and + mc.azure_monitor_profile and + mc.azure_monitor_profile.app_monitoring and + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces and + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.enabled + ) + + +def _is_monitoring_aad_auth(cluster, addon_consts): + """Whether Azure Monitor logs uses managed identity (AAD) auth on this cluster. + + The AMP containerInsights profile carries no auth information, and the RP mirrors a legacy + addon into it, so a cluster onboarded with shared key auth still ends up with a + containerInsights profile. The presence of that profile is therefore inconclusive, and the + omsagent addon config is the only authoritative source of the auth mode. + + Mirror the RP's derivation: + + * no omsagent addon at all -> fresh onboarding, which always defaults to AAD auth + * addon present but disabled -> a re-enable, which the RP also treats as fresh onboarding + * otherwise -> the addon's ``useAADAuth`` value, where absent or empty means legacy auth + """ + addon_profile = _get_monitoring_addon_profile(cluster, addon_consts) + if addon_profile is None or not addon_profile.enabled: + return True + msi_auth_key = addon_consts.get("CONST_MONITORING_USING_AAD_MSI_AUTH") + return safe_lower(_get_addon_config_value(addon_profile.config, msi_auth_key)) == "true" + + +def _build_monitoring_addon_shim(cluster, models, addon_consts): + """Build the object that drives DCR/DCE/DCRA/AMPLS provisioning. + + ``ensure_container_insights_for_monitoring`` reads only ``enabled`` and the workspace id out + of ``config``. Synthesizing those from the AMP containerInsights profile lets the provisioning + run without the legacy omsagent addon being present, while the fallback keeps clusters + onboarded before the AMP switch working unchanged. + + The auth mode is derived separately via :func:`_is_monitoring_aad_auth` rather than assumed + from the AMP profile, because the RP mirrors legacy shared key clusters into that profile. + """ + workspace_key = addon_consts.get("CONST_MONITORING_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID") + msi_auth_key = addon_consts.get("CONST_MONITORING_USING_AAD_MSI_AUTH") + + container_insights = _get_container_insights_profile(cluster) + if container_insights and container_insights.enabled and container_insights.log_analytics_workspace_resource_id: + return models.ManagedClusterAddonProfile( + enabled=True, + config={ + workspace_key: container_insights.log_analytics_workspace_resource_id, + msi_auth_key: "true" if _is_monitoring_aad_auth(cluster, addon_consts) else "false", + }, + ) + + return _get_monitoring_addon_profile(cluster, addon_consts) + + # TODO # 1. remove enable_rbac related implementation # 2. add validation for all/some of the parameters involved in the getter of outbound_type/enable_addons @@ -2828,14 +3041,13 @@ def get_container_network_logs(self, mc: ManagedCluster) -> Union[bool, None]: enable_addons = self.raw_param.get("enable_addons") monitoring_via_enable_addons = enable_addons and "monitoring" in enable_addons - # Check if monitoring is already enabled on the cluster + # Check if Azure Monitor logs is being enabled by this command through the AMP path + enable_azure_monitor_logs = bool(self.raw_param.get("enable_azure_monitor_logs")) + + # Check if monitoring is already enabled on the cluster, via the AMP containerInsights + # profile or the legacy omsagent addon addon_consts = self.get_addon_consts() - monitoring_addon_key = _get_monitoring_addon_key_from_consts(mc.addon_profiles, addon_consts) - monitoring_on_cluster = ( - mc.addon_profiles and - mc.addon_profiles.get(monitoring_addon_key) and - mc.addon_profiles[monitoring_addon_key].enabled - ) + monitoring_on_cluster = _is_monitoring_enabled_on_mc(mc, addon_consts) # Check if ACNS is being enabled or already enabled acns_enabled = ( @@ -2852,13 +3064,27 @@ def get_container_network_logs(self, mc: ManagedCluster) -> Union[bool, None]: network_dataplane = network_dataplane_param or network_dataplane_cluster cilium_enabled = safe_lower(network_dataplane) == "cilium" - monitoring_enabled = monitoring_via_enable_addons or monitoring_on_cluster + monitoring_enabled = monitoring_via_enable_addons or enable_azure_monitor_logs or monitoring_on_cluster if enable_cnl and (not acns_enabled or not monitoring_enabled or not cilium_enabled): raise InvalidArgumentValueError( - "Container network logs requires ACNS to be enabled, the monitoring addon to be enabled, " + "Container network logs requires ACNS to be enabled, Azure Monitor logs to be enabled, " "and the cilium network dataplane." ) + + # Container network logs rely on high log scale mode, whose data path is a data collection + # rule. Data collection rules are only provisioned for managed identity authentication, so + # the feature cannot work on a cluster still using legacy shared key authentication. + if enable_cnl and not _is_monitoring_aad_auth(mc, addon_consts): + raise InvalidArgumentValueError( + "Container network logs requires Azure Monitor logs to use managed identity " + "authentication. This cluster is onboarded with legacy (shared key) authentication, " + "which does not support the data collection rule that container network logs depends " + "on. Migrate the cluster to managed identity authentication first, then retry. See " + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/" + "container-insights-authentication?tabs=cli#migrate-to-managed-identity-authentication" + ) + enable_cnl = bool(enable_cnl) if enable_cnl is not None else False disable_cnl = bool(disable_cnl) if disable_cnl is not None else False return enable_cnl or not disable_cnl @@ -3184,9 +3410,14 @@ def _get_enable_addons(self, enable_validation: bool = False) -> List[str]: # check monitoring/workspace_resource_id workspace_resource_id = self._get_workspace_resource_id(read_only=True) - if "monitoring" not in enable_addons and workspace_resource_id: + if ( + "monitoring" not in enable_addons and + workspace_resource_id and + not self.raw_param.get("enable_azure_monitor_logs") + ): raise RequiredArgumentMissingError( - '"--workspace-resource-id" requires "--enable-addons monitoring".') + '"--workspace-resource-id" requires "--enable-addons monitoring" or ' + '"--enable-azure-monitor-logs".') # check virtual node/aci_subnet_name/vnet_subnet_id # Note: The external parameters involved in the validation are not verified in their own getters. @@ -3276,9 +3507,14 @@ def _get_workspace_resource_id( # validation if enable_validation: enable_addons = self._get_enable_addons(enable_validation=False) - if workspace_resource_id and "monitoring" not in enable_addons: + if ( + workspace_resource_id and + "monitoring" not in enable_addons and + not self.raw_param.get("enable_azure_monitor_logs") + ): raise RequiredArgumentMissingError( - '"--workspace-resource-id" requires "--enable-addons monitoring".') + '"--workspace-resource-id" requires "--enable-addons monitoring" or ' + '"--enable-azure-monitor-logs".') # this parameter does not need validation return workspace_resource_id @@ -3302,6 +3538,11 @@ def get_enable_msi_auth_for_monitoring(self) -> Union[bool, None]: :return: bool or None """ + # The Azure Monitor profile is managed identity only, so onboarding through + # --enable-azure-monitor-logs always authenticates with managed identity. + if self.raw_param.get("enable_azure_monitor_logs"): + return True + # determine the value of constants addon_consts = self.get_addon_consts() CONST_MONITORING_USING_AAD_MSI_AUTH = addon_consts.get("CONST_MONITORING_USING_AAD_MSI_AUTH") @@ -3344,6 +3585,129 @@ def get_enable_msi_auth_for_monitoring(self) -> Union[bool, None]: # this parameter does not need validation return enable_msi_auth_for_monitoring + # Azure Monitor logs (Container Insights on the Azure Monitor profile) + def _get_enable_azure_monitor_logs(self, enable_validation: bool = False) -> bool: + """Internal function to obtain the value of enable_azure_monitor_logs. + + :return: bool + """ + enable_azure_monitor_logs = self.raw_param.get("enable_azure_monitor_logs") + if enable_validation and enable_azure_monitor_logs: + if self._get_disable_azure_monitor_logs(False): + raise MutuallyExclusiveArgumentError( + "Cannot specify --enable-azure-monitor-logs and --disable-azure-monitor-logs at the same time." + ) + # The Azure Monitor profile is managed identity only, so the legacy auth flag is + # meaningless on this path. On update the parameter defaults to None, so any value is + # an explicit request. On create it defaults to True, which is indistinguishable from + # the user passing it; only an explicit False is detectable there, and that is the case + # that actually conflicts because it asks for shared key auth. + enable_msi_auth = self.raw_param.get("enable_msi_auth_for_monitoring") + explicitly_requested = ( + enable_msi_auth is not None + if self.decorator_mode == DecoratorMode.UPDATE + else enable_msi_auth is False + ) + if explicitly_requested: + raise MutuallyExclusiveArgumentError( + "Cannot specify --enable-msi-auth-for-monitoring with --enable-azure-monitor-logs. " + "Azure Monitor logs always uses managed identity authentication, so the flag has no " + "effect. Remove --enable-msi-auth-for-monitoring, or use '--enable-addons monitoring' " + "if you need to control the authentication mode." + ) + return bool(enable_azure_monitor_logs) + + def get_enable_azure_monitor_logs(self) -> bool: + """Obtain the value of enable_azure_monitor_logs. + + :return: bool + """ + return self._get_enable_azure_monitor_logs(enable_validation=True) + + def _get_disable_azure_monitor_logs(self, enable_validation: bool = False) -> bool: + """Internal function to obtain the value of disable_azure_monitor_logs. + + :return: bool + """ + disable_azure_monitor_logs = self.raw_param.get("disable_azure_monitor_logs") + if enable_validation and disable_azure_monitor_logs and self._get_enable_azure_monitor_logs(False): + raise MutuallyExclusiveArgumentError( + "Cannot specify --enable-azure-monitor-logs and --disable-azure-monitor-logs at the same time." + ) + return bool(disable_azure_monitor_logs) + + def get_disable_azure_monitor_logs(self) -> bool: + """Obtain the value of disable_azure_monitor_logs. + + :return: bool + """ + return self._get_disable_azure_monitor_logs(enable_validation=True) + + def _validate_container_insights_setting(self, flag_name: str) -> None: + """Validate that an AMP containerInsights setting can be applied by this command. + + These settings live only on the Azure Monitor profile, so they need the profile to be + turned on by this command or, on update, to be on already. + """ + if self._get_disable_azure_monitor_logs(False): + raise InvalidArgumentValueError( + f"{flag_name} cannot be specified when --disable-azure-monitor-logs is used." + ) + if self._get_enable_azure_monitor_logs(False): + return + if self.decorator_mode == DecoratorMode.UPDATE: + if _is_monitoring_enabled_on_mc(self.mc, self.get_addon_consts()): + return + raise InvalidArgumentValueError( + f"{flag_name} can only be specified when --enable-azure-monitor-logs is also " + "specified or Azure Monitor logs is already enabled on the cluster." + ) + raise InvalidArgumentValueError( + f"{flag_name} can only be specified when --enable-azure-monitor-logs is also specified." + ) + + def get_syslog_port(self) -> Union[int, None]: + """Obtain the value of syslog_port. + + Returns None when the flag is omitted, which leaves the server default (28330) in place. + + :return: int or None + """ + syslog_port = self.raw_param.get("syslog_port") + if syslog_port is None: + return None + + if syslog_port < 1 or syslog_port > 65535: + raise InvalidArgumentValueError( + "--syslog-port must be a valid TCP port between 1 and 65535." + ) + self._validate_container_insights_setting("--syslog-port") + return syslog_port + + def get_disable_prometheus_metrics_scraping(self) -> Union[bool, None]: + """Obtain the value to write to containerInsights.disablePrometheusMetricsScraping. + + Returns None when neither flag is given, so the field is left untouched. + + :return: bool or None + """ + enable_scraping = self.raw_param.get("enable_prometheus_metrics_scraping") + disable_scraping = self.raw_param.get("disable_prometheus_metrics_scraping") + + if enable_scraping and disable_scraping: + raise MutuallyExclusiveArgumentError( + "Cannot specify --enable-prometheus-metrics-scraping and " + "--disable-prometheus-metrics-scraping at the same time." + ) + if not enable_scraping and not disable_scraping: + return None + + self._validate_container_insights_setting( + "--disable-prometheus-metrics-scraping" if disable_scraping + else "--enable-prometheus-metrics-scraping" + ) + return bool(disable_scraping) + def get_enable_syslog(self) -> Union[bool, None]: """Obtain the value of enable_syslog. @@ -3374,6 +3738,19 @@ def get_data_collection_settings(self) -> Union[str, None]: data_collection_settings_file_path ) ) + # The settings are serialized into the data collection rule request, so the size that + # matters is that of the parsed contents, not of the path they were read from. Checking + # the path length instead would never trigger, letting an oversized file through to + # fail the DCR call with an opaque "Request Header Fields Too Large". Raise here rather + # than dropping the settings, which would silently fall back to the default collection + # settings and change what the cluster ingests without telling the caller. + serialized_length = len(json.dumps(get_file_json(data_collection_settings_file_path))) + if serialized_length > CONST_DATA_COLLECTION_SETTINGS_MAX_CHARS: + raise InvalidArgumentValueError( + f"--data-collection-settings is too large: {serialized_length} characters once " + f"serialized, the limit is {CONST_DATA_COLLECTION_SETTINGS_MAX_CHARS}. Reduce " + "the number of namespaces or streams in the file and retry." + ) return data_collection_settings_file_path def get_ampls_resource_id(self) -> Union[str, None]: @@ -6032,6 +6409,252 @@ def get_disable_azure_monitor_metrics(self) -> bool: """ return self._get_disable_azure_monitor_metrics(enable_validation=True) + # OpenTelemetry (azureMonitorProfile.appMonitoring.openTelemetry*) + def _get_enable_opentelemetry_metrics(self, enable_validation: bool = False) -> bool: + """Internal function to obtain the value of enable_opentelemetry_metrics. + + :return: bool + """ + enable_opentelemetry_metrics = self.raw_param.get("enable_opentelemetry_metrics") + + if enable_validation and enable_opentelemetry_metrics: + if self._get_disable_opentelemetry_metrics(enable_validation=False): + raise MutuallyExclusiveArgumentError( + "Cannot specify --enable-opentelemetry-metrics and " + "--disable-opentelemetry-metrics at the same time." + ) + + # OpenTelemetry metrics ride on the Azure Monitor metrics profile, so it must either be + # turned on by this same command or, on update, already be on. + azure_monitor_enabled_in_profile = ( + self.decorator_mode == DecoratorMode.UPDATE and + self.mc and + self.mc.azure_monitor_profile and + self.mc.azure_monitor_profile.metrics and + self.mc.azure_monitor_profile.metrics.enabled + ) + if not self.raw_param.get("enable_azure_monitor_metrics") and not azure_monitor_enabled_in_profile: + raise ArgumentUsageError( + "OpenTelemetry metrics requires Azure Monitor metrics to be enabled. " + "Please add --enable-azure-monitor-metrics to your command." + ) + return bool(enable_opentelemetry_metrics) + + def get_enable_opentelemetry_metrics(self) -> bool: + """Obtain the value of enable_opentelemetry_metrics. + + :return: bool + """ + return self._get_enable_opentelemetry_metrics(enable_validation=True) + + def _get_disable_opentelemetry_metrics(self, enable_validation: bool = False) -> bool: + """Internal function to obtain the value of disable_opentelemetry_metrics. + + :return: bool + """ + disable_opentelemetry_metrics = self.raw_param.get("disable_opentelemetry_metrics") + if ( + enable_validation and + disable_opentelemetry_metrics and + self._get_enable_opentelemetry_metrics(enable_validation=False) + ): + raise MutuallyExclusiveArgumentError( + "Cannot specify --enable-opentelemetry-metrics and " + "--disable-opentelemetry-metrics at the same time." + ) + return bool(disable_opentelemetry_metrics) + + def get_disable_opentelemetry_metrics(self) -> bool: + """Obtain the value of disable_opentelemetry_metrics. + + :return: bool + """ + return self._get_disable_opentelemetry_metrics(enable_validation=True) + + def _get_enable_opentelemetry_logs_traces(self, enable_validation: bool = False) -> bool: + """Internal function to obtain the value of enable_opentelemetry_logs_traces. + + :return: bool + """ + enable_opentelemetry_logs_traces = self.raw_param.get("enable_opentelemetry_logs_traces") + + if enable_validation and enable_opentelemetry_logs_traces: + if self._get_disable_opentelemetry_logs_traces(enable_validation=False): + raise MutuallyExclusiveArgumentError( + "Cannot specify --enable-opentelemetry-logs-traces and " + "--disable-opentelemetry-logs-traces at the same time." + ) + + # OpenTelemetry logs and traces ride on Azure Monitor logs, which may be turned on by + # this command through the Azure Monitor profile or the legacy monitoring addon, or on + # update already be on through either of those on the cluster. + enabled_by_this_command = bool( + self.raw_param.get("enable_azure_monitor_logs") or + "monitoring" in (self.raw_param.get("enable_addons") or "") + ) + monitoring_currently_enabled = ( + self.decorator_mode == DecoratorMode.UPDATE and + self.mc and + _is_monitoring_enabled_on_mc(self.mc, self.get_addon_consts()) + ) + if not enabled_by_this_command and not monitoring_currently_enabled: + raise ArgumentUsageError( + "OpenTelemetry logs and traces requires Azure Monitor logs to be enabled. " + "Please add --enable-azure-monitor-logs to your command." + ) + return bool(enable_opentelemetry_logs_traces) + + def get_enable_opentelemetry_logs_traces(self) -> bool: + """Obtain the value of enable_opentelemetry_logs_traces. + + :return: bool + """ + return self._get_enable_opentelemetry_logs_traces(enable_validation=True) + + def _get_disable_opentelemetry_logs_traces(self, enable_validation: bool = False) -> bool: + """Internal function to obtain the value of disable_opentelemetry_logs_traces. + + :return: bool + """ + disable_opentelemetry_logs_traces = self.raw_param.get("disable_opentelemetry_logs_traces") + if ( + enable_validation and + disable_opentelemetry_logs_traces and + self._get_enable_opentelemetry_logs_traces(enable_validation=False) + ): + raise MutuallyExclusiveArgumentError( + "Cannot specify --enable-opentelemetry-logs-traces and " + "--disable-opentelemetry-logs-traces at the same time." + ) + return bool(disable_opentelemetry_logs_traces) + + def get_disable_opentelemetry_logs_traces(self) -> bool: + """Obtain the value of disable_opentelemetry_logs_traces. + + :return: bool + """ + return self._get_disable_opentelemetry_logs_traces(enable_validation=True) + + def _get_opentelemetry_metrics_port(self, param_name: str, flag_name: str) -> Union[int, None]: + """Shared validation for the OTLP metrics host port flags. + + :return: int or None + """ + port = self.raw_param.get(param_name) + if port is None: + return None + + if port < 1 or port > 65535: + raise InvalidArgumentValueError( + f"{flag_name} must be a valid TCP port between 1 and 65535." + ) + if self.get_disable_azure_monitor_metrics(): + raise InvalidArgumentValueError( + f"{flag_name} cannot be specified when --disable-azure-monitor-metrics is used." + ) + if self.get_disable_opentelemetry_metrics(): + raise InvalidArgumentValueError( + f"{flag_name} cannot be specified when --disable-opentelemetry-metrics is used." + ) + + if self.get_enable_opentelemetry_metrics(): + return port + if self.decorator_mode == DecoratorMode.UPDATE: + already_enabled = ( + self.mc and + self.mc.azure_monitor_profile and + self.mc.azure_monitor_profile.app_monitoring and + self.mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics and + self.mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.enabled + ) + if already_enabled: + return port + raise InvalidArgumentValueError( + f"{flag_name} can only be specified when --enable-opentelemetry-metrics is also " + "specified or OpenTelemetry metrics are already enabled." + ) + raise InvalidArgumentValueError( + f"{flag_name} can only be specified when --enable-opentelemetry-metrics is also specified." + ) + + def get_opentelemetry_metrics_port_http(self) -> Union[int, None]: + """Obtain the value of opentelemetry_metrics_port_http. + + :return: int or None + """ + return self._get_opentelemetry_metrics_port( + "opentelemetry_metrics_port_http", "--opentelemetry-metrics-port-http" + ) + + def get_opentelemetry_metrics_port_grpc(self) -> Union[int, None]: + """Obtain the value of opentelemetry_metrics_port_grpc. + + :return: int or None + """ + return self._get_opentelemetry_metrics_port( + "opentelemetry_metrics_port_grpc", "--opentelemetry-metrics-port-grpc" + ) + + def _get_opentelemetry_logs_traces_port(self, param_name: str, flag_name: str) -> Union[int, None]: + """Shared validation for the OTLP logs and traces host port flags. + + :return: int or None + """ + port = self.raw_param.get(param_name) + if port is None: + return None + + if port < 1 or port > 65535: + raise InvalidArgumentValueError( + f"{flag_name} must be a valid TCP port between 1 and 65535." + ) + if self.get_disable_azure_monitor_logs(): + raise InvalidArgumentValueError( + f"{flag_name} cannot be specified when --disable-azure-monitor-logs is used." + ) + if self.get_disable_opentelemetry_logs_traces(): + raise InvalidArgumentValueError( + f"{flag_name} cannot be specified when --disable-opentelemetry-logs-traces is used." + ) + + if self.get_enable_opentelemetry_logs_traces(): + return port + if self.decorator_mode == DecoratorMode.UPDATE: + already_enabled = ( + self.mc and + self.mc.azure_monitor_profile and + self.mc.azure_monitor_profile.app_monitoring and + self.mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces and + self.mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.enabled + ) + if already_enabled: + return port + raise InvalidArgumentValueError( + f"{flag_name} can only be specified when --enable-opentelemetry-logs-traces is also " + "specified or OpenTelemetry logs and traces are already enabled." + ) + raise InvalidArgumentValueError( + f"{flag_name} can only be specified when --enable-opentelemetry-logs-traces is also specified." + ) + + def get_opentelemetry_logs_traces_port_http(self) -> Union[int, None]: + """Obtain the value of opentelemetry_logs_traces_port_http. + + :return: int or None + """ + return self._get_opentelemetry_logs_traces_port( + "opentelemetry_logs_traces_port_http", "--opentelemetry-logs-traces-port-http" + ) + + def get_opentelemetry_logs_traces_port_grpc(self) -> Union[int, None]: + """Obtain the value of opentelemetry_logs_traces_port_grpc. + + :return: int or None + """ + return self._get_opentelemetry_logs_traces_port( + "opentelemetry_logs_traces_port_grpc", "--opentelemetry-logs-traces-port-grpc" + ) + def validate_control_plane_metrics_params(self) -> None: """Validate the --enable/--disable-control-plane-metrics flag combo and its interaction with --enable/--disable-azure-monitor-metrics. @@ -7426,14 +8049,15 @@ def set_up_addon_profiles(self, mc: ManagedCluster) -> ManagedCluster: CONST_AZURE_KEYVAULT_SECRETS_PROVIDER_ADDON_NAME ] = self.build_azure_keyvault_secrets_provider_addon_profile() - # Set up container network logs if enabled + # Set up container network logs if enabled. This is written on the Azure Monitor profile + # rather than the legacy omsagent addon config key. container_network_logs_enabled = self.context.get_container_network_logs(mc) if container_network_logs_enabled is not None: - monitoring_addon_profile = addon_profiles.get(CONST_MONITORING_ADDON_NAME) - if monitoring_addon_profile: - config = monitoring_addon_profile.config or {} - config["enableRetinaNetworkFlags"] = str(container_network_logs_enabled) - monitoring_addon_profile.config = config + self._ensure_container_insights(mc).container_network_logs = ( + CONST_CONTAINER_NETWORK_LOGS_ENABLED + if container_network_logs_enabled + else CONST_CONTAINER_NETWORK_LOGS_DISABLED + ) # Trigger validation for high log scale mode when container network logs are enabled. # This ensures proper error messages are raised before cluster creation if the user @@ -7442,6 +8066,11 @@ def set_up_addon_profiles(self, mc: ManagedCluster) -> ManagedCluster: self.context.get_enable_high_log_scale_mode() mc.addon_profiles = addon_profiles + + # Handle enable Azure Monitor logs, which onboards through the Azure Monitor profile and + # intentionally does not author an omsagent addon entry. + if self.context.get_enable_azure_monitor_logs(): + self._setup_azure_monitor_logs(mc) return mc def set_up_aad_profile(self, mc: ManagedCluster) -> ManagedCluster: @@ -7915,6 +8544,124 @@ def set_up_k8s_support_plan(self, mc: ManagedCluster) -> ManagedCluster: mc.support_plan = support_plan return mc + def _ensure_azure_monitor_profile(self, mc: ManagedCluster) -> None: + """Ensure the azure monitor profile exists on the managed cluster.""" + if mc.azure_monitor_profile is None: + mc.azure_monitor_profile = self.models.ManagedClusterAzureMonitorProfile() + + def _ensure_container_insights(self, mc: ManagedCluster): + """Ensure the AMP containerInsights profile exists and return it.""" + self._ensure_azure_monitor_profile(mc) + if mc.azure_monitor_profile.container_insights is None: + mc.azure_monitor_profile.container_insights = ( + self.models.ManagedClusterAzureMonitorProfileContainerInsights() + ) + return mc.azure_monitor_profile.container_insights + + def _ensure_app_monitoring_profile(self, mc: ManagedCluster) -> None: + """Ensure the app monitoring profile exists on the managed cluster.""" + self._ensure_azure_monitor_profile(mc) + if mc.azure_monitor_profile.app_monitoring is None: + mc.azure_monitor_profile.app_monitoring = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoring() + ) + + def _setup_azure_monitor_logs(self, mc: ManagedCluster) -> None: + """Set up Azure Monitor logs on the Azure Monitor profile.""" + # The Azure Monitor profile is managed identity only, so a cluster created with a service + # principal can never authenticate to the workspace. Reject before a default workspace is + # created on the user's behalf. + _raise_if_service_principal_cluster(mc) + + workspace_resource_id = self.context.raw_param.get("workspace_resource_id") + if not workspace_resource_id: + workspace_resource_id = self.context.external_functions.ensure_default_log_analytics_workspace_for_monitoring( # pylint: disable=line-too-long + self.cmd, + self.context.get_subscription_id(), + self.context.get_resource_group_name(), + ) + workspace_resource_id = "/" + workspace_resource_id.strip(" /") + + # Write the Azure Monitor profile rather than the legacy omsagent addon. The AMP path is + # managed identity only, so no auth mode is recorded here. + container_insights = self._ensure_container_insights(mc) + container_insights.enabled = True + container_insights.log_analytics_workspace_resource_id = workspace_resource_id + + container_network_logs_enabled = self.context.get_container_network_logs(mc) + if container_network_logs_enabled is not None: + container_insights.container_network_logs = ( + CONST_CONTAINER_NETWORK_LOGS_ENABLED + if container_network_logs_enabled + else CONST_CONTAINER_NETWORK_LOGS_DISABLED + ) + + _apply_container_insights_settings( + container_insights, + self.context.get_syslog_port(), + self.context.get_disable_prometheus_metrics_scraping(), + ) + + # DCR and DCRA creation is deferred to postprocessing_after_mc_created so that all flags + # are finalized and the cluster exists. + self.context.set_intermediate("monitoring_addon_enabled", True, overwrite_exists=True) + + def _setup_opentelemetry_metrics(self, mc: ManagedCluster) -> None: + """Set up the OpenTelemetry metrics configuration.""" + self._ensure_app_monitoring_profile(mc) + + otlp_metrics_config = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryMetrics(enabled=True) + ) + metrics_port_http = self.context.get_opentelemetry_metrics_port_http() + if metrics_port_http is not None: + otlp_metrics_config.http_port = metrics_port_http + metrics_port_grpc = self.context.get_opentelemetry_metrics_port_grpc() + if metrics_port_grpc is not None: + otlp_metrics_config.grpc_port = metrics_port_grpc + + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics = otlp_metrics_config + + def _disable_opentelemetry_metrics(self, mc: ManagedCluster) -> None: + """Disable the OpenTelemetry metrics configuration.""" + self._ensure_app_monitoring_profile(mc) + if mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics is None: + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryMetrics(enabled=False) + ) + else: + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.enabled = False + # Clear the ports when disabling OpenTelemetry metrics + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.http_port = None + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.grpc_port = None + + def _setup_opentelemetry_logs_traces(self, mc: ManagedCluster) -> None: + """Set up the OpenTelemetry logs and traces configuration.""" + self._ensure_app_monitoring_profile(mc) + + otel_logs_cls = self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces + otlp_logs_config = otel_logs_cls(enabled=True) + logs_port_http = self.context.get_opentelemetry_logs_traces_port_http() + if logs_port_http is not None: + otlp_logs_config.http_port = logs_port_http + logs_port_grpc = self.context.get_opentelemetry_logs_traces_port_grpc() + if logs_port_grpc is not None: + otlp_logs_config.grpc_port = logs_port_grpc + + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces = otlp_logs_config + + def _disable_opentelemetry_logs_traces(self, mc: ManagedCluster) -> None: + """Disable the OpenTelemetry logs and traces configuration.""" + self._ensure_app_monitoring_profile(mc) + otel_logs_cls = self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces + if mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces is None: + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces = otel_logs_cls(enabled=False) + else: + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.enabled = False + # Clear the ports when disabling OpenTelemetry logs and traces + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.http_port = None + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.grpc_port = None + def set_up_azure_monitor_profile(self, mc: ManagedCluster) -> ManagedCluster: """Set up azure monitor profile for the ManagedCluster object. :return: the ManagedCluster object @@ -7958,6 +8705,14 @@ def set_up_azure_monitor_profile(self, mc: ManagedCluster) -> ManagedCluster: mc.azure_monitor_profile.app_monitoring.auto_instrumentation = ( self.models.ManagedClusterAzureMonitorProfileAppMonitoringAutoInstrumentation(enabled=True) ) + if self.context.get_enable_opentelemetry_metrics(): + self._setup_opentelemetry_metrics(mc) + if self.context.get_disable_opentelemetry_metrics(): + self._disable_opentelemetry_metrics(mc) + if self.context.get_enable_opentelemetry_logs_traces(): + self._setup_opentelemetry_logs_traces(mc) + if self.context.get_disable_opentelemetry_logs_traces(): + self._disable_opentelemetry_logs_traces(mc) return mc def set_up_ingress_web_app_routing(self, mc: ManagedCluster) -> ManagedCluster: @@ -8311,6 +9066,60 @@ def immediate_processing_after_request(self, mc: ManagedCluster) -> None: "Could not create a role assignment for subnet. Are you an Owner on this subscription?" ) + def _postprocess_monitoring(self, cluster: ManagedCluster) -> None: + """Provision the monitoring data path after the cluster is created.""" + # Azure Monitor logs onboarded through the Azure Monitor profile is managed identity only, + # and unlike the legacy addon path it has no build-time DCR creation step, so the data + # collection rule is created here alongside the association. + azure_monitor_logs_enabled = self.context.get_enable_azure_monitor_logs() + enable_msi_auth_for_monitoring = self.context.get_enable_msi_auth_for_monitoring() + + if not enable_msi_auth_for_monitoring: + # add cluster spn/msi Monitoring Metrics Publisher role assignment to publish metrics to MDM + # mdm metrics is supported only in azure public cloud, so add the role assignment only in this cloud + if self.cmd.cli_ctx.cloud.name.lower() == "azurecloud": + from azure.mgmt.core.tools import resource_id + + cluster_resource_id = resource_id( + subscription=self.context.get_subscription_id(), + resource_group=self.context.get_resource_group_name(), + namespace="Microsoft.ContainerService", + type="managedClusters", + name=self.context.get_name(), + ) + self.context.external_functions.add_monitoring_role_assignment( + cluster, cluster_resource_id, self.cmd + ) + return + + if not azure_monitor_logs_enabled and self.context.raw_param.get("enable_addons") is None: + return + + # Create the DCR Association here. Drive provisioning off whichever profile carries the + # workspace, so the legacy omsagent addon object is not required to exist. + addon_consts = self.context.get_addon_consts() + monitoring_profile = _build_monitoring_addon_shim(cluster, self.models, addon_consts) + if monitoring_profile is None: + return + + self.context.external_functions.ensure_container_insights_for_monitoring( + self.cmd, + monitoring_profile, + self.context.get_subscription_id(), + self.context.get_resource_group_name(), + self.context.get_name(), + self.context.get_location(), + remove_monitoring=False, + aad_route=enable_msi_auth_for_monitoring, + create_dcr=azure_monitor_logs_enabled, + create_dcra=True, + enable_syslog=self.context.get_enable_syslog(), + data_collection_settings=self.context.get_data_collection_settings(), + is_private_cluster=self.context.get_enable_private_cluster(), + ampls_resource_id=self.context.get_ampls_resource_id(), + enable_high_log_scale_mode=self.context.get_enable_high_log_scale_mode(), + ) + # pylint: disable=too-many-locals def postprocessing_after_mc_created(self, cluster: ManagedCluster) -> None: """Postprocessing performed after the cluster is created. @@ -8320,45 +9129,7 @@ def postprocessing_after_mc_created(self, cluster: ManagedCluster) -> None: # monitoring addon monitoring_addon_enabled = self.context.get_intermediate("monitoring_addon_enabled", default_value=False) if monitoring_addon_enabled: - enable_msi_auth_for_monitoring = self.context.get_enable_msi_auth_for_monitoring() - if not enable_msi_auth_for_monitoring: - # add cluster spn/msi Monitoring Metrics Publisher role assignment to publish metrics to MDM - # mdm metrics is supported only in azure public cloud, so add the role assignment only in this cloud - cloud_name = self.cmd.cli_ctx.cloud.name - if cloud_name.lower() == "azurecloud": - from azure.mgmt.core.tools import resource_id - - cluster_resource_id = resource_id( - subscription=self.context.get_subscription_id(), - resource_group=self.context.get_resource_group_name(), - namespace="Microsoft.ContainerService", - type="managedClusters", - name=self.context.get_name(), - ) - self.context.external_functions.add_monitoring_role_assignment( - cluster, cluster_resource_id, self.cmd - ) - elif self.context.raw_param.get("enable_addons") is not None: - # Create the DCR Association here - addon_consts = self.context.get_addon_consts() - monitoring_addon_key = _get_monitoring_addon_key_from_consts(cluster.addon_profiles, addon_consts) - self.context.external_functions.ensure_container_insights_for_monitoring( - self.cmd, - cluster.addon_profiles[monitoring_addon_key], - self.context.get_subscription_id(), - self.context.get_resource_group_name(), - self.context.get_name(), - self.context.get_location(), - remove_monitoring=False, - aad_route=self.context.get_enable_msi_auth_for_monitoring(), - create_dcr=False, - create_dcra=True, - enable_syslog=self.context.get_enable_syslog(), - data_collection_settings=self.context.get_data_collection_settings(), - is_private_cluster=self.context.get_enable_private_cluster(), - ampls_resource_id=self.context.get_ampls_resource_id(), - enable_high_log_scale_mode=self.context.get_enable_high_log_scale_mode(), - ) + self._postprocess_monitoring(cluster) # ingress appgw addon ingress_appgw_addon_enabled = self.context.get_intermediate("ingress_appgw_addon_enabled", default_value=False) @@ -9246,8 +10017,6 @@ def update_monitoring_profile_flow_logs(self, mc: ManagedCluster) -> ManagedClus self._ensure_mc(mc) addon_consts = self.context.get_addon_consts() - CONST_MONITORING_USING_AAD_MSI_AUTH = addon_consts.get("CONST_MONITORING_USING_AAD_MSI_AUTH") - monitoring_addon_key = _get_monitoring_addon_key_from_consts(mc.addon_profiles, addon_consts) enable_high_log_scale_mode = self.context.get_enable_high_log_scale_mode() enable_cnl = self.context.raw_param.get("enable_container_network_logs") @@ -9260,41 +10029,43 @@ def update_monitoring_profile_flow_logs(self, mc: ManagedCluster) -> ManagedClus # Validate HLSM on the update path if enable_high_log_scale_mode is True and not enable_cnl: - # HLSM requires monitoring addon with MSI auth to be enabled - monitoring_addon_profile = mc.addon_profiles.get(monitoring_addon_key) if mc.addon_profiles else None - if ( - not monitoring_addon_profile or - not monitoring_addon_profile.enabled or - safe_lower( - (monitoring_addon_profile.config or {}).get(CONST_MONITORING_USING_AAD_MSI_AUTH) - ) != "true" - ): + # HLSM requires Azure Monitor logs to be enabled, either through the AMP + # containerInsights profile or the legacy omsagent addon, or by this same command. + monitoring_being_enabled = bool(self.context.raw_param.get("enable_azure_monitor_logs")) + if not monitoring_being_enabled and not _is_monitoring_enabled_on_mc(mc, addon_consts): + raise RequiredArgumentMissingError( + "--enable-high-log-scale-mode requires Azure Monitor logs to be enabled on the " + "cluster. Please enable it first with --enable-azure-monitor-logs or " + "--enable-addons monitoring." + ) + # High log scale mode needs a data collection rule, which only exists for managed + # identity authentication. The auth mode must be read off the omsagent addon: the RP + # mirrors legacy shared key clusters into the AMP profile, so an enabled + # containerInsights profile does not by itself mean managed identity is in use. + if not _is_monitoring_aad_auth(mc, addon_consts): raise RequiredArgumentMissingError( - "--enable-high-log-scale-mode requires the monitoring addon to be enabled with MSI auth " - "(useAADAuth=true). Please enable the monitoring addon with --enable-addons monitoring first." + "--enable-high-log-scale-mode requires MSI authentication to be enabled for the " + "monitoring addon. Please enable it with --enable-msi-auth-for-monitoring." ) if enable_high_log_scale_mode is False: # Check if CNL is already enabled on the cluster — cannot disable HLSM while CNL is active - monitoring_addon_profile = mc.addon_profiles.get(monitoring_addon_key) if mc.addon_profiles else None - if monitoring_addon_profile and monitoring_addon_profile.config: - existing_cnl = safe_lower( - monitoring_addon_profile.config.get("enableRetinaNetworkFlags") + if _is_container_network_logs_enabled_on_mc(mc, addon_consts): + raise MutuallyExclusiveArgumentError( + "Cannot disable --enable-high-log-scale-mode while container network logs are enabled. " + "Please disable container network logs first with --disable-container-network-logs." ) - if existing_cnl == "true": - raise MutuallyExclusiveArgumentError( - "Cannot disable --enable-high-log-scale-mode while container network logs are enabled. " - "Please disable container network logs first with --disable-container-network-logs." - ) container_network_logs_enabled = self.context.get_container_network_logs(mc) if container_network_logs_enabled is not None: - if mc.addon_profiles: - monitoring_addon_profile = mc.addon_profiles.get(monitoring_addon_key) - if monitoring_addon_profile: - config = monitoring_addon_profile.config or {} - config["enableRetinaNetworkFlags"] = str(container_network_logs_enabled) - mc.addon_profiles[monitoring_addon_key].config = config + # Written on the AMP profile rather than the legacy omsagent config key. This runs in + # addition to _setup_azure_monitor_logs because either may execute first depending on + # the order the base class invokes them; both write the same value. + self._ensure_container_insights(mc).container_network_logs = ( + CONST_CONTAINER_NETWORK_LOGS_ENABLED + if container_network_logs_enabled + else CONST_CONTAINER_NETWORK_LOGS_DISABLED + ) # When CNL or HLSM flags are provided, mark that monitoring postprocessing is needed # so the DCR gets updated with the correct streams @@ -10061,9 +10832,7 @@ def update_azure_monitor_profile(self, mc: ManagedCluster) -> ManagedCluster: metric_annotations_allow_list=str(ksm_metric_annotations_allow_list)) if self.context.get_disable_azure_monitor_metrics(): - if mc.azure_monitor_profile is None: - mc.azure_monitor_profile = self.models.ManagedClusterAzureMonitorProfile() - mc.azure_monitor_profile.metrics = self.models.ManagedClusterAzureMonitorProfileMetrics(enabled=False) + self._disable_azure_monitor_metrics(mc) if ( self.context.raw_param.get("enable_azure_monitor_metrics") or @@ -10124,6 +10893,492 @@ def update_azure_monitor_profile(self, mc: ManagedCluster) -> ManagedCluster: mc.azure_monitor_profile.app_monitoring.auto_instrumentation = ( self.models.ManagedClusterAzureMonitorProfileAppMonitoringAutoInstrumentation(enabled=False) ) + + # Read the OTLP host port flags before dispatching so their validation runs even when the + # receiver is being disabled in the same command, which would otherwise accept a port and + # silently drop it. Each getter returns None immediately when its own flag was not + # supplied, so this is a no-op for every other update. + metrics_port_http = self.context.get_opentelemetry_metrics_port_http() + metrics_port_grpc = self.context.get_opentelemetry_metrics_port_grpc() + logs_traces_port_http = self.context.get_opentelemetry_logs_traces_port_http() + logs_traces_port_grpc = self.context.get_opentelemetry_logs_traces_port_grpc() + + # Exactly one of these three applies per signal: the enable path builds a fresh receiver + # configuration and consumes the ports itself, the disable path clears the configuration, + # and only when neither flag is given can a port flag stand on its own against a receiver + # that is already enabled. + if self.context.get_enable_opentelemetry_metrics(): + self._setup_opentelemetry_metrics(mc) + elif self.context.get_disable_opentelemetry_metrics(): + self._disable_opentelemetry_metrics(mc) + else: + self._update_opentelemetry_metrics_ports(mc, metrics_port_http, metrics_port_grpc) + + if self.context.get_enable_opentelemetry_logs_traces(): + self._setup_opentelemetry_logs_traces(mc) + elif self.context.get_disable_opentelemetry_logs_traces(): + self._disable_opentelemetry_logs_traces(mc) + else: + self._update_opentelemetry_logs_traces_ports( + mc, logs_traces_port_http, logs_traces_port_grpc + ) + return mc + + def _ensure_azure_monitor_profile(self, mc: ManagedCluster) -> None: + """Ensure the azure monitor profile exists on the managed cluster.""" + if mc.azure_monitor_profile is None: + mc.azure_monitor_profile = self.models.ManagedClusterAzureMonitorProfile() + + def _ensure_container_insights(self, mc: ManagedCluster): + """Ensure the AMP containerInsights profile exists and return it.""" + self._ensure_azure_monitor_profile(mc) + if mc.azure_monitor_profile.container_insights is None: + mc.azure_monitor_profile.container_insights = ( + self.models.ManagedClusterAzureMonitorProfileContainerInsights() + ) + return mc.azure_monitor_profile.container_insights + + def _ensure_app_monitoring_profile(self, mc: ManagedCluster) -> None: + """Ensure the app monitoring profile exists on the managed cluster.""" + self._ensure_azure_monitor_profile(mc) + if mc.azure_monitor_profile.app_monitoring is None: + mc.azure_monitor_profile.app_monitoring = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoring() + ) + + def _setup_opentelemetry_metrics(self, mc: ManagedCluster) -> None: + """Set up the OpenTelemetry metrics configuration.""" + self._ensure_app_monitoring_profile(mc) + + otlp_metrics_config = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryMetrics(enabled=True) + ) + metrics_port_http = self.context.get_opentelemetry_metrics_port_http() + if metrics_port_http is not None: + otlp_metrics_config.http_port = metrics_port_http + metrics_port_grpc = self.context.get_opentelemetry_metrics_port_grpc() + if metrics_port_grpc is not None: + otlp_metrics_config.grpc_port = metrics_port_grpc + + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics = otlp_metrics_config + + def _disable_opentelemetry_metrics(self, mc: ManagedCluster) -> None: + """Disable the OpenTelemetry metrics configuration.""" + self._ensure_app_monitoring_profile(mc) + if mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics is None: + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryMetrics(enabled=False) + ) + else: + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.enabled = False + # Clear the ports when disabling OpenTelemetry metrics + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.http_port = None + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.grpc_port = None + + def _setup_opentelemetry_logs_traces(self, mc: ManagedCluster) -> None: + """Set up the OpenTelemetry logs and traces configuration.""" + self._ensure_app_monitoring_profile(mc) + + otel_logs_cls = self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces + otlp_logs_config = otel_logs_cls(enabled=True) + logs_port_http = self.context.get_opentelemetry_logs_traces_port_http() + if logs_port_http is not None: + otlp_logs_config.http_port = logs_port_http + logs_port_grpc = self.context.get_opentelemetry_logs_traces_port_grpc() + if logs_port_grpc is not None: + otlp_logs_config.grpc_port = logs_port_grpc + + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces = otlp_logs_config + + def _disable_opentelemetry_logs_traces(self, mc: ManagedCluster) -> None: + """Disable the OpenTelemetry logs and traces configuration.""" + self._ensure_app_monitoring_profile(mc) + otel_logs_cls = self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces + if mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces is None: + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces = otel_logs_cls(enabled=False) + else: + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.enabled = False + # Clear the ports when disabling OpenTelemetry logs and traces + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.http_port = None + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.grpc_port = None + + def _update_opentelemetry_metrics_ports( + self, mc: ManagedCluster, port_http: Union[int, None], port_grpc: Union[int, None] + ) -> None: + """Apply a port-only update to an already enabled OTLP metrics receiver. + + Supports changing a host port without re-specifying --enable-opentelemetry-metrics. The + ports have already been validated by their getters, which permit this only when the + receiver is already enabled and raise an actionable error otherwise, so reaching here with + a port means the receiver configuration exists. The existing configuration is mutated in + place rather than rebuilt (as the enable path does) so that the current enabled state and + the sibling port that was not supplied are both preserved. + """ + if port_http is None and port_grpc is None: + return + + otlp_metrics_config = mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics + if port_http is not None: + otlp_metrics_config.http_port = port_http + if port_grpc is not None: + otlp_metrics_config.grpc_port = port_grpc + + def _update_opentelemetry_logs_traces_ports( + self, mc: ManagedCluster, port_http: Union[int, None], port_grpc: Union[int, None] + ) -> None: + """Apply a port-only update to an already enabled OTLP logs and traces receiver. + + See _update_opentelemetry_metrics_ports for why the existing configuration is mutated in + place instead of being rebuilt. + """ + if port_http is None and port_grpc is None: + return + + otlp_logs_config = mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + if port_http is not None: + otlp_logs_config.http_port = port_http + if port_grpc is not None: + otlp_logs_config.grpc_port = port_grpc + + def _setup_azure_monitor_logs(self, mc: ManagedCluster) -> None: + """Set up Azure Monitor logs on the Azure Monitor profile.""" + addon_consts = self.context.get_addon_consts() + + # The Azure Monitor profile is managed identity only, so a service principal cluster can + # never authenticate to the workspace. Reject before a default workspace is created. + _raise_if_service_principal_cluster(mc) + + # --enable-azure-monitor-logs onboards through the Azure Monitor profile, which is managed + # identity only. A cluster already onboarded with legacy (shared key) authentication keeps + # that authentication mode on the server side, so this flag cannot be honoured as asked. + # Reject it up front, before a default workspace is created, rather than silently leaving + # the cluster on legacy auth. This is checked before the "already enabled" guard below so + # the more actionable migration message wins for a legacy-auth cluster. + if not _is_monitoring_aad_auth(mc, addon_consts): + raise ArgumentUsageError( + "Azure Monitor logs is already enabled on this cluster using legacy " + "(non-managed-identity) authentication. '--enable-azure-monitor-logs' requires " + "managed identity authentication. Migrate the cluster to managed identity " + "authentication first, then retry. See " + "https://learn.microsoft.com/en-us/azure/azure-monitor/containers/" + "container-insights-authentication?tabs=cli#migrate-to-managed-identity-authentication" + ) + + # Re-onboarding an already onboarded cluster is rejected, matching the behaviour of + # 'az aks enable-addons -a monitoring'. Silently re-running would otherwise recreate the + # default workspace and re-provision DCR/DCRA artifacts for a cluster that is already set + # up, which hides configuration mistakes such as a mistyped --workspace-resource-id. + if _is_monitoring_enabled_on_mc(mc, addon_consts): + raise ArgumentUsageError( + "Azure Monitor logs is already enabled for this managed cluster.\n" + "To change the Azure Monitor logs configuration, run " + "'az aks update --disable-azure-monitor-logs' before enabling it again." + ) + + workspace_resource_id = self.context.raw_param.get("workspace_resource_id") + if not workspace_resource_id: + workspace_resource_id = self.context.external_functions.ensure_default_log_analytics_workspace_for_monitoring( # pylint: disable=line-too-long + self.cmd, + self.context.get_subscription_id(), + self.context.get_resource_group_name(), + ) + workspace_resource_id = "/" + workspace_resource_id.strip(" /") + + # Write the Azure Monitor profile rather than the legacy omsagent addon. No auth mode is + # recorded here: the RP derives it, defaulting new onboardings (and re-enables of a disabled + # addon) to managed identity. + container_insights = self._ensure_container_insights(mc) + + # The guards above reject clusters that are already enabled, so reaching here is always a + # genuine onboarding rather than a reconfigure. Start from the documented defaults so the + # result does not depend on what a previous onboarding left behind: the RP preserves any + # containerInsights field that is absent from the request, so a stale syslog port, scraping + # choice or container network logs setting would otherwise be inherited silently. Values + # the user asked for are applied on top of this below. + _reset_container_insights_to_defaults(container_insights) + container_insights.enabled = True + container_insights.log_analytics_workspace_resource_id = workspace_resource_id + + # Container network logs are applied here as well as in update_monitoring_profile_flow_logs, + # because that method may run before this one depending on the order the base class invokes + # them. Both write the same value, so the result is order-independent. + container_network_logs_enabled = self.context.get_container_network_logs(mc) + if container_network_logs_enabled is not None: + container_insights.container_network_logs = ( + CONST_CONTAINER_NETWORK_LOGS_ENABLED + if container_network_logs_enabled + else CONST_CONTAINER_NETWORK_LOGS_DISABLED + ) + + _apply_container_insights_settings( + container_insights, + self.context.get_syslog_port(), + self.context.get_disable_prometheus_metrics_scraping(), + ) + + # Reaching here means a genuine onboarding: the guards above reject service principal + # clusters, legacy-auth clusters and clusters that are already enabled. The DCR and the + # DCRA therefore always have to be provisioned, otherwise the agent is deployed with no + # data collection rule attached and no logs are ever ingested. Provisioned once the profile + # above is fully built, so the DCR reflects the final shape. + self._provision_azure_monitor_logs_dcr(mc, addon_consts) + + # monitoring_addon_postprocessing_required is deliberately not set: the DCR and the DCRA + # have already been provisioned above, and setting it would repeat the same work after the + # cluster PUT. + self.context.set_intermediate("monitoring_addon_enabled", True, overwrite_exists=True) + + def _provision_azure_monitor_logs_dcr(self, mc: ManagedCluster, addon_consts: dict) -> None: + """Create the DCR and the DCRA before the cluster PUT. + + 'az aks enable-addons -a monitoring' provisions these artifacts first and only then updates + the cluster, so by the time the RP rolls out the ama-logs DaemonSet the data collection + rule is already associated and mdsd downloads it within seconds. + + Deferring the work to postprocessing_after_mc_created inverts that order: the agent starts + before the DCRA exists, finds no configuration to download, and then backs off for several + minutes before retrying. The agent ingests nothing for the whole of that window and + restarts once the configuration finally arrives, because its liveness probe treats the + newly appeared DCR as a configuration change. Provisioning up front keeps the flag's + behaviour identical to the addon it replaces. + """ + monitoring_profile = _build_monitoring_addon_shim(mc, self.models, addon_consts) + if not (monitoring_profile and monitoring_profile.enabled): + return + + data_collection_settings = self.context.get_data_collection_settings() + + self.context.external_functions.ensure_container_insights_for_monitoring( + self.cmd, + monitoring_profile, + self.context.get_subscription_id(), + self.context.get_resource_group_name(), + self.context.get_name(), + mc.location or self.context.get_location(), + remove_monitoring=False, + # The legacy-auth guard in _setup_azure_monitor_logs has already rejected anything that + # is not managed identity, so the AAD route is the only reachable one here. + aad_route=True, + create_dcr=True, + create_dcra=True, + enable_syslog=self.context.get_enable_syslog(), + data_collection_settings=data_collection_settings, + is_private_cluster=self.context.get_enable_private_cluster(), + ampls_resource_id=self.context.get_ampls_resource_id(), + enable_high_log_scale_mode=self.context.get_enable_high_log_scale_mode(), + ) + + def _confirm_disable_azure_monitor_metrics(self, mc: ManagedCluster) -> None: + """Take the confirmation for a metrics disable that also turns OpenTelemetry metrics off. + + Returns without prompting once _confirm_monitoring_disables has already collected it, so + the question is asked exactly once per command no matter which path reaches here. + """ + if not self.context.get_disable_azure_monitor_metrics(): + return + # Mirror the handler's own guards so no question is asked for a disable that would not + # actually turn anything off. + if not _is_azure_monitor_metrics_enabled(mc) or not _is_opentelemetry_metrics_enabled(mc): + return + if self.context.get_yes() or self._monitoring_disables_confirmed(): + return + + msg = ( + "OpenTelemetry metrics are enabled on this cluster and are collected by Azure " + "Monitor metrics. Disabling Azure Monitor metrics will also disable OpenTelemetry " + "metrics. Do you want to continue?" + ) + if not prompt_y_n(msg, default="n"): + raise DecoratorEarlyExitException() + + def _confirm_disable_azure_monitor_logs(self, mc: ManagedCluster) -> None: + """Take the confirmation for a logs disable that also turns OpenTelemetry logs/traces off. + + See _confirm_disable_azure_monitor_metrics for why this can be a no-op. + """ + if not self.context.get_disable_azure_monitor_logs(): + return + addon_consts = self.context.get_addon_consts() + if not _is_monitoring_enabled_on_mc(mc, addon_consts): + return + if not _is_opentelemetry_logs_traces_enabled(mc): + return + if self.context.get_yes() or self._monitoring_disables_confirmed(): + return + + msg = ( + "OpenTelemetry logs and traces are enabled on this cluster and are collected by " + "Azure Monitor logs. Disabling Azure Monitor logs will also disable OpenTelemetry " + "logs and traces. Do you want to continue?" + ) + if not prompt_y_n(msg, default="n"): + raise DecoratorEarlyExitException() + + def _monitoring_disables_confirmed(self) -> bool: + return self.context.get_intermediate("monitoring_disables_confirmed", default_value=False) + + def confirm_monitoring_disables(self, mc: ManagedCluster) -> None: + """Collect every monitoring disable confirmation before any cleanup runs. + + --disable-azure-monitor-metrics and --disable-azure-monitor-logs each delete collection + resources in Azure before the cluster PUT happens. Prompting from inside those handlers + means the metrics artifacts are already gone by the time the logs question is asked, so + declining it aborts the command with the cluster still reporting metrics as enabled while + its data collection objects and recording rules no longer exist. Asking everything up + front keeps the command all or nothing. + + :return: None + """ + self._ensure_mc(mc) + + self._confirm_disable_azure_monitor_metrics(mc) + self._confirm_disable_azure_monitor_logs(mc) + self.context.set_intermediate("monitoring_disables_confirmed", True, overwrite_exists=True) + + def _disable_azure_monitor_logs(self, mc: ManagedCluster) -> None: + """Disable Azure Monitor logs on the Azure Monitor profile.""" + addon_consts = self.context.get_addon_consts() + + # Azure Monitor logs may be on through the AMP profile, or through the legacy addon on + # clusters onboarded before the AMP switch. Absence of the addon must not short-circuit + # the disable. + if not _is_monitoring_enabled_on_mc(mc, addon_consts): + return + + # OpenTelemetry logs and traces are collected by the Container Insights agent, so disabling + # Azure Monitor logs necessarily turns them off too. The confirmation is normally taken up + # front by confirm_monitoring_disables, before any cleanup has run; this call only prompts + # when the handler is driven directly. + opentelemetry_logs_enabled = _is_opentelemetry_logs_traces_enabled(mc) + self._confirm_disable_azure_monitor_logs(mc) + + # Perform DCR/DCRA cleanup BEFORE disabling, the same way aks_disable_addons does. Only + # managed identity clusters have a DCR/DCRA to clean up, so decide from local state first + # to avoid an ARM round trip when there is nothing to do. The auth mode has to come from + # the omsagent addon: the RP mirrors legacy shared key clusters into the AMP profile, so + # the presence of that profile says nothing about how the agent authenticates. + if _is_monitoring_aad_auth(mc, addon_consts): + current_cluster = self.client.get(self.context.get_resource_group_name(), self.context.get_name()) + monitoring_profile = _build_monitoring_addon_shim(current_cluster, self.models, addon_consts) + + if monitoring_profile and monitoring_profile.enabled: + try: + self.context.external_functions.ensure_container_insights_for_monitoring( + self.cmd, + monitoring_profile, + self.context.get_subscription_id(), + self.context.get_resource_group_name(), + self.context.get_name(), + current_cluster.location, + remove_monitoring=True, + aad_route=_is_monitoring_aad_auth(current_cluster, addon_consts), + create_dcr=False, + create_dcra=True, + enable_syslog=False, + data_collection_settings=None, + is_private_cluster=False, + ampls_resource_id=None, + enable_high_log_scale_mode=False, + ) + except TypeError: + # Ignore TypeError just like aks_disable_addons does + pass + + # Disable through the AMP profile. The RP keeps the legacy addon in sync, so the addon + # object is intentionally left untouched here. Every containerInsights field is reset to + # its default so a later --enable-azure-monitor-logs starts from a clean profile instead of + # silently inheriting the old syslog port, scraping choice or container network logs + # setting. + _reset_container_insights_to_defaults(self._ensure_container_insights(mc)) + + # OpenTelemetry logs and traces ride on the Container Insights agent, so they go down with + # it. The confirmation for this was taken above. + if opentelemetry_logs_enabled: + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.enabled = False + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.http_port = None + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.grpc_port = None + + def _disable_azure_monitor_metrics(self, mc: ManagedCluster) -> None: + """Disable Azure Monitor metrics on the Azure Monitor profile.""" + azure_monitor_metrics_enabled = ( + mc.azure_monitor_profile and + mc.azure_monitor_profile.metrics and + mc.azure_monitor_profile.metrics.enabled + ) + + # Nothing to turn off, so the payload is left untouched rather than writing a redundant + # disabled metrics profile. Any leftover DCR/DCRA and recording rule cleanup still runs in + # update_azure_monitor_profile, which is driven by the raw flag rather than cluster state. + if not azure_monitor_metrics_enabled: + return + + # OpenTelemetry metrics are ingested through the managed Prometheus pipeline that Azure + # Monitor metrics sets up, so disabling the parent necessarily turns them off too. The + # confirmation is normally taken up front by confirm_monitoring_disables, before any + # cleanup has run; this call only prompts when the handler is driven directly. + opentelemetry_metrics_enabled = _is_opentelemetry_metrics_enabled(mc) + self._confirm_disable_azure_monitor_metrics(mc) + + mc.azure_monitor_profile.metrics = self.models.ManagedClusterAzureMonitorProfileMetrics(enabled=False) + + # OpenTelemetry metrics ride on the managed Prometheus pipeline, so they go down with it. + # The confirmation for this was taken above. + if opentelemetry_metrics_enabled: + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.enabled = False + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.http_port = None + mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.grpc_port = None + + def update_azure_monitor_logs(self, mc: ManagedCluster) -> ManagedCluster: + """Update Azure Monitor logs (Container Insights) for the ManagedCluster object. + + :return: the ManagedCluster object + """ + self._ensure_mc(mc) + + if self.context.get_enable_azure_monitor_logs(): + self._setup_azure_monitor_logs(mc) + if self.context.get_disable_azure_monitor_logs(): + self._disable_azure_monitor_logs(mc) + return mc + + def update_azure_monitor_logs_settings(self, mc: ManagedCluster) -> ManagedCluster: + """Update the AMP containerInsights tuning settings for the ManagedCluster object. + + These flags are independent of --enable-azure-monitor-logs, so they also apply to a cluster + where Azure Monitor logs is already enabled. When none of the flags are given nothing is + touched, which keeps the rest of the monitoring configuration intact. + + :return: the ManagedCluster object + """ + self._ensure_mc(mc) + + # These flags require DCR reprovisioning. Skip it when enabling logs (the DCR is created + # inline) or disabling logs (the DCR is removed). Read them before the cluster PUT so + # invalid data collection settings fail before postprocessing. + enable_syslog = self.context.get_enable_syslog() + data_collection_settings = self.context.get_data_collection_settings() + ampls_resource_id = self.context.get_ampls_resource_id() + if ( + ( + enable_syslog is not None or + data_collection_settings is not None or + ampls_resource_id is not None + ) and + not self.context.raw_param.get("enable_azure_monitor_logs") and + not self.context.raw_param.get("disable_azure_monitor_logs") + ): + self.context.set_intermediate( + "monitoring_addon_postprocessing_required", True, overwrite_exists=True + ) + + syslog_port = self.context.get_syslog_port() + disable_prometheus_scraping = self.context.get_disable_prometheus_metrics_scraping() + if syslog_port is None and disable_prometheus_scraping is None: + return mc + + _apply_container_insights_settings( + self._ensure_container_insights(mc), syslog_port, disable_prometheus_scraping + ) return mc # pylint: disable=too-many-statements,too-many-locals @@ -10783,8 +12038,15 @@ def update_mc_profile_default(self) -> ManagedCluster: mc = self.update_workload_auto_scaler_profile(mc) # update kubernetes support plan mc = self.update_k8s_support_plan(mc) + # collect every monitoring disable confirmation before any of them deletes collection + # resources, so a declined prompt cannot leave a signal enabled without its resources + self.confirm_monitoring_disables(mc) # update azure monitor metrics profile mc = self.update_azure_monitor_profile(mc) + # update azure monitor logs (container insights) enablement + mc = self.update_azure_monitor_logs(mc) + # update azure monitor logs (container insights) settings + mc = self.update_azure_monitor_logs_settings(mc) # update azure container storage mc = self.update_azure_container_storage(mc) # update cluster upgrade settings @@ -10924,24 +12186,27 @@ def postprocessing_after_mc_created(self, cluster: ManagedCluster) -> None: enable_msi_auth_for_monitoring and self.context.raw_param.get("enable_addons") is not None ) or monitoring_addon_postprocessing_required: addon_consts = self.context.get_addon_consts() - monitoring_addon_key = _get_monitoring_addon_key_from_consts(cluster.addon_profiles, addon_consts) - self.context.external_functions.ensure_container_insights_for_monitoring( - self.cmd, - cluster.addon_profiles[monitoring_addon_key], - self.context.get_subscription_id(), - self.context.get_resource_group_name(), - self.context.get_name(), - self.context.get_location(), - remove_monitoring=False, - aad_route=True, - create_dcr=monitoring_addon_postprocessing_required, - create_dcra=enable_msi_auth_for_monitoring, - enable_syslog=self.context.get_enable_syslog(), - data_collection_settings=self.context.get_data_collection_settings(), - is_private_cluster=self.context.get_enable_private_cluster(), - ampls_resource_id=self.context.get_ampls_resource_id(), - enable_high_log_scale_mode=self.context.get_enable_high_log_scale_mode(), - ) + # Drive provisioning off whichever profile carries the workspace, so the legacy + # omsagent addon object is not required to exist. + monitoring_profile = _build_monitoring_addon_shim(cluster, self.models, addon_consts) + if monitoring_profile: + self.context.external_functions.ensure_container_insights_for_monitoring( + self.cmd, + monitoring_profile, + self.context.get_subscription_id(), + self.context.get_resource_group_name(), + self.context.get_name(), + self.context.get_location(), + remove_monitoring=False, + aad_route=True, + create_dcr=monitoring_addon_postprocessing_required, + create_dcra=enable_msi_auth_for_monitoring, + enable_syslog=self.context.get_enable_syslog(), + data_collection_settings=self.context.get_data_collection_settings(), + is_private_cluster=self.context.get_enable_private_cluster(), + ampls_resource_id=self.context.get_ampls_resource_id(), + enable_high_log_scale_mode=self.context.get_enable_high_log_scale_mode(), + ) # ingress appgw addon ingress_appgw_addon_enabled = self.context.get_intermediate("ingress_appgw_addon_enabled", default_value=False) diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py index e7e2ff97f29..1a03e102257 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_aks_commands.py @@ -14608,7 +14608,7 @@ def test_aks_create_acns_with_flow_logs( self.check("provisioningState", "Succeeded"), self.check("networkProfile.advancedNetworking.observability.enabled", True), self.check("addonProfiles.omsagent.enabled", True), - self.check("addonProfiles.omsagent.config.enableRetinaNetworkFlags", "True"), + self.check("azureMonitorProfile.containerInsights.containerNetworkLogs", "Enabled"), ], ) @@ -14629,7 +14629,7 @@ def test_aks_create_acns_with_flow_logs( ], ) self._wait_for_cluster_property( - "addonProfiles.omsagent.config.enableRetinaNetworkFlags", "False" + "azureMonitorProfile.containerInsights.containerNetworkLogs", "Disabled" ) # update: enable high log scale mode independently via aks update @@ -14655,7 +14655,7 @@ def test_aks_create_acns_with_flow_logs( ], ) self._wait_for_cluster_property( - "addonProfiles.omsagent.config.enableRetinaNetworkFlags", "True" + "azureMonitorProfile.containerInsights.containerNetworkLogs", "Enabled" ) # delete diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_custom.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_custom.py index 50cb48971f8..aae2b4eecbd 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_custom.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_custom.py @@ -15,14 +15,19 @@ import yaml from azure.cli.command_modules.acs._consts import ( CONST_AZURE_POLICY_ADDON_NAME, + CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT, + CONST_CONTAINER_NETWORK_LOGS_DISABLED, + CONST_CONTAINER_NETWORK_LOGS_ENABLED, CONST_HTTP_APPLICATION_ROUTING_ADDON_NAME, CONST_KUBE_DASHBOARD_ADDON_NAME, CONST_MONITORING_ADDON_NAME, + CONST_MONITORING_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID, CONST_MONITORING_USING_AAD_MSI_AUTH, ) from azure.cli.command_modules.acs.addonconfiguration import ( _create_or_update_dcr_with_table_readiness_retry, ensure_default_log_analytics_workspace_for_monitoring, + warn_on_legacy_monitoring_auth, ) from azure.cli.command_modules.acs.custom import ( _get_command_context, @@ -34,6 +39,7 @@ aks_agentpool_get_rollback_versions, aks_agentpool_rollback, aks_agentpool_upgrade, + aks_disable_addons, aks_enable_addons, aks_stop, aks_upgrade, @@ -2024,5 +2030,270 @@ def test_other_errors_keep_three_attempt_limit(self): self.mock_sleep.assert_not_called() +class TestWarnOnLegacyMonitoringAuth(unittest.TestCase): + def _warn_mock(self): + return mock.patch("azure.cli.command_modules.acs.addonconfiguration.logger.warning") + + def test_warns_for_explicit_false_with_monitoring_addon(self): + with self._warn_mock() as warn: + warn_on_legacy_monitoring_auth(False, "monitoring") + warn.assert_called_once() + self.assertIn("legacy shared key authentication", warn.call_args[0][0]) + + def test_warns_when_monitoring_is_one_of_several_addons(self): + with self._warn_mock() as warn: + warn_on_legacy_monitoring_auth(False, "monitoring,virtual-node") + warn.assert_called_once() + + def test_no_warning_when_msi_auth_is_true(self): + with self._warn_mock() as warn: + warn_on_legacy_monitoring_auth(True, "monitoring") + warn.assert_not_called() + + def test_no_warning_when_msi_auth_is_not_specified(self): + with self._warn_mock() as warn: + warn_on_legacy_monitoring_auth(None, "monitoring") + warn.assert_not_called() + + def test_no_warning_without_monitoring_addon(self): + with self._warn_mock() as warn: + warn_on_legacy_monitoring_auth(False, "virtual-node") + warn.assert_not_called() + + def test_no_warning_without_addons(self): + with self._warn_mock() as warn: + warn_on_legacy_monitoring_auth(False, None) + warn.assert_not_called() + + def test_silent_for_addon_names_that_merely_contain_monitoring(self): + # A substring check would misfire on these, so the list is matched token by token. + for addons in ("monitoring-preview", "notmonitoring"): + with self._warn_mock() as warn: + warn_on_legacy_monitoring_auth(False, addons) + warn.assert_not_called() + + def test_tolerates_whitespace_and_casing_in_the_addon_list(self): + with self._warn_mock() as warn: + warn_on_legacy_monitoring_auth(False, " azure-policy , Monitoring ") + warn.assert_called_once() + + +class TestMonitoringValidatorRegistration(unittest.TestCase): + """The monitoring cross-flag validators must stay wired to the commands. + + These validators inspect the whole namespace, so the CLI runs them for every invocation as + long as each is attached to at least one argument of the command -- it does not matter which + one. Nothing else asserts that they are still attached, and detaching one would silently drop + every cross-flag check it performs, leaving combinations such as + --enable-prometheus-metrics-scraping without --enable-azure-monitor-logs, or duplicate + OpenTelemetry HTTP/gRPC ports, accepted and then ignored. + """ + + def _arguments(self, command_name): + import argparse + + from azure.cli.command_modules.acs import ContainerServiceCommandsLoader + from azure.cli.core.mock import DummyCli + + class _Invocation: + def __init__(self, command_string): + self.data = {"command_string": command_string} + self.parser = argparse.ArgumentParser() + + cli_ctx = DummyCli() + cli_ctx.invocation = _Invocation(command_name) + loader = ContainerServiceCommandsLoader(cli_ctx) + loader.load_command_table(command_name.split()) + loader.command_table[command_name].load_arguments() + loader.load_arguments(command_name) + return { + dest: arg.settings + for dest, arg in loader.argument_registry.arguments.get(command_name, {}).items() + } + + def test_validator_is_attached_to_create_and_update(self): + for command_name, validator_name in ( + ("aks create", "validate_container_insights_settings_for_create"), + ("aks update", "validate_container_insights_settings_for_update"), + ("aks create", "validate_azure_monitor_and_opentelemetry_for_create"), + ("aks update", "validate_azure_monitor_and_opentelemetry_for_update"), + ): + arguments = self._arguments(command_name) + attached = [ + dest + for dest, settings in arguments.items() + if getattr(settings.get("validator"), "__name__", "") == validator_name + ] + self.assertTrue( + attached, + "{} has no argument carrying {}, so its cross-flag validation never " + "runs.".format(command_name, validator_name), + ) + + def test_container_insights_flags_are_registered_on_create_and_update(self): + # The validators read these off the namespace, so they can only reject bad combinations + # while they remain registered on the command. + for command_name in ("aks create", "aks update"): + arguments = self._arguments(command_name) + for dest in ( + "enable_prometheus_metrics_scraping", + "disable_prometheus_metrics_scraping", + "syslog_port", + "opentelemetry_metrics_port_http", + "opentelemetry_metrics_port_grpc", + "opentelemetry_logs_traces_port_http", + "opentelemetry_logs_traces_port_grpc", + ): + self.assertIn(dest, arguments, command_name) + + +class AKSDisableAddonsMonitoringTestCase(unittest.TestCase): + """`az aks disable-addons -a monitoring` must behave like --disable-azure-monitor-logs.""" + + def setUp(self): + self.cli = MockCLI() + self.cmd = MockCmd(self.cli) + self.models = AKSManagedClusterModels(self.cmd, ResourceType.MGMT_CONTAINERSERVICE) + + def _instance(self, otlp_logs_traces=True): + app_monitoring = self.models.ManagedClusterAzureMonitorProfileAppMonitoring() + if otlp_logs_traces: + app_monitoring.open_telemetry_logs_and_traces = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces( + enabled=True, http_port=4320, grpc_port=4319 + ) + ) + return self.models.ManagedCluster( + location="test_location", + addon_profiles={ + CONST_MONITORING_ADDON_NAME: self.models.ManagedClusterAddonProfile( + enabled=True, + config={ + CONST_MONITORING_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID: "test_workspace", + CONST_MONITORING_USING_AAD_MSI_AUTH: "true", + }, + ) + }, + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + log_analytics_workspace_resource_id="test_workspace", + syslog_port=2832, + disable_prometheus_metrics_scraping=True, + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_ENABLED, + ), + app_monitoring=app_monitoring, + ), + ) + + def _run(self, instance, addons="monitoring", answer=True, yes=False): + """Drive aks_disable_addons, recording prompts and cleanups in the order they happen.""" + order = [] + client = mock.MagicMock() + client.get.return_value = instance + + def ask(msg, default=None): + order.append("prompt") + self.assertIn("OpenTelemetry logs and traces", msg) + return answer + + def cleanup(*args, **kwargs): + order.append("dcra_cleanup") + + def put(no_wait, put_func, rg, name, mc, **kwargs): + order.append("put") + return mc + + with mock.patch( + "azure.cli.command_modules.acs.custom.get_subscription_id", + return_value="test_sub_id", + ), mock.patch( + "azure.cli.command_modules.acs.custom.prompt_y_n", side_effect=ask + ), mock.patch( + "azure.cli.command_modules.acs.custom.ensure_container_insights_for_monitoring", + side_effect=cleanup, + ), mock.patch( + "azure.cli.command_modules.acs.custom.sdk_no_wait", side_effect=put + ): + result = aks_disable_addons( + self.cmd, client, "test_rg", "test_name", addons, yes=yes + ) + return order, result + + def _assert_container_insights_reset(self, instance): + container_insights = instance.azure_monitor_profile.container_insights + self.assertFalse(container_insights.enabled) + self.assertEqual( + container_insights.syslog_port, CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT + ) + self.assertFalse(container_insights.disable_prometheus_metrics_scraping) + self.assertEqual( + container_insights.container_network_logs, CONST_CONTAINER_NETWORK_LOGS_DISABLED + ) + + def test_declining_the_prompt_aborts_before_any_cleanup(self): + instance = self._instance() + order, result = self._run(instance, answer=False) + + # The prompt has to come first, and declining must leave the cluster completely untouched. + self.assertEqual(order, ["prompt"]) + self.assertIsNone(result) + self.assertTrue(instance.azure_monitor_profile.container_insights.enabled) + self.assertEqual(instance.azure_monitor_profile.container_insights.syslog_port, 2832) + self.assertTrue( + instance.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.enabled + ) + + def test_accepting_the_prompt_resets_settings_and_disables_opentelemetry(self): + instance = self._instance() + order, _ = self._run(instance) + + self.assertEqual(order, ["prompt", "dcra_cleanup", "put"]) + self._assert_container_insights_reset(instance) + open_telemetry = ( + instance.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + ) + self.assertFalse(open_telemetry.enabled) + self.assertIsNone(open_telemetry.http_port) + self.assertIsNone(open_telemetry.grpc_port) + + def test_yes_skips_the_prompt(self): + instance = self._instance() + order, _ = self._run(instance, yes=True) + + self.assertEqual(order, ["dcra_cleanup", "put"]) + self._assert_container_insights_reset(instance) + + def test_settings_are_reset_even_without_opentelemetry(self): + instance = self._instance(otlp_logs_traces=False) + order, _ = self._run(instance) + + # Nothing to warn about, but a later --enable-azure-monitor-logs must still start clean. + self.assertEqual(order, ["dcra_cleanup", "put"]) + self._assert_container_insights_reset(instance) + + def test_monitoring_is_recognised_alongside_other_addons(self): + instance = self._instance() + order, _ = self._run( + instance, + addons="kube-dashboard,monitoring", + ) + + self.assertEqual(order, ["prompt", "dcra_cleanup", "put"]) + self._assert_container_insights_reset(instance) + + def test_disabling_another_addon_leaves_monitoring_untouched(self): + instance = self._instance() + order, _ = self._run(instance, addons="kube-dashboard") + + self.assertEqual(order, ["put"]) + container_insights = instance.azure_monitor_profile.container_insights + self.assertTrue(container_insights.enabled) + self.assertEqual(container_insights.syslog_port, 2832) + self.assertTrue( + instance.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.enabled + ) + + if __name__ == "__main__": unittest.main() diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_managed_cluster_decorator.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_managed_cluster_decorator.py index 7bd8f0cfc31..89b21beddfe 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_managed_cluster_decorator.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_managed_cluster_decorator.py @@ -4,6 +4,9 @@ # -------------------------------------------------------------------------------------------- import importlib +import json +import os +import tempfile import unittest from unittest import mock from unittest.mock import Mock, call, patch, ANY @@ -36,6 +39,10 @@ CONST_VIRTUAL_NODE_ADDON_NAME, CONST_VIRTUAL_NODE_SUBNET_NAME, CONST_MONITORING_USING_AAD_MSI_AUTH, + CONST_CONTAINER_NETWORK_LOGS_ENABLED, + CONST_CONTAINER_NETWORK_LOGS_DISABLED, + CONST_MONITORING_ENABLE_RETINA_NETWORK_FLAGS, + CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT, CONST_LOAD_BALANCER_SKU_STANDARD, CONST_LOAD_BALANCER_SKU_BASIC, CONST_APP_ROUTING_ISTIO_MODE_ENABLED, @@ -64,6 +71,8 @@ ) from azure.cli.command_modules.acs.agentpool_decorator import AKSAgentPoolContext, AKSAgentPoolParamDict from azure.cli.command_modules.acs.managed_cluster_decorator import ( + CONST_DATA_COLLECTION_SETTINGS_MAX_CHARS, + _is_container_network_logs_enabled_on_mc, AKSManagedClusterContext, AKSManagedClusterCreateDecorator, AKSManagedClusterModels, @@ -16482,6 +16491,7 @@ def test_enable_container_network_logs(self): addon_profiles={ "omsagent": self.models.ManagedClusterAddonProfile( enabled=True, + config={CONST_MONITORING_USING_AAD_MSI_AUTH: "true"}, ) }, ) @@ -16502,9 +16512,14 @@ def test_enable_container_network_logs(self): addon_profiles={ "omsagent": self.models.ManagedClusterAddonProfile( enabled=True, - config={"enableRetinaNetworkFlags": "True"} + config={CONST_MONITORING_USING_AAD_MSI_AUTH: "true"}, ) }, + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_ENABLED, + ), + ), ) self.assertEqual(dec_mc_1, ground_truth_mc_1) @@ -16532,9 +16547,14 @@ def test_enable_container_network_logs(self): addon_profiles={ "omsagent": self.models.ManagedClusterAddonProfile( enabled=True, - config={"enableRetinaNetworkFlags": "True"} + config={CONST_MONITORING_USING_AAD_MSI_AUTH: "true"}, ) }, + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_ENABLED, + ), + ), ) dec_2.context.attach_mc(mc_2) dec_mc_2 = dec_2.update_monitoring_profile_flow_logs(mc_2) @@ -16553,9 +16573,14 @@ def test_enable_container_network_logs(self): addon_profiles={ "omsagent": self.models.ManagedClusterAddonProfile( enabled=True, - config={"enableRetinaNetworkFlags": "False"} + config={CONST_MONITORING_USING_AAD_MSI_AUTH: "true"}, ) }, + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_DISABLED, + ), + ), ) self.assertEqual(dec_mc_2, ground_truth_mc_2) @@ -16626,11 +16651,14 @@ def test_enable_container_network_logs(self): config={ CONST_MONITORING_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID: "/test_workspace_resource_id", CONST_MONITORING_USING_AAD_MSI_AUTH: "true", - "enableRetinaNetworkFlags": "True", }, ), } self.assertEqual(dec_mc_4.addon_profiles["omsagent"], ground_truth_mc_4["omsagent"]) + self.assertEqual( + dec_mc_4.azure_monitor_profile.container_insights.container_network_logs, + CONST_CONTAINER_NETWORK_LOGS_ENABLED, + ) # Case 5: enable_acns and enable_container_network_logs without monitoring addon dec_5 = AKSManagedClusterCreateDecorator( @@ -16798,18 +16826,15 @@ def test_enable_container_network_logs(self): ) dec_9.context.attach_mc(mc_9) dec_mc_9 = dec_9.update_monitoring_profile_flow_logs(mc_9) - # HLSM should be enabled but CNL remains unset — no enableRetinaNetworkFlags change + # HLSM should be enabled but CNL remains unset — no containerNetworkLogs change # The monitoring_addon_postprocessing_required intermediate should be set self.assertTrue( dec_9.context.get_intermediate("monitoring_addon_postprocessing_required") ) # Verify HLSM is resolved to True self.assertEqual(dec_9.context.get_enable_high_log_scale_mode(), True) - # Verify CNL flag was NOT added to addon config (HLSM alone doesn't set it) - self.assertNotIn( - "enableRetinaNetworkFlags", - dec_mc_9.addon_profiles["omsagent"].config or {}, - ) + # Verify CNL was NOT set on the Azure Monitor profile (HLSM alone doesn't set it) + self.assertIsNone(dec_mc_9.azure_monitor_profile) # Case 10: UPDATE - disable HLSM while CNL is active -> should ERROR dec_10 = AKSManagedClusterUpdateDecorator( @@ -16833,12 +16858,14 @@ def test_enable_container_network_logs(self): addon_profiles={ "omsagent": self.models.ManagedClusterAddonProfile( enabled=True, - config={ - CONST_MONITORING_USING_AAD_MSI_AUTH: "true", - "enableRetinaNetworkFlags": "True", - }, + config={CONST_MONITORING_USING_AAD_MSI_AUTH: "true"}, ) }, + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_ENABLED, + ), + ), ) dec_10.context.attach_mc(mc_10) with self.assertRaises(MutuallyExclusiveArgumentError): @@ -16874,8 +16901,8 @@ def test_enable_container_network_logs(self): dec_11.context.attach_mc(mc_11) dec_mc_11 = dec_11.update_monitoring_profile_flow_logs(mc_11) self.assertEqual( - dec_mc_11.addon_profiles["omsagent"].config["enableRetinaNetworkFlags"], - "True", + dec_mc_11.azure_monitor_profile.container_insights.container_network_logs, + CONST_CONTAINER_NETWORK_LOGS_ENABLED, ) self.assertTrue( dec_11.context.get_intermediate("monitoring_addon_postprocessing_required") @@ -17030,8 +17057,8 @@ def test_enable_container_network_logs(self): dec_16.context.attach_mc(mc_16) dec_mc_16 = dec_16.update_monitoring_profile_flow_logs(mc_16) self.assertEqual( - dec_mc_16.addon_profiles[CONST_MONITORING_ADDON_NAME].config["enableRetinaNetworkFlags"], - "True", + dec_mc_16.azure_monitor_profile.container_insights.container_network_logs, + CONST_CONTAINER_NETWORK_LOGS_ENABLED, ) self.assertTrue( dec_16.context.get_intermediate("monitoring_addon_postprocessing_required") @@ -17059,12 +17086,14 @@ def test_enable_container_network_logs(self): addon_profiles={ "omsAgent": self.models.ManagedClusterAddonProfile( enabled=True, - config={ - CONST_MONITORING_USING_AAD_MSI_AUTH: "true", - "enableRetinaNetworkFlags": "True", - }, + config={CONST_MONITORING_USING_AAD_MSI_AUTH: "true"}, ) }, + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_ENABLED, + ), + ), ) dec_17.context.attach_mc(mc_17) with self.assertRaises(MutuallyExclusiveArgumentError): @@ -17455,5 +17484,1925 @@ def test_update_upstream_kubescheduler_user_configuration(self): self.assertEqual(dec_mc_5, ground_truth_mc_5) +class AKSAzureMonitorLogsCreateTestCase(unittest.TestCase): + def setUp(self): + self.cli_ctx = MockCLI() + self.cmd = MockCmd(self.cli_ctx) + self.models = AKSManagedClusterModels(self.cmd, ResourceType.MGMT_CONTAINERSERVICE) + self.client = MockClient() + + def _create_dec(self, raw_param): + params = { + "name": "test_name", + "resource_group_name": "test_rg_name", + "location": "test_location", + } + params.update(raw_param) + dec = AKSManagedClusterCreateDecorator( + self.cmd, + self.client, + params, + ResourceType.MGMT_CONTAINERSERVICE, + ) + dec.context.set_intermediate("subscription_id", "test_subscription_id") + return dec + + def test_enable_azure_monitor_logs_sets_container_insights(self): + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec._setup_azure_monitor_logs(mc) + + container_insights = mc.azure_monitor_profile.container_insights + self.assertTrue(container_insights.enabled) + self.assertEqual( + container_insights.log_analytics_workspace_resource_id, "/test_workspace_resource_id" + ) + self.assertTrue(dec.context.get_intermediate("monitoring_addon_enabled")) + # No legacy addon profile is written for the Azure Monitor profile path + self.assertIsNone(mc.addon_profiles) + + def test_enable_azure_monitor_logs_creates_default_workspace(self): + dec = self._create_dec({"enable_azure_monitor_logs": True}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_default_log_analytics_workspace_for_monitoring", + return_value="test_default_workspace_resource_id", + ): + dec._setup_azure_monitor_logs(mc) + self.assertEqual( + mc.azure_monitor_profile.container_insights.log_analytics_workspace_resource_id, + "/test_default_workspace_resource_id", + ) + + def test_enable_azure_monitor_logs_rejected_on_service_principal_cluster(self): + """On create the service principal profile is populated from --service-principal before the + addon profiles are set up, so the rejection applies there too.""" + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + service_principal_profile=self.models.ManagedClusterServicePrincipalProfile( + client_id="00000000-0000-0000-0000-000000000001", secret="secret" + ), + ) + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_default_log_analytics_workspace_for_monitoring", + ) as ensure_workspace_mock: + with self.assertRaises(ArgumentUsageError) as ctx: + dec._setup_azure_monitor_logs(mc) + + self.assertIn("service principal", str(ctx.exception)) + ensure_workspace_mock.assert_not_called() + + def test_enable_azure_monitor_logs_allowed_on_msi_cluster(self): + """Managed identity clusters report clientId 'msi'.""" + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + service_principal_profile=self.models.ManagedClusterServicePrincipalProfile( + client_id="msi" + ), + ) + dec.context.attach_mc(mc) + dec._setup_azure_monitor_logs(mc) + self.assertTrue(mc.azure_monitor_profile.container_insights.enabled) + + def test_enable_azure_monitor_logs_allowed_without_service_principal_profile(self): + """A managed identity cluster may have no service principal profile at all.""" + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec._setup_azure_monitor_logs(mc) + self.assertTrue(mc.azure_monitor_profile.container_insights.enabled) + + def test_enable_azure_monitor_logs_applies_settings(self): + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + "syslog_port": 28330, + "disable_prometheus_metrics_scraping": True, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec._setup_azure_monitor_logs(mc) + + container_insights = mc.azure_monitor_profile.container_insights + self.assertEqual(container_insights.syslog_port, 28330) + self.assertTrue(container_insights.disable_prometheus_metrics_scraping) + + def test_settings_without_enable_azure_monitor_logs_errors(self): + dec = self._create_dec({"syslog_port": 28330}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.context.get_syslog_port() + + def test_syslog_port_out_of_range_errors(self): + dec = self._create_dec({"enable_azure_monitor_logs": True, "syslog_port": 70000}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.context.get_syslog_port() + + def test_enable_azure_monitor_logs_with_explicit_legacy_auth_errors(self): + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "enable_msi_auth_for_monitoring": False, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(MutuallyExclusiveArgumentError): + dec.context.get_enable_azure_monitor_logs() + + def test_enable_azure_monitor_logs_forces_msi_auth(self): + dec = self._create_dec({"enable_azure_monitor_logs": True}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + self.assertTrue(dec.context.get_enable_msi_auth_for_monitoring()) + + def test_set_up_addon_profiles_authors_no_omsagent_addon(self): + # R1: --enable-azure-monitor-logs must configure the Azure Monitor profile only. + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec_mc = dec.set_up_addon_profiles(mc) + + self.assertTrue(dec_mc.azure_monitor_profile.container_insights.enabled) + self.assertEqual( + dec_mc.azure_monitor_profile.container_insights.log_analytics_workspace_resource_id, + "/test_workspace_resource_id", + ) + self.assertNotIn(CONST_MONITORING_ADDON_NAME, dec_mc.addon_profiles or {}) + self.assertTrue(dec.context.get_intermediate("monitoring_addon_enabled")) + + def test_postprocessing_provisions_dcr_and_dcra_from_amp_profile(self): + # R1 parity: DCR/DCE/DCRA/AMPLS provisioning must be driven off the Azure Monitor profile + # and must not require the omsagent addon to exist. + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as settings_file: + settings_file.write('{"interval": "1m"}') + settings_path = settings_file.name + self.addCleanup(os.unlink, settings_path) + + ampls_resource_id = ( + "/subscriptions/1234-5678/resourceGroups/test_rg_name/providers/" + "microsoft.insights/privatelinkscopes/test_ampls" + ) + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + "enable_syslog": True, + "data_collection_settings": settings_path, + "ampls_resource_id": ampls_resource_id, + "enable_high_log_scale_mode": True, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec.set_up_addon_profiles(mc) + + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ) as ensure_mock: + dec.postprocessing_after_mc_created(mc) + + ensure_mock.assert_called_once() + kwargs = ensure_mock.call_args.kwargs + self.assertTrue(kwargs["aad_route"]) + self.assertTrue(kwargs["create_dcr"]) + self.assertTrue(kwargs["create_dcra"]) + self.assertTrue(kwargs["enable_syslog"]) + self.assertEqual(kwargs["data_collection_settings"], settings_path) + self.assertEqual(kwargs["ampls_resource_id"], ampls_resource_id) + self.assertTrue(kwargs["enable_high_log_scale_mode"]) + # The addon shim is synthesized from the Azure Monitor profile, not the omsagent addon + addon_shim = ensure_mock.call_args.args[1] + self.assertTrue(addon_shim.enabled) + self.assertEqual( + addon_shim.config[CONST_MONITORING_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID], + "/test_workspace_resource_id", + ) + self.assertEqual(addon_shim.config[CONST_MONITORING_USING_AAD_MSI_AUTH], "true") + + def test_container_network_logs_written_to_azure_monitor_profile(self): + # R1: --enable-container-network-logs maps to containerInsights.containerNetworkLogs + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + "enable_acns": True, + "network_dataplane": "cilium", + "enable_container_network_logs": True, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec_mc = dec.set_up_addon_profiles(mc) + + self.assertEqual( + dec_mc.azure_monitor_profile.container_insights.container_network_logs, + CONST_CONTAINER_NETWORK_LOGS_ENABLED, + ) + self.assertNotIn(CONST_MONITORING_ADDON_NAME, dec_mc.addon_profiles or {}) + + def test_enable_prometheus_scraping_leaves_field_false(self): + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + "enable_prometheus_metrics_scraping": True, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec._setup_azure_monitor_logs(mc) + self.assertFalse( + mc.azure_monitor_profile.container_insights.disable_prometheus_metrics_scraping + ) + + +class AKSAzureMonitorLogsUpdateTestCase(unittest.TestCase): + def setUp(self): + self.cli_ctx = MockCLI() + self.cmd = MockCmd(self.cli_ctx) + self.models = AKSManagedClusterModels(self.cmd, ResourceType.MGMT_CONTAINERSERVICE) + self.client = MockClient() + + def _update_dec(self, raw_param): + params = { + "name": "test_name", + "resource_group_name": "test_rg_name", + } + params.update(raw_param) + dec = AKSManagedClusterUpdateDecorator( + self.cmd, + self.client, + params, + ResourceType.MGMT_CONTAINERSERVICE, + ) + dec.context.set_intermediate("subscription_id", "test_subscription_id") + return dec + + def test_enable_azure_monitor_logs_on_fresh_cluster(self): + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_container_insights_for_monitoring", + return_value=None, + ) as ensure_mock: + dec_mc = dec.update_azure_monitor_logs(mc) + + container_insights = dec_mc.azure_monitor_profile.container_insights + self.assertTrue(container_insights.enabled) + self.assertEqual( + container_insights.log_analytics_workspace_resource_id, "/test_workspace_resource_id" + ) + # A fresh enable must provision the DCR and the DCRA, otherwise the agent is deployed with + # no data collection rule attached and nothing is ingested. + ensure_mock.assert_called_once() + self.assertTrue(ensure_mock.call_args.kwargs["create_dcr"]) + self.assertTrue(ensure_mock.call_args.kwargs["create_dcra"]) + # And not queued for postprocessing, which would repeat the same work after the PUT. + self.assertFalse( + dec.context.get_intermediate( + "monitoring_addon_postprocessing_required", default_value=False + ) + ) + + def test_enable_azure_monitor_logs_starts_from_a_clean_profile(self): + """A re-enable must not inherit what the previous onboarding left behind. + + The RP preserves any containerInsights field that is absent from the request, so an old + syslog port, scraping choice or container network logs setting would otherwise come back + silently on the next --enable-azure-monitor-logs. + """ + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=False, + log_analytics_workspace_resource_id="/stale_workspace", + syslog_port=2832, + disable_prometheus_metrics_scraping=True, + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_ENABLED, + ) + ), + ) + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_container_insights_for_monitoring", + return_value=None, + ): + dec_mc = dec.update_azure_monitor_logs(mc) + + container_insights = dec_mc.azure_monitor_profile.container_insights + self.assertTrue(container_insights.enabled) + self.assertEqual( + container_insights.log_analytics_workspace_resource_id, "/test_workspace_resource_id" + ) + self.assertEqual( + container_insights.syslog_port, CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT + ) + self.assertFalse(container_insights.disable_prometheus_metrics_scraping) + self.assertEqual( + container_insights.container_network_logs, CONST_CONTAINER_NETWORK_LOGS_DISABLED + ) + + def test_enable_azure_monitor_logs_keeps_settings_asked_for_in_the_same_command(self): + """Starting from the defaults must not discard values supplied alongside the enable.""" + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + "enable_syslog": True, + "syslog_port": 2833, + "disable_prometheus_metrics_scraping": True, + } + ) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=False, syslog_port=2832 + ) + ), + ) + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_container_insights_for_monitoring", + return_value=None, + ): + dec_mc = dec.update_azure_monitor_logs(mc) + + container_insights = dec_mc.azure_monitor_profile.container_insights + self.assertTrue(container_insights.enabled) + self.assertEqual(container_insights.syslog_port, 2833) + self.assertTrue(container_insights.disable_prometheus_metrics_scraping) + + def test_enable_azure_monitor_logs_provisions_dcr_before_cluster_put(self): + """The DCR and the DCRA are provisioned while the profile is being built, not deferred to + postprocessing_after_mc_created. + + Postprocessing runs after the cluster PUT, so the RP has already rolled out the ama-logs + DaemonSet by the time the DCRA appears. The agent then starts with nothing to download, + backs off for several minutes before retrying, ingests nothing for that whole window and + restarts once the configuration finally lands. 'az aks enable-addons -a monitoring' creates + the artifacts before its PUT, and this flag has to match that ordering. + """ + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_container_insights_for_monitoring", + return_value=None, + ) as ensure_mock: + dec.update_azure_monitor_logs(mc) + + ensure_mock.assert_called_once() + self.assertTrue(ensure_mock.call_args.kwargs["aad_route"]) + self.assertFalse(ensure_mock.call_args.kwargs["remove_monitoring"]) + + def test_enable_azure_monitor_logs_on_legacy_auth_cluster_errors(self): + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + addon_profiles={ + CONST_MONITORING_ADDON_NAME: self.models.ManagedClusterAddonProfile( + enabled=True, + config={CONST_MONITORING_USING_AAD_MSI_AUTH: "false"}, + ) + }, + ) + dec.context.attach_mc(mc) + with self.assertRaises(ArgumentUsageError): + dec.update_azure_monitor_logs(mc) + + def test_enable_azure_monitor_logs_on_disabled_legacy_addon_is_allowed(self): + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + addon_profiles={ + CONST_MONITORING_ADDON_NAME: self.models.ManagedClusterAddonProfile(enabled=False) + }, + ) + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_container_insights_for_monitoring", + return_value=None, + ): + dec_mc = dec.update_azure_monitor_logs(mc) + self.assertTrue(dec_mc.azure_monitor_profile.container_insights.enabled) + + def test_enable_azure_monitor_logs_rejected_when_already_enabled(self): + """Re-onboarding an already onboarded cluster is rejected, matching + 'az aks enable-addons -a monitoring'.""" + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "/new_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + log_analytics_workspace_resource_id="/old_workspace_resource_id", + ), + ), + ) + dec.context.attach_mc(mc) + with self.assertRaises(ArgumentUsageError) as ctx: + dec.update_azure_monitor_logs(mc) + + message = str(ctx.exception) + self.assertIn("already enabled for this managed cluster", message) + self.assertIn("--disable-azure-monitor-logs", message) + # the original workspace is left untouched + self.assertEqual( + mc.azure_monitor_profile.container_insights.log_analytics_workspace_resource_id, + "/old_workspace_resource_id", + ) + + def test_enable_azure_monitor_logs_rejected_before_provisioning_default_workspace(self): + """The already-enabled rejection must happen before a default workspace is created.""" + dec = self._update_dec({"enable_azure_monitor_logs": True}) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + log_analytics_workspace_resource_id="/old_workspace_resource_id", + ), + ), + ) + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_default_log_analytics_workspace_for_monitoring", + ) as ensure_workspace_mock: + with self.assertRaises(ArgumentUsageError): + dec.update_azure_monitor_logs(mc) + + ensure_workspace_mock.assert_not_called() + + def test_enable_azure_monitor_logs_allowed_when_container_insights_disabled(self): + """A disabled profile is a fresh onboarding, not a re-onboarding.""" + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "/new_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=False, + log_analytics_workspace_resource_id="/old_workspace_resource_id", + ), + ), + ) + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_container_insights_for_monitoring", + return_value=None, + ) as ensure_mock: + dec_mc = dec.update_azure_monitor_logs(mc) + + container_insights = dec_mc.azure_monitor_profile.container_insights + self.assertTrue(container_insights.enabled) + self.assertEqual( + container_insights.log_analytics_workspace_resource_id, "/new_workspace_resource_id" + ) + # The DCR destination is rewritten up front, before the cluster PUT. + ensure_mock.assert_called_once() + self.assertTrue(ensure_mock.call_args.kwargs["create_dcr"]) + + def test_legacy_auth_rejection_wins_over_already_enabled_rejection(self): + """A legacy-auth cluster is also 'already enabled', but the migration message is the + actionable one, so it must be the error the user sees.""" + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + addon_profiles={ + CONST_MONITORING_ADDON_NAME: self.models.ManagedClusterAddonProfile( + enabled=True, + config={CONST_MONITORING_USING_AAD_MSI_AUTH: "false"}, + ) + }, + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + ), + ), + ) + dec.context.attach_mc(mc) + with self.assertRaises(ArgumentUsageError) as ctx: + dec.update_azure_monitor_logs(mc) + + self.assertIn("legacy", str(ctx.exception)) + + def test_enable_azure_monitor_logs_rejected_on_service_principal_cluster(self): + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + service_principal_profile=self.models.ManagedClusterServicePrincipalProfile( + client_id="00000000-0000-0000-0000-000000000001" + ), + ) + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_default_log_analytics_workspace_for_monitoring", + ) as ensure_workspace_mock: + with self.assertRaises(ArgumentUsageError) as ctx: + dec.update_azure_monitor_logs(mc) + + self.assertIn("service principal", str(ctx.exception)) + # rejected before any workspace is provisioned on the user's behalf + ensure_workspace_mock.assert_not_called() + + def test_enable_azure_monitor_logs_allowed_on_msi_cluster(self): + dec = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "test_workspace_resource_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + service_principal_profile=self.models.ManagedClusterServicePrincipalProfile( + client_id="msi" + ), + ) + dec.context.attach_mc(mc) + with patch.object( + dec.context.external_functions, + "ensure_container_insights_for_monitoring", + return_value=None, + ): + dec_mc = dec.update_azure_monitor_logs(mc) + + self.assertTrue(dec_mc.azure_monitor_profile.container_insights.enabled) + + def test_disable_azure_monitor_logs(self): + dec = self._update_dec({"disable_azure_monitor_logs": True}) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + log_analytics_workspace_resource_id="/test_workspace_resource_id", + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_ENABLED, + syslog_port=29000, + disable_prometheus_metrics_scraping=True, + ), + ), + ) + dec.context.attach_mc(mc) + self.client.get = Mock(return_value=mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ) as ensure_mock: + dec_mc = dec.update_azure_monitor_logs(mc) + + ensure_mock.assert_called_once() + self.assertEqual(ensure_mock.call_args.kwargs["remove_monitoring"], True) + self.assertEqual(ensure_mock.call_args.kwargs["aad_route"], True) + + container_insights = dec_mc.azure_monitor_profile.container_insights + self.assertFalse(container_insights.enabled) + # Every behavioural field is reset, so a later re-enable starts from a clean profile. + self.assertEqual( + container_insights.container_network_logs, CONST_CONTAINER_NETWORK_LOGS_DISABLED + ) + self.assertEqual( + container_insights.syslog_port, CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT + ) + self.assertFalse(container_insights.disable_prometheus_metrics_scraping) + # The workspace id is deliberately preserved: blanking it makes the RP mirror an empty + # string into the omsagent addon config, and ARM then rejects every later write of the + # cluster with LinkedInvalidPropertyId. + self.assertEqual( + container_insights.log_analytics_workspace_resource_id, "/test_workspace_resource_id" + ) + + def test_disable_azure_monitor_logs_emits_every_default_field(self): + """Every reset field must be present in the payload: the RP only overwrites fields it + receives, so a field left out would survive the disable.""" + dec = self._update_dec({"disable_azure_monitor_logs": True}) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + log_analytics_workspace_resource_id="/test_workspace_resource_id", + syslog_port=29000, + ), + ), + ) + dec.context.attach_mc(mc) + self.client.get = Mock(return_value=mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ): + dec_mc = dec.update_azure_monitor_logs(mc) + + payload = dict(dec_mc.azure_monitor_profile.container_insights) + for field in ( + "enabled", + "syslogPort", + "disablePrometheusMetricsScraping", + "containerNetworkLogs", + ): + self.assertIn(field, payload) + + def test_disable_azure_monitor_logs_reset_survives_reenable(self): + """After a disable, re-enabling starts from a clean profile.""" + dec_disable = self._update_dec({"disable_azure_monitor_logs": True}) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + log_analytics_workspace_resource_id="/test_workspace_resource_id", + container_network_logs=CONST_CONTAINER_NETWORK_LOGS_ENABLED, + syslog_port=29000, + disable_prometheus_metrics_scraping=True, + ), + ), + ) + dec_disable.context.attach_mc(mc) + self.client.get = Mock(return_value=mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ): + dec_disable.update_azure_monitor_logs(mc) + + dec_enable = self._update_dec( + { + "enable_azure_monitor_logs": True, + "workspace_resource_id": "/new_workspace_resource_id", + } + ) + dec_enable.context.attach_mc(mc) + with patch.object( + dec_enable.context.external_functions, + "ensure_container_insights_for_monitoring", + return_value=None, + ): + dec_enable.update_azure_monitor_logs(mc) + + container_insights = mc.azure_monitor_profile.container_insights + self.assertTrue(container_insights.enabled) + self.assertEqual( + container_insights.log_analytics_workspace_resource_id, "/new_workspace_resource_id" + ) + self.assertEqual( + container_insights.container_network_logs, CONST_CONTAINER_NETWORK_LOGS_DISABLED + ) + self.assertEqual( + container_insights.syslog_port, CONST_CONTAINER_INSIGHTS_DEFAULT_SYSLOG_PORT + ) + self.assertFalse(container_insights.disable_prometheus_metrics_scraping) + + def _mc_with_otlp_logs_traces(self, enabled=True): + return self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + log_analytics_workspace_resource_id="/test_workspace_resource_id", + ), + app_monitoring=self.models.ManagedClusterAzureMonitorProfileAppMonitoring( + open_telemetry_logs_and_traces=self.models. + ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces( + enabled=enabled, http_port=8080, grpc_port=8081 + ) + ), + ), + ) + + def test_disable_azure_monitor_logs_prompts_when_otlp_logs_and_traces_enabled(self): + dec = self._update_dec({"disable_azure_monitor_logs": True, "yes": False}) + mc = self._mc_with_otlp_logs_traces() + dec.context.attach_mc(mc) + self.client.get = Mock(return_value=mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator.prompt_y_n", return_value=True + ) as prompt_mock, patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ): + dec_mc = dec.update_azure_monitor_logs(mc) + + prompt_mock.assert_called_once() + self.assertIn("OpenTelemetry logs and traces", prompt_mock.call_args[0][0]) + self.assertFalse(dec_mc.azure_monitor_profile.container_insights.enabled) + otlp_logs = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + self.assertFalse(otlp_logs.enabled) + self.assertIsNone(otlp_logs.http_port) + self.assertIsNone(otlp_logs.grpc_port) + + def test_disable_azure_monitor_logs_aborts_when_confirmation_declined(self): + dec = self._update_dec({"disable_azure_monitor_logs": True, "yes": False}) + mc = self._mc_with_otlp_logs_traces() + dec.context.attach_mc(mc) + self.client.get = Mock(return_value=mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator.prompt_y_n", return_value=False + ), patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ) as ensure_mock: + with self.assertRaises(DecoratorEarlyExitException): + dec.update_azure_monitor_logs(mc) + + # nothing was torn down and the profile is untouched + ensure_mock.assert_not_called() + self.assertTrue(mc.azure_monitor_profile.container_insights.enabled) + self.assertTrue( + mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces.enabled + ) + + def test_disable_azure_monitor_logs_skips_prompt_with_yes(self): + dec = self._update_dec({"disable_azure_monitor_logs": True, "yes": True}) + mc = self._mc_with_otlp_logs_traces() + dec.context.attach_mc(mc) + self.client.get = Mock(return_value=mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator.prompt_y_n", return_value=True + ) as prompt_mock, patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ): + dec_mc = dec.update_azure_monitor_logs(mc) + + prompt_mock.assert_not_called() + self.assertFalse(dec_mc.azure_monitor_profile.container_insights.enabled) + + def test_disable_azure_monitor_logs_no_prompt_when_otlp_logs_disabled(self): + dec = self._update_dec({"disable_azure_monitor_logs": True, "yes": False}) + mc = self._mc_with_otlp_logs_traces(enabled=False) + dec.context.attach_mc(mc) + self.client.get = Mock(return_value=mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator.prompt_y_n", return_value=True + ) as prompt_mock, patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ): + dec_mc = dec.update_azure_monitor_logs(mc) + + prompt_mock.assert_not_called() + self.assertFalse(dec_mc.azure_monitor_profile.container_insights.enabled) + + def test_disable_azure_monitor_logs_noop_when_not_enabled(self): + dec = self._update_dec({"disable_azure_monitor_logs": True}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_container_insights_for_monitoring", + ) as ensure_mock: + dec_mc = dec.update_azure_monitor_logs(mc) + ensure_mock.assert_not_called() + self.assertIsNone(dec_mc.azure_monitor_profile) + + def test_enable_and_disable_azure_monitor_logs_errors(self): + dec = self._update_dec( + {"enable_azure_monitor_logs": True, "disable_azure_monitor_logs": True} + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(MutuallyExclusiveArgumentError): + dec.context.get_enable_azure_monitor_logs() + + def test_enable_azure_monitor_logs_with_explicit_msi_auth_flag_errors(self): + dec = self._update_dec( + {"enable_azure_monitor_logs": True, "enable_msi_auth_for_monitoring": True} + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(MutuallyExclusiveArgumentError): + dec.context.get_enable_azure_monitor_logs() + + def test_update_settings_on_already_enabled_cluster(self): + dec = self._update_dec({"syslog_port": 28331}) + mc = self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + ), + ), + ) + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_logs_settings(mc) + self.assertEqual(dec_mc.azure_monitor_profile.container_insights.syslog_port, 28331) + + def test_update_settings_on_legacy_addon_cluster(self): + dec = self._update_dec({"disable_prometheus_metrics_scraping": True}) + mc = self.models.ManagedCluster( + location="test_location", + addon_profiles={ + CONST_MONITORING_ADDON_NAME: self.models.ManagedClusterAddonProfile(enabled=True) + }, + ) + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_logs_settings(mc) + self.assertTrue( + dec_mc.azure_monitor_profile.container_insights.disable_prometheus_metrics_scraping + ) + + def test_update_settings_without_monitoring_errors(self): + dec = self._update_dec({"syslog_port": 28331}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.update_azure_monitor_logs_settings(mc) + + def test_update_settings_noop_when_no_flags(self): + dec = self._update_dec({}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_logs_settings(mc) + self.assertIsNone(dec_mc.azure_monitor_profile) + + def _mc_with_container_insights(self): + return self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + ), + ), + ) + + def _postprocessing_required(self, dec): + return dec.context.get_intermediate( + "monitoring_addon_postprocessing_required", default_value=False + ) + + def test_enable_syslog_requests_dcr_reprovisioning(self): + # The syslog data source and the Microsoft-Syslog stream live in the DCR, so writing the + # cluster alone would leave syslog uncollected. + dec = self._update_dec({"enable_syslog": True, "syslog_port": 2833}) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_logs_settings(mc) + + self.assertEqual(dec_mc.azure_monitor_profile.container_insights.syslog_port, 2833) + self.assertTrue(self._postprocessing_required(dec)) + + def test_disable_syslog_requests_dcr_reprovisioning(self): + # Turning syslog off also changes the DCR shape, so it has to be re-provisioned to drop + # the syslog data source. + dec = self._update_dec({"enable_syslog": False, "syslog_port": 2833}) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec.update_azure_monitor_logs_settings(mc) + + self.assertTrue(self._postprocessing_required(dec)) + + def test_syslog_port_alone_does_not_request_dcr_reprovisioning(self): + # The port never reaches the DCR, so there is nothing to re-provision for it. + dec = self._update_dec({"syslog_port": 2833}) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec.update_azure_monitor_logs_settings(mc) + + self.assertFalse(self._postprocessing_required(dec)) + + def test_prometheus_scraping_alone_does_not_request_dcr_reprovisioning(self): + dec = self._update_dec({"disable_prometheus_metrics_scraping": True}) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec.update_azure_monitor_logs_settings(mc) + + self.assertFalse(self._postprocessing_required(dec)) + + def test_enable_syslog_with_enable_azure_monitor_logs_skips_dcr_reprovisioning(self): + # The onboarding path already provisions the DCR inline before the cluster PUT, so asking + # for postprocessing as well would repeat the same work afterwards. + dec = self._update_dec( + {"enable_azure_monitor_logs": True, "enable_syslog": True, "syslog_port": 2833} + ) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec.update_azure_monitor_logs_settings(mc) + + self.assertFalse(self._postprocessing_required(dec)) + + def test_enable_syslog_with_disable_azure_monitor_logs_skips_dcr_reprovisioning(self): + # Offboarding tears the DCR down, so it must not be recreated by the syslog flag. + dec = self._update_dec({"disable_azure_monitor_logs": True, "enable_syslog": True}) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec.update_azure_monitor_logs_settings(mc) + + self.assertFalse(self._postprocessing_required(dec)) + + def _settings_file(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as settings_file: + settings_file.write('{"interval": "1m"}') + settings_path = settings_file.name + self.addCleanup(os.unlink, settings_path) + return settings_path + + def test_data_collection_settings_requests_dcr_reprovisioning(self): + # dataCollectionSettings only exists inside the DCR, so an update that does not + # re-provision the rule would leave the previous collection settings in place. + dec = self._update_dec({"data_collection_settings": self._settings_file()}) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec.update_azure_monitor_logs_settings(mc) + + self.assertTrue(self._postprocessing_required(dec)) + + def test_ampls_resource_id_requests_dcr_reprovisioning(self): + # The private link scope association is made while provisioning the DCR, so changing it + # requires the rule to be provisioned again. + dec = self._update_dec( + { + "ampls_resource_id": ( + "/subscriptions/1234-5678/resourceGroups/test_rg_name/providers/" + "microsoft.insights/privatelinkscopes/test_ampls" + ) + } + ) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec.update_azure_monitor_logs_settings(mc) + + self.assertTrue(self._postprocessing_required(dec)) + + def test_data_collection_settings_with_disable_azure_monitor_logs_skips_dcr_reprovisioning(self): + dec = self._update_dec( + { + "disable_azure_monitor_logs": True, + "data_collection_settings": self._settings_file(), + } + ) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + dec.update_azure_monitor_logs_settings(mc) + + self.assertFalse(self._postprocessing_required(dec)) + + def test_unreadable_data_collection_settings_fails_before_cluster_put(self): + # Validating here keeps a bad path from being discovered only after the cluster has + # already been updated. + dec = self._update_dec({"data_collection_settings": "/tmp/does_not_exist_dcs.json"}) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + + with self.assertRaises(InvalidArgumentValueError): + dec.update_azure_monitor_logs_settings(mc) + + def _settings_file_of_serialized_size(self, target_chars): + # Build a settings file whose JSON payload is comfortably larger than the limit. + payload = {"namespaces": ["ns-{:05d}-padding-padding".format(i) for i in range(target_chars // 25)]} + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as settings_file: + json.dump(payload, settings_file) + settings_path = settings_file.name + self.addCleanup(os.unlink, settings_path) + return settings_path + + def test_oversized_data_collection_settings_is_rejected(self): + # The settings are embedded in the data collection rule request, which the service + # rejects with an opaque "Request Header Fields Too Large" once they grow too big. + # Fail here, with the actual size, instead of letting that happen after the cluster PUT. + settings_path = self._settings_file_of_serialized_size( + CONST_DATA_COLLECTION_SETTINGS_MAX_CHARS * 2 + ) + dec = self._update_dec({"data_collection_settings": settings_path}) + mc = self._mc_with_container_insights() + dec.context.attach_mc(mc) + + with self.assertRaises(InvalidArgumentValueError) as cm: + dec.update_azure_monitor_logs_settings(mc) + self.assertIn(str(CONST_DATA_COLLECTION_SETTINGS_MAX_CHARS), str(cm.exception)) + + def test_data_collection_settings_limit_measures_contents_not_path(self): + # Measuring the path length instead of the parsed settings would never reach the limit, + # so an oversized file would sail through while a small one could be dropped. + settings_path = self._settings_file() + self.assertLess(len(settings_path), CONST_DATA_COLLECTION_SETTINGS_MAX_CHARS) + dec = self._update_dec({"data_collection_settings": settings_path}) + + self.assertEqual(dec.context.get_data_collection_settings(), settings_path) + + + + def setUp(self): + self.cli_ctx = MockCLI() + self.cmd = MockCmd(self.cli_ctx) + self.models = AKSManagedClusterModels(self.cmd, ResourceType.MGMT_CONTAINERSERVICE) + self.client = MockClient() + + def _create_dec(self, raw_param): + params = { + "name": "test_name", + "resource_group_name": "test_rg_name", + "location": "test_location", + } + params.update(raw_param) + return AKSManagedClusterCreateDecorator( + self.cmd, + self.client, + params, + ResourceType.MGMT_CONTAINERSERVICE, + ) + + def test_enable_opentelemetry_metrics_with_ports(self): + dec = self._create_dec( + { + "enable_managed_identity": True, + "enable_azure_monitor_metrics": True, + "enable_opentelemetry_metrics": True, + "opentelemetry_metrics_port_http": 4318, + "opentelemetry_metrics_port_grpc": 4317, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec._setup_opentelemetry_metrics(mc) + + otlp = mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.http_port, 4318) + self.assertEqual(otlp.grpc_port, 4317) + + def test_enable_opentelemetry_logs_traces_with_ports(self): + dec = self._create_dec( + { + "enable_azure_monitor_logs": True, + "enable_opentelemetry_logs_traces": True, + "opentelemetry_logs_traces_port_http": 4320, + "opentelemetry_logs_traces_port_grpc": 4319, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + dec._setup_opentelemetry_logs_traces(mc) + + otlp = mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.http_port, 4320) + self.assertEqual(otlp.grpc_port, 4319) + + def test_opentelemetry_metrics_without_azure_monitor_metrics_errors(self): + dec = self._create_dec({"enable_opentelemetry_metrics": True}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(ArgumentUsageError): + dec.context.get_enable_opentelemetry_metrics() + + def test_opentelemetry_logs_traces_without_azure_monitor_logs_errors(self): + dec = self._create_dec({"enable_opentelemetry_logs_traces": True}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(ArgumentUsageError): + dec.context.get_enable_opentelemetry_logs_traces() + + def test_opentelemetry_logs_traces_with_monitoring_addon_allowed(self): + dec = self._create_dec( + {"enable_addons": "monitoring", "enable_opentelemetry_logs_traces": True} + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + self.assertTrue(dec.context.get_enable_opentelemetry_logs_traces()) + + def test_opentelemetry_metrics_port_out_of_range_errors(self): + dec = self._create_dec( + { + "enable_managed_identity": True, + "enable_azure_monitor_metrics": True, + "enable_opentelemetry_metrics": True, + "opentelemetry_metrics_port_http": 70000, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.context.get_opentelemetry_metrics_port_http() + + def test_opentelemetry_metrics_port_without_enable_errors(self): + dec = self._create_dec({"opentelemetry_metrics_port_http": 4318}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.context.get_opentelemetry_metrics_port_http() + + def test_enable_and_disable_opentelemetry_metrics_errors(self): + dec = self._create_dec( + { + "enable_managed_identity": True, + "enable_azure_monitor_metrics": True, + "enable_opentelemetry_metrics": True, + "disable_opentelemetry_metrics": True, + } + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(MutuallyExclusiveArgumentError): + dec.context.get_enable_opentelemetry_metrics() + + def test_set_up_azure_monitor_profile_wires_opentelemetry(self): + dec = self._create_dec( + { + "enable_managed_identity": True, + "enable_azure_monitor_metrics": True, + "enable_opentelemetry_metrics": True, + "opentelemetry_metrics_port_grpc": 4317, + "enable_azure_monitor_logs": True, + "enable_opentelemetry_logs_traces": True, + "opentelemetry_logs_traces_port_grpc": 4319, + "azure_monitor_workspace_resource_id": "test_workspace_id", + } + ) + mc = self.models.ManagedCluster( + location="test_location", + identity=self.models.ManagedClusterIdentity(type="SystemAssigned"), + ) + dec.context.attach_mc(mc) + dec_mc = dec.set_up_azure_monitor_profile(mc) + + app_monitoring = dec_mc.azure_monitor_profile.app_monitoring + self.assertTrue(app_monitoring.open_telemetry_metrics.enabled) + self.assertEqual(app_monitoring.open_telemetry_metrics.grpc_port, 4317) + self.assertTrue(app_monitoring.open_telemetry_logs_and_traces.enabled) + self.assertEqual(app_monitoring.open_telemetry_logs_and_traces.grpc_port, 4319) + + +class ContainerNetworkLogsEnabledOnMcTestCase(unittest.TestCase): + """The Azure Monitor profile is the source of truth for container network logs.""" + + def setUp(self): + self.cli_ctx = MockCLI() + self.cmd = MockCmd(self.cli_ctx) + self.models = AKSManagedClusterModels(self.cmd, ResourceType.MGMT_CONTAINERSERVICE) + self.addon_consts = { + "CONST_MONITORING_ADDON_NAME": CONST_MONITORING_ADDON_NAME, + "CONST_MONITORING_ENABLE_RETINA_NETWORK_FLAGS": CONST_MONITORING_ENABLE_RETINA_NETWORK_FLAGS, + } + + def _mc(self, amp_value, legacy_value): + mc = self.models.ManagedCluster(location="test_location") + if amp_value is not None: + mc.azure_monitor_profile = self.models.ManagedClusterAzureMonitorProfile( + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + container_network_logs=amp_value, + ) + ) + if legacy_value is not None: + mc.addon_profiles = { + CONST_MONITORING_ADDON_NAME: self.models.ManagedClusterAddonProfile( + enabled=True, + config={CONST_MONITORING_ENABLE_RETINA_NETWORK_FLAGS: legacy_value}, + ) + } + return mc + + def test_disabled_profile_wins_over_stale_legacy_config(self): + # The legacy addon config key mirrors the profile server side, so it still reports the + # previous value while an update is in flight. Reading it here would reject a command + # that disables container network logs and high log scale mode together. + mc = self._mc(CONST_CONTAINER_NETWORK_LOGS_DISABLED, "true") + + self.assertFalse(_is_container_network_logs_enabled_on_mc(mc, self.addon_consts)) + + def test_enabled_profile_wins_over_stale_legacy_config(self): + mc = self._mc(CONST_CONTAINER_NETWORK_LOGS_ENABLED, "false") + + self.assertTrue(_is_container_network_logs_enabled_on_mc(mc, self.addon_consts)) + + def test_legacy_config_is_used_when_the_profile_says_nothing(self): + # Clusters onboarded through the omsagent addon have no value on the profile. + self.assertTrue(_is_container_network_logs_enabled_on_mc(self._mc(None, "true"), self.addon_consts)) + self.assertFalse(_is_container_network_logs_enabled_on_mc(self._mc(None, "false"), self.addon_consts)) + + def test_disabled_when_neither_source_reports_anything(self): + self.assertFalse( + _is_container_network_logs_enabled_on_mc(self.models.ManagedCluster(location="l"), self.addon_consts) + ) + + +class AKSMonitoringDisableConfirmationTestCase(unittest.TestCase): + """Both monitoring disables must be confirmed before either deletes collection resources.""" + + def setUp(self): + self.cli_ctx = MockCLI() + self.cmd = MockCmd(self.cli_ctx) + self.models = AKSManagedClusterModels(self.cmd, ResourceType.MGMT_CONTAINERSERVICE) + + def _dec_and_mc(self, raw_param, otlp_metrics=False, otlp_logs_traces=False): + params = {"name": "test_name", "resource_group_name": "test_rg_name"} + params.update(raw_param) + + class _Client: + def get(self, rg, name): + return self.cluster + + client = _Client() + dec = AKSManagedClusterUpdateDecorator( + self.cmd, client, params, ResourceType.MGMT_CONTAINERSERVICE + ) + app_monitoring = self.models.ManagedClusterAzureMonitorProfileAppMonitoring() + if otlp_metrics: + app_monitoring.open_telemetry_metrics = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryMetrics( + enabled=True, http_port=4318, grpc_port=4317 + ) + ) + if otlp_logs_traces: + app_monitoring.open_telemetry_logs_and_traces = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces( + enabled=True, http_port=4320, grpc_port=4319 + ) + ) + mc = self.models.ManagedCluster( + location="test_location", + addon_profiles={ + CONST_MONITORING_ADDON_NAME: self.models.ManagedClusterAddonProfile( + enabled=True, + config={ + CONST_MONITORING_LOG_ANALYTICS_WORKSPACE_RESOURCE_ID: "test_workspace", + CONST_MONITORING_USING_AAD_MSI_AUTH: "true", + }, + ) + }, + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + metrics=self.models.ManagedClusterAzureMonitorProfileMetrics(enabled=True), + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, log_analytics_workspace_resource_id="test_workspace" + ), + app_monitoring=app_monitoring, + ), + ) + dec.context.attach_mc(mc) + dec.context.set_intermediate("subscription_id", "test_sub_id") + client.cluster = mc + return dec, mc + + def _run(self, dec, mc, answers): + """Drive the disable path, recording prompts and cleanups in the order they happen.""" + order = [] + remaining = list(answers) + + def ask(msg, default=None): + order.append("prompt_logs" if "logs and traces" in msg else "prompt_metrics") + return remaining.pop(0) + + prefix = "azure.cli.command_modules.acs.managed_cluster_decorator." + with patch( + prefix + "ensure_azure_monitor_profile_prerequisites", + side_effect=lambda *a, **k: order.append("cleanup_metrics"), + ), patch( + prefix + "ensure_container_insights_for_monitoring", + side_effect=lambda *a, **k: order.append("cleanup_logs"), + ), patch( + prefix + "prompt_y_n", side_effect=ask + ): + try: + dec.confirm_monitoring_disables(mc) + mc = dec.update_azure_monitor_profile(mc) + mc = dec.update_azure_monitor_logs(mc) + order.append("cluster_put") + except DecoratorEarlyExitException: + order.append("early_exit") + return order + + def test_declining_logs_does_not_delete_metrics_resources(self): + # The reported bug: metrics cleanup used to run before the logs question was asked, so + # declining it left the cluster with metrics enabled but no collection resources. + dec, mc = self._dec_and_mc( + {"disable_azure_monitor_metrics": True, "disable_azure_monitor_logs": True}, + otlp_metrics=True, + otlp_logs_traces=True, + ) + order = self._run(dec, mc, [True, False]) + + self.assertEqual(order, ["prompt_metrics", "prompt_logs", "early_exit"]) + self.assertNotIn("cleanup_metrics", order) + self.assertNotIn("cleanup_logs", order) + + def test_declining_logs_without_opentelemetry_metrics_still_deletes_nothing(self): + # Only the logs prompt is raised here, which is the exact combination reported: managed + # metrics plus OpenTelemetry logs and traces, with no OpenTelemetry metrics. + dec, mc = self._dec_and_mc( + {"disable_azure_monitor_metrics": True, "disable_azure_monitor_logs": True}, + otlp_logs_traces=True, + ) + order = self._run(dec, mc, [False]) + + self.assertEqual(order, ["prompt_logs", "early_exit"]) + self.assertTrue(mc.azure_monitor_profile.metrics.enabled) + + def test_accepting_both_confirms_before_any_cleanup(self): + dec, mc = self._dec_and_mc( + {"disable_azure_monitor_metrics": True, "disable_azure_monitor_logs": True}, + otlp_metrics=True, + otlp_logs_traces=True, + ) + order = self._run(dec, mc, [True, True]) + + self.assertEqual( + order, + [ + "prompt_metrics", + "prompt_logs", + "cleanup_metrics", + "cleanup_logs", + "cluster_put", + ], + ) + + def test_each_confirmation_is_asked_only_once(self): + # The handlers still prompt when driven directly, so the up front pass must stop them + # asking the same question a second time. + dec, mc = self._dec_and_mc( + {"disable_azure_monitor_metrics": True, "disable_azure_monitor_logs": True}, + otlp_metrics=True, + otlp_logs_traces=True, + ) + order = self._run(dec, mc, [True, True]) + + self.assertEqual(order.count("prompt_metrics"), 1) + self.assertEqual(order.count("prompt_logs"), 1) + + def test_declining_metrics_stops_before_logs_cleanup(self): + dec, mc = self._dec_and_mc( + {"disable_azure_monitor_metrics": True, "disable_azure_monitor_logs": True}, + otlp_metrics=True, + otlp_logs_traces=True, + ) + order = self._run(dec, mc, [False]) + + self.assertEqual(order, ["prompt_metrics", "early_exit"]) + + def test_yes_skips_both_confirmations(self): + dec, mc = self._dec_and_mc( + { + "disable_azure_monitor_metrics": True, + "disable_azure_monitor_logs": True, + "yes": True, + }, + otlp_metrics=True, + otlp_logs_traces=True, + ) + order = self._run(dec, mc, []) + + self.assertEqual(order, ["cleanup_metrics", "cleanup_logs", "cluster_put"]) + + def test_no_confirmation_without_opentelemetry(self): + dec, mc = self._dec_and_mc( + {"disable_azure_monitor_metrics": True, "disable_azure_monitor_logs": True} + ) + order = self._run(dec, mc, []) + + self.assertEqual(order, ["cleanup_metrics", "cleanup_logs", "cluster_put"]) + + def test_no_confirmation_when_nothing_is_being_disabled(self): + dec, mc = self._dec_and_mc({}, otlp_metrics=True, otlp_logs_traces=True) + order = self._run(dec, mc, []) + + self.assertNotIn("prompt_metrics", order) + self.assertNotIn("prompt_logs", order) + + def test_update_pipeline_collects_confirmations_before_monitoring_is_touched(self): + # The fix is an ordering guarantee inside the real update pipeline. Every other test in + # this class drives confirm_monitoring_disables by hand, so none of them notices if + # update_mc_profile_default stops calling it, or starts calling it after the monitoring + # handlers have already deleted collection resources. + import inspect + + from azure.cli.command_modules.acs.custom import aks_update + + raw_param_dict = { + name: param.default + for name, param in inspect.signature(aks_update).parameters.items() + if param.default is not param.empty + } + raw_param_dict.update({"resource_group_name": "test_rg_name", "name": "test_name"}) + + existing_mc = self.models.ManagedCluster( + location="test_location", + agent_pool_profiles=[self.models.ManagedClusterAgentPoolProfile(name="nodepool1")], + network_profile=self.models.ContainerServiceNetworkProfile(load_balancer_sku="standard"), + identity=self.models.ManagedClusterIdentity(type="SystemAssigned"), + identity_profile={ + "kubeletidentity": self.models.UserAssignedIdentity( + resource_id="test_resource_id", + client_id="test_client_id", + object_id="test_object_id", + ) + }, + ) + + class _Client: + def get(self, rg, name): # pylint: disable=unused-argument + return existing_mc + + dec = AKSManagedClusterUpdateDecorator( + self.cmd, _Client(), raw_param_dict, ResourceType.MGMT_CONTAINERSERVICE + ) + + order = [] + real_confirm = AKSManagedClusterUpdateDecorator.confirm_monitoring_disables + real_update = AKSManagedClusterUpdateDecorator.update_azure_monitor_profile + + def recording_confirm(self_, mc): + order.append("confirm_monitoring_disables") + return real_confirm(self_, mc) + + def recording_update(self_, mc): + order.append("update_azure_monitor_profile") + return real_update(self_, mc) + + prefix = "azure.cli.command_modules.acs.managed_cluster_decorator." + with patch( + prefix + "get_rg_location", return_value="test_location" + ), patch( + prefix + "Profile", + return_value=Mock(get_subscription_id=Mock(return_value="1234-5678-9012")), + ), patch.object( + AKSManagedClusterUpdateDecorator, "check_raw_parameters", return_value=True + ), patch.object( + AKSManagedClusterUpdateDecorator, "confirm_monitoring_disables", recording_confirm + ), patch.object( + AKSManagedClusterUpdateDecorator, "update_azure_monitor_profile", recording_update + ): + dec.update_mc_profile_default() + + self.assertIn( + "confirm_monitoring_disables", + order, + "update_mc_profile_default no longer collects the monitoring disable confirmations, " + "so a declined prompt can leave a signal enabled without its collection resources.", + ) + self.assertLess( + order.index("confirm_monitoring_disables"), + order.index("update_azure_monitor_profile"), + "the monitoring disable confirmations must be collected before " + "update_azure_monitor_profile runs any cleanup.", + ) + + +class AKSOpenTelemetryUpdateTestCase(unittest.TestCase): + def setUp(self): + self.cli_ctx = MockCLI() + self.cmd = MockCmd(self.cli_ctx) + self.models = AKSManagedClusterModels(self.cmd, ResourceType.MGMT_CONTAINERSERVICE) + self.client = MockClient() + + def _update_dec(self, raw_param): + params = { + "name": "test_name", + "resource_group_name": "test_rg_name", + } + params.update(raw_param) + return AKSManagedClusterUpdateDecorator( + self.cmd, + self.client, + params, + ResourceType.MGMT_CONTAINERSERVICE, + ) + + def _mc_with_monitoring(self): + return self.models.ManagedCluster( + location="test_location", + azure_monitor_profile=self.models.ManagedClusterAzureMonitorProfile( + metrics=self.models.ManagedClusterAzureMonitorProfileMetrics(enabled=True), + container_insights=self.models.ManagedClusterAzureMonitorProfileContainerInsights( + enabled=True, + ), + ), + ) + + def test_enable_opentelemetry_metrics_on_enabled_cluster(self): + dec = self._update_dec( + {"enable_opentelemetry_metrics": True, "opentelemetry_metrics_port_grpc": 4317} + ) + mc = self._mc_with_monitoring() + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.grpc_port, 4317) + + def test_enable_opentelemetry_logs_traces_on_enabled_cluster(self): + dec = self._update_dec( + {"enable_opentelemetry_logs_traces": True, "opentelemetry_logs_traces_port_http": 4320} + ) + mc = self._mc_with_monitoring() + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.http_port, 4320) + + def test_disable_opentelemetry_metrics_clears_ports(self): + dec = self._update_dec({"disable_opentelemetry_metrics": True}) + mc = self._mc_with_monitoring() + mc.azure_monitor_profile.app_monitoring = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoring( + open_telemetry_metrics=( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryMetrics( + enabled=True, http_port=4318, grpc_port=4317 + ) + ), + ) + ) + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics + self.assertFalse(otlp.enabled) + self.assertIsNone(otlp.http_port) + self.assertIsNone(otlp.grpc_port) + + def test_disable_opentelemetry_logs_traces_clears_ports(self): + dec = self._update_dec({"disable_opentelemetry_logs_traces": True}) + mc = self._mc_with_monitoring() + otel_logs_cls = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces + ) + mc.azure_monitor_profile.app_monitoring = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoring( + open_telemetry_logs_and_traces=otel_logs_cls( + enabled=True, http_port=4320, grpc_port=4319 + ), + ) + ) + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + self.assertFalse(otlp.enabled) + self.assertIsNone(otlp.http_port) + self.assertIsNone(otlp.grpc_port) + + def test_enable_opentelemetry_metrics_without_metrics_enabled_errors(self): + dec = self._update_dec({"enable_opentelemetry_metrics": True}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(ArgumentUsageError): + dec.context.get_enable_opentelemetry_metrics() + + def test_enable_opentelemetry_logs_traces_without_logs_enabled_errors(self): + dec = self._update_dec({"enable_opentelemetry_logs_traces": True}) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + with self.assertRaises(ArgumentUsageError): + dec.context.get_enable_opentelemetry_logs_traces() + + def test_enable_opentelemetry_logs_traces_with_enable_azure_monitor_logs(self): + dec = self._update_dec( + {"enable_azure_monitor_logs": True, "enable_opentelemetry_logs_traces": True} + ) + mc = self.models.ManagedCluster(location="test_location") + dec.context.attach_mc(mc) + self.assertTrue(dec.context.get_enable_opentelemetry_logs_traces()) + + def test_opentelemetry_port_out_of_range_errors(self): + dec = self._update_dec( + { + "enable_opentelemetry_metrics": True, + "opentelemetry_metrics_port_grpc": 70000, + } + ) + mc = self._mc_with_monitoring() + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.update_azure_monitor_profile(mc) + + + def _mc_with_otlp_metrics(self): + mc = self._mc_with_monitoring() + mc.azure_monitor_profile.app_monitoring = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoring( + open_telemetry_metrics=( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryMetrics( + enabled=True, http_port=4318, grpc_port=4317 + ) + ), + ) + ) + return mc + + def test_disable_azure_monitor_metrics_also_disables_opentelemetry_metrics(self): + # OpenTelemetry metrics are ingested through the managed Prometheus pipeline that Azure + # Monitor metrics provisions, so disabling the parent has to take them down too. + dec = self._update_dec({"disable_azure_monitor_metrics": True, "yes": True}) + mc = self._mc_with_otlp_metrics() + dec.context.attach_mc(mc) + dec.context.set_intermediate("subscription_id", "test_sub_id") + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_azure_monitor_profile_prerequisites" + ): + dec_mc = dec.update_azure_monitor_profile(mc) + + self.assertFalse(dec_mc.azure_monitor_profile.metrics.enabled) + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics + self.assertFalse(otlp.enabled) + self.assertIsNone(otlp.http_port) + self.assertIsNone(otlp.grpc_port) + + def test_disable_azure_monitor_metrics_prompts_when_opentelemetry_metrics_enabled(self): + dec = self._update_dec({"disable_azure_monitor_metrics": True, "yes": False}) + mc = self._mc_with_otlp_metrics() + dec.context.attach_mc(mc) + dec.context.set_intermediate("subscription_id", "test_sub_id") + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator.prompt_y_n", return_value=True + ) as prompt_mock, patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_azure_monitor_profile_prerequisites" + ): + dec_mc = dec.update_azure_monitor_profile(mc) + + prompt_mock.assert_called_once() + self.assertIn("OpenTelemetry metrics", prompt_mock.call_args[0][0]) + self.assertFalse(dec_mc.azure_monitor_profile.metrics.enabled) + self.assertFalse(dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.enabled) + + def test_disable_azure_monitor_metrics_aborts_when_confirmation_declined(self): + dec = self._update_dec({"disable_azure_monitor_metrics": True, "yes": False}) + mc = self._mc_with_otlp_metrics() + dec.context.attach_mc(mc) + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator.prompt_y_n", return_value=False + ), patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_azure_monitor_profile_prerequisites" + ), self.assertRaises(DecoratorEarlyExitException): + dec.update_azure_monitor_profile(mc) + + # Nothing is mutated when the user declines. + self.assertTrue(mc.azure_monitor_profile.metrics.enabled) + self.assertTrue(mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics.enabled) + + def test_disable_azure_monitor_metrics_no_prompt_when_opentelemetry_metrics_disabled(self): + dec = self._update_dec({"disable_azure_monitor_metrics": True, "yes": False}) + mc = self._mc_with_monitoring() + dec.context.attach_mc(mc) + dec.context.set_intermediate("subscription_id", "test_sub_id") + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator.prompt_y_n", return_value=True + ) as prompt_mock, patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_azure_monitor_profile_prerequisites" + ): + dec_mc = dec.update_azure_monitor_profile(mc) + + prompt_mock.assert_not_called() + self.assertFalse(dec_mc.azure_monitor_profile.metrics.enabled) + + def test_disable_azure_monitor_metrics_noop_when_metrics_already_disabled(self): + # Nothing to turn off, so the OpenTelemetry metrics settings are left alone and no + # confirmation is asked for. + dec = self._update_dec({"disable_azure_monitor_metrics": True, "yes": False}) + mc = self._mc_with_otlp_metrics() + mc.azure_monitor_profile.metrics.enabled = False + dec.context.attach_mc(mc) + dec.context.set_intermediate("subscription_id", "test_sub_id") + with patch( + "azure.cli.command_modules.acs.managed_cluster_decorator.prompt_y_n", return_value=True + ) as prompt_mock, patch( + "azure.cli.command_modules.acs.managed_cluster_decorator." + "ensure_azure_monitor_profile_prerequisites" + ) as prereq_mock: + dec_mc = dec.update_azure_monitor_profile(mc) + + prompt_mock.assert_not_called() + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.http_port, 4318) + # Leftover DCR/DCRA cleanup is still driven by the flag, not by cluster state. + prereq_mock.assert_called_once() + + def _mc_with_otlp_logs_traces(self): + mc = self._mc_with_monitoring() + otel_logs_cls = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoringOpenTelemetryLogsAndTraces + ) + mc.azure_monitor_profile.app_monitoring = ( + self.models.ManagedClusterAzureMonitorProfileAppMonitoring( + open_telemetry_logs_and_traces=otel_logs_cls( + enabled=True, http_port=4320, grpc_port=4319 + ), + ) + ) + return mc + + def test_opentelemetry_logs_traces_port_only_update_applies(self): + # A port may be changed on an already onboarded cluster without re-specifying + # --enable-opentelemetry-logs-traces. + dec = self._update_dec({"opentelemetry_logs_traces_port_http": 2331}) + mc = self._mc_with_otlp_logs_traces() + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + self.assertEqual(otlp.http_port, 2331) + # The receiver stays enabled and the port that was not supplied is preserved. + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.grpc_port, 4319) + + def test_opentelemetry_metrics_port_only_update_applies(self): + dec = self._update_dec({"opentelemetry_metrics_port_grpc": 2332}) + mc = self._mc_with_otlp_metrics() + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics + self.assertEqual(otlp.grpc_port, 2332) + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.http_port, 4318) + + def test_opentelemetry_both_ports_only_update_applies(self): + dec = self._update_dec( + { + "opentelemetry_logs_traces_port_http": 2331, + "opentelemetry_logs_traces_port_grpc": 2333, + } + ) + mc = self._mc_with_otlp_logs_traces() + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.http_port, 2331) + self.assertEqual(otlp.grpc_port, 2333) + + def test_opentelemetry_logs_traces_port_only_on_disabled_cluster_errors(self): + # Without the receiver already enabled the port has nothing to apply to, so the update + # must fail loudly rather than silently doing nothing. + dec = self._update_dec({"opentelemetry_logs_traces_port_http": 2331}) + mc = self._mc_with_monitoring() + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.update_azure_monitor_profile(mc) + + def test_opentelemetry_metrics_port_only_on_disabled_cluster_errors(self): + dec = self._update_dec({"opentelemetry_metrics_port_grpc": 2332}) + mc = self._mc_with_monitoring() + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.update_azure_monitor_profile(mc) + + def test_update_without_opentelemetry_params_leaves_ports_untouched(self): + # The port-only branch runs on every update, so an unrelated update must be a no-op. + dec = self._update_dec({}) + mc = self._mc_with_otlp_logs_traces() + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_logs_and_traces + self.assertTrue(otlp.enabled) + self.assertEqual(otlp.http_port, 4320) + self.assertEqual(otlp.grpc_port, 4319) + + def test_opentelemetry_metrics_port_with_disable_errors(self): + # Disabling the receiver and setting one of its ports in the same command is + # contradictory, so it must be rejected instead of silently dropping the port. + dec = self._update_dec( + {"disable_opentelemetry_metrics": True, "opentelemetry_metrics_port_http": 2331} + ) + mc = self._mc_with_otlp_metrics() + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.update_azure_monitor_profile(mc) + + def test_opentelemetry_logs_traces_port_with_disable_errors(self): + dec = self._update_dec( + { + "disable_opentelemetry_logs_traces": True, + "opentelemetry_logs_traces_port_grpc": 2333, + } + ) + mc = self._mc_with_otlp_logs_traces() + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError): + dec.update_azure_monitor_profile(mc) + + def test_every_port_flag_is_rejected_alongside_its_disable(self): + # Each of the four ports is read separately before the enable/disable dispatch, so each + # one needs its own case: dropping any single read would silently accept the port again. + cases = [ + ("disable_opentelemetry_metrics", "opentelemetry_metrics_port_http", True), + ("disable_opentelemetry_metrics", "opentelemetry_metrics_port_grpc", True), + ("disable_opentelemetry_logs_traces", "opentelemetry_logs_traces_port_http", False), + ("disable_opentelemetry_logs_traces", "opentelemetry_logs_traces_port_grpc", False), + ] + for disable_flag, port_flag, is_metrics in cases: + with self.subTest(disable=disable_flag, port=port_flag): + dec = self._update_dec({disable_flag: True, port_flag: 2331}) + mc = self._mc_with_otlp_metrics() if is_metrics else self._mc_with_otlp_logs_traces() + dec.context.attach_mc(mc) + with self.assertRaises(InvalidArgumentValueError) as cm: + dec.update_azure_monitor_profile(mc) + self.assertIn(port_flag.replace("_", "-"), str(cm.exception)) + self.assertIn(disable_flag.replace("_", "-"), str(cm.exception)) + + def test_disable_opentelemetry_metrics_without_port_still_disables(self): + # The eager port reads must not disturb a plain disable. + dec = self._update_dec({"disable_opentelemetry_metrics": True}) + mc = self._mc_with_otlp_metrics() + dec.context.attach_mc(mc) + dec_mc = dec.update_azure_monitor_profile(mc) + + otlp = dec_mc.azure_monitor_profile.app_monitoring.open_telemetry_metrics + self.assertFalse(otlp.enabled) + + if __name__ == "__main__": unittest.main() diff --git a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py index a0b361bd3af..b6dfad341bb 100644 --- a/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py +++ b/src/azure-cli/azure/cli/command_modules/acs/tests/latest/test_validators.py @@ -2046,6 +2046,263 @@ def test_no_ssh_key_still_skips(self): self.assertIsNone(namespace.ssh_key_value) +class TestAzureMonitorLogsValidators(unittest.TestCase): + def test_enable_azure_monitor_logs_with_monitoring_addon_errors(self): + namespace = SimpleNamespace(enable_azure_monitor_logs=True, enable_addons="monitoring") + with self.assertRaises(ArgumentUsageError): + validators.validate_azure_monitor_logs_and_enable_addons(namespace) + + def test_enable_azure_monitor_logs_with_other_addon_passes(self): + namespace = SimpleNamespace(enable_azure_monitor_logs=True, enable_addons="http_application_routing") + validators.validate_azure_monitor_logs_and_enable_addons(namespace) + + def test_monitoring_addon_without_azure_monitor_logs_passes(self): + namespace = SimpleNamespace(enable_azure_monitor_logs=False, enable_addons="monitoring") + validators.validate_azure_monitor_logs_and_enable_addons(namespace) + + def test_enable_and_disable_azure_monitor_logs_errors(self): + namespace = SimpleNamespace(enable_azure_monitor_logs=True, disable_azure_monitor_logs=True) + with self.assertRaises(MutuallyExclusiveArgumentError): + validators.validate_azure_monitor_logs_enable_disable(namespace) + + def test_only_enable_azure_monitor_logs_passes(self): + namespace = SimpleNamespace( + enable_azure_monitor_logs=True, + disable_azure_monitor_logs=False, + enable_msi_auth_for_monitoring=None, + ) + validators.validate_azure_monitor_logs_enable_disable(namespace) + + def test_create_explicit_legacy_msi_auth_flag_errors(self): + namespace = SimpleNamespace( + enable_azure_monitor_logs=True, + enable_addons=None, + enable_msi_auth_for_monitoring=False, + ) + with self.assertRaises(MutuallyExclusiveArgumentError): + validators.validate_azure_monitor_logs_and_enable_addons(namespace) + + def test_create_default_msi_auth_flag_passes(self): + # On create the flag defaults to True, which is indistinguishable from explicit use. + namespace = SimpleNamespace( + enable_azure_monitor_logs=True, + enable_addons=None, + enable_msi_auth_for_monitoring=True, + ) + validators.validate_azure_monitor_logs_and_enable_addons(namespace) + + def test_update_explicit_msi_auth_flag_errors(self): + for value in (True, False): + namespace = SimpleNamespace( + enable_azure_monitor_logs=True, + disable_azure_monitor_logs=False, + enable_msi_auth_for_monitoring=value, + ) + with self.assertRaises(MutuallyExclusiveArgumentError): + validators.validate_azure_monitor_logs_enable_disable(namespace) + + def test_msi_auth_flag_without_azure_monitor_logs_passes(self): + namespace = SimpleNamespace( + enable_azure_monitor_logs=False, + disable_azure_monitor_logs=False, + enable_msi_auth_for_monitoring=False, + ) + validators.validate_azure_monitor_logs_enable_disable(namespace) + + +def _ci_settings_namespace(**kwargs): + defaults = { + "syslog_port": None, + "enable_prometheus_metrics_scraping": False, + "disable_prometheus_metrics_scraping": False, + "enable_azure_monitor_logs": False, + "disable_azure_monitor_logs": False, + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +class TestContainerInsightsSettingsValidators(unittest.TestCase): + def test_enable_and_disable_prometheus_scraping_errors(self): + namespace = _ci_settings_namespace( + enable_prometheus_metrics_scraping=True, + disable_prometheus_metrics_scraping=True, + enable_azure_monitor_logs=True, + ) + with self.assertRaises(MutuallyExclusiveArgumentError): + validators.validate_container_insights_settings_for_create(namespace) + + def test_syslog_port_out_of_range_errors(self): + namespace = _ci_settings_namespace(syslog_port=70000, enable_azure_monitor_logs=True) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_container_insights_settings_for_create(namespace) + + def test_syslog_port_zero_errors(self): + namespace = _ci_settings_namespace(syslog_port=0, enable_azure_monitor_logs=True) + with self.assertRaises(InvalidArgumentValueError): + validators.validate_container_insights_settings_for_create(namespace) + + def test_settings_without_enable_azure_monitor_logs_errors_on_create(self): + namespace = _ci_settings_namespace(syslog_port=28330) + with self.assertRaises(ArgumentUsageError): + validators.validate_container_insights_settings_for_create(namespace) + + def test_settings_with_enable_azure_monitor_logs_passes_on_create(self): + namespace = _ci_settings_namespace( + syslog_port=28330, + enable_prometheus_metrics_scraping=True, + enable_azure_monitor_logs=True, + ) + validators.validate_container_insights_settings_for_create(namespace) + + def test_settings_with_disable_azure_monitor_logs_errors(self): + namespace = _ci_settings_namespace(syslog_port=28330, disable_azure_monitor_logs=True) + with self.assertRaises(ArgumentUsageError): + validators.validate_container_insights_settings_for_update(namespace) + + def test_settings_on_update_defer_enablement_check(self): + namespace = _ci_settings_namespace(syslog_port=28330) + validators.validate_container_insights_settings_for_update(namespace) + + def test_no_settings_passes(self): + validators.validate_container_insights_settings_for_create(_ci_settings_namespace()) + + +def _otel_namespace(**kwargs): + defaults = { + "enable_opentelemetry_metrics": False, + "disable_opentelemetry_metrics": False, + "opentelemetry_metrics_port_http": None, + "opentelemetry_metrics_port_grpc": None, + "enable_opentelemetry_logs_traces": False, + "disable_opentelemetry_logs_traces": False, + "opentelemetry_logs_traces_port_http": None, + "opentelemetry_logs_traces_port_grpc": None, + "enable_azure_monitor_metrics": False, + "enable_azure_monitor_logs": False, + "enable_addons": None, + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +class TestOpenTelemetryValidators(unittest.TestCase): + def test_port_out_of_range_errors(self): + namespace = _otel_namespace(opentelemetry_metrics_port_http=70000) + with self.assertRaises(ArgumentUsageError): + validators.validate_opentelemetry_ports(namespace) + + def test_duplicate_ports_error(self): + namespace = _otel_namespace( + opentelemetry_metrics_port_http=4318, + opentelemetry_logs_traces_port_http=4318, + ) + with self.assertRaises(ArgumentUsageError): + validators.validate_opentelemetry_ports(namespace) + + def test_distinct_ports_pass(self): + namespace = _otel_namespace( + opentelemetry_metrics_port_http=4318, + opentelemetry_metrics_port_grpc=4317, + opentelemetry_logs_traces_port_http=4320, + opentelemetry_logs_traces_port_grpc=4319, + ) + validators.validate_opentelemetry_ports(namespace) + + def test_logs_traces_only_duplicate_ports_rejected_by_aggregate_validator(self): + # A logs/traces-only command supplies no metrics flags at all. The aggregate validator is + # the single entry point the commands register, so it must still reach the port checks; + # otherwise duplicate HTTP/gRPC ports would be accepted for such commands. + for validator in ( + validators.validate_azure_monitor_and_opentelemetry_for_create, + validators.validate_azure_monitor_and_opentelemetry_for_update, + ): + with self.subTest(validator=validator.__name__): + namespace = _otel_namespace( + enable_opentelemetry_logs_traces=True, + enable_azure_monitor_logs=True, + opentelemetry_logs_traces_port_http=4318, + opentelemetry_logs_traces_port_grpc=4318, + ) + with self.assertRaises(ArgumentUsageError) as cm: + validator(namespace) + self.assertIn("must all be different", str(cm.exception)) + self.assertIn("--opentelemetry-logs-traces-port-http", str(cm.exception)) + self.assertIn("--opentelemetry-logs-traces-port-grpc", str(cm.exception)) + + def test_enable_and_disable_otel_metrics_errors(self): + namespace = _otel_namespace( + enable_opentelemetry_metrics=True, + disable_opentelemetry_metrics=True, + ) + with self.assertRaises(MutuallyExclusiveArgumentError): + validators.validate_opentelemetry_metrics_dependencies(namespace) + + def test_otel_metrics_without_azure_monitor_metrics_errors_on_create(self): + namespace = _otel_namespace(enable_opentelemetry_metrics=True) + with self.assertRaises(ArgumentUsageError): + validators.validate_opentelemetry_metrics_dependencies(namespace) + + def test_otel_metrics_with_azure_monitor_metrics_passes_on_create(self): + namespace = _otel_namespace( + enable_opentelemetry_metrics=True, + enable_azure_monitor_metrics=True, + ) + validators.validate_opentelemetry_metrics_dependencies(namespace) + + def test_otel_metrics_on_update_defers_dependency_check(self): + namespace = _otel_namespace(enable_opentelemetry_metrics=True) + validators.validate_opentelemetry_metrics_dependencies_for_update(namespace) + + def test_enable_and_disable_otel_logs_traces_errors(self): + namespace = _otel_namespace( + enable_opentelemetry_logs_traces=True, + disable_opentelemetry_logs_traces=True, + ) + with self.assertRaises(MutuallyExclusiveArgumentError): + validators.validate_opentelemetry_logs_traces_dependencies(namespace) + + def test_otel_logs_traces_without_azure_monitor_logs_errors_on_create(self): + namespace = _otel_namespace(enable_opentelemetry_logs_traces=True) + with self.assertRaises(ArgumentUsageError): + validators.validate_opentelemetry_logs_traces_dependencies(namespace) + + def test_otel_logs_traces_with_azure_monitor_logs_passes_on_create(self): + namespace = _otel_namespace( + enable_opentelemetry_logs_traces=True, + enable_azure_monitor_logs=True, + ) + validators.validate_opentelemetry_logs_traces_dependencies(namespace) + + def test_otel_logs_traces_with_monitoring_addon_passes_on_create(self): + namespace = _otel_namespace( + enable_opentelemetry_logs_traces=True, + enable_addons="monitoring", + ) + validators.validate_opentelemetry_logs_traces_dependencies(namespace) + + def test_otel_logs_traces_on_update_defers_dependency_check(self): + namespace = _otel_namespace(enable_opentelemetry_logs_traces=True) + validators.validate_opentelemetry_logs_traces_dependencies_for_update(namespace) + + def test_aggregate_create_validator_runs_all_checks(self): + namespace = _otel_namespace( + enable_opentelemetry_metrics=True, + enable_azure_monitor_metrics=True, + opentelemetry_metrics_port_http=4318, + opentelemetry_metrics_port_grpc=4318, + ) + with self.assertRaises(ArgumentUsageError): + validators.validate_azure_monitor_and_opentelemetry_for_create(namespace) + + def test_aggregate_update_validator_passes_for_valid_namespace(self): + namespace = _otel_namespace( + enable_opentelemetry_metrics=True, + opentelemetry_metrics_port_grpc=4317, + ) + validators.validate_azure_monitor_and_opentelemetry_for_update(namespace) + + class TestNatGatewayV2Validators(unittest.TestCase): def _ns(self, **kwargs): defaults = {