diff --git a/meta/runtime.yml b/meta/runtime.yml index 0e79e0ea9e..5d026c2afa 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -88,6 +88,8 @@ action_groups: - azure.azcollection.azure_rm_cognitivesearch_info - azure.azcollection.azure_rm_cognitiveservicesaccount - azure.azcollection.azure_rm_cognitiveservicesaccount_info + - azure.azcollection.azure_rm_cognitiveservicesdeployment + - azure.azcollection.azure_rm_cognitiveservicesdeployment_info - azure.azcollection.azure_rm_cognitiveservicesmodel_info - azure.azcollection.azure_rm_containerapp - azure.azcollection.azure_rm_containerapp_info diff --git a/plugins/modules/azure_rm_cognitiveservicesaccount.py b/plugins/modules/azure_rm_cognitiveservicesaccount.py index af28050657..d653ec7181 100644 --- a/plugins/modules/azure_rm_cognitiveservicesaccount.py +++ b/plugins/modules/azure_rm_cognitiveservicesaccount.py @@ -183,6 +183,20 @@ type: SystemAssigned disable_local_auth: true +- name: Create an Azure OpenAI account + azure.azcollection.azure_rm_cognitiveservicesaccount: + resource_group: myResourceGroup + name: myopenaiaccount + kind: OpenAI + location: eastus + sku: S0 + # A custom subdomain is required for Azure OpenAI data-plane / token auth. + custom_domain_name: myopenaiaccount + tags: + purpose: generative-ai + # Deploy models into this account with + # azure.azcollection.azure_rm_cognitiveservicesdeployment. + - name: Update tags on existing account azure.azcollection.azure_rm_cognitiveservicesaccount: resource_group: myResourceGroup diff --git a/plugins/modules/azure_rm_cognitiveservicesdeployment.py b/plugins/modules/azure_rm_cognitiveservicesdeployment.py new file mode 100644 index 0000000000..6c500c2672 --- /dev/null +++ b/plugins/modules/azure_rm_cognitiveservicesdeployment.py @@ -0,0 +1,383 @@ +#!/usr/bin/python +# +# Copyright (c) 2026 Bill Peck (@p3ck) +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: azure_rm_cognitiveservicesdeployment +version_added: "4.2.0" +short_description: Manage model deployments in an Azure AI / OpenAI account +description: + - Create, update, and delete a model deployment within an Azure AI Services + or Azure OpenAI (Cognitive Services) account. + - An Azure OpenAI account is a Cognitive Services account with I(kind=OpenAI); + create it with M(azure.azcollection.azure_rm_cognitiveservicesaccount). +options: + resource_group: + description: + - Name of the resource group containing the account. + required: true + type: str + aliases: + - resource_group_name + account_name: + description: + - Name of the Cognitive Services / Azure OpenAI account that hosts the deployment. + required: true + type: str + name: + description: + - Name of the model deployment. + required: true + type: str + model: + description: + - The model to deploy. + - Required when creating a deployment. + type: dict + suboptions: + name: + description: + - Name of the model to deploy, e.g. C(gpt-4o-mini). + type: str + required: true + format: + description: + - The format of the model, e.g. C(OpenAI). + type: str + default: OpenAI + version: + description: + - The version of the model. If omitted, Azure selects the default version. + type: str + sku: + description: + - The resource SKU controlling the deployment type and capacity. + type: dict + suboptions: + name: + description: + - SKU name, e.g. C(Standard), C(GlobalStandard), C(DataZoneStandard). + type: str + capacity: + description: + - Capacity (quota) assigned to the deployment, in units of + thousands of tokens-per-minute for most OpenAI models. + type: int + rai_policy_name: + description: + - Name of the responsible-AI (content filter) policy to apply. + type: str + version_upgrade_option: + description: + - Deployment model version upgrade option. + type: str + choices: + - OnceNewDefaultVersionAvailable + - OnceCurrentVersionExpired + - NoAutoUpgrade + state: + description: + - Assert the state of the deployment. Use C(present) to create/update, + C(absent) to delete. + type: str + default: present + choices: + - present + - absent +extends_documentation_fragment: + - azure.azcollection.azure + - azure.azcollection.azure_tags +author: + - Bill Peck (@p3ck) +''' + +EXAMPLES = ''' +- name: Deploy a GPT-4.1 mini model (Standard) + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: myResourceGroup + account_name: myopenaiaccount + name: gpt-4.1-mini + model: + name: gpt-4.1-mini + version: "2025-04-14" + sku: + name: Standard + capacity: 10 + version_upgrade_option: OnceNewDefaultVersionAvailable + +- name: Scale the deployment capacity (idempotent re-run after changing capacity) + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: myResourceGroup + account_name: myopenaiaccount + name: gpt-4.1-mini + model: + name: gpt-4.1-mini + version: "2025-04-14" + sku: + name: Standard + capacity: 50 + +- name: Delete a model deployment + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: myResourceGroup + account_name: myopenaiaccount + name: gpt-4.1-mini + state: absent +''' + +RETURN = ''' +state: + description: + - The model deployment as returned by Azure. + returned: when I(state=present) + type: dict + sample: { + "id": "/subscriptions/xxx/resourceGroups/myResourceGroup/providers/Microsoft.CognitiveServices/accounts/myopenaiaccount/deployments/gpt-4.1-mini", + "name": "gpt-4.1-mini", + "sku": {"name": "Standard", "capacity": 10}, + "properties": { + "model": {"format": "OpenAI", "name": "gpt-4.1-mini", "version": "2025-04-14"}, + "provisioning_state": "Succeeded", + "version_upgrade_option": "OnceNewDefaultVersionAvailable" + }, + "type": "Microsoft.CognitiveServices/accounts/deployments" + } +''' + +import copy + +from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_common_ext import AzureRMModuleBaseExt + +try: + from azure.core.exceptions import ResourceNotFoundError +except ImportError: + # This is handled in azure_rm_common + pass + + +class AzureRMCognitiveServicesDeployment(AzureRMModuleBaseExt): + def __init__(self): + self.module_arg_spec = dict( + resource_group=dict(type='str', required=True, aliases=['resource_group_name']), + account_name=dict(type='str', required=True), + name=dict(type='str', required=True), + model=dict(type='dict', options=dict( + name=dict(type='str', required=True), + format=dict(type='str', default='OpenAI'), + version=dict(type='str'), + )), + sku=dict(type='dict', options=dict( + name=dict(type='str'), + capacity=dict(type='int'), + )), + rai_policy_name=dict(type='str'), + version_upgrade_option=dict(type='str', choices=[ + 'OnceNewDefaultVersionAvailable', + 'OnceCurrentVersionExpired', + 'NoAutoUpgrade', + ]), + state=dict(type='str', default='present', choices=['present', 'absent']), + ) + + self.resource_group = None + self.account_name = None + self.name = None + self.model = None + self.sku = None + self.rai_policy_name = None + self.version_upgrade_option = None + self.state = None + self.tags = None + + self.results = dict(changed=False, compare=[]) + + super(AzureRMCognitiveServicesDeployment, self).__init__( + derived_arg_spec=self.module_arg_spec, + supports_check_mode=True, + supports_tags=True + ) + + def exec_module(self, **kwargs): + for key in list(self.module_arg_spec.keys()) + ['tags']: + setattr(self, key, kwargs[key]) + + existing = self.get_deployment() + + if self.state == 'present': + if not existing: + if not self.model: + self.module.fail_json( + msg="model is required to create deployment '{0}'; " + "it does not exist yet".format(self.name)) + params = self.build_deployment_parameters() + if self.tags: + params['tags'] = self.update_tags(None)[1] + if not self.check_mode: + self.results['state'] = self.create_or_update_deployment(params) + else: + self.results['state'] = params + self.results['changed'] = True + else: + params = self.build_deployment_parameters() + if self.check_update_needed(existing, params): + # begin_create_or_update issues a PUT (full replace), so + # carry forward the existing model/sku the user did not + # resupply; otherwise the PUT would be incomplete. + body = self._merge_for_update(existing, params) + if not self.check_mode: + self.results['state'] = self.create_or_update_deployment(body) + else: + self.results['state'] = body + self.results['changed'] = True + else: + self.results['state'] = existing + else: # absent + if existing: + if not self.check_mode: + self.delete_deployment() + self.results['changed'] = True + self.results['state'] = dict() + + return self.results + + def get_deployment(self): + """Return the deployment as a dict, or None if it does not exist.""" + self.log("Getting deployment {0} on account {1}".format(self.name, self.account_name)) + try: + obj = self.cognitive_services_management_client.deployments.get( + self.resource_group, + self.account_name, + self.name + ) + return obj.as_dict() + except ResourceNotFoundError: + self.log("Deployment {0} not found".format(self.name)) + return None + + def build_deployment_parameters(self): + """Build the deployment body (snake_case keys matching the SDK model).""" + properties = {} + if self.model is not None: + model = {} + for key in ('format', 'name', 'version'): + value = self.model.get(key) + if value is not None: + model[key] = value + properties['model'] = model + if self.rai_policy_name is not None: + properties['rai_policy_name'] = self.rai_policy_name + if self.version_upgrade_option is not None: + properties['version_upgrade_option'] = self.version_upgrade_option + + params = {} + if self.sku is not None: + sku = {} + for key in ('name', 'capacity'): + value = self.sku.get(key) + if value is not None: + sku[key] = value + if sku: + params['sku'] = sku + if properties: + params['properties'] = properties + return params + + def check_update_needed(self, existing, params): + """Return True when the existing deployment differs from the desired params.""" + changed = False + + update_tags, newtags = self.update_tags(existing.get('tags')) + if newtags: + params['tags'] = newtags + if update_tags: + changed = True + + # default_compare mutates the "new" dict it is given (it fills unset + # keys from "old"), so compare against a copy to keep params clean. + if not self.default_compare({}, copy.deepcopy(params), existing, '', self.results): + changed = True + + return changed + + # Deployment properties the service populates and rejects on write. + # Everything else under "properties" is writable and must survive a PUT. + READ_ONLY_PROPERTIES = frozenset([ + 'provisioning_state', + 'capabilities', + 'call_rate_limit', + 'rate_limits', + 'dynamic_throttling_enabled', + 'current_capacity', + ]) + + def _merge_for_update(self, existing, params): + """Build the PUT body for an update. + + begin_create_or_update issues a PUT (full replace), so every writable + property the user did not resupply must be carried forward from the + existing deployment, or it would be reset. Read-only, service-populated + fields must NOT be echoed back. + """ + body = copy.deepcopy(params) + + # Carry forward sku / tags when the user did not supply them. + if 'sku' not in body and existing.get('sku') is not None: + existing_sku = {k: v for k, v in existing['sku'].items() + if v is not None} + if existing_sku: + body['sku'] = existing_sku + if 'tags' not in body and existing.get('tags'): + body['tags'] = existing['tags'] + + # Carry forward every writable property the user did not supply. + existing_props = existing.get('properties') or {} + props = body.setdefault('properties', {}) + for key, value in existing_props.items(): + if key in self.READ_ONLY_PROPERTIES or value is None: + continue + props.setdefault(key, value) + if not props: + body.pop('properties', None) + return body + + def create_or_update_deployment(self, params): + """Create or update the deployment and return it as a dict.""" + self.log("Creating/updating deployment {0}".format(self.name)) + try: + poller = self.cognitive_services_management_client.deployments.begin_create_or_update( + self.resource_group, + self.account_name, + self.name, + params + ) + obj = self.get_poller_result(poller) + return obj.as_dict() + except Exception as exc: + self.module.fail_json(msg="Failed to create/update deployment {0}: {1}".format(self.name, str(exc))) + + def delete_deployment(self): + """Delete the deployment.""" + self.log("Deleting deployment {0}".format(self.name)) + try: + poller = self.cognitive_services_management_client.deployments.begin_delete( + self.resource_group, + self.account_name, + self.name + ) + self.get_poller_result(poller) + except Exception as exc: + self.module.fail_json(msg="Failed to delete deployment {0}: {1}".format(self.name, str(exc))) + + +def main(): + AzureRMCognitiveServicesDeployment() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/azure_rm_cognitiveservicesdeployment_info.py b/plugins/modules/azure_rm_cognitiveservicesdeployment_info.py new file mode 100644 index 0000000000..d72e1c05db --- /dev/null +++ b/plugins/modules/azure_rm_cognitiveservicesdeployment_info.py @@ -0,0 +1,164 @@ +#!/usr/bin/python +# +# Copyright (c) 2026 Bill Peck (@p3ck) +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: azure_rm_cognitiveservicesdeployment_info +version_added: "4.2.0" +short_description: Get Azure AI / OpenAI model deployment facts +description: + - Get facts for a specific model deployment or list all deployments in an + Azure AI Services / Azure OpenAI (Cognitive Services) account. +options: + resource_group: + description: + - Name of the resource group containing the account. + required: true + type: str + aliases: + - resource_group_name + account_name: + description: + - Name of the Cognitive Services / Azure OpenAI account. + required: true + type: str + name: + description: + - Name of a specific deployment. If omitted, all deployments in the account are returned. + type: str + tags: + description: + - Limit results by providing a list of tags. Format tags as 'key' or 'key:value'. + type: list + elements: str +extends_documentation_fragment: + - azure.azcollection.azure +author: + - Bill Peck (@p3ck) +''' + +EXAMPLES = ''' +- name: Get facts for a specific deployment + azure.azcollection.azure_rm_cognitiveservicesdeployment_info: + resource_group: myResourceGroup + account_name: myopenaiaccount + name: gpt-4o-mini + +- name: List all deployments in an account + azure.azcollection.azure_rm_cognitiveservicesdeployment_info: + resource_group: myResourceGroup + account_name: myopenaiaccount +''' + +RETURN = ''' +deployments: + description: + - List of model deployments. + returned: always + type: list + elements: dict + sample: [ + { + "id": "/subscriptions/xxx/resourceGroups/myResourceGroup/providers/Microsoft.CognitiveServices/accounts/myopenaiaccount/deployments/gpt-4o-mini", + "name": "gpt-4o-mini", + "sku": {"name": "Standard", "capacity": 10}, + "properties": { + "model": {"format": "OpenAI", "name": "gpt-4o-mini", "version": "2024-07-18"}, + "provisioning_state": "Succeeded" + }, + "type": "Microsoft.CognitiveServices/accounts/deployments" + } + ] +''' + +from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_common import AzureRMModuleBase + +try: + from azure.core.exceptions import ResourceNotFoundError +except ImportError: + # This is handled in azure_rm_common + pass + + +class AzureRMCognitiveServicesDeploymentInfo(AzureRMModuleBase): + def __init__(self): + self.module_arg_spec = dict( + resource_group=dict(type='str', required=True, aliases=['resource_group_name']), + account_name=dict(type='str', required=True), + name=dict(type='str'), + tags=dict(type='list', elements='str'), + ) + + self.resource_group = None + self.account_name = None + self.name = None + self.tags = None + + self.results = dict( + changed=False, + deployments=[] + ) + + super(AzureRMCognitiveServicesDeploymentInfo, self).__init__( + derived_arg_spec=self.module_arg_spec, + supports_check_mode=True, + supports_tags=False, + facts_module=True + ) + + def exec_module(self, **kwargs): + for key in self.module_arg_spec: + setattr(self, key, kwargs[key]) + + if self.name: + results = self.get_deployment() + else: + results = self.list_deployments() + + self.results['deployments'] = [ + d for d in results if self.has_tags(d.get('tags'), self.tags) + ] + return self.results + + def get_deployment(self): + """Get a specific deployment.""" + self.log('Getting deployment {0}'.format(self.name)) + try: + obj = self.cognitive_services_management_client.deployments.get( + self.resource_group, + self.account_name, + self.name + ) + return [obj.as_dict()] + except ResourceNotFoundError: + self.log('Deployment {0} not found'.format(self.name)) + return [] + + def list_deployments(self): + """List all deployments in the account.""" + self.log('Listing deployments in account {0}'.format(self.account_name)) + results = [] + try: + deployments = self.cognitive_services_management_client.deployments.list( + self.resource_group, + self.account_name + ) + for obj in deployments: + results.append(obj.as_dict()) + except ResourceNotFoundError: + self.log('Account {0} not found'.format(self.account_name)) + return results + + +def main(): + AzureRMCognitiveServicesDeploymentInfo() + + +if __name__ == '__main__': + main() diff --git a/pr-pipelines.yml b/pr-pipelines.yml index 699d65e56f..e14711526e 100644 --- a/pr-pipelines.yml +++ b/pr-pipelines.yml @@ -53,6 +53,7 @@ parameters: - "azure_rm_cdnprofile" - "azure_rm_cognitiveservices" - "azure_rm_cognitiveservicesaccount" + - "azure_rm_cognitiveservicesdeployment" - "azure_rm_containerapp" - "azure_rm_containerinstance" - "azure_rm_containerregistry" diff --git a/tests/integration/targets/azure_rm_cognitiveservicesdeployment/aliases b/tests/integration/targets/azure_rm_cognitiveservicesdeployment/aliases new file mode 100644 index 0000000000..5bec11dd53 --- /dev/null +++ b/tests/integration/targets/azure_rm_cognitiveservicesdeployment/aliases @@ -0,0 +1,3 @@ +cloud/azure +shippable/azure/group11 +destructive diff --git a/tests/integration/targets/azure_rm_cognitiveservicesdeployment/meta/main.yml b/tests/integration/targets/azure_rm_cognitiveservicesdeployment/meta/main.yml new file mode 100644 index 0000000000..95e1952f98 --- /dev/null +++ b/tests/integration/targets/azure_rm_cognitiveservicesdeployment/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_azure diff --git a/tests/integration/targets/azure_rm_cognitiveservicesdeployment/tasks/main.yml b/tests/integration/targets/azure_rm_cognitiveservicesdeployment/tasks/main.yml new file mode 100644 index 0000000000..dd3d07346c --- /dev/null +++ b/tests/integration/targets/azure_rm_cognitiveservicesdeployment/tasks/main.yml @@ -0,0 +1,188 @@ +--- +- name: Create resource group for OpenAI deployment tests + azure.azcollection.azure_rm_resourcegroup: + name: "{{ resource_group }}-aoai" + location: eastus + +- name: Azure OpenAI deployment tests + block: + - name: Build a unique account name + ansible.builtin.set_fact: + account_name: "aoai{{ resource_group | hash('md5') | truncate(16, True, '') }}" + deployment_name: "text-embedding-3-small" + + # An Azure OpenAI account is a Cognitive Services account with kind=OpenAI. + # A custom subdomain is required for OpenAI accounts. + - name: Create Azure OpenAI account + azure.azcollection.azure_rm_cognitiveservicesaccount: + resource_group: "{{ resource_group }}-aoai" + name: "{{ account_name }}" + kind: OpenAI + location: eastus + sku: S0 + custom_domain_name: "{{ account_name }}" + register: account + + - name: Create deployment (check mode) + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + name: "{{ deployment_name }}" + model: + name: text-embedding-3-small + version: "1" + sku: + name: Standard + capacity: 1 + version_upgrade_option: NoAutoUpgrade + check_mode: true + register: dep_check + + - name: Assert check mode reports change without creating + ansible.builtin.assert: + that: + - dep_check.changed + + - name: Create deployment + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + name: "{{ deployment_name }}" + model: + name: text-embedding-3-small + version: "1" + sku: + name: Standard + capacity: 1 + version_upgrade_option: NoAutoUpgrade + register: dep_create + + - name: Assert deployment created + ansible.builtin.assert: + that: + - dep_create.changed + - dep_create.state.name == deployment_name + - dep_create.state.properties.model.name == 'text-embedding-3-small' + + - name: Create deployment again (idempotent) + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + name: "{{ deployment_name }}" + model: + name: text-embedding-3-small + version: "1" + sku: + name: Standard + capacity: 1 + version_upgrade_option: NoAutoUpgrade + register: dep_idem + + - name: Assert no change on second run + ansible.builtin.assert: + that: + - not dep_idem.changed + + - name: Get deployment via info + azure.azcollection.azure_rm_cognitiveservicesdeployment_info: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + name: "{{ deployment_name }}" + register: dep_info + + - name: Assert info returns the deployment + ansible.builtin.assert: + that: + - dep_info.deployments | length == 1 + - dep_info.deployments[0].name == deployment_name + + - name: List all deployments via info + azure.azcollection.azure_rm_cognitiveservicesdeployment_info: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + register: dep_list + + - name: Assert list returns at least the created deployment + ansible.builtin.assert: + that: + - dep_list.deployments | length >= 1 + - deployment_name in (dep_list.deployments | map(attribute='name') | list) + + # Change only the capacity, omitting model and version_upgrade_option. Since + # create_or_update is a full-replace PUT, this verifies the module carries + # forward the writable properties the user did not resupply. + - name: Scale the deployment capacity (update, capacity only) + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + name: "{{ deployment_name }}" + sku: + name: Standard + capacity: 2 + register: dep_update + + - name: Assert update changed and preserved other writable properties + ansible.builtin.assert: + that: + - dep_update.changed + - dep_update.state.sku.capacity == 2 + # model and the non-default version_upgrade_option must survive the PUT + - dep_update.state.properties.model.name == 'text-embedding-3-small' + - dep_update.state.properties.model.version == '1' + - dep_update.state.properties.version_upgrade_option == 'NoAutoUpgrade' + + - name: Info for a non-existent deployment returns empty + azure.azcollection.azure_rm_cognitiveservicesdeployment_info: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + name: does-not-exist + register: dep_missing + + - name: Assert empty list, not failure + ansible.builtin.assert: + that: + - dep_missing.deployments | length == 0 + + - name: Delete deployment + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + name: "{{ deployment_name }}" + state: absent + register: dep_delete + + - name: Assert deleted + ansible.builtin.assert: + that: + - dep_delete.changed + + - name: Delete deployment again (idempotent absent) + azure.azcollection.azure_rm_cognitiveservicesdeployment: + resource_group: "{{ resource_group }}-aoai" + account_name: "{{ account_name }}" + name: "{{ deployment_name }}" + state: absent + register: dep_delete_idem + + - name: Assert no change on second delete + ansible.builtin.assert: + that: + - not dep_delete_idem.changed + + always: + - name: Clean up OpenAI account + azure.azcollection.azure_rm_cognitiveservicesaccount: + resource_group: "{{ resource_group }}-aoai" + name: "{{ account_name }}" + kind: OpenAI + state: absent + purge: true + ignore_errors: true + + - name: Clean up resource group + azure.azcollection.azure_rm_resourcegroup: + name: "{{ resource_group }}-aoai" + location: eastus + state: absent + force_delete_nonempty: true + ignore_errors: true