From 61a16dd5e6c2718cd5fa1f50ef7c1c70e7699d6b Mon Sep 17 00:00:00 2001 From: Bill Peck Date: Fri, 9 Oct 2026 09:07:14 -0400 Subject: [PATCH 1/4] feat(azure_rm_searchdatasource): add data source and info modules Add azure_rm_searchdatasource and azure_rm_searchdatasource_info to manage data source connections in an Azure AI Search service (data plane), built on the azure-search-documents SDK via the shared AzureRMSearchDataPlaneMixin (SearchIndexerClient), mirroring the merged azure_rm_searchindex pattern. Idempotent and check-mode aware. Azure redacts credentials.connectionString on read, so it is dropped from the drift comparison; on a full-replace PUT update the module merges user-supplied keys onto the existing data source to preserve unsupplied settings, and sends the sentinel when the connection string is not resupplied. type and connection_string are required only on create. Co-Authored-By: Claude Opus 4.8 --- meta/runtime.yml | 2 + plugins/modules/azure_rm_searchdatasource.py | 298 ++++++++++++++++++ .../modules/azure_rm_searchdatasource_info.py | 125 ++++++++ 3 files changed, 425 insertions(+) create mode 100644 plugins/modules/azure_rm_searchdatasource.py create mode 100644 plugins/modules/azure_rm_searchdatasource_info.py diff --git a/meta/runtime.yml b/meta/runtime.yml index bbe1900e8..2c50861ef 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -341,6 +341,8 @@ action_groups: - azure.azcollection.azure_rm_route_info - azure.azcollection.azure_rm_routetable - azure.azcollection.azure_rm_routetable_info + - azure.azcollection.azure_rm_searchdatasource + - azure.azcollection.azure_rm_searchdatasource_info - azure.azcollection.azure_rm_searchindex - azure.azcollection.azure_rm_searchindex_info - azure.azcollection.azure_rm_securitygroup diff --git a/plugins/modules/azure_rm_searchdatasource.py b/plugins/modules/azure_rm_searchdatasource.py new file mode 100644 index 000000000..48cd8234c --- /dev/null +++ b/plugins/modules/azure_rm_searchdatasource.py @@ -0,0 +1,298 @@ +#!/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_searchdatasource +version_added: "4.2.0" +short_description: Manage a data source in an Azure AI Search service +description: + - Create, update, and delete a data source connection within an Azure AI + Search service (data plane). A data source tells an indexer where to read + documents from (Blob storage, Azure SQL, Cosmos DB, ADLS Gen2, or Table). +options: + resource_group: + description: + - Name of the resource group containing the search service. + required: true + type: str + search_service_name: + description: + - Name of the Azure AI Search service that hosts the data source. + required: true + type: str + name: + description: + - Name of the data source. + required: true + type: str + admin_key: + description: + - Admin API key for the search service, used to authenticate data-plane + requests via the C(api-key) header. + - This is supplementary to the standard Azure credentials. The module + always requires standard Azure authentication parameters and a + subscription ID (see the I(azure.azcollection.azure) documentation + fragment) to run, regardless of whether this is set. + - If omitted, data-plane requests are authenticated with an RBAC bearer + token (managed identity / service principal) using the data-plane + scope C(https://search.azure.com/.default). + type: str + type: + description: + - The type of the data source. Required when creating a data source. + type: str + choices: + - azureblob + - azuretable + - azuresql + - cosmosdb + - adlsgen2 + connection_string: + description: + - Connection string for the data source. Required when creating a data source. + - Sent to Azure as C(credentials.connectionString). Azure redacts this + value on read, so changes to the connection string alone are not + detected as drift; every other field is compared normally. + - On update, if omitted, the existing connection string is preserved. + type: str + container: + description: + - The container/table/collection the indexer reads from. + type: dict + suboptions: + name: + description: Name of the container, table, or collection. + type: str + required: true + query: + description: + - Optional query that narrows or shapes the rows/blobs returned + (for example a SQL query, a blob path prefix, or a Cosmos DB query). + type: str + data_change_detection_policy: + description: + - Change-detection policy used for incremental indexing. + - Passed through to the Azure AI Search API using camelCase keys; see the Azure AI Search reference for structure. + type: dict + data_deletion_detection_policy: + description: + - Soft-delete detection policy used to remove documents from the index. + - Passed through to the Azure AI Search API using camelCase keys; see the Azure AI Search reference for structure. + type: dict + description: + description: + - Free-text description of the data source. + type: str + state: + description: + - Assert the state of the data source. Use C(present) to create/update, C(absent) to delete. + type: str + default: present + choices: + - present + - absent +extends_documentation_fragment: + - azure.azcollection.azure +author: + - Bill Peck (@p3ck) +''' + +EXAMPLES = ''' +- name: Create a blob data source + azure.azcollection.azure_rm_searchdatasource: + resource_group: myResourceGroup + search_service_name: mysearchsvc + name: blob-ds + type: azureblob + connection_string: "{{ storage_connection_string }}" + container: + name: documents + query: knowledge-base/ + state: present + +- name: Delete a data source + azure.azcollection.azure_rm_searchdatasource: + resource_group: myResourceGroup + search_service_name: mysearchsvc + name: blob-ds + state: absent +''' + +RETURN = ''' +state: + description: + - The data source definition as returned by Azure AI Search. + - The connection string is redacted by Azure and is not returned in clear text. + returned: when I(state=present) + type: dict + sample: {"name": "blob-ds", "type": "azureblob", "container": {"name": "documents"}} +''' + +import copy + +from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_common_ext import AzureRMModuleBaseExt +from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_search_common import ( + AzureRMSearchDataPlaneMixin, + ResourceNotFoundError, +) + +# Azure AI Search sentinel: keep the existing connection string unchanged on update. +UNCHANGED_CONNECTION_STRING = "" + + +class AzureRMSearchDataSource(AzureRMSearchDataPlaneMixin, AzureRMModuleBaseExt): + + def __init__(self): + self.module_arg_spec = dict( + resource_group=dict(type='str', required=True), + search_service_name=dict(type='str', required=True), + name=dict(type='str', required=True), + admin_key=dict(type='str', no_log=True), + type=dict(type='str', choices=['azureblob', 'azuretable', 'azuresql', 'cosmosdb', 'adlsgen2']), + connection_string=dict(type='str', no_log=True), + container=dict(type='dict', options=dict( + name=dict(type='str', required=True), + query=dict(type='str'), + )), + data_change_detection_policy=dict(type='dict'), + data_deletion_detection_policy=dict(type='dict'), + description=dict(type='str'), + state=dict(type='str', default='present', choices=['present', 'absent']), + ) + self.resource_group = None + self.search_service_name = None + self.name = None + self.admin_key = None + self.type = None + self.connection_string = None + self.container = None + self.data_change_detection_policy = None + self.data_deletion_detection_policy = None + self.description = None + self.state = None + self.results = dict(changed=False) + super(AzureRMSearchDataSource, self).__init__( + derived_arg_spec=self.module_arg_spec, + supports_check_mode=True, + supports_tags=False, + ) + + def exec_module(self, **kwargs): + for key in list(self.module_arg_spec.keys()): + setattr(self, key, kwargs[key]) + + self.client = self.get_search_indexer_client( + self.search_service_name, admin_key=self.admin_key) + + existing = self._get_existing() + + if self.state == 'present': + if existing is None: + if self.type is None: + self.fail(msg="type is required to create data source '%s'; " + "it does not exist yet" % self.name) + if self.connection_string is None: + self.fail(msg="connection_string is required to create data source " + "'%s'; it does not exist yet" % self.name) + desired = self._build_body(existing) + if existing is None: + self.results['changed'] = True + if not self.check_mode: + self.results['state'] = self._create_or_update(desired) + else: + self.results['state'] = desired + else: + if not self._is_current(desired, existing): + # create_or_update is a full-replace PUT; merge the user- + # supplied keys onto the existing data source so unsupplied + # settings are preserved. + body = self._merge_existing(desired, existing) + self.results['changed'] = True + if not self.check_mode: + self.results['state'] = self._create_or_update(body) + else: + self.results['state'] = body + else: + self.results['state'] = existing + else: # absent + if existing is not None: + self.results['changed'] = True + if not self.check_mode: + self.client.delete_data_source_connection(self.name) + return self.results + + def _get_existing(self): + # The SDK raises ResourceNotFoundError when the data source does not + # exist; translate to None. Otherwise return the camelCase wire shape. + try: + return self.client.get_data_source_connection(self.name).as_dict() + except ResourceNotFoundError: + return None + + def _build_body(self, existing): + body = {"name": self.name} + # type is immutable once set; fall back to the existing value on update + # so a caller updating other fields need not repeat it. + if self.type is not None: + body["type"] = self.type + elif existing is not None and existing.get("type") is not None: + body["type"] = existing["type"] + if self.connection_string is not None: + body["credentials"] = {"connectionString": self.connection_string} + if self.container is not None: + container = {"name": self.container["name"]} + if self.container.get("query") is not None: + container["query"] = self.container["query"] + body["container"] = container + if self.data_change_detection_policy is not None: + body["dataChangeDetectionPolicy"] = self.data_change_detection_policy + if self.data_deletion_detection_policy is not None: + body["dataDeletionDetectionPolicy"] = self.data_deletion_detection_policy + if self.description is not None: + body["description"] = self.description + return body + + def _merge_existing(self, desired, existing): + # Overlay the user-supplied keys onto a copy of the existing data source + # so a PUT update does not drop settings the user did not resupply. + # Response-only @odata.* annotations must not be echoed back. + merged = {k: v for k, v in copy.deepcopy(existing).items() + if not k.startswith('@odata.')} + merged.update(desired) + # Azure redacts the connection string on read, so the value carried in + # `existing` is not usable. If the user did not supply a new connection + # string, send the sentinel to preserve the current one. + if 'credentials' not in desired: + merged['credentials'] = {"connectionString": UNCHANGED_CONNECTION_STRING} + return merged + + def _create_or_update(self, body): + # create_or_update_data_source_connection accepts a plain camelCase dict + # and returns the full model; .as_dict() yields the wire shape. + return self.client.create_or_update_data_source_connection(body).as_dict() + + def _is_current(self, desired, existing): + # Azure redacts credentials.connectionString on read, so it can never + # match what we would PUT. Drop credentials from the comparison; every + # other field is compared via default_compare (union-of-keys walk, so + # server defaults present only in `existing` are ignored). Deep-copy so + # the comparison cannot mutate the body we would PUT. + compare_body = copy.deepcopy(desired) + compare_body.pop("credentials", None) + result = dict(compare=[]) + return self.default_compare({}, compare_body, existing, '', result) + + +def main(): + AzureRMSearchDataSource() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/azure_rm_searchdatasource_info.py b/plugins/modules/azure_rm_searchdatasource_info.py new file mode 100644 index 000000000..d481c3b00 --- /dev/null +++ b/plugins/modules/azure_rm_searchdatasource_info.py @@ -0,0 +1,125 @@ +#!/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_searchdatasource_info +version_added: "4.2.0" +short_description: Get information about Azure AI Search data sources +description: + - Get details of a single data source, or list all data sources in an Azure AI Search service. +options: + resource_group: + description: + - Name of the resource group containing the search service. + required: true + type: str + search_service_name: + description: + - Name of the Azure AI Search service. + required: true + type: str + name: + description: + - Name of a specific data source to fetch. If omitted, all data sources are listed. + type: str + admin_key: + description: + - Admin API key for the search service, used to authenticate data-plane + requests via the C(api-key) header. + - This is supplementary to the standard Azure credentials. The module + always requires standard Azure authentication parameters and a + subscription ID (see the I(azure.azcollection.azure) documentation + fragment) to run, regardless of whether this is set. + - If omitted, data-plane requests are authenticated with an RBAC bearer + token (managed identity / service principal) using the data-plane + scope C(https://search.azure.com/.default). + type: str +extends_documentation_fragment: + - azure.azcollection.azure +author: + - Bill Peck (@p3ck) +''' + +EXAMPLES = ''' +- name: List all data sources + azure.azcollection.azure_rm_searchdatasource_info: + resource_group: myResourceGroup + search_service_name: mysearchsvc + +- name: Get one data source + azure.azcollection.azure_rm_searchdatasource_info: + resource_group: myResourceGroup + search_service_name: mysearchsvc + name: blob-ds +''' + +RETURN = ''' +datasources: + description: + - List of data source definitions. + - The connection string is redacted by Azure and is not returned in clear text. + returned: always + type: list + elements: dict + sample: [{"name": "blob-ds", "type": "azureblob", "container": {"name": "documents"}}] +''' + +from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_common_ext import AzureRMModuleBaseExt +from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_search_common import ( + AzureRMSearchDataPlaneMixin, + ResourceNotFoundError, +) + + +class AzureRMSearchDataSourceInfo(AzureRMSearchDataPlaneMixin, AzureRMModuleBaseExt): + + def __init__(self): + self.module_arg_spec = dict( + resource_group=dict(type='str', required=True), + search_service_name=dict(type='str', required=True), + name=dict(type='str'), + admin_key=dict(type='str', no_log=True), + ) + self.resource_group = None + self.search_service_name = None + self.name = None + self.admin_key = None + self.results = dict(changed=False, datasources=[]) + super(AzureRMSearchDataSourceInfo, 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 list(self.module_arg_spec.keys()): + setattr(self, key, kwargs[key]) + client = self.get_search_indexer_client( + self.search_service_name, admin_key=self.admin_key) + if self.name: + try: + ds = client.get_data_source_connection(self.name) + self.results['datasources'] = [ds.as_dict()] + except ResourceNotFoundError: + self.results['datasources'] = [] + else: + self.results['datasources'] = [ + ds.as_dict() for ds in client.get_data_source_connections() + ] + return self.results + + +def main(): + AzureRMSearchDataSourceInfo() + + +if __name__ == '__main__': + main() From 9d946a7050b6a4a9d5cd6718c7ab56445a2ef5e7 Mon Sep 17 00:00:00 2001 From: Bill Peck Date: Fri, 9 Oct 2026 09:07:15 -0400 Subject: [PATCH 2/4] test(azure_rm_searchdatasource): add integration target Stands up a search service (AAD data-plane auth + Search Service Contributor) and a storage account, then exercises create/idempotent/info/update/delete. The update changes only the description (omitting type, connection_string and container) to verify the PUT merge preserves the other fields and the connection string. Co-Authored-By: Claude Opus 4.8 --- .../targets/azure_rm_searchdatasource/aliases | 3 + .../azure_rm_searchdatasource/meta/main.yml | 2 + .../azure_rm_searchdatasource/tasks/main.yml | 231 ++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 tests/integration/targets/azure_rm_searchdatasource/aliases create mode 100644 tests/integration/targets/azure_rm_searchdatasource/meta/main.yml create mode 100644 tests/integration/targets/azure_rm_searchdatasource/tasks/main.yml diff --git a/tests/integration/targets/azure_rm_searchdatasource/aliases b/tests/integration/targets/azure_rm_searchdatasource/aliases new file mode 100644 index 000000000..453d98742 --- /dev/null +++ b/tests/integration/targets/azure_rm_searchdatasource/aliases @@ -0,0 +1,3 @@ +cloud/azure +shippable/azure/group16 +destructive diff --git a/tests/integration/targets/azure_rm_searchdatasource/meta/main.yml b/tests/integration/targets/azure_rm_searchdatasource/meta/main.yml new file mode 100644 index 000000000..95e1952f9 --- /dev/null +++ b/tests/integration/targets/azure_rm_searchdatasource/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_azure diff --git a/tests/integration/targets/azure_rm_searchdatasource/tasks/main.yml b/tests/integration/targets/azure_rm_searchdatasource/tasks/main.yml new file mode 100644 index 000000000..dfcb09277 --- /dev/null +++ b/tests/integration/targets/azure_rm_searchdatasource/tasks/main.yml @@ -0,0 +1,231 @@ +- name: Create a resource group for the search datasource test + azure.azcollection.azure_rm_resourcegroup: + name: "{{ resource_group }}-searchds" + location: eastus + +- name: Run search datasource tests + block: + - name: Build unique names + ansible.builtin.set_fact: + svc_name: "searchds{{ resource_group | hash('md5') | truncate(14, True, '') }}" + sa_name: "sads{{ resource_group | hash('md5') | truncate(20, True, '') }}" + + - name: Create AI Search service + azure.azcollection.azure_rm_cognitivesearch: + resource_group: "{{ resource_group }}-searchds" + name: "{{ svc_name }}" + sku: basic + register: svc + + # Data-plane RBAC is off by default on a new search service (API-key only), + # so enable AAD auth and grant the test principal a data-plane role. This + # is what exercises the https://search.azure.com/.default token path. + - name: Enable AAD (RBAC) data-plane auth on the search service + azure.azcollection.azure_rm_resource: + api_version: '2023-11-01' + resource_group: "{{ resource_group }}-searchds" + provider: search + resource_type: searchservices + resource_name: "{{ svc_name }}" + method: PATCH + body: + properties: + authOptions: + aadOrApiKey: + aadAuthFailureMode: http403 + + - name: Resolve the test service principal object id + azure.azcollection.azure_rm_adserviceprincipal_info: + app_id: "{{ azure_client_id }}" + register: sp_info + + - name: Grant Search Service Contributor to the test principal + azure.azcollection.azure_rm_roleassignment: + scope: "{{ svc.state.id }}" + assignee_object_id: "{{ sp_info.service_principals[0].object_id }}" + # Search Service Contributor: manage data-plane object definitions + # (indexes, data sources, indexers, skillsets). + # https://learn.microsoft.com/en-us/azure/search/search-security-rbac + role_definition_id: "/subscriptions/{{ azure_subscription_id }}/providers/Microsoft.Authorization/roleDefinitions/7ca78c08-252a-4471-8644-bb5ff32d4ba0" + state: present + + # A blob data source connection is validated by Azure at create time, so + # stand up a real storage account + container for it to point at. + - name: Create storage account for the data source + azure.azcollection.azure_rm_storageaccount: + resource_group: "{{ resource_group }}-searchds" + name: "{{ sa_name }}" + account_type: Standard_LRS + kind: StorageV2 + + - name: Create a blob container + azure.azcollection.azure_rm_storageblob: + resource_group: "{{ resource_group }}-searchds" + account_name: "{{ sa_name }}" + container_name: documents + + - name: Get the storage account connection string + azure.azcollection.azure_rm_storageaccount_info: + resource_group: "{{ resource_group }}-searchds" + name: "{{ sa_name }}" + show_connection_string: true + register: sa_info + + - name: Set connection string fact + ansible.builtin.set_fact: + ds_connection_string: "{{ sa_info.storageaccounts[0].primary_endpoints.blob.connectionstring }}" + + - name: Create data source (check mode) + azure.azcollection.azure_rm_searchdatasource: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: it-ds + type: azureblob + connection_string: "{{ ds_connection_string }}" + container: + name: documents + check_mode: true + register: ds_check + # Absorb AAD auth-option and role-assignment propagation (can take minutes). + retries: 20 + delay: 15 + until: ds_check is not failed + + - name: Assert check mode reports change without creating + ansible.builtin.assert: + that: + - ds_check.changed + + - name: Create data source + azure.azcollection.azure_rm_searchdatasource: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: it-ds + type: azureblob + connection_string: "{{ ds_connection_string }}" + container: + name: documents + register: ds_create + + - name: Assert created (RBAC data-plane scope worked) + ansible.builtin.assert: + that: + - ds_create.changed + - ds_create.state.name == 'it-ds' + - ds_create.state.type == 'azureblob' + + - name: Create data source again (idempotent) + azure.azcollection.azure_rm_searchdatasource: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: it-ds + type: azureblob + connection_string: "{{ ds_connection_string }}" + container: + name: documents + register: ds_idem + + - name: Assert no change on second run (credentials redaction ignored) + ansible.builtin.assert: + that: + - not ds_idem.changed + + - name: Get data source via info + azure.azcollection.azure_rm_searchdatasource_info: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: it-ds + register: ds_info + + - name: Assert info returns the data source + ansible.builtin.assert: + that: + - ds_info.datasources | length == 1 + - ds_info.datasources[0].name == 'it-ds' + + # Change ONLY the description, omitting type, connection_string and + # container. create_or_update is a full-replace PUT, so this verifies the + # module carries forward the container (and preserves the connection string + # via the sentinel) instead of dropping them. + - name: Update the data source (description only) + azure.azcollection.azure_rm_searchdatasource: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: it-ds + description: Knowledge base documents + register: ds_update + + - name: Assert update changed and preserved other fields + ansible.builtin.assert: + that: + - ds_update.changed + - ds_update.state.description == 'Knowledge base documents' + - ds_update.state.type == 'azureblob' + - ds_update.state.container.name == 'documents' + + - name: Update the data source again (idempotent) + azure.azcollection.azure_rm_searchdatasource: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: it-ds + description: Knowledge base documents + register: ds_update_idem + + - name: Assert no change on repeated description-only update + ansible.builtin.assert: + that: + - not ds_update_idem.changed + + - name: Info for a non-existent data source returns empty + azure.azcollection.azure_rm_searchdatasource_info: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: does-not-exist + register: ds_missing + + - name: Assert empty list, not failure + ansible.builtin.assert: + that: + - ds_missing.datasources | length == 0 + + - name: Delete data source + azure.azcollection.azure_rm_searchdatasource: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: it-ds + state: absent + register: ds_delete + + - name: Assert deleted + ansible.builtin.assert: + that: + - ds_delete.changed + + - name: Delete data source again (idempotent absent) + azure.azcollection.azure_rm_searchdatasource: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + name: it-ds + state: absent + register: ds_delete_idem + + - name: Assert no change on second delete + ansible.builtin.assert: + that: + - not ds_delete_idem.changed + + always: + - name: Clean up search service + azure.azcollection.azure_rm_cognitivesearch: + resource_group: "{{ resource_group }}-searchds" + name: "{{ svc_name }}" + state: absent + ignore_errors: true + + - name: Clean up resource group + azure.azcollection.azure_rm_resourcegroup: + name: "{{ resource_group }}-searchds" + location: eastus + state: absent + force_delete_nonempty: true + ignore_errors: true From f3c57cbc673fb49fbb3004a2cf598af9d2e968af Mon Sep 17 00:00:00 2001 From: Bill Peck Date: Fri, 9 Oct 2026 09:07:15 -0400 Subject: [PATCH 3/4] ci(azure_rm_searchdatasource): add integration target to pr-pipelines Co-Authored-By: Claude Opus 4.8 --- pr-pipelines.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/pr-pipelines.yml b/pr-pipelines.yml index 1c70d4079..1897e79ef 100644 --- a/pr-pipelines.yml +++ b/pr-pipelines.yml @@ -206,6 +206,7 @@ parameters: - "azure_rm_tags" - "azure_rm_dedicatedhost" - "azure_rm_searchindex" + - "azure_rm_searchdatasource" - "inventory_azure" - "setup_azure" From c69db96982ffd0badf445e7022141ca341450a31 Mon Sep 17 00:00:00 2001 From: Bill Peck Date: Mon, 12 Oct 2026 08:59:38 -0400 Subject: [PATCH 4/4] fix(azure_rm_searchdatasource): address review (types, identity, merge, list test) Review feedback on #2384: - Add the remaining SDK SearchIndexerDataSourceType values to `type`: mysql, onelake, sharepoint. - Expose the data source `identity` (pass-through dict) for managed-identity connections. - Strip the unmanaged encryptionKey in _merge_existing (its credentials are redacted on read), consistent with azure_rm_searchsynonymmap. - Add a list-path integration step (info without name). Live integration in eastus: ok=33 failed=0. Co-Authored-By: Claude Opus 4.8 --- plugins/modules/azure_rm_searchdatasource.py | 26 ++++++++++++++++--- .../azure_rm_searchdatasource/tasks/main.yml | 12 +++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/plugins/modules/azure_rm_searchdatasource.py b/plugins/modules/azure_rm_searchdatasource.py index 48cd8234c..55a8df775 100644 --- a/plugins/modules/azure_rm_searchdatasource.py +++ b/plugins/modules/azure_rm_searchdatasource.py @@ -54,6 +54,9 @@ - azuresql - cosmosdb - adlsgen2 + - mysql + - onelake + - sharepoint connection_string: description: - Connection string for the data source. Required when creating a data source. @@ -90,6 +93,16 @@ description: - Free-text description of the data source. type: str + identity: + description: + - Explicit managed identity the data source uses to connect (as an + alternative to a key in the connection string). + - Passed through to the Azure AI Search API as C(identity) using + camelCase keys; set its C(@odata.type) to + C(#Microsoft.Azure.Search.DataNoneIdentity) (system-assigned or none) + or C(#Microsoft.Azure.Search.DataUserAssignedIdentity) with a + C(userAssignedIdentity) resource ID. See the Azure AI Search reference. + type: dict state: description: - Assert the state of the data source. Use C(present) to create/update, C(absent) to delete. @@ -155,7 +168,8 @@ def __init__(self): search_service_name=dict(type='str', required=True), name=dict(type='str', required=True), admin_key=dict(type='str', no_log=True), - type=dict(type='str', choices=['azureblob', 'azuretable', 'azuresql', 'cosmosdb', 'adlsgen2']), + type=dict(type='str', choices=['azureblob', 'azuretable', 'azuresql', 'cosmosdb', + 'adlsgen2', 'mysql', 'onelake', 'sharepoint']), connection_string=dict(type='str', no_log=True), container=dict(type='dict', options=dict( name=dict(type='str', required=True), @@ -164,6 +178,7 @@ def __init__(self): data_change_detection_policy=dict(type='dict'), data_deletion_detection_policy=dict(type='dict'), description=dict(type='str'), + identity=dict(type='dict'), state=dict(type='str', default='present', choices=['present', 'absent']), ) self.resource_group = None @@ -176,6 +191,7 @@ def __init__(self): self.data_change_detection_policy = None self.data_deletion_detection_policy = None self.description = None + self.identity = None self.state = None self.results = dict(changed=False) super(AzureRMSearchDataSource, self).__init__( @@ -257,14 +273,18 @@ def _build_body(self, existing): body["dataDeletionDetectionPolicy"] = self.data_deletion_detection_policy if self.description is not None: body["description"] = self.description + if self.identity is not None: + body["identity"] = self.identity return body def _merge_existing(self, desired, existing): # Overlay the user-supplied keys onto a copy of the existing data source # so a PUT update does not drop settings the user did not resupply. - # Response-only @odata.* annotations must not be echoed back. + # Response-only @odata.* annotations must not be echoed back, and the + # unmanaged encryptionKey (whose credentials are redacted on read) must + # not be echoed either. merged = {k: v for k, v in copy.deepcopy(existing).items() - if not k.startswith('@odata.')} + if not k.startswith('@odata.') and k != 'encryptionKey'} merged.update(desired) # Azure redacts the connection string on read, so the value carried in # `existing` is not usable. If the user did not supply a new connection diff --git a/tests/integration/targets/azure_rm_searchdatasource/tasks/main.yml b/tests/integration/targets/azure_rm_searchdatasource/tasks/main.yml index dfcb09277..57378f0cb 100644 --- a/tests/integration/targets/azure_rm_searchdatasource/tasks/main.yml +++ b/tests/integration/targets/azure_rm_searchdatasource/tasks/main.yml @@ -143,6 +143,18 @@ - ds_info.datasources | length == 1 - ds_info.datasources[0].name == 'it-ds' + - name: List all data sources via info (no name) + azure.azcollection.azure_rm_searchdatasource_info: + resource_group: "{{ resource_group }}-searchds" + search_service_name: "{{ svc_name }}" + register: ds_list + + - name: Assert list returns at least the created data source + ansible.builtin.assert: + that: + - ds_list.datasources | length >= 1 + - "'it-ds' in (ds_list.datasources | map(attribute='name') | list)" + # Change ONLY the description, omitting type, connection_string and # container. create_or_update is a full-replace PUT, so this verifies the # module carries forward the container (and preserves the connection string