diff --git a/src/aks-preview/HISTORY.rst b/src/aks-preview/HISTORY.rst index cbd1f82ab8d..d9d2e761cf4 100644 --- a/src/aks-preview/HISTORY.rst +++ b/src/aks-preview/HISTORY.rst @@ -12,6 +12,10 @@ To release a new version, please select a new version number (usually plus 1 to Pending +++++++ +18.0.0b28 ++++++++ +* Add interactive AI-powered debugging tool `az aks agent`. + 18.0.0b27 +++++++ * Add framework for interactive AI-powered debugging tool. 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 49ac36422ea..89dec9efabb 100644 --- a/src/aks-preview/azext_aks_preview/_help.py +++ b/src/aks-preview/azext_aks_preview/_help.py @@ -3953,100 +3953,98 @@ 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 + - 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.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.yaml + 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 f9cc0c59325..3443121848f 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, @@ -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 ( @@ -224,7 +225,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, @@ -2780,70 +2781,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(), CONST_AGENT_CONFIG_FILE_NAME), + 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/_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/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/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 } 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( 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..bcf49ef5570 --- /dev/null +++ b/src/aks-preview/azext_aks_preview/tests/latest/test_agent.py @@ -0,0 +1,204 @@ +# -------------------------------------------------------------------------------------------- +# 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() + + +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""" + + @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('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""" + # 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 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: + 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('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 + 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 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: + 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() + + +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() diff --git a/src/aks-preview/setup.py b/src/aks-preview/setup.py index 682bed05060..884fb4ccebe 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.0b27" +VERSION = "18.0.0b28" CLASSIFIERS = [ "Development Status :: 4 - Beta", @@ -24,7 +24,7 @@ ] DEPENDENCIES = [ - "holmesgpt==0.12.4; python_version >= '3.10'", + "holmesgpt==0.12.6; python_version >= '3.10'", ] with open1("README.rst", "r", encoding="utf-8") as f: