diff --git a/playbooks/inventory/linode.yaml b/playbooks/inventory/linode.yaml index 4999b58..e267824 100644 --- a/playbooks/inventory/linode.yaml +++ b/playbooks/inventory/linode.yaml @@ -1,9 +1,96 @@ --- -- name: Generate Linode inventory file +- name: Update Linode Dynamic Inventory + hosts: localhost + gather_facts: true + connection: local + + vars: + # Override these variables as needed + linode_api_token: "{{ lookup('env', 'LINODE_API_TOKEN') }}" + linode_inventory_output_dir: "/tmp/linode_inventory" + inventory_format: "json" # or "ini" + awx_integration: true + cleanup_temp_files: false + + # Optional filters + include_only_running: false + specific_regions: [] # e.g., ['us-east', 'us-west'] + specific_tags: [] # e.g., ['production', 'web'] + + pre_tasks: + - name: Check for Linode API token + ansible.builtin.fail: + msg: "LINODE_API_TOKEN environment variable or linode_api_token variable must be set" + when: + - linode_api_token is undefined or linode_api_token == "" + - lookup('env', 'LINODE_API_TOKEN') == "" + + - name: Display configuration + ansible.builtin.debug: + msg: | + Linode Inventory Configuration: + Output directory: {{ linode_inventory_output_dir }} + Output format: {{ inventory_format }} + AWX integration: {{ awx_integration }} + Include only running: {{ include_only_running }} + + roles: + - role: linode_inventory + vars: + linode_api_token: "{{ linode_api_token }}" + + post_tasks: + - name: Display next steps + ansible.builtin.debug: + msg: | + Inventory update complete! + + Next steps for AWX integration: + 1. Copy the inventory script to your SCM repository + 2. Create a custom inventory source in AWX + 3. Point it to the linode_inventory.py script + 4. Set up the Linode API credential + + Files created: + - JSON inventory: {{ linode_inventory_output_dir }}/{{ linode_inventory_output_file }} + {% if inventory_format == "ini" %} + - INI inventory: {{ linode_inventory_output_dir }}/linode_static_inventory.ini + {% endif %} + +# Optional: Run against discovered Linode hosts +- name: Example task against discovered Linode hosts hosts: localhost gather_facts: false - roles: - - roles/inventory/linode - vars: - linode_token: "{{ lookup('env', 'LINODE_TOKEN') }}" + tasks: + - name: Load dynamic inventory + ansible.builtin.include_vars: + file: "{{ linode_inventory_output_dir | default('/tmp/linode_inventory') }}/{{ linode_inventory_output_file | default('linode_inventory.json') }}" + name: dynamic_inventory + - name: Add discovered hosts to in-memory inventory + ansible.builtin.add_host: + name: "{{ item.key }}" + groups: "{{ group_names | default(['discovered_linodes']) }}" + ansible_host: "{{ item.value.ansible_host }}" + linode_id: "{{ item.value.linode_id }}" + linode_region: "{{ item.value.linode_region }}" + linode_type: "{{ item.value.linode_type }}" + linode_status: "{{ item.value.linode_status }}" + loop: "{{ dynamic_inventory._meta.hostvars | dict2items }}" + when: item.value.linode_status == "running" + +- name: Test connection to discovered Linode hosts + hosts: discovered_linodes + gather_facts: false + vars: + ansible_user: root + ansible_ssh_common_args: '-o StrictHostKeyChecking=no -o ConnectTimeout=10' + tasks: + - name: Test connectivity + ansible.builtin.ping: + register: ping_result + ignore_errors: true + + - name: Display connectivity status + ansible.builtin.debug: + msg: "{{ inventory_hostname }} ({{ ansible_host }}): {{ 'REACHABLE' if ping_result is succeeded else 'UNREACHABLE' }}" diff --git a/roles/inventory/linode/defaults/main.yml b/roles/inventory/linode/defaults/main.yml new file mode 100644 index 0000000..ea1fac0 --- /dev/null +++ b/roles/inventory/linode/defaults/main.yml @@ -0,0 +1,26 @@ +--- +# Default variables for linode_inventory role + +# Linode API settings +linode_api_url: "https://api.linode.com/v4" +linode_inventory_output_dir: "/tmp" +linode_inventory_output_file: "linode_inventory.json" + +# Inventory grouping options +create_region_groups: true +create_type_groups: true +create_status_groups: true +create_tag_groups: true + +# Default groups to create +default_groups: + - all + - ungrouped + +# Filter options +include_only_running: false +specific_regions: [] +specific_tags: [] + +# Output format +inventory_format: "json" # json or ini diff --git a/roles/inventory/linode/files/linode_inventory.py b/roles/inventory/linode/files/linode_inventory.py new file mode 100644 index 0000000..aff1330 --- /dev/null +++ b/roles/inventory/linode/files/linode_inventory.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +import json +import requests +import sys +import os +from argparse import ArgumentParser + +class LinodeInventory: + def __init__(self): + self.api_token = os.environ.get('LINODE_API_TOKEN') + if not self.api_token: + raise ValueError("LINODE_API_TOKEN environment variable is required") + + self.base_url = "https://api.linode.com/v4" + self.headers = { + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json" + } + + def get_instances(self): + """Fetch all Linode instances""" + url = f"{self.base_url}/linode/instances" + response = requests.get(url, headers=self.headers) + response.raise_for_status() + return response.json()['data'] + + def generate_inventory(self): + """Generate Ansible inventory from Linode instances""" + inventory = { + '_meta': { + 'hostvars': {} + }, + 'all': { + 'children': ['ungrouped'] + }, + 'ungrouped': { + 'hosts': [] + } + } + + # Group definitions + regions = {} + types = {} + statuses = {} + + try: + instances = self.get_instances() + + for instance in instances: + # Use Linode label as hostname (this is what you wanted!) + hostname = instance['label'] + + # Get primary IPv4 address + ipv4_addresses = instance.get('ipv4', []) + primary_ip = ipv4_addresses[0] if ipv4_addresses else None + + # Add to ungrouped hosts + inventory['ungrouped']['hosts'].append(hostname) + + # Host variables + inventory['_meta']['hostvars'][hostname] = { + 'ansible_host': primary_ip, + 'linode_id': instance['id'], + 'linode_label': instance['label'], + 'linode_region': instance['region'], + 'linode_type': instance['type'], + 'linode_status': instance['status'], + 'linode_ipv4': instance.get('ipv4', []), + 'linode_ipv6': instance.get('ipv6'), + 'linode_tags': instance.get('tags', []), + 'linode_specs': instance.get('specs', {}), + 'linode_hypervisor': instance.get('hypervisor'), + 'linode_created': instance.get('created'), + 'linode_updated': instance.get('updated') + } + + # Group by region + region_group = f"region_{instance['region'].replace('-', '_')}" + if region_group not in regions: + regions[region_group] = {'hosts': []} + regions[region_group]['hosts'].append(hostname) + + # Group by instance type + type_group = f"type_{instance['type'].replace('-', '_').replace('.', '_')}" + if type_group not in types: + types[type_group] = {'hosts': []} + types[type_group]['hosts'].append(hostname) + + # Group by status + status_group = f"status_{instance['status']}" + if status_group not in statuses: + statuses[status_group] = {'hosts': []} + statuses[status_group]['hosts'].append(hostname) + + # Group by tags + for tag in instance.get('tags', []): + tag_group = f"tag_{tag.replace('-', '_').replace(' ', '_')}" + if tag_group not in inventory: + inventory[tag_group] = {'hosts': []} + inventory[tag_group]['hosts'].append(hostname) + + except requests.exceptions.RequestException as e: + print(f"Error fetching Linode instances: {e}", file=sys.stderr) + return {} + + # Add all groups to inventory + inventory.update(regions) + inventory.update(types) + inventory.update(statuses) + + # Add group children to 'all' + all_groups = list(regions.keys()) + list(types.keys()) + list(statuses.keys()) + tag_groups = [k for k in inventory.keys() if k.startswith('tag_')] + all_groups.extend(tag_groups) + + if all_groups: + inventory['all']['children'].extend(all_groups) + + return inventory + + def get_host_vars(self, hostname): + """Get variables for a specific host""" + inventory = self.generate_inventory() + return inventory['_meta']['hostvars'].get(hostname, {}) + +def main(): + parser = ArgumentParser(description='Linode Dynamic Inventory') + parser.add_argument('--list', action='store_true', help='List all hosts') + parser.add_argument('--host', help='Get variables for specific host') + + args = parser.parse_args() + + try: + inventory = LinodeInventory() + + if args.list: + print(json.dumps(inventory.generate_inventory(), indent=2)) + elif args.host: + print(json.dumps(inventory.get_host_vars(args.host), indent=2)) + else: + parser.print_help() + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/roles/inventory/linode/library/linode_inventory.py b/roles/inventory/linode/library/linode_inventory.py deleted file mode 100644 index f21ab4b..0000000 --- a/roles/inventory/linode/library/linode_inventory.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 - -from ansible.module_utils.basic import AnsibleModule -import os -import requests - -def main(): - module = AnsibleModule(argument_spec={}) - - token = os.getenv("LINODE_TOKEN") - if not token: - module.fail_json(msg="LINODE_TOKEN is not set") - - response = requests.get("https://api.linode.com/v4/linode/instances", headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - }) - - if response.status_code != 200: - module.fail_json(msg=f"Linode API error: {response.status_code}", details=response.text) - - linodes = response.json().get("data", []) - results = { - "changed": False, - "linodes": [ - { - "name": l["label"], - "ip": l["ipv4"][0] if l["ipv4"] else None, - "region": l["region"], - "tags": l["tags"], - "type": l["type"] - } for l in linodes - ] - } - - module.exit_json(**results) - -if __name__ == "__main__": - main() diff --git a/roles/inventory/linode/meta/main.yml b/roles/inventory/linode/meta/main.yml new file mode 100644 index 0000000..d83c1a7 --- /dev/null +++ b/roles/inventory/linode/meta/main.yml @@ -0,0 +1,30 @@ +--- +galaxy_info: + author: Kevin M Thompson + description: Ansible role for Linode dynamic inventory management + company: Ewnix + license: MIT + min_ansible_version: 2.9 + platforms: + - name: EL + versions: + - 7 + - 8 + - 9 + - name: Ubuntu + versions: + - 18.04 + - 20.04 + - 22.04 + - name: Debian + versions: + - 10 + - 11 + galaxy_tags: + - linode + - inventory + - dynamic + - cloud + - automation + +dependencies: [] diff --git a/roles/inventory/linode/tasks/main.yaml b/roles/inventory/linode/tasks/main.yaml deleted file mode 100644 index 68d7bfd..0000000 --- a/roles/inventory/linode/tasks/main.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -- name: Get Linode inventory using local module - linode_inventory: - register: linode_result - -- name: Write inventory to file - copy: - dest: /tmp/linode_inventory.json - content: "{{ linode_result | to_nice_json }}" - mode: '0644' - diff --git a/roles/inventory/linode/tasks/main.yml b/roles/inventory/linode/tasks/main.yml new file mode 100644 index 0000000..fae629c --- /dev/null +++ b/roles/inventory/linode/tasks/main.yml @@ -0,0 +1,97 @@ +--- +# Main tasks for linode_inventory role + +- name: Validate required variables + ansible.builtin.assert: + that: + - linode_api_token is defined + - linode_api_token | length > 0 + fail_msg: "linode_api_token must be defined and not empty" + quiet: true + +- name: Ensure output directory exists + ansible.builtin.file: + path: "{{ linode_inventory_output_dir }}" + state: directory + mode: '0755' + delegate_to: localhost + +- name: Check if Python requests module is available + ansible.builtin.command: python3 -c "import requests" + register: python_requests_check + failed_when: false + changed_when: false + delegate_to: localhost + +- name: Install Python requests if not available + ansible.builtin.pip: + name: requests + state: present + when: python_requests_check.rc != 0 + delegate_to: localhost + +- name: Copy Linode inventory script + ansible.builtin.copy: + src: linode_inventory.py + dest: "{{ linode_inventory_output_dir }}/linode_inventory.py" + mode: '0755' + delegate_to: localhost + +- name: Execute Linode inventory script + ansible.builtin.command: + cmd: python3 {{ linode_inventory_output_dir }}/linode_inventory.py --list + environment: + LINODE_API_TOKEN: "{{ linode_api_token }}" + register: linode_inventory_result + delegate_to: localhost + changed_when: true + +- name: Parse inventory JSON + ansible.builtin.set_fact: + linode_inventory_data: "{{ linode_inventory_result.stdout | from_json }}" + +- name: Save inventory to file + ansible.builtin.copy: + content: "{{ linode_inventory_data | to_nice_json }}" + dest: "{{ temp_inventory_path }}" + mode: '0644' + delegate_to: localhost + +- name: Display inventory summary + ansible.builtin.debug: + msg: | + Linode Dynamic Inventory Summary: + Total hosts discovered: {{ linode_inventory_data._meta.hostvars | length }} + Groups created: {{ linode_inventory_data.keys() | reject('equalto', '_meta') | list | length }} + Inventory saved to: {{ temp_inventory_path }} + +- name: Show discovered hosts + ansible.builtin.debug: + msg: "Host: {{ item.key }} ({{ item.value.ansible_host }}) - Region: {{ item.value.linode_region }} - Status: {{ item.value.linode_status }}" + loop: "{{ linode_inventory_data._meta.hostvars | dict2items }}" + loop_control: + label: "{{ item.key }}" + +- name: Create static inventory file (optional) + ansible.builtin.template: + src: inventory.ini.j2 + dest: "{{ linode_inventory_output_dir }}/linode_static_inventory.ini" + mode: '0644' + when: inventory_format == "ini" + delegate_to: localhost + +# AWX/Tower specific tasks +- name: Create inventory update script for AWX + ansible.builtin.template: + src: awx_inventory_update.sh.j2 + dest: "{{ linode_inventory_output_dir }}/awx_inventory_update.sh" + mode: '0755' + delegate_to: localhost + when: awx_integration | default(false) + +- name: Clean up temporary script + ansible.builtin.file: + path: "{{ linode_inventory_output_dir }}/linode_inventory.py" + state: absent + delegate_to: localhost + when: cleanup_temp_files | default(true) diff --git a/roles/inventory/linode/templates/inventory.ini.j2 b/roles/inventory/linode/templates/inventory.ini.j2 new file mode 100644 index 0000000..6ea0695 --- /dev/null +++ b/roles/inventory/linode/templates/inventory.ini.j2 @@ -0,0 +1,22 @@ +# Generated Linode Inventory +# Generated on: {{ ansible_date_time.iso8601 }} + +{% for host_name, host_vars in linode_inventory_data._meta.hostvars.items() %} +{{ host_name }} ansible_host={{ host_vars.ansible_host }} linode_id={{ host_vars.linode_id }} linode_region={{ host_vars.linode_region }} linode_type={{ host_vars.linode_type }} linode_status={{ host_vars.linode_status }} +{% endfor %} + +{% for group_name, group_data in linode_inventory_data.items() %} +{% if group_name != '_meta' and group_name != 'all' %} +[{{ group_name }}] +{% if group_data.hosts is defined %} +{% for host in group_data.hosts %} +{{ host }} +{% endfor %} +{% endif %} + +{% endif %} +{% endfor %} + +[all:vars] +ansible_user=root +ansible_ssh_common_args='-o StrictHostKeyChecking=no' diff --git a/roles/inventory/linode/vars/main.yml b/roles/inventory/linode/vars/main.yml new file mode 100644 index 0000000..1ff6470 --- /dev/null +++ b/roles/inventory/linode/vars/main.yml @@ -0,0 +1,14 @@ +--- +# Internal variables for linode_inventory role + +linode_inventory_script_path: "{{ role_path }}/files/linode_inventory.py" +temp_inventory_path: "{{ linode_inventory_output_dir }}/{{ linode_inventory_output_file }}" + +# Required environment variables +required_env_vars: + - LINODE_API_TOKEN + +# Python packages required +python_requirements: + - requests + - json