From 0f0fbd4d8038d10a80a4bea1860414a5e2ed4345 Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Fri, 15 Aug 2025 07:59:55 +0000 Subject: [PATCH 1/9] enable aks agent command --- src/aks-preview/HISTORY.rst | 5 + src/aks-preview/azext_aks_preview/_help.py | 193 +++++++++--------- src/aks-preview/azext_aks_preview/_params.py | 131 ++++++------ .../azext_aks_preview/agent/prompt.py | 15 +- src/aks-preview/azext_aks_preview/commands.py | 2 +- 5 files changed, 171 insertions(+), 175 deletions(-) diff --git a/src/aks-preview/HISTORY.rst b/src/aks-preview/HISTORY.rst index d21cbe90ed7..c456fc4e81c 100644 --- a/src/aks-preview/HISTORY.rst +++ b/src/aks-preview/HISTORY.rst @@ -13,6 +13,11 @@ Pending +++++++ * Add framework for interactive AI-powered debugging tool. +18.0.0b27 ++++++++ +* Add interactive AI-powered debugging tool `az aks agent`. +* Add framework for interactive AI-powered debugging tool. + 18.0.0b26 +++++++ * Add `az aks identity-binding` command group for identity binding feature. diff --git a/src/aks-preview/azext_aks_preview/_help.py b/src/aks-preview/azext_aks_preview/_help.py index b8287a1df2d..ef6904471eb 100644 --- a/src/aks-preview/azext_aks_preview/_help.py +++ b/src/aks-preview/azext_aks_preview/_help.py @@ -3944,100 +3944,99 @@ short-summary: Name of the identity binding to show. """ -# pylint: disable=line-too-long -# helps[ -# "aks agent" -# ] = """ -# type: command -# short-summary: Run AI assistant to analyze and troubleshoot Kubernetes clusters. -# long-summary: |- -# This command allows you to ask questions about your Azure Kubernetes cluster and get answers using AI models. -# Environment variables must be set to use the AI model, please refer to https://docs.litellm.ai/docs/providers to learn more about supported AI providers and models and required environment variables. -# parameters: -# - name: --name -n -# type: string -# short-summary: Name of the managed cluster. -# - name: --resource-group -g -# type: string -# short-summary: Name of the resource group. -# - name: --model -# type: string -# short-summary: Model to use for the LLM. -# - name: --api-key -# type: string -# short-summary: API key to use for the LLM (if not given, uses environment variables AZURE_API_KEY, OPENAI_API_KEY). -# - name: --config-file -# type: string -# short-summary: Path to configuration file. -# - name: --max-steps -# type: int -# short-summary: Maximum number of steps the LLM can take to investigate the issue. -# - name: --no-interactive -# type: bool -# short-summary: Disable interactive mode. When set, the agent will not prompt for input and will run in batch mode. -# - name: --no-echo-request -# type: bool -# short-summary: Disable echoing back the question provided to AKS Agent in the output. -# - name: --show-tool-output -# type: bool -# short-summary: Show the output of each tool that was called during the analysis. -# - name: --refresh-toolsets -# type: bool -# short-summary: Refresh the toolsets status. -# -# examples: -# - name: Ask about pod issues in the cluster with Azure OpenAI -# text: |- -# export AZURE_API_BASE="https://my-azureopenai-service.openai.azure.com/" -# export AZURE_API_VERSION="2025-01-01-preview" -# export AZURE_API_KEY="sk-xxx" -# az aks agent "Why are my pods not starting?" --name MyManagedCluster --resource-group MyResourceGroup --model azure/my-gpt4.1-deployment -# - name: Ask about pod issues in the cluster with OpenAI -# text: |- -# export OPENAI_API_KEY="sk-xxx" -# az aks agent "Why are my pods not starting?" --name MyManagedCluster --resource-group MyResourceGroup --model gpt-4o -# text: az aks agent "Why are my pods not starting?" -# - name: Run in interactive mode without a question -# text: az aks agent "Check the pod status in my cluster" --name MyManagedCluster --resource-group MyResourceGroup --model azure/my-gpt4.1-deployment --api-key "sk-xxx" -# - name: Run in non-interactive batch mode -# text: az aks agent "Diagnose networking issues" --no-interactive --max-steps 15 --model azure/my-gpt4.1-deployment -# - name: Show detailed tool output during analysis -# text: az aks agent "Why is my service workload unavailable in namespace workload-ns?" --show-tool-output --model azure/my-gpt4.1-deployment -# - name: Use custom configuration file -# text: az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.config --model azure/my-gpt4.1-deployment -# - name: Run agent with no echo of the original question -# text: az aks agent "What is the status of my cluster?" --no-echo-request --model azure/my-gpt4.1-deployment -# - name: Refresh toolsets to get the latest available tools -# text: az aks agent "What is the status of my cluster?" --refresh-toolsets --model azure/my-gpt4.1-deploymen -# - name: Run agent with config file -# text: | -# az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.config -# Here is an example of config file: -# ```json -# model: "gpt-4o" -# api_key: "..." -# # define a list of mcp servers, mcp server can be defined -# mcp_servers: -# aks_mcp: -# description: "The AKS-MCP is a Model Context Protocol (MCP) server that enables AI assistants to interact with Azure Kubernetes Service (AKS) clusters" -# url: "http://localhost:8003/sse" -# -# # try adding your own tools or toggle the built-in toolsets here -# # e.g. query company-specific data, fetch logs from your existing observability tools, etc -# # To check how to add a customized toolset, please refer to https://docs.robusta.dev/master/configuration/holmesgpt/custom_toolsets.html#custom-toolsets -# # To find all built-in toolsets, please refer to https://docs.robusta.dev/master/configuration/holmesgpt/builtin_toolsets.html -# toolsets: -# # add a new json processor toolset -# json_processor: -# description: "A toolset for processing JSON data using jq" -# prerequisites: -# - command: "jq --version" # Ensure jq is installed -# tools: -# - name: "process_json" -# description: "A tool that uses jq to process JSON input" -# command: "echo '{{ json_input }}' | jq '.'" # Example jq command to format JSON -# # disable a built-in toolsets -# aks/core: -# enabled: false -# ``` -# """ +helps[ + "aks agent" +] = """ + type: command + short-summary: Run AI assistant to analyze and troubleshoot Kubernetes clusters. + long-summary: |- + This command allows you to ask questions about your Azure Kubernetes cluster and get answers using AI models. + Environment variables must be set to use the AI model, please refer to https://docs.litellm.ai/docs/providers to learn more about supported AI providers and models and required environment variables. + parameters: + - name: --name -n + type: string + short-summary: Name of the managed cluster. + - name: --resource-group -g + type: string + short-summary: Name of the resource group. + - name: --model + type: string + short-summary: Model to use for the LLM. + - name: --api-key + type: string + short-summary: API key to use for the LLM (if not given, uses environment variables AZURE_API_KEY, OPENAI_API_KEY). + - name: --config-file + type: string + short-summary: Path to configuration file. + - name: --max-steps + type: int + short-summary: Maximum number of steps the LLM can take to investigate the issue. + - name: --no-interactive + type: bool + short-summary: Disable interactive mode. When set, the agent will not prompt for input and will run in batch mode. + - name: --no-echo-request + type: bool + short-summary: Disable echoing back the question provided to AKS Agent in the output. + - name: --show-tool-output + type: bool + short-summary: Show the output of each tool that was called during the analysis. + - name: --refresh-toolsets + type: bool + short-summary: Refresh the toolsets status. + + examples: + - name: Ask about pod issues in the cluster with Azure OpenAI + text: |- + export AZURE_API_BASE="https://my-azureopenai-service.openai.azure.com/" + export AZURE_API_VERSION="2025-01-01-preview" + export AZURE_API_KEY="sk-xxx" + az aks agent "Why are my pods not starting?" --name MyManagedCluster --resource-group MyResourceGroup --model azure/my-gpt4.1-deployment + - name: Ask about pod issues in the cluster with OpenAI + text: |- + export OPENAI_API_KEY="sk-xxx" + az aks agent "Why are my pods not starting?" --name MyManagedCluster --resource-group MyResourceGroup --model gpt-4o + text: az aks agent "Why are my pods not starting?" + - name: Run in interactive mode without a question + text: az aks agent "Check the pod status in my cluster" --name MyManagedCluster --resource-group MyResourceGroup --model azure/my-gpt4.1-deployment --api-key "sk-xxx" + - name: Run in non-interactive batch mode + text: az aks agent "Diagnose networking issues" --no-interactive --max-steps 15 --model azure/my-gpt4.1-deployment + - name: Show detailed tool output during analysis + text: az aks agent "Why is my service workload unavailable in namespace workload-ns?" --show-tool-output --model azure/my-gpt4.1-deployment + - name: Use custom configuration file + text: az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.config --model azure/my-gpt4.1-deployment + - name: Run agent with no echo of the original question + text: az aks agent "What is the status of my cluster?" --no-echo-request --model azure/my-gpt4.1-deployment + - name: Refresh toolsets to get the latest available tools + text: az aks agent "What is the status of my cluster?" --refresh-toolsets --model azure/my-gpt4.1-deploymen + - name: Run agent with config file + text: | + az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.config + Here is an example of config file: + ```json + model: "gpt-4o" + api_key: "..." + # define a list of mcp servers, mcp server can be defined + mcp_servers: + aks_mcp: + description: "The AKS-MCP is a Model Context Protocol (MCP) server that enables AI assistants to interact with Azure Kubernetes Service (AKS) clusters" + url: "http://localhost:8003/sse" + + # try adding your own tools or toggle the built-in toolsets here + # e.g. query company-specific data, fetch logs from your existing observability tools, etc + # To check how to add a customized toolset, please refer to https://docs.robusta.dev/master/configuration/holmesgpt/custom_toolsets.html#custom-toolsets + # To find all built-in toolsets, please refer to https://docs.robusta.dev/master/configuration/holmesgpt/builtin_toolsets.html + toolsets: + # add a new json processor toolset + json_processor: + description: "A toolset for processing JSON data using jq" + prerequisites: + - command: "jq --version" # Ensure jq is installed + tools: + - name: "process_json" + description: "A tool that uses jq to process JSON input" + command: "echo '{{ json_input }}' | jq '.'" # Example jq command to format JSON + # disable a built-in toolsets + aks/core: + enabled: false + ``` +""" diff --git a/src/aks-preview/azext_aks_preview/_params.py b/src/aks-preview/azext_aks_preview/_params.py index b5b5deb99fe..7db0b2b9ef0 100644 --- a/src/aks-preview/azext_aks_preview/_params.py +++ b/src/aks-preview/azext_aks_preview/_params.py @@ -23,7 +23,7 @@ validate_nat_gateway_idle_timeout, validate_nat_gateway_managed_outbound_ip_count, ) -# from azure.cli.core.api import get_config_dir +from azure.cli.core.api import get_config_dir from azure.cli.core.commands.parameters import ( edge_zone_type, file_type, @@ -224,7 +224,7 @@ validate_max_blocked_nodes, validate_resource_group_parameter, validate_location_resource_group_cluster_parameters, - # validate_agent_config_file, + validate_agent_config_file, ) from azext_aks_preview.azurecontainerstorage._consts import ( CONST_ACSTOR_ALL, @@ -2777,70 +2777,69 @@ def load_arguments(self, _): action="store_true", ) -# pylint: disable=line-too-long -# with self.argument_context("aks agent") as c: -# c.positional( -# "prompt", -# help="Ask any question and answer using available tools.", -# ) -# c.argument( -# "resource_group_name", -# options_list=["--resource-group", "-g"], -# help="Name of resource group.", -# required=False, -# ) -# c.argument( -# "name", -# options_list=["--name", "-n"], -# help="Name of the managed cluster.", -# required=False, -# ) -# c.argument( -# "max_steps", -# type=int, -# default=10, -# required=False, -# help="Maximum number of steps the LLM can take to investigate the issue.", -# ) -# c.argument( -# "config_file", -# default=os.path.join(get_config_dir(), "aksAgent.config"), -# validator=validate_agent_config_file, -# required=False, -# help="Path to the config file.", -# ) -# c.argument( -# "model", -# help="The model to use for the LLM.", -# required=False, -# type=str, -# ) -# c.argument( -# "api-key", -# help="API key to use for the LLM (if not given, uses environment variables AZURE_API_KEY, OPENAI_API_KEY)", -# required=False, -# type=str, -# ) -# c.argument( -# "no_interactive", -# help="Disable interactive mode. When set, the agent will not prompt for input and will run in batch mode.", -# action="store_true", -# ) -# c.argument( -# "no_echo_request", -# help="Disable echoing back the question provided to AKS Agent in the output.", -# action="store_true", -# ) -# c.argument( -# "show_tool_output", -# help="Show the output of each tool that was called.", -# action="store_true", -# ) -# c.argument( -# "refresh_toolsets", -# help="Refresh the toolsets status.", -# action="store_true", -# ) + with self.argument_context("aks agent") as c: + c.positional( + "prompt", + help="Ask any question and answer using available tools.", + ) + c.argument( + "resource_group_name", + options_list=["--resource-group", "-g"], + help="Name of resource group.", + required=False, + ) + c.argument( + "name", + options_list=["--name", "-n"], + help="Name of the managed cluster.", + required=False, + ) + c.argument( + "max_steps", + type=int, + default=10, + required=False, + help="Maximum number of steps the LLM can take to investigate the issue.", + ) + c.argument( + "config_file", + default=os.path.join(get_config_dir(), "aksAgent.config"), + validator=validate_agent_config_file, + required=False, + help="Path to the config file.", + ) + c.argument( + "model", + help="The model to use for the LLM.", + required=False, + type=str, + ) + c.argument( + "api-key", + help="API key to use for the LLM (if not given, uses environment variables AZURE_API_KEY, OPENAI_API_KEY)", + required=False, + type=str, + ) + c.argument( + "no_interactive", + help="Disable interactive mode. When set, the agent will not prompt for input and will run in batch mode.", + action="store_true", + ) + c.argument( + "no_echo_request", + help="Disable echoing back the question provided to AKS Agent in the output.", + action="store_true", + ) + c.argument( + "show_tool_output", + help="Show the output of each tool that was called.", + action="store_true", + ) + c.argument( + "refresh_toolsets", + help="Refresh the toolsets status.", + action="store_true", + ) def _get_default_install_location(exe_name): diff --git a/src/aks-preview/azext_aks_preview/agent/prompt.py b/src/aks-preview/azext_aks_preview/agent/prompt.py index 4f40ac24049..7856c8d6817 100644 --- a/src/aks-preview/azext_aks_preview/agent/prompt.py +++ b/src/aks-preview/azext_aks_preview/agent/prompt.py @@ -37,17 +37,10 @@ 1. **IMMEDIATELY STOP ALL OPERATIONS** - Do not proceed with any investigation 2. **DO NOT ATTEMPT ANY TROUBLESHOOTING** - No kubectl commands, no Azure commands, nothing 3. **DO NOT INFER THE RESOURCE NAME** - Do not assume any resource name, resource group, or subscription ID -4. **ONLY display the context failure message** on separate lines: -``` -Cluster name: -Resource group: -Subscription ID: - -Please provide the correct cluster context. You can either: -1. Specify the context in this session: "Please use cluster 'my-cluster' in resource group 'my-rg' under subscription 'my-subscription'" -2. Or restart with context: `az aks agent --name --resource-group --subscription ` -``` -**IMPORTANT**: When displaying the CLI command example above, use it EXACTLY as written with the placeholder format ``, ``, ``. +4. **ONLY display the context failure message** exactly as follows with no extra blank lines (replace the first three placeholders with actual detected values or None): + - list "Cluster name", "Resource group", "Subscription ID" with detected value or None + - prompt to the user to either provide the the cluster context in the prompt including Cluster name", "Resource group" and "Subscription ID", or + - restart the command specifying the cluster info in flags with examples (e.g., --name --resource-group --subscription ) {% endif %} diff --git a/src/aks-preview/azext_aks_preview/commands.py b/src/aks-preview/azext_aks_preview/commands.py index f40ccf6cb15..0b5735539f1 100644 --- a/src/aks-preview/azext_aks_preview/commands.py +++ b/src/aks-preview/azext_aks_preview/commands.py @@ -188,7 +188,7 @@ def load_command_table(self, _): "operation-abort", "aks_operation_abort", supports_no_wait=True ) g.custom_command("bastion", "aks_bastion") - # g.custom_command("agent", "aks_agent") + g.custom_command("agent", "aks_agent") # AKS maintenance configuration commands with self.command_group( From dbee46c97c4118e2ebf5c387993fcf24b6b5973e Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Fri, 15 Aug 2025 08:00:42 +0000 Subject: [PATCH 2/9] bump homlesgpt to 0.12.4 --- src/aks-preview/HISTORY.rst | 1 - src/aks-preview/azext_aks_preview/_help.py | 3 +-- src/aks-preview/setup.py | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/aks-preview/HISTORY.rst b/src/aks-preview/HISTORY.rst index c456fc4e81c..5f45d3acfd7 100644 --- a/src/aks-preview/HISTORY.rst +++ b/src/aks-preview/HISTORY.rst @@ -11,7 +11,6 @@ To release a new version, please select a new version number (usually plus 1 to Pending +++++++ -* Add framework for interactive AI-powered debugging tool. 18.0.0b27 +++++++ diff --git a/src/aks-preview/azext_aks_preview/_help.py b/src/aks-preview/azext_aks_preview/_help.py index ef6904471eb..8ec7022c79b 100644 --- a/src/aks-preview/azext_aks_preview/_help.py +++ b/src/aks-preview/azext_aks_preview/_help.py @@ -3995,7 +3995,6 @@ text: |- export OPENAI_API_KEY="sk-xxx" az aks agent "Why are my pods not starting?" --name MyManagedCluster --resource-group MyResourceGroup --model gpt-4o - text: az aks agent "Why are my pods not starting?" - name: Run in interactive mode without a question text: az aks agent "Check the pod status in my cluster" --name MyManagedCluster --resource-group MyResourceGroup --model azure/my-gpt4.1-deployment --api-key "sk-xxx" - name: Run in non-interactive batch mode @@ -4007,7 +4006,7 @@ - name: Run agent with no echo of the original question text: az aks agent "What is the status of my cluster?" --no-echo-request --model azure/my-gpt4.1-deployment - name: Refresh toolsets to get the latest available tools - text: az aks agent "What is the status of my cluster?" --refresh-toolsets --model azure/my-gpt4.1-deploymen + text: az aks agent "What is the status of my cluster?" --refresh-toolsets --model azure/my-gpt4.1-deployment - name: Run agent with config file text: | az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.config diff --git a/src/aks-preview/setup.py b/src/aks-preview/setup.py index 9dc62a95f23..a5ef1e3e4f0 100644 --- a/src/aks-preview/setup.py +++ b/src/aks-preview/setup.py @@ -9,7 +9,7 @@ from setuptools import find_packages, setup -VERSION = "18.0.0b26" +VERSION = "18.0.0b27" CLASSIFIERS = [ "Development Status :: 4 - Beta", @@ -24,7 +24,7 @@ ] DEPENDENCIES = [ - "holmesgpt==0.12.4; python_version >= '3.10'", + "holmesgpt==0.12.5; python_version >= '3.10'", ] with open1("README.rst", "r", encoding="utf-8") as f: From 1981b27ca431e553f81da0b3120747cb2d8be9ef Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Tue, 19 Aug 2025 23:58:56 +0000 Subject: [PATCH 3/9] use aksAgent.yaml as the default config file name --- src/aks-preview/azext_aks_preview/_consts.py | 2 +- src/aks-preview/azext_aks_preview/_help.py | 4 ++-- src/aks-preview/azext_aks_preview/_params.py | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/aks-preview/azext_aks_preview/_consts.py b/src/aks-preview/azext_aks_preview/_consts.py index 5cec8e35be9..87ce6ca9ada 100644 --- a/src/aks-preview/azext_aks_preview/_consts.py +++ b/src/aks-preview/azext_aks_preview/_consts.py @@ -378,4 +378,4 @@ CONST_AGENT_CONFIG_PATH_DIR_ENV_KEY = "HOLMES_CONFIGPATH_DIR" CONST_AGENT_NAME = "AKS AGENT" CONST_AGENT_NAME_ENV_KEY = "AGENT_NAME" -CONST_AGENT_CONFIG_FILE_NAME = "aksAgent.config" +CONST_AGENT_CONFIG_FILE_NAME = "aksAgent.yaml" diff --git a/src/aks-preview/azext_aks_preview/_help.py b/src/aks-preview/azext_aks_preview/_help.py index 8ec7022c79b..0f44ed607ec 100644 --- a/src/aks-preview/azext_aks_preview/_help.py +++ b/src/aks-preview/azext_aks_preview/_help.py @@ -4002,14 +4002,14 @@ - name: Show detailed tool output during analysis text: az aks agent "Why is my service workload unavailable in namespace workload-ns?" --show-tool-output --model azure/my-gpt4.1-deployment - name: Use custom configuration file - text: az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.config --model azure/my-gpt4.1-deployment + text: az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.yaml --model azure/my-gpt4.1-deployment - name: Run agent with no echo of the original question text: az aks agent "What is the status of my cluster?" --no-echo-request --model azure/my-gpt4.1-deployment - name: Refresh toolsets to get the latest available tools text: az aks agent "What is the status of my cluster?" --refresh-toolsets --model azure/my-gpt4.1-deployment - name: Run agent with config file text: | - az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.config + az aks agent "Check kubernetes pod resource usage" --config-file /path/to/custom.yaml Here is an example of config file: ```json model: "gpt-4o" diff --git a/src/aks-preview/azext_aks_preview/_params.py b/src/aks-preview/azext_aks_preview/_params.py index 7db0b2b9ef0..bdf4b83c5e8 100644 --- a/src/aks-preview/azext_aks_preview/_params.py +++ b/src/aks-preview/azext_aks_preview/_params.py @@ -150,7 +150,8 @@ CONST_ADVANCED_NETWORKPOLICIES_FQDN, CONST_ADVANCED_NETWORKPOLICIES_L7, CONST_TRANSIT_ENCRYPTION_TYPE_NONE, - CONST_TRANSIT_ENCRYPTION_TYPE_WIREGUARD + CONST_TRANSIT_ENCRYPTION_TYPE_WIREGUARD, + CONST_AGENT_CONFIG_FILE_NAME, ) from azext_aks_preview._validators import ( @@ -2803,7 +2804,7 @@ def load_arguments(self, _): ) c.argument( "config_file", - default=os.path.join(get_config_dir(), "aksAgent.config"), + default=os.path.join(get_config_dir(), CONST_AGENT_CONFIG_FILE_NAME), validator=validate_agent_config_file, required=False, help="Path to the config file.", From 22d7b7606a476491e2972f8f9da0a09d2f762a06 Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Thu, 21 Aug 2025 10:14:51 +0000 Subject: [PATCH 4/9] bump holmesgpt to 0.13.0 --- src/aks-preview/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aks-preview/setup.py b/src/aks-preview/setup.py index a5ef1e3e4f0..97804c46801 100644 --- a/src/aks-preview/setup.py +++ b/src/aks-preview/setup.py @@ -24,7 +24,7 @@ ] DEPENDENCIES = [ - "holmesgpt==0.12.5; python_version >= '3.10'", + "holmesgpt==0.13.0; python_version >= '3.10'", ] with open1("README.rst", "r", encoding="utf-8") as f: From 3b4291076306f20557d6400ef222641685afa27a Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Fri, 22 Aug 2025 06:03:34 +0000 Subject: [PATCH 5/9] use holmesgpt 0.12.6 --- src/aks-preview/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aks-preview/setup.py b/src/aks-preview/setup.py index 385ded3bb8c..884fb4ccebe 100644 --- a/src/aks-preview/setup.py +++ b/src/aks-preview/setup.py @@ -24,7 +24,7 @@ ] DEPENDENCIES = [ - "holmesgpt==0.13.0; python_version >= '3.10'", + "holmesgpt==0.12.6; python_version >= '3.10'", ] with open1("README.rst", "r", encoding="utf-8") as f: From 5e8e4ab8edb033fbdd500348c8b189eef3567f15 Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Mon, 25 Aug 2025 03:50:08 +0000 Subject: [PATCH 6/9] add UT --- .../azext_aks_preview/_validators.py | 37 +- .../tests/latest/test_agent.py | 458 ++++++++++++++++++ .../tests/latest/test_validators.py | 348 ++++++++++++- 3 files changed, 798 insertions(+), 45 deletions(-) create mode 100644 src/aks-preview/azext_aks_preview/tests/latest/test_agent.py diff --git a/src/aks-preview/azext_aks_preview/_validators.py b/src/aks-preview/azext_aks_preview/_validators.py index 1cef9949c38..a58b313a11c 100644 --- a/src/aks-preview/azext_aks_preview/_validators.py +++ b/src/aks-preview/azext_aks_preview/_validators.py @@ -8,38 +8,32 @@ import os import os.path import re -import yaml from ipaddress import ip_network from math import isclose, isnan -from azure.cli.core import keys -from azure.cli.core.api import get_config_dir -from azure.cli.core.azclierror import ( - ArgumentUsageError, - InvalidArgumentValueError, - MutuallyExclusiveArgumentError, - RequiredArgumentMissingError, -) -from azure.cli.core.commands.validators import validate_tag -from azure.cli.core.util import CLIError -from azure.mgmt.core.tools import is_valid_resource_id +import yaml from azext_aks_preview._consts import ( - ADDONS, + ADDONS, CONST_AGENT_CONFIG_FILE_NAME, + CONST_AZURE_SERVICE_MESH_MAX_EGRESS_NAME_LENGTH, CONST_LOAD_BALANCER_BACKEND_POOL_TYPE_NODE_IP, CONST_LOAD_BALANCER_BACKEND_POOL_TYPE_NODE_IPCONFIGURATION, CONST_MANAGED_CLUSTER_SKU_TIER_FREE, - CONST_MANAGED_CLUSTER_SKU_TIER_STANDARD, CONST_MANAGED_CLUSTER_SKU_TIER_PREMIUM, - CONST_OS_SKU_AZURELINUX, - CONST_OS_SKU_CBLMARINER, - CONST_OS_SKU_MARINER, + CONST_MANAGED_CLUSTER_SKU_TIER_STANDARD, CONST_NETWORK_POD_IP_ALLOCATION_MODE_DYNAMIC_INDIVIDUAL, CONST_NETWORK_POD_IP_ALLOCATION_MODE_STATIC_BLOCK, - CONST_NODEPOOL_MODE_GATEWAY, - CONST_AZURE_SERVICE_MESH_MAX_EGRESS_NAME_LENGTH, - CONST_AGENT_CONFIG_FILE_NAME, -) + CONST_NODEPOOL_MODE_GATEWAY, CONST_OS_SKU_AZURELINUX, + CONST_OS_SKU_CBLMARINER, CONST_OS_SKU_MARINER) from azext_aks_preview._helpers import _fuzzy_match +from azure.cli.core import keys +from azure.cli.core.api import get_config_dir +from azure.cli.core.azclierror import (ArgumentUsageError, + InvalidArgumentValueError, + MutuallyExclusiveArgumentError, + RequiredArgumentMissingError) +from azure.cli.core.commands.validators import validate_tag +from azure.cli.core.util import CLIError +from azure.mgmt.core.tools import is_valid_resource_id from knack.log import get_logger logger = get_logger(__name__) @@ -1010,6 +1004,7 @@ def validate_agent_config_file(namespace): config_file = namespace.config_file if not config_file: return + # default config file path can be empty default_config_path = os.path.join(get_config_dir(), CONST_AGENT_CONFIG_FILE_NAME) if config_file == default_config_path and not os.path.exists(config_file): return diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py b/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py new file mode 100644 index 00000000000..f27ab724b72 --- /dev/null +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py @@ -0,0 +1,458 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- +import logging +import os +import sys +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, Mock, call, patch + +from azext_aks_preview._consts import (CONST_AGENT_CONFIG_PATH_DIR_ENV_KEY, + CONST_AGENT_NAME, + CONST_AGENT_NAME_ENV_KEY) +from azext_aks_preview.agent.agent import aks_agent, init_log +from azure.cli.core.util import CLIError + +# Mock the holmes modules before any imports that might trigger holmes imports +sys.modules['holmes'] = Mock() +sys.modules['holmes.config'] = Mock() +sys.modules['holmes.core'] = Mock() +sys.modules['holmes.core.prompt'] = Mock() +sys.modules['holmes.interactive'] = Mock() +sys.modules['holmes.plugins'] = Mock() +sys.modules['holmes.plugins.destinations'] = Mock() +sys.modules['holmes.plugins.interfaces'] = Mock() +sys.modules['holmes.plugins.prompts'] = Mock() +sys.modules['holmes.utils'] = Mock() +sys.modules['holmes.utils.console'] = Mock() +sys.modules['holmes.utils.console.logging'] = Mock() +sys.modules['holmes.utils.console.result'] = Mock() + + +class TestInitLog(unittest.TestCase): + """Test cases for init_log function""" + + @patch('azext_aks_preview.agent.agent.logging.getLogger') + @patch('holmes.utils.console.logging.init_logging') + def test_init_log_sets_log_levels_and_calls_init_logging(self, mock_init_logging, mock_get_logger): + """Test that init_log sets appropriate log levels and calls init_logging""" + # Arrange + mock_logger = Mock() + mock_get_logger.return_value = mock_logger + mock_console = Mock() + mock_init_logging.return_value = mock_console + + # Act + result = init_log() + + # Assert + self.assertEqual(result, mock_console) + + # Verify all loggers are set to WARNING level + expected_logger_calls = [ + call("LiteLLM"), + call("telemetry.main"), + call("telemetry.process"), + call("telemetry.save"), + call("telemetry.client"), + call("az_command_data_logger"), + ] + mock_get_logger.assert_has_calls(expected_logger_calls, any_order=True) + + # Verify setLevel is called with WARNING for each logger + expected_setlevel_calls = [call(logging.WARNING)] * 6 + mock_logger.setLevel.assert_has_calls(expected_setlevel_calls) + + # Verify init_logging is called with empty list + mock_init_logging.assert_called_once_with([]) + + @patch('azext_aks_preview.agent.agent.logging.getLogger') + def test_init_log_logger_level_setting(self, mock_get_logger): + """Test that specific loggers get WARNING level set""" + # Arrange + mock_logger = Mock() + mock_get_logger.return_value = mock_logger + + with patch('holmes.utils.console.logging.init_logging') as mock_init_logging: + mock_init_logging.return_value = Mock() + + # Act + init_log() + + # Assert that setLevel was called 6 times with WARNING + self.assertEqual(mock_logger.setLevel.call_count, 6) + for call_args in mock_logger.setLevel.call_args_list: + self.assertEqual(call_args[0][0], logging.WARNING) + + +class TestAksAgent(unittest.TestCase): + """Test cases for aks_agent function""" + + def setUp(self): + """Set up test fixtures""" + self.mock_cmd = Mock() + self.mock_cmd.cli_ctx = Mock() + # Fix the cli_ctx.data structure to be subscriptable + self.mock_cmd.cli_ctx.data = {'subscription_id': 'test-subscription-id'} + + # Default parameters for aks_agent function + self.default_params = { + 'cmd': self.mock_cmd, + 'resource_group_name': 'test-rg', + 'name': 'test-cluster', + 'prompt': 'test prompt', + 'model': 'test-model', + 'api_key': 'test-key', + 'max_steps': 10, + 'config_file': '/path/to/config.yaml', + 'no_interactive': False, + 'no_echo_request': False, + 'show_tool_output': True, + 'refresh_toolsets': False, + } + + def test_aks_agent_python_version_check(self): + """Test that aks_agent raises error for Python version < 3.10""" + with patch.object(sys, 'version_info', (3, 9, 0)): + with patch('azext_aks_preview.agent.agent.CLITelemetryClient'): + with self.assertRaises(CLIError) as cm: + aks_agent(**self.default_params) + + self.assertIn("Please upgrade the python version to 3.10", str(cm.exception)) + + @patch('sys.stdin.isatty') + @patch('azext_aks_preview.agent.agent.CLITelemetryClient') + @patch('azext_aks_preview.agent.agent.init_log') + @patch('azure.cli.core.api.get_config_dir') + @patch('azure.cli.core.commands.client_factory.get_subscription_id') + @patch('pathlib.Path') + def test_aks_agent_no_prompt_no_interactive_raises_error(self, mock_path, mock_get_subscription_id, + mock_get_config_dir, mock_init_log, + mock_cli_telemetry, mock_stdin_isatty): + """Test that aks_agent raises error when no prompt and not interactive mode""" + # Arrange + mock_stdin_isatty.return_value = True # No piped input + mock_console = Mock() + mock_init_log.return_value = mock_console + mock_get_config_dir.return_value = "/home/user/.azure" + mock_get_subscription_id.return_value = "test-subscription" + + mock_config_path = Mock() + mock_path.return_value = mock_config_path + + with patch.dict(os.environ, {}, clear=True): + with patch('holmes.config.Config') as mock_config_class: + mock_config = Mock() + mock_config_class.load_from_file.return_value = mock_config + mock_ai = Mock() + mock_config.create_console_toolcalling_llm.return_value = mock_ai + + # Act & Assert + params = self.default_params.copy() + params['prompt'] = None + params['no_interactive'] = True # Not interactive + + with self.assertRaises(CLIError) as cm: + aks_agent(**params) + + self.assertIn("Either the 'prompt' argument must be provided", str(cm.exception)) + + @patch('sys.stdin.isatty') + @patch('azext_aks_preview.agent.agent.CLITelemetryClient') + @patch('azext_aks_preview.agent.agent.init_log') + @patch('azure.cli.core.api.get_config_dir') + @patch('azure.cli.core.commands.client_factory.get_subscription_id') + @patch('pathlib.Path') + def test_aks_agent_interactive_mode(self, mock_path, mock_get_subscription_id, mock_get_config_dir, + mock_init_log, mock_cli_telemetry, mock_stdin_isatty): + """Test aks_agent in interactive mode""" + # Arrange + mock_stdin_isatty.return_value = True + mock_console = Mock() + mock_init_log.return_value = mock_console + mock_get_config_dir.return_value = "/home/user/.azure" + mock_get_subscription_id.return_value = "test-subscription" + + mock_config_path = Mock() + mock_path.return_value = mock_config_path + + with patch.dict(os.environ, {}, clear=True): + with patch('holmes.config.Config') as mock_config_class: + mock_config = Mock() + mock_config_class.load_from_file.return_value = mock_config + mock_ai = Mock() + mock_config.create_console_toolcalling_llm.return_value = mock_ai + + with patch('holmes.interactive.run_interactive_loop') as mock_run_interactive: + with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: + mock_load_prompt.return_value = "AKS context" + + # Act + params = self.default_params.copy() + params['no_interactive'] = False # Interactive mode + aks_agent(**params) + + # Assert + mock_run_interactive.assert_called_once() + + # Verify interactive loop is called with correct parameters + call_args = mock_run_interactive.call_args + self.assertEqual(call_args[0][0], mock_ai) # ai parameter + self.assertEqual(call_args[0][1], mock_console) # console parameter + self.assertEqual(call_args[0][2], 'test prompt') # prompt parameter + self.assertEqual(call_args[1]['show_tool_output'], True) + self.assertEqual(call_args[1]['system_prompt_additions'], "AKS context") + + @patch('sys.stdin.isatty') + @patch('azext_aks_preview.agent.agent.CLITelemetryClient') + @patch('azext_aks_preview.agent.agent.init_log') + @patch('azure.cli.core.api.get_config_dir') + @patch('azure.cli.core.commands.client_factory.get_subscription_id') + @patch('pathlib.Path') + def test_aks_agent_non_interactive_mode(self, mock_path, mock_get_subscription_id, mock_get_config_dir, + mock_init_log, mock_cli_telemetry, mock_stdin_isatty): + """Test aks_agent in non-interactive mode""" + # Arrange + mock_stdin_isatty.return_value = True + mock_console = Mock() + mock_init_log.return_value = mock_console + mock_get_config_dir.return_value = "/home/user/.azure" + mock_get_subscription_id.return_value = "test-subscription" + + mock_config_path = Mock() + mock_path.return_value = mock_config_path + + with patch.dict(os.environ, {}, clear=True): + with patch('holmes.config.Config') as mock_config_class: + mock_config = Mock() + mock_config_class.load_from_file.return_value = mock_config + mock_ai = Mock() + mock_config.create_console_toolcalling_llm.return_value = mock_ai + mock_config.get_runbook_catalog.return_value = {} + + with patch('holmes.core.prompt.build_initial_ask_messages') as mock_build_messages: + mock_messages = [{'role': 'user', 'content': 'test'}] + mock_build_messages.return_value = mock_messages + + mock_response = Mock() + mock_response.messages = mock_messages + mock_ai.call.return_value = mock_response + + with patch('holmes.utils.console.result.handle_result') as mock_handle_result: + with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: + with patch('holmes.plugins.interfaces.Issue') as mock_issue: + with patch('uuid.uuid4') as mock_uuid: + with patch('socket.gethostname') as mock_hostname: + mock_load_prompt.return_value = "AKS context" + mock_uuid.return_value = "test-uuid" + mock_hostname.return_value = "test-host" + mock_issue_instance = Mock() + mock_issue.return_value = mock_issue_instance + + # Act + params = self.default_params.copy() + params['no_interactive'] = True # Non-interactive mode + aks_agent(**params) + + # Assert + mock_build_messages.assert_called_once() + mock_ai.call.assert_called_once_with(mock_messages) + mock_handle_result.assert_called_once() + + # Verify Issue is created with correct parameters + mock_issue.assert_called_once() + issue_kwargs = mock_issue.call_args[1] + self.assertEqual(issue_kwargs['id'], "test-uuid") + self.assertEqual(issue_kwargs['name'], 'test prompt') + self.assertEqual(issue_kwargs['source_type'], "holmes-ask") + self.assertEqual(issue_kwargs['source_instance_id'], "test-host") + + @patch('sys.stdin.isatty') + @patch('sys.stdin.read') + @patch('azext_aks_preview.agent.agent.CLITelemetryClient') + @patch('azext_aks_preview.agent.agent.init_log') + @patch('azure.cli.core.api.get_config_dir') + @patch('azure.cli.core.commands.client_factory.get_subscription_id') + @patch('pathlib.Path') + def test_aks_agent_piped_input_with_prompt(self, mock_path, mock_get_subscription_id, mock_get_config_dir, + mock_init_log, mock_cli_telemetry, mock_stdin_read, mock_stdin_isatty): + """Test aks_agent combines piped input with provided prompt""" + # Arrange + mock_stdin_isatty.return_value = False + mock_stdin_read.return_value = "kubectl get pods output" + mock_console = Mock() + mock_init_log.return_value = mock_console + mock_get_config_dir.return_value = "/home/user/.azure" + mock_get_subscription_id.return_value = "test-subscription" + + mock_config_path = Mock() + mock_path.return_value = mock_config_path + + with patch.dict(os.environ, {}, clear=True): + with patch('holmes.config.Config') as mock_config_class: + mock_config = Mock() + mock_config_class.load_from_file.return_value = mock_config + mock_ai = Mock() + mock_config.create_console_toolcalling_llm.return_value = mock_ai + mock_config.get_runbook_catalog.return_value = {} + + with patch('holmes.core.prompt.build_initial_ask_messages') as mock_build_messages: + mock_messages = [{'role': 'user', 'content': 'test'}] + mock_build_messages.return_value = mock_messages + + mock_response = Mock() + mock_response.messages = mock_messages + mock_ai.call.return_value = mock_response + + with patch('holmes.utils.console.result.handle_result'): + with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: + with patch('holmes.plugins.interfaces.Issue'): + with patch('uuid.uuid4'): + with patch('socket.gethostname'): + mock_load_prompt.return_value = "AKS context" + + # Act + params = self.default_params.copy() + params['prompt'] = "What's wrong with my pods?" + aks_agent(**params) + + # Assert that build_initial_ask_messages was called with combined prompt + mock_build_messages.assert_called_once() + call_args = mock_build_messages.call_args[0] + combined_prompt = call_args[1] # prompt parameter + + self.assertIn("kubectl get pods output", combined_prompt) + self.assertIn("What's wrong with my pods?", combined_prompt) + self.assertIn("Here's some piped output:", combined_prompt) + + @patch('sys.stdin.isatty') + @patch('azext_aks_preview.agent.agent.CLITelemetryClient') + @patch('azext_aks_preview.agent.agent.init_log') + @patch('azure.cli.core.api.get_config_dir') + @patch('azure.cli.core.commands.client_factory.get_subscription_id') + @patch('pathlib.Path') + def test_aks_agent_echo_request_enabled(self, mock_path, mock_get_subscription_id, mock_get_config_dir, + mock_init_log, mock_cli_telemetry, mock_stdin_isatty): + """Test aks_agent echoes request when echo is enabled""" + # Arrange + mock_stdin_isatty.return_value = True + mock_console = Mock() + mock_init_log.return_value = mock_console + mock_get_config_dir.return_value = "/home/user/.azure" + mock_get_subscription_id.return_value = "test-subscription" + + mock_config_path = Mock() + mock_path.return_value = mock_config_path + + with patch.dict(os.environ, {}, clear=True): + with patch('holmes.config.Config') as mock_config_class: + mock_config = Mock() + mock_config_class.load_from_file.return_value = mock_config + mock_ai = Mock() + mock_config.create_console_toolcalling_llm.return_value = mock_ai + mock_config.get_runbook_catalog.return_value = {} + + with patch('holmes.core.prompt.build_initial_ask_messages') as mock_build_messages: + mock_messages = [{'role': 'user', 'content': 'test'}] + mock_build_messages.return_value = mock_messages + + mock_response = Mock() + mock_response.messages = mock_messages + mock_ai.call.return_value = mock_response + + with patch('holmes.utils.console.result.handle_result'): + with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: + with patch('holmes.plugins.interfaces.Issue'): + with patch('uuid.uuid4'): + with patch('socket.gethostname'): + mock_load_prompt.return_value = "AKS context" + + # Act + params = self.default_params.copy() + params['no_interactive'] = True # Non-interactive + params['no_echo_request'] = False # Echo enabled + aks_agent(**params) + + # Assert that console.print was called with the user prompt + mock_console.print.assert_any_call("[bold yellow]User:[/bold yellow] test prompt") + + @patch('azext_aks_preview.agent.agent.CLITelemetryClient') + def test_aks_agent_telemetry_client_usage(self, mock_cli_telemetry): + """Test that aks_agent uses CLITelemetryClient context manager""" + # Arrange + mock_cli_telemetry.return_value.__enter__ = Mock(return_value=Mock()) + mock_cli_telemetry.return_value.__exit__ = Mock(return_value=None) + + with patch.object(sys, 'version_info', (3, 9, 0)): + # Act & Assert + with self.assertRaises(CLIError): + aks_agent(**self.default_params) + + # Verify CLITelemetryClient was used as context manager + mock_cli_telemetry.assert_called_once() + mock_cli_telemetry.return_value.__enter__.assert_called_once() + mock_cli_telemetry.return_value.__exit__.assert_called_once() + + @patch('sys.stdin.isatty') + @patch('sys.stdin.read') + @patch('azext_aks_preview.agent.agent.CLITelemetryClient') + @patch('azext_aks_preview.agent.agent.init_log') + @patch('azure.cli.core.api.get_config_dir') + @patch('azure.cli.core.commands.client_factory.get_subscription_id') + @patch('pathlib.Path') + def test_aks_agent_piped_input_no_prompt_default_question(self, mock_path, mock_get_subscription_id, + mock_get_config_dir, mock_init_log, mock_cli_telemetry, + mock_stdin_read, mock_stdin_isatty): + """Test aks_agent with piped input but no prompt uses default question""" + # Arrange + mock_stdin_isatty.return_value = False + mock_stdin_read.return_value = "error logs from pod" + mock_console = Mock() + mock_init_log.return_value = mock_console + mock_get_config_dir.return_value = "/home/user/.azure" + mock_get_subscription_id.return_value = "test-subscription" + + mock_config_path = Mock() + mock_path.return_value = mock_config_path + + with patch.dict(os.environ, {}, clear=True): + with patch('holmes.config.Config') as mock_config_class: + mock_config = Mock() + mock_config_class.load_from_file.return_value = mock_config + mock_ai = Mock() + mock_config.create_console_toolcalling_llm.return_value = mock_ai + mock_config.get_runbook_catalog.return_value = {} + + with patch('holmes.core.prompt.build_initial_ask_messages') as mock_build_messages: + mock_messages = [{'role': 'user', 'content': 'test'}] + mock_build_messages.return_value = mock_messages + + mock_response = Mock() + mock_response.messages = mock_messages + mock_ai.call.return_value = mock_response + + with patch('holmes.utils.console.result.handle_result'): + with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: + with patch('holmes.plugins.interfaces.Issue'): + with patch('uuid.uuid4'): + with patch('socket.gethostname'): + mock_load_prompt.return_value = "AKS context" + + # Act + params = self.default_params.copy() + params['prompt'] = None # No prompt provided + aks_agent(**params) + + # Assert that build_initial_ask_messages was called with default question + mock_build_messages.assert_called_once() + call_args = mock_build_messages.call_args[0] + prompt_with_default = call_args[1] # prompt parameter + + self.assertIn("error logs from pod", prompt_with_default) + self.assertIn("What can you tell me about this output?", prompt_with_default) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_validators.py b/src/aks-preview/azext_aks_preview/tests/latest/test_validators.py index 1e0cff20403..51880a7f181 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_validators.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_validators.py @@ -2,21 +2,22 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +import os +import shutil +import tempfile import unittest -from unittest.mock import patch from types import SimpleNamespace +from unittest.mock import patch import azext_aks_preview._validators as validators import azext_aks_preview.azurecontainerstorage._consts as acstor_consts import azext_aks_preview.azurecontainerstorage._validators as acstor_validator from azext_aks_preview._consts import ADDONS -from azure.cli.core.azclierror import ( - ArgumentUsageError, - InvalidArgumentValueError, - MutuallyExclusiveArgumentError, - RequiredArgumentMissingError, - UnknownError, -) +from azure.cli.core.azclierror import (ArgumentUsageError, + InvalidArgumentValueError, + MutuallyExclusiveArgumentError, + RequiredArgumentMissingError, + UnknownError) from azure.cli.core.util import CLIError @@ -108,14 +109,17 @@ class MaxSurgeNamespace: def __init__(self, max_surge): self.max_surge = max_surge + class MaxUnavailableNamespace: def __init__(self, max_unavailable): self.max_unavailable = max_unavailable + class MaxBlockedNodesNamespace: def __init__(self, max_blocked_nodes): self.max_blocked_nodes = max_blocked_nodes + class SpotMaxPriceNamespace: def __init__(self, spot_max_price): self.priority = "Spot" @@ -162,6 +166,7 @@ def test_throws_on_negative(self): validators.validate_max_surge(MaxSurgeNamespace("-3")) self.assertTrue("positive" in str(cm.exception), msg=str(cm.exception)) + class TestMaxUnavailable(unittest.TestCase): def test_valid_cases(self): valid = ["5", "33%", "1", "100%", "0"] @@ -178,6 +183,7 @@ def test_throws_on_negative(self): validators.validate_max_unavailable(MaxUnavailableNamespace("-3")) self.assertTrue("positive" in str(cm.exception), msg=str(cm.exception)) + class TestMaxBlockedNodes(unittest.TestCase): def test_valid_cases(self): valid = ["5", "33%", "1", "100%", "0"] @@ -194,6 +200,7 @@ def test_throws_on_negative(self): validators.validate_max_blocked_nodes(MaxBlockedNodesNamespace("-3")) self.assertTrue("positive" in str(cm.exception), msg=str(cm.exception)) + class TestSpotMaxPrice(unittest.TestCase): def test_valid_cases(self): valid = [5, 5.12345, -1.0, 0.068, 0.071, 5.00000000] @@ -735,6 +742,7 @@ def test_empty_nodepool_application_security_groups(self): validators.validate_application_security_groups( namespace ) + def test_multiple_application_security_groups(self): asg_ids = ",".join([ "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg1/providers/Microsoft.Network/applicationSecurityGroups/asg1", @@ -809,6 +817,7 @@ def test_valid_start_time(self): namespace = MaintenanceWindowNameSpace(start_date="00:30") validators.validate_start_time(namespace) + class ManagedNamespace: def __init__(self, name=None, cpu_request=None, cpu_limit=None, memory_request=None, memory_limit=None): self.name = name @@ -817,6 +826,7 @@ def __init__(self, name=None, cpu_request=None, cpu_limit=None, memory_request=N self.memory_request = memory_request self.memory_limit = memory_limit + class TestValidateManagedNamespace(unittest.TestCase): def test_invalid_namespace_name(self): namespace = ManagedNamespace(name="Abc") @@ -824,7 +834,7 @@ def test_invalid_namespace_name(self): with self.assertRaises(ValueError) as cm: validators.validate_namespace_name(namespace) self.assertEqual(str(cm.exception), err) - + def test_valid_namespace_name(self): namespace = ManagedNamespace(name="abc") validators.validate_namespace_name(namespace) @@ -835,7 +845,7 @@ def test_invalid_cpu_request(self): with self.assertRaises(ValueError) as cm: validators.validate_resource_quota(namespace) self.assertEqual(str(cm.exception), err) - + def test_invalid_cpu_limit(self): namespace = ManagedNamespace(cpu_request="200m", cpu_limit="2t") err = "--cpu-limit must be specified in millicores, like 200m" @@ -861,6 +871,7 @@ def test_valid_resource_quotas(self): namespace = ManagedNamespace(cpu_request="500m", cpu_limit="800m", memory_request="1Gi", memory_limit="2Gi") validators.validate_resource_quota(namespace) + class TestValidateDisableAzureContainerStorage(unittest.TestCase): def test_disable_when_extension_not_installed(self): is_extension_installed = False @@ -1256,8 +1267,8 @@ def test_enable_with_same_ephemeral_disk_nvme_perf_tier_already_set(self): perf_tier = acstor_consts.CONST_EPHEMERAL_NVME_PERF_TIER_PREMIUM storage_pool_type = acstor_consts.CONST_STORAGE_POOL_TYPE_EPHEMERAL_DISK err = ( - "Azure Container Storage is already configured with --ephemeral-disk-nvme-perf-tier " - f"value set to {perf_tier}." + "Azure Container Storage is already configured with --ephemeral-disk-nvme-perf-tier " + f"value set to {perf_tier}." ) with self.assertRaises(InvalidArgumentValueError) as cm: acstor_validator.validate_enable_azure_container_storage_params( @@ -1269,8 +1280,8 @@ def test_enable_with_same_ephemeral_disk_volume_type_already_set(self): disk_vol_type = acstor_consts.CONST_DISK_TYPE_PV_WITH_ANNOTATION storage_pool_type = acstor_consts.CONST_STORAGE_POOL_TYPE_EPHEMERAL_DISK err = ( - "Azure Container Storage is already configured with --ephemeral-disk-volume-type " - f"value set to {disk_vol_type}." + "Azure Container Storage is already configured with --ephemeral-disk-volume-type " + f"value set to {disk_vol_type}." ) with self.assertRaises(InvalidArgumentValueError) as cm: acstor_validator.validate_enable_azure_container_storage_params( @@ -1283,9 +1294,9 @@ def test_enable_with_same_ephemeral_disk_nvme_perf_tier_and_ephemeral_temp_disk_ disk_vol_type = acstor_consts.CONST_DISK_TYPE_PV_WITH_ANNOTATION storage_pool_type = acstor_consts.CONST_STORAGE_POOL_TYPE_EPHEMERAL_DISK err = ( - "Azure Container Storage is already configured with --ephemeral-disk-volume-type " - f"value set to {disk_vol_type} and --ephemeral-disk-nvme-perf-tier " - f"value set to {perf_tier}." + "Azure Container Storage is already configured with --ephemeral-disk-volume-type " + f"value set to {disk_vol_type} and --ephemeral-disk-nvme-perf-tier " + f"value set to {perf_tier}." ) with self.assertRaises(InvalidArgumentValueError) as cm: acstor_validator.validate_enable_azure_container_storage_params( @@ -1368,7 +1379,7 @@ def test_missing_nodepool_from_cluster_nodepool_list_multiple(self): storage_pool_type = acstor_consts.CONST_STORAGE_POOL_TYPE_EPHEMERAL_DISK storage_pool_option = acstor_consts.CONST_STORAGE_POOL_OPTION_SSD nodepool_list = "pool1,pool2" - agentpools = {"nodepool1": {}, "nodepool2":{}} + agentpools = {"nodepool1": {}, "nodepool2": {}} err = ( "Node pool: pool1 not found. Please provide a comma separated " "string of existing node pool names in --azure-container-storage-nodepools." @@ -1387,7 +1398,8 @@ def test_system_nodepool_with_taint(self): storage_pool_type = acstor_consts.CONST_STORAGE_POOL_TYPE_EPHEMERAL_DISK storage_pool_option = acstor_consts.CONST_STORAGE_POOL_OPTION_SSD nodepool_list = "nodepool1" - agentpools = {"nodepool1": {"mode": "System", "node_taints": ["CriticalAddonsOnly=true:NoSchedule"]}, "nodepool2": {"count": 1}} + agentpools = {"nodepool1": {"mode": "System", "node_taints": [ + "CriticalAddonsOnly=true:NoSchedule"]}, "nodepool2": {"count": 1}} err = ( 'Unable to install Azure Container Storage on system nodepool: nodepool1 ' 'since it has a taint CriticalAddonsOnly=true:NoSchedule. Remove the taint from the node pool ' @@ -1432,7 +1444,8 @@ def test_valid_enable_for_ephemeral_disk_pool(self): storage_pool_type = acstor_consts.CONST_STORAGE_POOL_TYPE_EPHEMERAL_DISK storage_pool_option = acstor_consts.CONST_STORAGE_POOL_OPTION_NVME nodepool_list = "nodepool1" - agentpools = {"nodepool1": {"vm_size": "Standard_L8s_v3", "mode": "System", "count": 5}, "nodepool2": {"vm_size": "Standard_L8s_v3"}} + agentpools = {"nodepool1": {"vm_size": "Standard_L8s_v3", "mode": "System", + "count": 5}, "nodepool2": {"vm_size": "Standard_L8s_v3"}} acstor_validator.validate_enable_azure_container_storage_params( storage_pool_type, storage_pool_name, None, storage_pool_option, storage_pool_size, nodepool_list, agentpools, False, False, False, False, False, None, None, acstor_consts.CONST_DISK_TYPE_EPHEMERAL_VOLUME_ONLY, acstor_consts.CONST_EPHEMERAL_NVME_PERF_TIER_STANDARD ) @@ -1444,7 +1457,8 @@ def test_valid_enable_for_ephemeral_disk_pool_with_ephemeral_disk_volume_type(se storage_pool_option = acstor_consts.CONST_STORAGE_POOL_OPTION_NVME nodepool_list = "nodepool1" ephemeral_disk_volume_type = acstor_consts.CONST_DISK_TYPE_PV_WITH_ANNOTATION - agentpools = {"nodepool1": {"vm_size": "Standard_L8s_v3", "mode": "System", "count": 3}, "nodepool2": {"vm_size": "Standard_L8s_v3"}} + agentpools = {"nodepool1": {"vm_size": "Standard_L8s_v3", "mode": "System", + "count": 3}, "nodepool2": {"vm_size": "Standard_L8s_v3"}} acstor_validator.validate_enable_azure_container_storage_params( storage_pool_type, storage_pool_name, None, storage_pool_option, storage_pool_size, nodepool_list, agentpools, False, False, False, False, False, ephemeral_disk_volume_type, None, acstor_consts.CONST_DISK_TYPE_EPHEMERAL_VOLUME_ONLY, acstor_consts.CONST_EPHEMERAL_NVME_PERF_TIER_STANDARD ) @@ -1464,7 +1478,8 @@ def test_valid_enable_for_ephemeral_disk_pool_with_ephemeral_disk_nvme_perf_tier storage_pool_option = acstor_consts.CONST_STORAGE_POOL_OPTION_NVME nodepool_list = "nodepool1" perf_tier = acstor_consts.CONST_EPHEMERAL_NVME_PERF_TIER_PREMIUM - agentpools = {"nodepool1": {"vm_size": "Standard_L8s_v3", "count": 4}, "nodepool2": {"vm_size": "Standard_L8s_v3"}} + agentpools = {"nodepool1": {"vm_size": "Standard_L8s_v3", "count": 4}, + "nodepool2": {"vm_size": "Standard_L8s_v3"}} acstor_validator.validate_enable_azure_container_storage_params( storage_pool_type, storage_pool_name, None, storage_pool_option, storage_pool_size, nodepool_list, agentpools, False, False, False, False, False, None, perf_tier, acstor_consts.CONST_DISK_TYPE_EPHEMERAL_VOLUME_ONLY, acstor_consts.CONST_EPHEMERAL_NVME_PERF_TIER_STANDARD ) @@ -1497,7 +1512,7 @@ def test_extension_installed_storagepool_type_installed(self): storage_pool_size = "5Ti" storage_pool_type = acstor_consts.CONST_STORAGE_POOL_TYPE_AZURE_DISK storage_pool_sku = acstor_consts.CONST_STORAGE_POOL_SKU_PREMIUM_LRS - agentpools = {"nodepool1": {"node_labels": {"acstor.azure.com/io-engine": "acstor"}, "count": 3}, "nodepool2" :{}} + agentpools = {"nodepool1": {"node_labels": {"acstor.azure.com/io-engine": "acstor"}, "count": 3}, "nodepool2": {}} err = ( "Invalid --enable-azure-container-storage value. " "Azure Container Storage is already enabled for storage pool type " @@ -1514,16 +1529,19 @@ def test_valid_cluster_update(self): storage_pool_size = "5Ti" storage_pool_type = acstor_consts.CONST_STORAGE_POOL_TYPE_AZURE_DISK storage_pool_sku = acstor_consts.CONST_STORAGE_POOL_SKU_PREMIUM_LRS - agentpools = {"nodepool1": {"node_labels": {"acstor.azure.com/io-engine": "acstor"}, "mode": "User", "count": 3}, "nodepool2": {}} + agentpools = {"nodepool1": {"node_labels": {"acstor.azure.com/io-engine": "acstor"}, + "mode": "User", "count": 3}, "nodepool2": {}} acstor_validator.validate_enable_azure_container_storage_params( storage_pool_type, storage_pool_name, storage_pool_sku, None, storage_pool_size, None, agentpools, True, False, False, False, False, None, None, acstor_consts.CONST_DISK_TYPE_EPHEMERAL_VOLUME_ONLY, acstor_consts.CONST_EPHEMERAL_NVME_PERF_TIER_STANDARD ) + class GatewayPrefixSizeSpace: def __init__(self, gateway_prefix_size=None, mode=None): self.gateway_prefix_size = gateway_prefix_size self.mode = mode + class TestValidateGatewayPrefixSize(unittest.TestCase): def test_none_gateway_prefix_size(self): namespace = GatewayPrefixSizeSpace() @@ -1554,6 +1572,7 @@ def test_valid_gateway_prefix_size(self): namespace = GatewayPrefixSizeSpace(gateway_prefix_size=30, mode="Gateway") validators.validate_gateway_prefix_size(namespace) + class TestValidateCustomEndpoints(unittest.TestCase): def test_empty_custom_endpoints(self): namespace = SimpleNamespace( @@ -1581,5 +1600,286 @@ def test_valid_custom_endpoints(self): validators.validate_custom_endpoints(namespace) +class TestValidateParamYamlFile(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.valid_yaml_file = os.path.join(self.temp_dir, "valid.yaml") + self.invalid_yaml_file = os.path.join(self.temp_dir, "invalid.yaml") + self.readonly_yaml_file = os.path.join(self.temp_dir, "readonly.yaml") + self.nonexistent_file = os.path.join(self.temp_dir, "nonexistent.yaml") + + # Create valid YAML file + with open(self.valid_yaml_file, 'w') as f: + f.write("key1: value1\nkey2:\n - item1\n - item2\n") + + # Create invalid YAML file + with open(self.invalid_yaml_file, 'w') as f: + f.write("invalid: yaml: content: [\n - unclosed\n") + + # Create readonly YAML file + with open(self.readonly_yaml_file, 'w') as f: + f.write("key: value\n") + os.chmod(self.readonly_yaml_file, 0o000) # Remove all permissions + + def tearDown(self): + # Restore permissions before cleanup + if os.path.exists(self.readonly_yaml_file): + os.chmod(self.readonly_yaml_file, 0o644) + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_none_yaml_path(self): + """Test that None yaml_path returns without error""" + validators._validate_param_yaml_file(None, "config-file") + + def test_empty_yaml_path(self): + """Test that empty string yaml_path returns without error""" + validators._validate_param_yaml_file("", "config-file") + + def test_nonexistent_file(self): + """Test that non-existent file raises InvalidArgumentValueError""" + with self.assertRaises(InvalidArgumentValueError) as cm: + validators._validate_param_yaml_file(self.nonexistent_file, "config-file") + self.assertIn("file is not found", str(cm.exception)) + self.assertIn("config-file", str(cm.exception)) + + def test_unreadable_file(self): + """Test that unreadable file raises InvalidArgumentValueError""" + import os + + # Skip on Windows as it handles permissions differently + if os.name == 'nt': + self.skipTest("Skipping readonly test on Windows") + + with self.assertRaises(InvalidArgumentValueError) as cm: + validators._validate_param_yaml_file(self.readonly_yaml_file, "config-file") + self.assertIn("file is not readable", str(cm.exception)) + self.assertIn("config-file", str(cm.exception)) + + def test_invalid_yaml_file(self): + """Test that invalid YAML content raises InvalidArgumentValueError""" + with self.assertRaises(InvalidArgumentValueError) as cm: + validators._validate_param_yaml_file(self.invalid_yaml_file, "config-file") + self.assertIn("file is not a valid YAML file", str(cm.exception)) + self.assertIn("config-file", str(cm.exception)) + + def test_valid_yaml_file(self): + """Test that valid YAML file passes validation""" + # Should not raise any exception + validators._validate_param_yaml_file(self.valid_yaml_file, "config-file") + + def test_different_param_names(self): + """Test that different parameter names are included in error messages""" + with self.assertRaises(InvalidArgumentValueError) as cm: + validators._validate_param_yaml_file(self.nonexistent_file, "my-custom-param") + self.assertIn("my-custom-param", str(cm.exception)) + + @patch('builtins.open') + def test_general_exception_handling(self, mock_open): + """Test that general exceptions are caught and re-raised as InvalidArgumentValueError""" + mock_open.side_effect = PermissionError("Access denied") + + with self.assertRaises(InvalidArgumentValueError) as cm: + validators._validate_param_yaml_file(self.valid_yaml_file, "config-file") + self.assertIn("An error occurred while reading the config file", str(cm.exception)) + self.assertIn("config-file", str(cm.exception)) + + def test_complex_yaml_file(self): + """Test validation with complex YAML structure""" + import os + complex_yaml_file = os.path.join(self.temp_dir, "complex.yaml") + with open(complex_yaml_file, 'w') as f: + f.write(""" +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-config + namespace: default +data: + config.yaml: | + server: + host: localhost + port: 8080 + features: + - auth + - logging + database: + url: "postgresql://user:pass@host:5432/db" + pool_size: 10 +""") + + # Should not raise any exception + validators._validate_param_yaml_file(complex_yaml_file, "config-file") + + def test_empty_yaml_file(self): + """Test validation with empty YAML file""" + import os + empty_yaml_file = os.path.join(self.temp_dir, "empty.yaml") + with open(empty_yaml_file, 'w') as f: + f.write("") + + # Should not raise any exception - empty file is valid YAML + validators._validate_param_yaml_file(empty_yaml_file, "config-file") + + +class AgentConfigFileNamespace: + def __init__(self, config_file=None): + self.config_file = config_file + + +class TestValidateAgentConfigFile(unittest.TestCase): + def setUp(self): + + self.temp_dir = tempfile.mkdtemp() + self.valid_yaml_file = os.path.join(self.temp_dir, "valid_agent.yaml") + self.invalid_yaml_file = os.path.join(self.temp_dir, "invalid_agent.yaml") + self.readonly_yaml_file = os.path.join(self.temp_dir, "readonly_agent.yaml") + self.nonexistent_file = os.path.join(self.temp_dir, "nonexistent_agent.yaml") + + # Create valid YAML file + with open(self.valid_yaml_file, 'w') as f: + f.write(""" +model=azure/gpt-4.1 +""") + + # Create invalid YAML file + with open(self.invalid_yaml_file, 'w') as f: + f.write("invalid: yaml: content: [\n - unclosed\n") + + # Create readonly YAML file + with open(self.readonly_yaml_file, 'w') as f: + f.write("agent:\n config: test\n") + os.chmod(self.readonly_yaml_file, 0o000) # Remove all permissions + + def tearDown(self): + import os + import shutil + + # Restore permissions before cleanup + if os.path.exists(self.readonly_yaml_file): + os.chmod(self.readonly_yaml_file, 0o644) + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_none_config_file(self): + """Test that None config_file returns without error""" + namespace = AgentConfigFileNamespace(None) + validators.validate_agent_config_file(namespace) + + def test_empty_config_file(self): + """Test that empty string config_file returns without error""" + namespace = AgentConfigFileNamespace("") + validators.validate_agent_config_file(namespace) + + def test_valid_config_file(self): + """Test that valid YAML config file passes validation""" + namespace = AgentConfigFileNamespace(self.valid_yaml_file) + # Should not raise any exception + validators.validate_agent_config_file(namespace) + + def test_invalid_yaml_config_file(self): + """Test that invalid YAML config file raises InvalidArgumentValueError""" + namespace = AgentConfigFileNamespace(self.invalid_yaml_file) + with self.assertRaises(InvalidArgumentValueError) as cm: + validators.validate_agent_config_file(namespace) + self.assertIn("file is not a valid YAML file", str(cm.exception)) + self.assertIn("config-file", str(cm.exception)) + + def test_nonexistent_config_file(self): + """Test that non-existent config file raises InvalidArgumentValueError""" + namespace = AgentConfigFileNamespace(self.nonexistent_file) + with self.assertRaises(InvalidArgumentValueError) as cm: + validators.validate_agent_config_file(namespace) + self.assertIn("file is not found", str(cm.exception)) + self.assertIn("config-file", str(cm.exception)) + + def test_unreadable_config_file(self): + """Test that unreadable config file raises InvalidArgumentValueError""" + import os + + # Skip on Windows as it handles permissions differently + if os.name == 'nt': + self.skipTest("Skipping readonly test on Windows") + + namespace = AgentConfigFileNamespace(self.readonly_yaml_file) + with self.assertRaises(InvalidArgumentValueError) as cm: + validators.validate_agent_config_file(namespace) + self.assertIn("file is not readable", str(cm.exception)) + self.assertIn("config-file", str(cm.exception)) + + @patch('azext_aks_preview._validators.get_config_dir') + @patch('azext_aks_preview._validators.os.path.exists') + def test_default_config_path_nonexistent(self, mock_exists, mock_get_config_dir): + """Test that default config path that doesn't exist returns without error""" + mock_get_config_dir.return_value = "/home/user/.azure" + mock_exists.return_value = False + + default_path = "/home/user/.azure/aksAgent.yaml" + namespace = AgentConfigFileNamespace(default_path) + + # Should not raise any exception when default path doesn't exist + validators.validate_agent_config_file(namespace) + + @patch('azext_aks_preview._validators.get_config_dir') + def test_default_config_path_exists_valid(self, mock_get_config_dir): + """Test that default config path with valid file passes validation""" + mock_get_config_dir.return_value = self.temp_dir + + default_path = os.path.join(self.temp_dir, "aksAgent.yaml") + # Create the default config file + with open(default_path, 'w') as f: + f.write("agent:\n config: default\n") + + namespace = AgentConfigFileNamespace(default_path) + # Should not raise any exception + validators.validate_agent_config_file(namespace) + + @patch('azext_aks_preview._validators.get_config_dir') + def test_default_config_path_exists_invalid(self, mock_get_config_dir): + """Test that default config path with invalid file raises error""" + mock_get_config_dir.return_value = self.temp_dir + + default_path = os.path.join(self.temp_dir, "aksAgent.yaml") + # Create the default config file with invalid YAML + with open(default_path, 'w') as f: + f.write("invalid: yaml: [\n unclosed\n") + + namespace = AgentConfigFileNamespace(default_path) + with self.assertRaises(InvalidArgumentValueError) as cm: + validators.validate_agent_config_file(namespace) + self.assertIn("file is not a valid YAML file", str(cm.exception)) + + def test_empty_agent_config_file(self): + """Test validation with empty agent config file""" + import os + empty_config_file = os.path.join(self.temp_dir, "empty_agent.yaml") + with open(empty_config_file, 'w') as f: + f.write("") + + namespace = AgentConfigFileNamespace(empty_config_file) + # Should not raise any exception - empty file is valid YAML + validators.validate_agent_config_file(namespace) + + @patch('builtins.open') + def test_file_access_exception(self, mock_open): + """Test that general file access exceptions are handled properly""" + mock_open.side_effect = PermissionError("Access denied") + + namespace = AgentConfigFileNamespace(self.valid_yaml_file) + with self.assertRaises(InvalidArgumentValueError) as cm: + validators.validate_agent_config_file(namespace) + self.assertIn("An error occurred while reading the config file", str(cm.exception)) + self.assertIn("config-file", str(cm.exception)) + + def test_minimal_valid_agent_config(self): + """Test validation with minimal valid agent configuration""" + import os + minimal_config_file = os.path.join(self.temp_dir, "minimal_agent.yaml") + with open(minimal_config_file, 'w') as f: + f.write("agent: {}") + + namespace = AgentConfigFileNamespace(minimal_config_file) + # Should not raise any exception + validators.validate_agent_config_file(namespace) + + if __name__ == "__main__": unittest.main() From 68cc25086631265d8349f3b1b0ca63e351bcab5b Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Mon, 25 Aug 2025 04:05:51 +0000 Subject: [PATCH 7/9] add sub id in the metrics --- src/aks-preview/azext_aks_preview/agent/telemetry.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/aks-preview/azext_aks_preview/agent/telemetry.py b/src/aks-preview/azext_aks_preview/agent/telemetry.py index 67025fd4903..8d1c27af3af 100644 --- a/src/aks-preview/azext_aks_preview/agent/telemetry.py +++ b/src/aks-preview/azext_aks_preview/agent/telemetry.py @@ -9,7 +9,8 @@ import platform from applicationinsights import TelemetryClient -from azure.cli.core.telemetry import _get_hash_mac_address, _get_user_agent +from azure.cli.core.telemetry import (_get_azure_subscription_id, + _get_hash_mac_address, _get_user_agent) DEFAULT_INSTRUMENTATION_KEY = "c301e561-daea-42d9-b9d1-65fca4166704" APPLICATIONINSIGHTS_INSTRUMENTATION_KEY_ENV = "APPLICATIONINSIGHTS_INSTRUMENTATION_KEY" @@ -62,10 +63,11 @@ def _generate_payload(self): return { "device.id": _get_hash_mac_address(), "service.name": "aks agent", + "userAzureSubscriptionId": _get_azure_subscription_id(), "OS.Type": platform.system().lower(), # eg. darwin, windows "OS.Version": platform.version().lower(), # eg. 10.0.14942 "OS.Platform": platform.platform().lower(), # eg. windows-10-10.0.19041-sp0 - "UserAgent": _get_user_agent(), + "userAgent": _get_user_agent(), "extensionname": extension_name, # extension and version } From afe529db37b907cd5d8c4250c39198e2fb066a12 Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Mon, 25 Aug 2025 07:38:21 +0000 Subject: [PATCH 8/9] skip test when python < 3.10 --- .../azext_aks_preview/tests/latest/test_agent.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py b/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py index f27ab724b72..f0d495865ff 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py @@ -31,6 +31,12 @@ sys.modules['holmes.utils.console.result'] = Mock() +def setUpModule(): + # Skip all tests in this module for Python versions below 3.10 + if sys.version_info < (3, 10): + raise unittest.SkipTest("Tests in this module require Python >= 3.10") + + class TestInitLog(unittest.TestCase): """Test cases for init_log function""" From 63d34d5eb3ff11519bb5e741d43f1c758deb4325 Mon Sep 17 00:00:00 2001 From: Qingchuan Hao Date: Mon, 25 Aug 2025 08:57:27 +0000 Subject: [PATCH 9/9] remove tests hard to maintain --- .../tests/latest/test_agent.py | 276 +----------------- 1 file changed, 8 insertions(+), 268 deletions(-) diff --git a/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py b/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py index f0d495865ff..bcf49ef5570 100644 --- a/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py @@ -40,40 +40,6 @@ def setUpModule(): class TestInitLog(unittest.TestCase): """Test cases for init_log function""" - @patch('azext_aks_preview.agent.agent.logging.getLogger') - @patch('holmes.utils.console.logging.init_logging') - def test_init_log_sets_log_levels_and_calls_init_logging(self, mock_init_logging, mock_get_logger): - """Test that init_log sets appropriate log levels and calls init_logging""" - # Arrange - mock_logger = Mock() - mock_get_logger.return_value = mock_logger - mock_console = Mock() - mock_init_logging.return_value = mock_console - - # Act - result = init_log() - - # Assert - self.assertEqual(result, mock_console) - - # Verify all loggers are set to WARNING level - expected_logger_calls = [ - call("LiteLLM"), - call("telemetry.main"), - call("telemetry.process"), - call("telemetry.save"), - call("telemetry.client"), - call("az_command_data_logger"), - ] - mock_get_logger.assert_has_calls(expected_logger_calls, any_order=True) - - # Verify setLevel is called with WARNING for each logger - expected_setlevel_calls = [call(logging.WARNING)] * 6 - mock_logger.setLevel.assert_has_calls(expected_setlevel_calls) - - # Verify init_logging is called with empty list - mock_init_logging.assert_called_once_with([]) - @patch('azext_aks_preview.agent.agent.logging.getLogger') def test_init_log_logger_level_setting(self, mock_get_logger): """Test that specific loggers get WARNING level set""" @@ -133,8 +99,8 @@ def test_aks_agent_python_version_check(self): @patch('azext_aks_preview.agent.agent.init_log') @patch('azure.cli.core.api.get_config_dir') @patch('azure.cli.core.commands.client_factory.get_subscription_id') - @patch('pathlib.Path') - def test_aks_agent_no_prompt_no_interactive_raises_error(self, mock_path, mock_get_subscription_id, + @patch('os.path.expanduser') + def test_aks_agent_no_prompt_no_interactive_raises_error(self, mock_expanduser, mock_get_subscription_id, mock_get_config_dir, mock_init_log, mock_cli_telemetry, mock_stdin_isatty): """Test that aks_agent raises error when no prompt and not interactive mode""" @@ -145,8 +111,8 @@ def test_aks_agent_no_prompt_no_interactive_raises_error(self, mock_path, mock_g mock_get_config_dir.return_value = "/home/user/.azure" mock_get_subscription_id.return_value = "test-subscription" - mock_config_path = Mock() - mock_path.return_value = mock_config_path + # Mock os.path.expanduser to return a simple path string + mock_expanduser.return_value = "/expanded/path/to/config.yaml" with patch.dict(os.environ, {}, clear=True): with patch('holmes.config.Config') as mock_config_class: @@ -170,176 +136,8 @@ def test_aks_agent_no_prompt_no_interactive_raises_error(self, mock_path, mock_g @patch('azext_aks_preview.agent.agent.init_log') @patch('azure.cli.core.api.get_config_dir') @patch('azure.cli.core.commands.client_factory.get_subscription_id') - @patch('pathlib.Path') - def test_aks_agent_interactive_mode(self, mock_path, mock_get_subscription_id, mock_get_config_dir, - mock_init_log, mock_cli_telemetry, mock_stdin_isatty): - """Test aks_agent in interactive mode""" - # Arrange - mock_stdin_isatty.return_value = True - mock_console = Mock() - mock_init_log.return_value = mock_console - mock_get_config_dir.return_value = "/home/user/.azure" - mock_get_subscription_id.return_value = "test-subscription" - - mock_config_path = Mock() - mock_path.return_value = mock_config_path - - with patch.dict(os.environ, {}, clear=True): - with patch('holmes.config.Config') as mock_config_class: - mock_config = Mock() - mock_config_class.load_from_file.return_value = mock_config - mock_ai = Mock() - mock_config.create_console_toolcalling_llm.return_value = mock_ai - - with patch('holmes.interactive.run_interactive_loop') as mock_run_interactive: - with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: - mock_load_prompt.return_value = "AKS context" - - # Act - params = self.default_params.copy() - params['no_interactive'] = False # Interactive mode - aks_agent(**params) - - # Assert - mock_run_interactive.assert_called_once() - - # Verify interactive loop is called with correct parameters - call_args = mock_run_interactive.call_args - self.assertEqual(call_args[0][0], mock_ai) # ai parameter - self.assertEqual(call_args[0][1], mock_console) # console parameter - self.assertEqual(call_args[0][2], 'test prompt') # prompt parameter - self.assertEqual(call_args[1]['show_tool_output'], True) - self.assertEqual(call_args[1]['system_prompt_additions'], "AKS context") - - @patch('sys.stdin.isatty') - @patch('azext_aks_preview.agent.agent.CLITelemetryClient') - @patch('azext_aks_preview.agent.agent.init_log') - @patch('azure.cli.core.api.get_config_dir') - @patch('azure.cli.core.commands.client_factory.get_subscription_id') - @patch('pathlib.Path') - def test_aks_agent_non_interactive_mode(self, mock_path, mock_get_subscription_id, mock_get_config_dir, - mock_init_log, mock_cli_telemetry, mock_stdin_isatty): - """Test aks_agent in non-interactive mode""" - # Arrange - mock_stdin_isatty.return_value = True - mock_console = Mock() - mock_init_log.return_value = mock_console - mock_get_config_dir.return_value = "/home/user/.azure" - mock_get_subscription_id.return_value = "test-subscription" - - mock_config_path = Mock() - mock_path.return_value = mock_config_path - - with patch.dict(os.environ, {}, clear=True): - with patch('holmes.config.Config') as mock_config_class: - mock_config = Mock() - mock_config_class.load_from_file.return_value = mock_config - mock_ai = Mock() - mock_config.create_console_toolcalling_llm.return_value = mock_ai - mock_config.get_runbook_catalog.return_value = {} - - with patch('holmes.core.prompt.build_initial_ask_messages') as mock_build_messages: - mock_messages = [{'role': 'user', 'content': 'test'}] - mock_build_messages.return_value = mock_messages - - mock_response = Mock() - mock_response.messages = mock_messages - mock_ai.call.return_value = mock_response - - with patch('holmes.utils.console.result.handle_result') as mock_handle_result: - with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: - with patch('holmes.plugins.interfaces.Issue') as mock_issue: - with patch('uuid.uuid4') as mock_uuid: - with patch('socket.gethostname') as mock_hostname: - mock_load_prompt.return_value = "AKS context" - mock_uuid.return_value = "test-uuid" - mock_hostname.return_value = "test-host" - mock_issue_instance = Mock() - mock_issue.return_value = mock_issue_instance - - # Act - params = self.default_params.copy() - params['no_interactive'] = True # Non-interactive mode - aks_agent(**params) - - # Assert - mock_build_messages.assert_called_once() - mock_ai.call.assert_called_once_with(mock_messages) - mock_handle_result.assert_called_once() - - # Verify Issue is created with correct parameters - mock_issue.assert_called_once() - issue_kwargs = mock_issue.call_args[1] - self.assertEqual(issue_kwargs['id'], "test-uuid") - self.assertEqual(issue_kwargs['name'], 'test prompt') - self.assertEqual(issue_kwargs['source_type'], "holmes-ask") - self.assertEqual(issue_kwargs['source_instance_id'], "test-host") - - @patch('sys.stdin.isatty') - @patch('sys.stdin.read') - @patch('azext_aks_preview.agent.agent.CLITelemetryClient') - @patch('azext_aks_preview.agent.agent.init_log') - @patch('azure.cli.core.api.get_config_dir') - @patch('azure.cli.core.commands.client_factory.get_subscription_id') - @patch('pathlib.Path') - def test_aks_agent_piped_input_with_prompt(self, mock_path, mock_get_subscription_id, mock_get_config_dir, - mock_init_log, mock_cli_telemetry, mock_stdin_read, mock_stdin_isatty): - """Test aks_agent combines piped input with provided prompt""" - # Arrange - mock_stdin_isatty.return_value = False - mock_stdin_read.return_value = "kubectl get pods output" - mock_console = Mock() - mock_init_log.return_value = mock_console - mock_get_config_dir.return_value = "/home/user/.azure" - mock_get_subscription_id.return_value = "test-subscription" - - mock_config_path = Mock() - mock_path.return_value = mock_config_path - - with patch.dict(os.environ, {}, clear=True): - with patch('holmes.config.Config') as mock_config_class: - mock_config = Mock() - mock_config_class.load_from_file.return_value = mock_config - mock_ai = Mock() - mock_config.create_console_toolcalling_llm.return_value = mock_ai - mock_config.get_runbook_catalog.return_value = {} - - with patch('holmes.core.prompt.build_initial_ask_messages') as mock_build_messages: - mock_messages = [{'role': 'user', 'content': 'test'}] - mock_build_messages.return_value = mock_messages - - mock_response = Mock() - mock_response.messages = mock_messages - mock_ai.call.return_value = mock_response - - with patch('holmes.utils.console.result.handle_result'): - with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: - with patch('holmes.plugins.interfaces.Issue'): - with patch('uuid.uuid4'): - with patch('socket.gethostname'): - mock_load_prompt.return_value = "AKS context" - - # Act - params = self.default_params.copy() - params['prompt'] = "What's wrong with my pods?" - aks_agent(**params) - - # Assert that build_initial_ask_messages was called with combined prompt - mock_build_messages.assert_called_once() - call_args = mock_build_messages.call_args[0] - combined_prompt = call_args[1] # prompt parameter - - self.assertIn("kubectl get pods output", combined_prompt) - self.assertIn("What's wrong with my pods?", combined_prompt) - self.assertIn("Here's some piped output:", combined_prompt) - - @patch('sys.stdin.isatty') - @patch('azext_aks_preview.agent.agent.CLITelemetryClient') - @patch('azext_aks_preview.agent.agent.init_log') - @patch('azure.cli.core.api.get_config_dir') - @patch('azure.cli.core.commands.client_factory.get_subscription_id') - @patch('pathlib.Path') - def test_aks_agent_echo_request_enabled(self, mock_path, mock_get_subscription_id, mock_get_config_dir, + @patch('os.path.expanduser') + def test_aks_agent_echo_request_enabled(self, mock_expanduser, mock_get_subscription_id, mock_get_config_dir, mock_init_log, mock_cli_telemetry, mock_stdin_isatty): """Test aks_agent echoes request when echo is enabled""" # Arrange @@ -349,8 +147,8 @@ def test_aks_agent_echo_request_enabled(self, mock_path, mock_get_subscription_i mock_get_config_dir.return_value = "/home/user/.azure" mock_get_subscription_id.return_value = "test-subscription" - mock_config_path = Mock() - mock_path.return_value = mock_config_path + # Mock os.path.expanduser to return a simple path string + mock_expanduser.return_value = "/expanded/path/to/config.yaml" with patch.dict(os.environ, {}, clear=True): with patch('holmes.config.Config') as mock_config_class: @@ -401,64 +199,6 @@ def test_aks_agent_telemetry_client_usage(self, mock_cli_telemetry): mock_cli_telemetry.return_value.__enter__.assert_called_once() mock_cli_telemetry.return_value.__exit__.assert_called_once() - @patch('sys.stdin.isatty') - @patch('sys.stdin.read') - @patch('azext_aks_preview.agent.agent.CLITelemetryClient') - @patch('azext_aks_preview.agent.agent.init_log') - @patch('azure.cli.core.api.get_config_dir') - @patch('azure.cli.core.commands.client_factory.get_subscription_id') - @patch('pathlib.Path') - def test_aks_agent_piped_input_no_prompt_default_question(self, mock_path, mock_get_subscription_id, - mock_get_config_dir, mock_init_log, mock_cli_telemetry, - mock_stdin_read, mock_stdin_isatty): - """Test aks_agent with piped input but no prompt uses default question""" - # Arrange - mock_stdin_isatty.return_value = False - mock_stdin_read.return_value = "error logs from pod" - mock_console = Mock() - mock_init_log.return_value = mock_console - mock_get_config_dir.return_value = "/home/user/.azure" - mock_get_subscription_id.return_value = "test-subscription" - - mock_config_path = Mock() - mock_path.return_value = mock_config_path - - with patch.dict(os.environ, {}, clear=True): - with patch('holmes.config.Config') as mock_config_class: - mock_config = Mock() - mock_config_class.load_from_file.return_value = mock_config - mock_ai = Mock() - mock_config.create_console_toolcalling_llm.return_value = mock_ai - mock_config.get_runbook_catalog.return_value = {} - - with patch('holmes.core.prompt.build_initial_ask_messages') as mock_build_messages: - mock_messages = [{'role': 'user', 'content': 'test'}] - mock_build_messages.return_value = mock_messages - - mock_response = Mock() - mock_response.messages = mock_messages - mock_ai.call.return_value = mock_response - - with patch('holmes.utils.console.result.handle_result'): - with patch('holmes.plugins.prompts.load_and_render_prompt') as mock_load_prompt: - with patch('holmes.plugins.interfaces.Issue'): - with patch('uuid.uuid4'): - with patch('socket.gethostname'): - mock_load_prompt.return_value = "AKS context" - - # Act - params = self.default_params.copy() - params['prompt'] = None # No prompt provided - aks_agent(**params) - - # Assert that build_initial_ask_messages was called with default question - mock_build_messages.assert_called_once() - call_args = mock_build_messages.call_args[0] - prompt_with_default = call_args[1] # prompt parameter - - self.assertIn("error logs from pod", prompt_with_default) - self.assertIn("What can you tell me about this output?", prompt_with_default) - if __name__ == "__main__": unittest.main()