diff --git a/library/ns1_data_feed.py b/library/ns1_data_feed.py new file mode 100644 index 0000000..0d81389 --- /dev/null +++ b/library/ns1_data_feed.py @@ -0,0 +1,173 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Michael Kearey +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +DOCUMENTATION = ''' +--- +module: ns1_data_feed + +short_description: Create and manage NS1 Data Feeds (connections from a Data Source). + +version_added: "2.10" + +description: + - Manages a specific Data Feed resource, linking a Data Source (e.g., a Monitor) + to the DNS platform. Requires the parent source ID and unique feed configuration. + +options: + apiKey: + description: + - Unique client API key. + type: str + required: true + state: + description: + - Whether the data feed should be C(present) or C(absent). + type: str + default: present + choices: + - absent + - present + source_id: + description: + - The unique ID of the parent Data Source (e.g., '__NS1__.data_source.monitoring.0'). + type: str + required: true + name: + description: + - The unique name of the data feed (e.g., '__NS1__...rhcoresite.vpn.neuralmagic.com-943'). + type: str + required: true + config: + description: + - Dictionary of feed-specific configuration, often including the 'jobid' of the associated monitor. + type: dict + required: true +''' + +EXAMPLES = ''' +- name: Create Data Feed for a specific monitor job + ns1_data_feed: + apiKey: "{{ ns1_token }}" + state: present + source_id: "{{ MONITOR_SOURCE_ID }}" + name: "__NS1__.data_feed.monitoring.my-site-80" + config: + jobid: "68e46785142c1200014d87d9" # ID of the monitoring job +''' + +RETURN = ''' +# Returns the full JSON representation of the data feed on success. +''' + +import copy # noqa +import json # noqa + +try: + from ansible.module_utils.ns1 import NS1ModuleBase +except ImportError: + from module_utils.ns1 import NS1ModuleBase # noqa + +try: + from ns1.rest.errors import ResourceException +except ImportError: + pass + +class NS1DataFeed(NS1ModuleBase): + def __init__(self): + # We simplify the arg spec since we are only using it for management + self.module_arg_spec = dict( + apiKey=dict(required=True, type='str'), + state=dict(required=False, type='str', default='present', choices=['present', 'absent']), + source_id=dict(required=True, type='str'), + name=dict(required=True, type='str'), + config=dict(required=True, type='dict'), + ) + NS1ModuleBase.__init__(self, self.module_arg_spec, supports_check_mode=True) + + def get_feed(self, source_id, feed_name): + """Loads an existing feed by name within a source.""" + try: + # ns1.datafeed().list() gives a list of feed dicts for a source ID + feeds = self.ns1.datafeed().list(source_id) + for feed_data in feeds: + if feed_data.get('name') == feed_name: + # Retrieve the specific feed object using sourceid and feedid + return self.ns1.datafeed().retrieve(source_id, feed_data.get('id')) + return None + except ResourceException as re: + # Fail gracefully if source_id is invalid + self.module.fail_json(msg="Error listing data feeds for source %s: %s" % (source_id, re.message)) + + def create_feed(self, source_id, name, config): + """Handles creating a new data feed.""" + body = { + "name": name, + "config": config, + } + + if self.module.check_mode: + self.module.exit_json(changed=True, msg="Data feed would be created.") + + try: + # Feed.create() uses PUT /data/feeds/ + feed_obj = self.ns1.datafeed().create(source_id, name, config) + except Exception as e: + self.module.fail_json(msg="NS1 API feed creation failed: %s" % str(e)) + + return feed_obj + + def delete_feed(self, source_id, feed_id): + """Handles deleting a data feed.""" + if self.module.check_mode: + self.module.exit_json(changed=True, msg="Data feed would be deleted.") + + try: + self.ns1.datafeed().delete(source_id, feed_id) + except Exception as e: + self.module.fail_json(msg="NS1 API feed deletion failed: %s" % str(e)) + + def exec_module(self): + """Entry point for the module.""" + state = self.module.params.get('state') + source_id = self.module.params.get('source_id') + name = self.module.params.get('name') + config = self.module.params.get('config') + + # NS1 SDK retrieve() method returns the raw dict data + feed_data = self.get_feed(source_id, name) + + if state == "absent": + if feed_data: + self.delete_feed(source_id, feed_data.get('id')) + self.module.exit_json(changed=True, msg="Data feed deleted.") + else: + self.module.exit_json(changed=False, msg="Data feed already absent.") + + # state == "present" + if feed_data: + # Feed found, check for update (simplification: assume all updates replace) + self.module.exit_json(changed=False, msg="Data feed present (Update logic simplified).") + else: + # Feed not found, create it + feed_obj = self.create_feed(source_id, name, config) + self.module.exit_json(changed=True, feed=feed_obj) + + +def main(): + feed_module = NS1DataFeed() + feed_module.exec_module() + +if __name__ == '__main__': + main() diff --git a/library/ns1_monitor_job.py b/library/ns1_monitor_job.py new file mode 100644 index 0000000..1d78920 --- /dev/null +++ b/library/ns1_monitor_job.py @@ -0,0 +1,510 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Michael Kearey (adapted from NS1 community) +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +DOCUMENTATION = ''' +--- +module: ns1_monitor_job + +short_description: Create, modify, and delete NS1 monitoring jobs. + +version_added: "2.9" + +description: + - Manages health check jobs within NS1, allowing configuration of URLs, + ports, expected status codes, and monitoring regions. + +options: + name: + description: + - The human-readable name for the monitoring job. (Maps to JSON 'name') + type: str + required: true + job_type: + description: + - The type of monitoring job (e.g., http, ping, tcp). (Maps to JSON 'job_type') + type: str + required: true + choices: + - http + - ping + - tcp + active: + description: + - Whether the monitoring job should be C(true) (enabled) or C(false) (disabled) upon creation/update. + type: bool + required: false + default: true + mute: + description: + - Whether to suppress notifications for the monitoring job. Set to C(true) to mute notifications. + type: bool + required: false + default: false + frequency: + description: + - The frequency (in seconds) at which to run the monitor job. + type: int + default: 60 + policy: + description: + - The monitoring policy (e.g., quorum, all, or comma-separated regions). + type: str + default: quorum + notify_delay: + description: + - Time (in seconds) to wait after a failure before sending a notification. + type: int + required: false + default: 0 + notify_repeat: + description: + - Time (in seconds) between repeat notifications while the job remains in a failed state. + type: int + required: false + default: 0 + notify_failback: + description: + - Whether to send a notification when the job returns to an 'up' state. + type: bool + required: false + default: true + notify_regional: + description: + - Whether to send notifications for regional failures, in addition to global status changes. + type: bool + required: false + default: false + notify_list: + description: + - The unique ID of the notification list to send alerts to. Must be a single string ID. + type: str + required: false + rapid_recheck: + description: + - Whether the job should be quickly re-run (after a 1 second delay) upon a state change before sending a notification. + type: bool + required: false + default: false + notes: + description: + - Free-form text notes to attach to the monitoring job. + type: str + required: false + regions: + description: + - List of NS1 regions to run the job from (e.g., ['lhr', 'sin']). (Maps to JSON 'regions') + type: list + required: false +config: + description: + - Dictionary containing job-specific configuration parameters. These settings vary based on the I(job_type). + type: dict + required: true + suboptions: + url: + description: + - The full URL (including scheme and port) that the monitor job will check. + type: str + required: true + connect_timeout: + description: + - The amount of time (in seconds) to wait when establishing a connection. + type: int + required: false + idle_timeout: + description: + - The amount of time (in seconds) to wait for a response after connection is established. + type: int + required: false + tls_add_verify: + description: + - Whether to enforce TLS certificate verification. Set to C(false) to bypass certificate errors. + type: bool + required: false + follow_redirect: + description: + - Whether to follow HTTP redirects (3xx status codes). + type: bool + required: false + user_agent: + description: + - The value of the User-Agent header to send with the request. + type: str + required: false + ipv6: + description: + - Whether to force monitoring over IPv6. + type: bool + required: false +rules: + description: + - Array of rules defining successful outcomes that the monitor job must satisfy to be marked 'Up'. + type: list + required: true + elements: dict + suboptions: + comparison: + description: + - The comparison operator to use. + type: str + required: true + choices: + - '==' + - '!=' + - '>' + - '>=' + - '<' + - '<=' + - '=~' + - '!~' + key: + description: + - The key of the job output metric to compare (e.g., 'status_code' for HTTP checks). + type: str + required: true + value: + description: + - The value to compare against the output key (e.g., '200'). Must be a string. + type: str + required: true + apiKey: + description: + - Unique client api key. + type: str + required: true + state: + description: + - Whether the job should be C(present) or C(absent). Use C(present) to create + or update and C(absent) to delete. + type: str + default: present + choices: + - absent + - present + +extends_documentation_fragment: + - ns1 + +author: + - 'Michael Kearey' +''' + +EXAMPLES = ''' +- name: Ensure HTTPS monitor is present + ns1_monitor_job: + apiKey: "{{ ns1_token }}" + name: rhcoresite.vpn.neuralmagic.com-943 + state: present + job_type: http + frequency: 30 + policy: quorum + config: + url: https://rhcoresite.vpn.neuralmagic.com:943/ + connect_timeout: 5 + idle_timeout: 3 + rules: + - comparison: '==' + key: 'status_code' + value: '200' + +- name: Delete a monitoring job + ns1_monitor_job: + apiKey: "{{ ns1_token }}" + name: rhcoresite.vpn.neuralmagic.com-943 + state: absent + job_type: http +''' + +RETURN = ''' +# Returns the full JSON representation of the monitor job on success. +''' + +import copy # noqa +import ruamel.yaml as yaml +import json # Import json for string/type handling + +try: + from ansible.module_utils.ns1 import NS1ModuleBase +except ImportError: + # import via absolute path when running via pytest + from module_utils.ns1 import NS1ModuleBase # noqa + +try: + from ns1.rest.errors import ResourceException +except ImportError: + # This is handled in NS1 module_utils + pass + +# Define the keys that can be updated in the monitor job +MONITOR_KEYS_MAP = dict( + active=dict(appendable=False), + mute=dict(appendable=False), + frequency=dict(appendable=False), + policy=dict(appendable=False), + + notify_delay=dict(appendable=False), + notify_repeat=dict(appendable=False), + notify_failback=dict(appendable=False), + notify_regional=dict(appendable=False), + notify_list=dict(appendable=False), + rapid_recheck=dict(appendable=False), + notes=dict(appendable=False), + + regions=dict(appendable=False), + config=dict(appendable=False), + rules=dict(appendable=False), +) + +class NS1MonitorJob(NS1ModuleBase): + def __init__(self): + self.module_arg_spec = dict( + apiKey=dict(required=True, type='str'), + state=dict(required=False, type='str', default='present', choices=['present', 'absent']), + + name=dict(required=True, type='str'), + job_type=dict(required=True, type='str', choices=['http', 'ping', 'tcp']), + + frequency=dict(required=False, type='int', default=60), + policy=dict(required=False, type='str', default='quorum'), + active=dict(required=False, type='bool', default=None), + mute=dict(required=False, type='bool', default=None), + rapid_recheck=dict(required=False, type='bool', default=None), + + notify_failback=dict(required=False, type='bool', default=None), + notify_regional=dict(required=False, type='bool', default=None), + notify_delay=dict(required=False, type='int', default=0), + notify_repeat=dict(required=False, type='int', default=0), + notify_list=dict(required=False, type='str', default=None), + notes=dict(required=False, type='str', default=None), + + regions=dict(required=False, type='list', default=None), + config=dict(required=True, type='dict'), + rules=dict(required=True, type='list', elements='dict'), + ) + NS1ModuleBase.__init__(self, self.module_arg_spec, supports_check_mode=True) + + def api_params(self): + """Sets up other parameters for the api call that may not be specified + in the modules from tasks file. + """ + params = dict( + (key, self.module.params.get(key)) + for key in MONITOR_KEYS_MAP + if self.module.params.get(key) is not None + ) + + # Ensure 'regions' and 'rules' is an empty list if None + if self.module.params.get('regions') is None: + params['regions'] = [] + if self.module.params.get('rules') is None: + params['rules'] = [] + #Ensure if notifyList is an empty string that its not passed on + if self.module.params.get('notify_list') == '': + params.pop('notify_list', None) + + return params + + def sanitize_job(self, job): + """Remove API-generated fields and normalize types for diffing, ordered by API response keys.""" + job.pop('id', None) + job.pop('name', None) + job.pop('status', None) + job.pop('created_at', None) + job.pop('updated_at', None) + job.pop('last_run', None) + job.pop('region_scope', None) + + if 'config' in job and job['config'] is not None: + job['config'].pop('follow_redirect', None) + job['config'].pop('ipv6', None) + job['config'].pop('user_agent', None) + job['config'].pop('response_codes', None) + + if 'regions' in job and job['regions'] is not None: + job['regions'] = sorted(job['regions']) + + return job + + def get_job(self): + """Loads an existing job by name and returns the SDK object.""" + try: + # We list all jobs to find the ID since we only have the name. + jobs = self.ns1.monitors().list() + for job_data in jobs: + if job_data.get('name') == self.module.params.get('name'): + # Load the job object using its ID + return self.ns1.monitors().retrieve(job_data.get('id')) + return None # Job not found + + except ResourceException as re: + self.module.fail_json(msg="Error retrieving monitor list: %s" % re.message) + + def create_job(self): + """Handles creating a new monitoring job.""" + + name = self.module.params.get('name') + job_type = self.module.params.get('job_type') + active = self.module.params.get('active') + mute = self.module.params.get('mute') + frequency = self.module.params.get('frequency') + policy = self.module.params.get('policy') + + config = self.module.params.get('config') + rules = self.module.params.get('rules') + + args = self.api_params() + + body = { + 'name': name, + 'job_type': job_type, + 'active': active, + 'mute': mute, + 'frequency': frequency, + 'policy': policy, + + 'config': config, + 'rules': rules, + } + + body.update(args) + body = {k: v for k, v in body.items() if v is not None} + + if self.module.check_mode: + self.module.exit_json(changed=True, msg="Monitor job would be created.") + + try: + job = self.ns1.monitors().create(body, errback=self.errback_generator()) + except Exception as e: + self.module.fail_json(msg="NS1 API job creation failed: %s" % str(e)) + + if hasattr(job, 'data'): + return job.data + else: + return job + + def update_job(self, job_obj): + """Handles updating an existing job.""" + import q + current_data = self.sanitize_job(copy.deepcopy(job_obj)) + changed = False + full_input_args = {} + + + for key in MONITOR_KEYS_MAP: + input_data = self.module.params.get(key) + + if input_data is None: + continue + + modified_input = copy.deepcopy(input_data) + + # --- Input Normalization --- + if key == 'regions' and isinstance(modified_input, list): + modified_input = sorted(modified_input) + + if key == 'config': + if 'idle_timeout' in modified_input: + modified_input['idle_timeout'] = int(modified_input['idle_timeout']) + if 'connect_timeout' in modified_input: + modified_input['connect_timeout'] = int(modified_input['connect_timeout']) + + if modified_input.get('follow_redirect') is False: + modified_input.pop('follow_redirect', None) + if modified_input.get('ipv6') is False: + modified_input.pop('ipv6', None) + if modified_input.get('user_agent') == 'NS1 HTTP Monitoring Job': + modified_input.pop('user_agent', None) + + full_input_args[key] = modified_input + + if key in current_data: + if modified_input != current_data[key]: + q(modified_input) + q(current_data[key]) + changed = True + elif key not in current_data: + changed = True + + if not changed: + self.module.exit_json(changed=False, job=job_obj) + + if self.module.check_mode: + self.module.exit_json(changed=True, msg="Monitor job would be updated.") + + update_payload = copy.deepcopy(job_obj) + update_payload.update(full_input_args) + + update_payload['name'] = self.module.params.get('name') + update_payload['job_type'] = self.module.params.get('job_type') + + job_id = job_obj.get('id') + + try: + # Call the REST client update method: Monitors.update(jobid, body, ...) + job = self.ns1.monitors().update( + job_id, # Positional Arg 1: jobid (URL path) + update_payload, # Positional Arg 2: body (full payload dict) + errback=self.errback_generator() + ) + except Exception as e: + self.module.fail_json( + msg="Failed to update monitor job via API: %s" % str(e), + payload=update_payload + ) + + return job + + def exec_module(self): + """Entry point for the module.""" + state = self.module.params.get('state') + job_obj = self.get_job() # Job is the SDK object OR None + + if state == "absent": + if job_obj: + if self.module.check_mode: + self.module.exit_json(changed=True, msg="Monitor job would be deleted.") + + job_id = job_obj.get('id') + self.ns1.monitors().delete(job_id, errback=self.errback_generator()) + self.module.exit_json(changed=True, msg="Monitor job deleted.") + else: + self.module.exit_json(changed=False, msg="Monitor job already absent.") + + # state == "present" + if job_obj: + # Job found, update it + try: + job_data = self.update_job(job_obj) + #job_data.pop('errback', None) + self.module.exit_json(changed=True, job=job_data) + except Exception as e: + self.module.fail_json(msg="Failed to update monitor job: %s" % str(e)) + else: + # Job not found, create it + try: + job_data = self.create_job() + #job_data.pop('errback', None) + self.module.exit_json(changed=True, job=job_data) + except Exception as e: + self.module.fail_json(msg="Failed to create monitor job: %s" % str(e)) + + +def main(): + job_module = NS1MonitorJob() + job_module.exec_module() + + +if __name__ == '__main__': + main() diff --git a/library/ns1_notifier_list.py b/library/ns1_notifier_list.py new file mode 100644 index 0000000..d8790d5 --- /dev/null +++ b/library/ns1_notifier_list.py @@ -0,0 +1,245 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Michael Kearey (adapted from NS1 community) +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +DOCUMENTATION = ''' +--- +module: ns1_notifier_list + +short_description: Create, modify, and delete NS1 notification lists. + +version_added: "2.10" + +description: + - Manages notification lists (recipient groups) used by monitor jobs to send alerts. + +options: + apiKey: + description: + - Unique client api key. + type: str + required: true + state: + description: + - Whether the notification list should be C(present) or C(absent). + type: str + default: present + choices: + - absent + - present + name: + description: + - The human-readable name for the notification list (e.g., 'MKeareyOnly'). + type: str + required: true + notify_list: + description: + - Array of notification targets (recipients/channels) for this list. + type: list + required: true + suboptions: + type: + description: + - The type of notification channel (e.g., 'email', 'datafeed', 'pagerduty'). + type: str + required: true + config: + description: + - Dictionary containing type-specific configuration (e.g., 'email' address or 'sourceid'). + type: dict + required: true +''' + +EXAMPLES = ''' +- name: Ensure specific notification list is present + ns1_notifier_list: + apiKey: "{{ ns1_token }}" + name: MyDevOpsTeam + state: present + notify_list: + - type: email + config: + email: support@example.com + - type: datafeed + config: + sourceid: 3f841cfa37e393be05252371af551457 # Data source ID +''' + +RETURN = ''' +# Returns the full JSON representation of the notification list on success. +''' + +import copy # noqa +# import ruamel.yaml as yaml, import json - assume these are handled by environment or base class + +try: + from ansible.module_utils.ns1 import NS1ModuleBase +except ImportError: + from module_utils.ns1 import NS1ModuleBase # noqa + +try: + from ns1.rest.errors import ResourceException +except ImportError: + pass + +# --- Monitor Keys Map is simpler for notify lists --- +NOTIFY_LIST_KEYS_MAP = dict( + name=dict(appendable=False), + notify_list=dict(appendable=False), # The list of recipients/channels +) + + +class NS1NotifierList(NS1ModuleBase): + def __init__(self): + # NOTE: Arguments must be in the same order as documentation for readability + self.module_arg_spec = dict( + apiKey=dict(required=True, type='str'), + state=dict(required=False, type='str', default='present', choices=['present', 'absent']), + name=dict(required=True, type='str'), + notify_list=dict( + required=True, + type='list', + elements='dict', + options=dict( + type=dict(required=True, type='str'), + config=dict(required=True, type='dict') + ) + ), + ) + NS1ModuleBase.__init__(self, self.module_arg_spec, supports_check_mode=True) + + def api_params(self): + """Prepares API arguments from module parameters.""" + params = dict( + (key, self.module.params.get(key)) + for key in NOTIFY_LIST_KEYS_MAP + if self.module.params.get(key) is not None + ) + return params + + def get_list(self): + """Loads an existing notification list by name and returns the raw data (dict).""" + # The NS1 SDK exposes the notify lists via ns1.notifylists() + try: + # We list all lists to find the ID since we only have the name. + lists = self.ns1.notifylists().list() + for list_data in lists: + if list_data.get('name') == self.module.params.get('name'): + # Retrieve method for NotifyLists also returns the raw dict data + return self.ns1.notifylists().retrieve(list_data.get('id')) + return None # List not found + + except ResourceException as re: + self.module.fail_json(msg="Error retrieving notification lists: %s" % re.message) + + def sanitize_list(self, list_data): + """Removes API-generated fields for diffing.""" + + # Remove system-generated read-only fields + list_data.pop('id', None) + list_data.pop('created_at', None) + list_data.pop('updated_at', None) + list_data.pop('created_by', None) + list_data.pop('updated_by', None) + + return list_data + + def create_list(self): + """Handles creating a new notification list.""" + + # Assemble the body based on the API requirements + body = { + 'name': self.module.params.get('name'), + 'notify_list': self.module.params.get('notify_list') + } + + if self.module.check_mode: + self.module.exit_json(changed=True, msg="Notification list would be created.") + + try: + list_obj = self.ns1.notifylists().create(body) + except Exception as e: + self.module.fail_json(msg="NS1 API list creation failed: %s" % str(e)) + + return list_obj + + def update_list(self, current_list): + """Handles updating an existing notification list.""" + + current_data = self.sanitize_list(copy.deepcopy(current_list)) + + # Assemble the desired payload for comparison + desired_payload = self.sanitize_list({ + 'name': self.module.params.get('name'), + 'notify_list': self.module.params.get('notify_list') + }) + + # Check for structural changes + if desired_payload == current_data: + self.module.exit_json(changed=False, list=current_list) + + if self.module.check_mode: + self.module.exit_json(changed=True, msg="Notification list would be updated.") + + list_id = current_list.get('id') + + try: + # NS1 API update requires the ID in the URL and the body payload + list_obj = self.ns1.notifylists().update(list_id, desired_payload) + except Exception as e: + self.module.fail_json(msg="NS1 API list update failed: %s" % str(e)) + + return list_obj + + + def exec_module(self): + """Entry point for the module.""" + state = self.module.params.get('state') + list_obj = self.get_list() # Returns raw dictionary data or None + + if state == "absent": + if list_obj: + if self.module.check_mode: + self.module.exit_json(changed=True, msg="Notification list would be deleted.") + + list_id = list_obj.get('id') + self.ns1.notifylists().delete(list_id) + self.module.exit_json(changed=True, msg="Notification list deleted.") + else: + self.module.exit_json(changed=False, msg="Notification list already absent.") + + # state == "present" + if list_obj: + # List found, update it + try: + list_data = self.update_list(list_obj) + self.module.exit_json(changed=True, job=list_data) + except Exception as e: + self.module.fail_json(msg="Failed to update notification list: %s" % str(e)) + else: + # List not found, create it + try: + list_data = self.create_list() + self.module.exit_json(changed=True, job=list_data) + except Exception as e: + self.module.fail_json(msg="Failed to create notification list: %s" % str(e)) + + +def main(): + list_module = NS1NotifierList() + list_module.exec_module() + + +if __name__ == '__main__': + main() diff --git a/library/ns1_notifier_list_info.py b/library/ns1_notifier_list_info.py new file mode 100644 index 0000000..ce4c784 --- /dev/null +++ b/library/ns1_notifier_list_info.py @@ -0,0 +1,108 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Michael Kearey (adapted from NS1 community) +# GNU General Public License v3.0+ + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +DOCUMENTATION = ''' +--- +module: ns1_notifier_list_info + +short_description: List all NS1 notification lists and their IDs. + +version_added: "2.10" + +description: + - Retrieves a list of all notification lists configured in NS1 Connect, + allowing other tasks to look up list IDs by name for monitor job configuration. + +options: + apiKey: + description: + - Unique client api key. + type: str + required: true +''' + +EXAMPLES = ''' +- name: Fetch All Notification Lists Globally + ns1_notifier_list_info: + apiKey: "{{ ns1_token }}" + register: all_notifiers_result + +- name: Debug Notifier List ID by Name + debug: + msg: "ID for MKeareyOnly is {{ all_notifiers_result.notifiers | selectattr('name', 'equalto', 'MKeareyOnly') | map(attribute='id') | first }}" + +''' + +RETURN = ''' +notifiers: + description: A dictionary containing the full list of notification lists. + type: list + returned: always + sample: + - id: 68e867b77994c100013efd80 + name: MKeareyOnly + notify_list: [{...}] +''' + +import copy # noqa + +try: + from ansible.module_utils.ns1 import NS1ModuleBase +except ImportError: + from module_utils.ns1 import NS1ModuleBase # noqa + +try: + from ns1.rest.errors import ResourceException +except ImportError: + pass + + +class NS1NotifierListInfo(NS1ModuleBase): + def __init__(self): + # Only apiKey is required, bypassing strict argument checks for lookup tasks + self.module_arg_spec = dict( + apiKey=dict(required=True, type='str'), + ) + NS1ModuleBase.__init__(self, self.module_arg_spec, supports_check_mode=True) + + def get_list(self): + """Fetches the complete list of notification lists from the API.""" + try: + # ns1.notifylists().list() returns the raw list of dictionaries + lists = self.ns1.notifylists().list() + + # Convert list to dictionary keyed by 'name' for easier Ansible lookup + return lists + + except ResourceException as re: + self.module.fail_json(msg="Error retrieving notification lists: %s" % re.message) + + def exec_module(self): + """Entry point for the module.""" + + # The module is always run as read-only, so changed=False is the default. + + notifiers = self.get_list() + + self.module.exit_json(changed=False, notifiers=notifiers) + + +def main(): + list_info_module = NS1NotifierListInfo() + list_info_module.exec_module() + + +if __name__ == '__main__': + main() diff --git a/library/ns1_record.py b/library/ns1_record.py index 5f84d33..74418df 100644 --- a/library/ns1_record.py +++ b/library/ns1_record.py @@ -44,7 +44,7 @@ required: false state: description: - - Whether the record should be present or not. Use C(present) to create + - Whether the record should be present or not. Use C(present) to create or update and C(absent) to delete. type: str default: present @@ -85,6 +85,22 @@ - Region (Answer Group) that the answer belongs to. type: str required: false + feeds: + description: + - An array of feeds associated with this answer. + type: list + required: false + suboptions: + feed: + description: + - The ID of the data feed. + type: str + required: true + source: + description: + - The ID of the data source. + type: str + required: true ignore_missing_zone: description: - Attempting to delete a record from a zone that is not present will @@ -118,6 +134,7 @@ - CNAME - DNAME - HINFO + - HTTPS - MX - NAPTR - NS @@ -158,6 +175,22 @@ - The filters' configuration type: dict required: false + feeds: + description: + - An array of feeds for the record, to manage data sources. + type: list + required: false + suboptions: + feed: + description: + - The ID of the data feed. + type: str + required: false + source: + description: + - The ID of the data source. + type: str + required: false ttl: description: - The TTL of the record. @@ -246,10 +279,23 @@ - issue - letsencrypt.org +- name: Ensure an HTTPS record at apex of zone + ns1_record: + apiKey: "{{ ns1_token }}" + name: test.com + zone: test.com + state: present + type: HTTPS + answers: + - answer: + - 0 + - ramalama.github.io. + - name: Register list of datasources ns1_datasource_info apiKey: "{{ ns1_token }}" register: datasource_info + - name: An answer with a connected data feed ns1_record: apiKey: "{{ ns1_token }}" @@ -291,6 +337,7 @@ filters=dict(appendable=True), ttl=dict(appendable=False), regions=dict(appendable=False), + feeds=dict(appendable=True), ) RECORD_TYPES = [ @@ -302,6 +349,7 @@ 'CNAME', 'DNAME', 'HINFO', + 'HTTPS', 'MX', 'NAPTR', 'NS', @@ -326,6 +374,15 @@ def __init__(self): answer=dict(type='list', default=None), meta=dict(type='dict', default=None), region=dict(type='str', default=None), + feeds=dict( + type='list', + elements='dict', + default=None, + options=dict( + feed=dict(type='str', default=None), + source=dict(type='str', default=None), + ), + ), ), ), ignore_missing_zone=dict( @@ -345,6 +402,16 @@ def __init__(self): config=dict(type='dict', default=None), ), ), + feeds=dict( + required=False, + type='list', + elements='dict', + default=None, + options=dict( + feed=dict(type='str', default=None), + source=dict(type='str', default=None), + ), + ), ttl=dict(required=False, type='int', default=3600), regions=dict(required=False, type='dict', default=None), state=dict( @@ -406,7 +473,7 @@ def api_params(self): def sanitize_record(self, record): """Remove fields from the API-returned record that we don't want to - pass back, or consider when diffing. + pass back, or consider when diffing. :param record: JSON record information from the API. :type record: Any @@ -427,8 +494,7 @@ def remove_ids(d): return d record = remove_ids(record) - for answer in record['answers']: - answer.pop('feeds', None) + return record def get_zone(self): @@ -450,11 +516,11 @@ def get_zone(self): # and the user doesn't care that the zone doesn't exist # nothing to do and no change self.module.exit_json(changed=False) - else: - # generic error or user cares about missing zone - self.module.fail_json( - msg="error code %s - %s " % (re.response.code, re.message) - ) + else: + # generic error or user cares about missing zone + self.module.fail_json( + msg="error code %s - %s " % (re.response.code, re.message) + ) return to_return def get_record(self, zone): @@ -468,13 +534,13 @@ def get_record(self, zone): to_return = None try: to_return = zone.loadRecord(self.module.params.get('name'), - self.module.params.get('type').upper()) + self.module.params.get('type').upper()) except ResourceException as re: if re.response.code != 404: self.module.fail_json( msg="error code %s - %s " % (re.response.code, re.message) ) - to_return = None + to_return = None return to_return def update(self, record): @@ -624,7 +690,6 @@ def exec_module(self): self.record_exit( changed=True, after=record.data, record=record) - def main(): r = NS1Record() r.exec_module()