diff --git a/src/aimanager/HISTORY.rst b/src/aimanager/HISTORY.rst index f3812405cfd..857a6232f74 100644 --- a/src/aimanager/HISTORY.rst +++ b/src/aimanager/HISTORY.rst @@ -3,6 +3,17 @@ Release History =============== +1.2.1b1 ++++++++ +* Add ``az aimanager model`` commands (``show``, ``list`` and ``calculate-cost``) to browse the + regional AI model catalog and estimate the cost of deploying a model. +* Add ``az aimanager modelsource`` commands (``add``, ``update``, ``list``, ``show``, + ``delete`` and ``wait``) to manage the model sources of an AI Manager. +* ``az aimanager namespace``: Add ``list-accesskeys`` and ``rotate-accesskeys`` commands to + read and rotate the namespace LLM gateway API keys. +* ``az aimanager namespace``: Accept ``--aimanager-name`` as an alias of ``--manager``/``-m`` + for consistency with ``az aimanager namespace modeldeployment``. + 1.2.0 ++++++ * Add ``az aimanager namespace modeldeployment`` commands to add, update, list, show, delete, diff --git a/src/aimanager/azext_aimanager/_help.py b/src/aimanager/azext_aimanager/_help.py index 4964224cc91..40094028adf 100644 --- a/src/aimanager/azext_aimanager/_help.py +++ b/src/aimanager/azext_aimanager/_help.py @@ -141,6 +141,33 @@ text: az aimanager namespace get-credentials -m my-ai-manager -g myrg --name team-alpha -f - """ +helps['aimanager namespace list-accesskeys'] = """ + type: command + short-summary: List the LLM gateway endpoint and API keys of an AI Manager namespace. + long-summary: |- + Returns the namespace-scoped, OpenAI-compatible inference gateway endpoint together with the + current primary and secondary API keys. Treat the keys as secrets: do not log them or persist + them in plaintext. + examples: + - name: List the access keys of a namespace + text: az aimanager namespace list-accesskeys -g myrg --aimanager-name my-ai-manager --name team-alpha + - name: Show only the gateway endpoint + text: az aimanager namespace list-accesskeys -g myrg --aimanager-name my-ai-manager --name team-alpha --query endpoint -o tsv +""" + +helps['aimanager namespace rotate-accesskeys'] = """ + type: command + short-summary: Rotate the LLM gateway API keys of an AI Manager namespace. + long-summary: |- + A new key is generated and installed as the primary key, and the previous primary key + overwrites the secondary key so clients can roll over without downtime. Returns the updated + access info. Any client still using the previous secondary key will stop being able to + authenticate. + examples: + - name: Rotate the access keys of a namespace + text: az aimanager namespace rotate-accesskeys -g myrg --aimanager-name my-ai-manager --name team-alpha +""" + helps['aimanager namespace modeldeployment'] = """ type: group short-summary: Manage model deployments within an AI Manager namespace. diff --git a/src/aimanager/azext_aimanager/_params.py b/src/aimanager/azext_aimanager/_params.py index b859b8577de..dc7472c05ea 100644 --- a/src/aimanager/azext_aimanager/_params.py +++ b/src/aimanager/azext_aimanager/_params.py @@ -54,7 +54,7 @@ def load_arguments(self, _): help='Comma-separated key=value pairs to specify custom headers.') with self.argument_context('aimanager namespace') as c: - c.argument('ai_manager_name', options_list=['--manager', '-m'], + c.argument('ai_manager_name', options_list=['--aimanager-name', '--manager', '-m'], validator=validate_ai_manager_name, help='The name of the AI Manager resource.') c.argument('namespace_name', options_list=['--name', '-n'], @@ -80,6 +80,11 @@ def load_arguments(self, _): c.argument('aks_custom_headers', options_list=['--aks-custom-headers'], help='Comma-separated key=value pairs to specify custom headers.') + for scope in ['aimanager namespace list-accesskeys', 'aimanager namespace rotate-accesskeys']: + with self.argument_context(scope) as c: + c.argument('aks_custom_headers', options_list=['--aks-custom-headers'], + help='Comma-separated key=value pairs to specify custom headers.') + with self.argument_context('aimanager namespace modeldeployment') as c: c.argument('ai_manager_name', options_list=['--aimanager-name'], validator=validate_ai_manager_name, diff --git a/src/aimanager/azext_aimanager/azext_metadata.json b/src/aimanager/azext_aimanager/azext_metadata.json index 8ad409021bd..bda33e00008 100644 --- a/src/aimanager/azext_aimanager/azext_metadata.json +++ b/src/aimanager/azext_aimanager/azext_metadata.json @@ -1,5 +1,5 @@ { "azext.isPreview": true, "azext.minCliCoreVersion": "2.61.0", - "version": "1.2.0" + "version": "1.2.1b1" } diff --git a/src/aimanager/azext_aimanager/commands.py b/src/aimanager/azext_aimanager/commands.py index d100c263b27..e440b90e675 100644 --- a/src/aimanager/azext_aimanager/commands.py +++ b/src/aimanager/azext_aimanager/commands.py @@ -50,6 +50,8 @@ def load_command_table(self, _): g.custom_command("list", "list_aimanager_namespace") g.custom_command("delete", "delete_aimanager_namespace", supports_no_wait=True, confirmation=True) g.custom_command("get-credentials", "aimanager_namespace_get_credentials") + g.custom_command("list-accesskeys", "aimanager_namespace_list_accesskeys") + g.custom_command("rotate-accesskeys", "aimanager_namespace_rotate_accesskeys", confirmation=True) g.wait_command("wait") # aimanager namespace modeldeployment command group diff --git a/src/aimanager/azext_aimanager/custom.py b/src/aimanager/azext_aimanager/custom.py index 6da27d25606..fc7fbdcf0ac 100644 --- a/src/aimanager/azext_aimanager/custom.py +++ b/src/aimanager/azext_aimanager/custom.py @@ -299,6 +299,30 @@ def aimanager_namespace_get_credentials(cmd, resource_group_name, ai_manager_name, namespace_name, headers=headers) _write_kubeconfig(credential_results, path, overwrite_existing, context_name) + +# pylint: disable=unused-argument +def aimanager_namespace_list_accesskeys(cmd, + client, + resource_group_name, + ai_manager_name, + namespace_name, + aks_custom_headers=None): + headers = get_aks_custom_headers(aks_custom_headers) + return client.list_access_keys( + resource_group_name, ai_manager_name, namespace_name, headers=headers) + + +# pylint: disable=unused-argument +def aimanager_namespace_rotate_accesskeys(cmd, + client, + resource_group_name, + ai_manager_name, + namespace_name, + aks_custom_headers=None): + headers = get_aks_custom_headers(aks_custom_headers) + return client.rotate_keys( + resource_group_name, ai_manager_name, namespace_name, headers=headers) + # endregion diff --git a/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys.py b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys.py new file mode 100644 index 00000000000..573f8aa2999 --- /dev/null +++ b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys.py @@ -0,0 +1,52 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest +from unittest.mock import MagicMock + +from azext_aimanager import custom + + +class MockCmd: + pass + + +class TestNamespaceAccessKeys(unittest.TestCase): + + def setUp(self): + self.cmd = MockCmd() + self.client = MagicMock() + + def test_list_accesskeys(self): + self.client.list_access_keys.return_value = "access-info" + + result = custom.aimanager_namespace_list_accesskeys( + self.cmd, self.client, "rg", "manager", "namespace") + + self.assertEqual(result, "access-info") + self.client.list_access_keys.assert_called_once_with( + "rg", "manager", "namespace", headers={}) + + def test_rotate_accesskeys(self): + self.client.rotate_keys.return_value = "rotated" + + result = custom.aimanager_namespace_rotate_accesskeys( + self.cmd, self.client, "rg", "manager", "namespace") + + self.assertEqual(result, "rotated") + self.client.rotate_keys.assert_called_once_with( + "rg", "manager", "namespace", headers={}) + + def test_custom_headers_are_forwarded(self): + custom.aimanager_namespace_list_accesskeys( + self.cmd, self.client, "rg", "manager", "namespace", + aks_custom_headers="a=1,b=2") + + self.client.list_access_keys.assert_called_once_with( + "rg", "manager", "namespace", headers={"a": "1", "b": "2"}) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py new file mode 100644 index 00000000000..96915c69928 --- /dev/null +++ b/src/aimanager/azext_aimanager/tests/latest/test_namespace_accesskeys_scenario.py @@ -0,0 +1,69 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from unittest.mock import MagicMock, patch + +from azure.cli.testsdk import ScenarioTest + +from azext_aimanager.vendored_sdks.v2026_05_02_preview import models + + +class NamespaceAccessKeysScenarioTest(ScenarioTest): + + def test_namespace_accesskeys_commands(self): + access_info = models.NamespaceAccessInfo({ + 'endpoint': 'https://team-alpha.example.eastus2.aksapp.io/v1', + 'primaryKey': 'primary-key-value', + 'secondaryKey': 'secondary-key-value', + }) + rotated_info = models.NamespaceAccessInfo({ + 'endpoint': 'https://team-alpha.example.eastus2.aksapp.io/v1', + 'primaryKey': 'new-primary-key-value', + 'secondaryKey': 'primary-key-value', + 'lastRotatedAt': '2026-08-14T00:00:00Z', + }) + + operations = MagicMock() + operations.list_access_keys.return_value = access_info + operations.rotate_keys.return_value = rotated_info + service_client = MagicMock() + service_client.ai_manager_namespaces = operations + + command_prefix = 'aimanager namespace {} -g rg --aimanager-name manager -n namespace' + + with patch('azext_aimanager._client_factory.get_aimanager_client', + return_value=service_client): + self.cmd( + command_prefix.format('list-accesskeys'), + checks=[ + self.check('endpoint', 'https://team-alpha.example.eastus2.aksapp.io/v1'), + self.check('primaryKey', 'primary-key-value'), + self.check('secondaryKey', 'secondary-key-value'), + ]) + + # the previous primary key is demoted to the secondary key on rotation + self.cmd( + command_prefix.format('rotate-accesskeys') + ' --yes', + checks=[ + self.check('primaryKey', 'new-primary-key-value'), + self.check('secondaryKey', 'primary-key-value'), + ]) + + # -m/--manager remains accepted for backwards compatibility + self.cmd( + 'aimanager namespace list-accesskeys -g rg -m manager -n namespace', + checks=[self.check('primaryKey', 'primary-key-value')]) + + operations.list_access_keys.assert_called_with( + 'rg', 'manager', 'namespace', headers={}) + + # --aks-custom-headers is parsed and forwarded to the request + self.cmd( + command_prefix.format('list-accesskeys') + ' --aks-custom-headers a=1,b=2', + checks=[self.check('primaryKey', 'primary-key-value')]) + + operations.list_access_keys.assert_called_with( + 'rg', 'manager', 'namespace', headers={'a': '1', 'b': '2'}) + operations.rotate_keys.assert_called_once_with('rg', 'manager', 'namespace', headers={}) diff --git a/src/aimanager/setup.py b/src/aimanager/setup.py index 399850f3d04..11bac3528e6 100644 --- a/src/aimanager/setup.py +++ b/src/aimanager/setup.py @@ -14,7 +14,7 @@ from distutils import log as logger logger.warn("Wheel is not available, disabling bdist_wheel hook") -VERSION = '1.2.0' +VERSION = '1.2.1b1' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers