Skip to content

Commit

Permalink
Include ARC hosts in inventory
Browse files Browse the repository at this point in the history
By default ARC hosts will not show up in inventory, but you can use the
following inventory config to get them.

plugin: azure.azcollection.azure_rm
include_arc_resource_groups: ['*']

Use hostvar_expressions to modify the default ansible ssh config

hostvar_expressions:
   ansible_host: "resource_group + '-' + name if 'Microsoft.HybridCompute/machines' == resource_type else (public_dns_hostnames + public_ipv4_address) | first"
   ansible_ssh_common_args: "'-F /tmp/' + resource_group + '-' + name + '/ssh_config' if 'Microsoft.HybridCompute/machines' == resource_type"

Use keyed_groups to organize them or tags

keyed_groups:
  - prefix: "type"
    key: resource_type
    trailing_separator: false

Use the azure_rm_arcssh action plugin to configure the dynamic inventory
hosts with ssh proxy settings:

- name: Configure ARC SSH Proxy
  hosts: localhost
  connection: local
  tasks:
    - name: Setup Proxy
      azure.azcollection.azure_rm_arcssh:
        inventory_hostname: "{{ item }}"
        ansible_host: "{{ hostvars[item].ansible_host }}"
        local_user: admin
        resource_group: "{{ hostvars[item].resource_group }}"
        resource_type: "{{ hostvars[item].resource_type }}"
        private_key_file: "~/.ssh/id_rsa"
        ssh_config_file: "/tmp/{{ hostvars[item].resource_group }}-{{ item }}/ssh_config"
        ssh_relay_file: "/tmp/{{ hostvars[item].resource_group }}-{{ item }}/relay_info"
        ssh_proxy_folder: "~/.clientsshproxy"
      loop: "{{ groups['type_Microsoft_HybridCompute_machines'] }}"

- name: Ping ARC Hosts
  hosts: type_Microsoft_HybridCompute_machines
  tasks:
    - name: Ping
      ansible.builtin.ping:
  • Loading branch information
p3ck committed Oct 22, 2024
1 parent d499c67 commit aeeee10
Show file tree
Hide file tree
Showing 9 changed files with 927 additions and 0 deletions.
144 changes: 144 additions & 0 deletions plugins/action/azure_rm_arcssh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Copyright (c) 2024 Bill Peck <[email protected]>
#
# 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

import os

from ansible.errors import AnsibleActionFail
from ansible.plugins.action import ActionBase
from ansible.utils.display import Display
from ansible_collections.azure.azcollection.plugins.plugin_utils import (file_utils, ssh_info, connectivity_utils)
from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_common_rest import GenericRestClient
from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_common import AzureRMAuth
from ansible_collections.azure.azcollection.plugins.module_utils.azure_rm_common import AZURE_COMMON_ARGS


display = Display()


class ActionModule(ActionBase):
''' Configures ssh proxy for connecting to ARC hosts '''

TRANSFERS_FILES = False
BYPASS_HOST_LOOP = True

def __init__(self, *args, **kwargs):
if not connectivity_utils.HAS_ORAS:
raise AnsibleActionFail("oras.client is not installed.", orig_exc=connectivity_utils.HAS_ORAS_EXC)
super(ActionModule, self).__init__(*args, **kwargs)

def run(self, tmp=None, task_vars=None):
''' run the pause action module '''
if task_vars is None:
task_vars = dict()

result = super(ActionModule, self).run(tmp, task_vars)
del tmp # tmp no longer has any effect

merged_arg_spec = dict()
merged_arg_spec.update(AZURE_COMMON_ARGS)
merged_arg_spec.update(
{
'inventory_hostname': {'type': 'str'},
'ansible_host': {'type': 'str'},
'resource_group': {'type': 'str'},
'resource_type': {'type': 'str'},
'private_key_file': {'type': 'str'},
'local_user': {'type': 'str'},
'port': {'type': 'int'},
'ssh_config_file': {'type': 'str'},
'ssh_relay_file': {'type': 'str'},
'ssh_proxy_folder': {'type': 'str'}
}
)

validation_result, new_module_args = self.validate_argument_spec(
argument_spec=merged_arg_spec,
)

auth_source = os.environ.get('ANSIBLE_AZURE_AUTH_SOURCE', None) or new_module_args.get('auth_source')
auth_options = dict(
auth_source=auth_source,
profile=new_module_args.get('profile'),
subscription_id=new_module_args.get('subscription_id'),
client_id=new_module_args.get('client_id'),
secret=new_module_args.get('secret'),
tenant=new_module_args.get('tenant'),
ad_user=new_module_args.get('ad_user'),
password=new_module_args.get('password'),
cloud_environment=new_module_args.get('cloud_environment'),
cert_validation_mode=new_module_args.get('cert_validation_mode'),
api_profile=new_module_args.get('api_profile'),
track1_cred=True,
adfs_authority_url=new_module_args.get('adfs_authority_url')
)

inventory_hostname = new_module_args.get('inventory_hostname')
ansible_host = new_module_args.get('ansible_host')
resource_group = new_module_args.get('resource_group')
resource_type = new_module_args.get('resource_type')
private_key_file = new_module_args.get('private_key_file')
local_user = new_module_args.get('local_user')
port = new_module_args.get('port')
ssh_config_file = new_module_args.get('ssh_config_file')
ssh_relay_file = new_module_args.get('ssh_relay_file')
ssh_proxy_folder = new_module_args.get('ssh_proxy_folder')
result.update(dict(
changed=False,
rc=0,
stderr='',
stdout=''
))

########################################################################
# Begin the hard work!

azure_auth = AzureRMAuth(**auth_options)
rest_client = GenericRestClient(azure_auth.azure_credential_track2,
azure_auth.subscription_id,
azure_auth._cloud_environment.endpoints.resource_manager,
credential_scopes=[azure_auth._cloud_environment.endpoints.resource_manager + ".default"])

config_session = ssh_info.ConfigSession(ssh_config_file,
ssh_relay_file,
resource_group,
inventory_hostname,
ansible_host,
private_key_file,
local_user,
port,
resource_type,
ssh_proxy_folder)

try:
cert_lifetime = None # If set to None we default to the max which is 1 hour
config_session.proxy_path = connectivity_utils.install_client_side_proxy(config_session.ssh_proxy_folder)
(config_session.relay_info,
config_session.new_service_config) = connectivity_utils.get_relay_information(rest_client,
azure_auth.subscription_id,
config_session.resource_group_name,
config_session.hostname,
config_session.resource_type,
cert_lifetime,
config_session.port)
except Exception as e:
raise AnsibleActionFail("Failed to get relay information.", orig_exc=e)

config_text = config_session.get_config_text()
ssh_config_path = config_session.ssh_config_file

ssh_config_dir = os.path.dirname(ssh_config_path)
if not os.path.isdir(ssh_config_dir):
os.makedirs(ssh_config_dir)

file_utils.write_to_file(ssh_config_path,
'w',
'\n'.join(config_text),
f"Couldn't write ssh config to file {ssh_config_path}.",
'utf-8')

result['stdout'] = "SSH proxy configured for %s in %s" % (inventory_hostname, config_session.ssh_config_file)
return result
4 changes: 4 additions & 0 deletions plugins/doc_fragments/azure_rm.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class ModuleDocFragment(object):
description: A list of resource group names to search for Azure StackHCI virtual machines. '\*' will
include all resource groups in the subscription.
default: []
include_arc_resource_groups:
description: A list of resource group names to search for Azure ARC machines. '\*' will include all
resource groups in the subscription.
default: []
include_vmss_resource_groups:
description: A list of resource group names to search for virtual machine scale sets (VMSSs). '\*' will
include all resource groups in the subscription.
Expand Down
89 changes: 89 additions & 0 deletions plugins/inventory/azure_rm.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
include_hcivm_resource_groups:
- myrg1
# fetches ARC hosts in specific resource groups (defaults to no ARC fetch)
include_arc_resource_groups:
- myrg1
# places a host in the named group if the associated condition evaluates to true
conditional_groups:
# since this will be true for every host, every host sourced from this inventory plugin config will be in the
Expand Down Expand Up @@ -298,6 +302,15 @@ def _enqueue_vm_list(self, rg='*'):
url = url.format(subscriptionId=self._clientconfig.subscription_id, rg=rg)
self._enqueue_get(url=url, api_version=self._compute_api_version, handler=self._on_vm_page_response)

def _enqueue_arc_list(self, rg='*'):
if not rg or rg == '*':
url = '/subscriptions/{subscriptionId}/providers/Microsoft.HybridCompute/machines'
else:
url = '/subscriptions/{subscriptionId}/resourceGroups/{rg}/providers/Microsoft.HybridCompute/machines'

url = url.format(subscriptionId=self._clientconfig.subscription_id, rg=rg)
self._enqueue_get(url=url, api_version=self._hybridcompute_api_version, handler=self._on_arc_page_response)

def _enqueue_arcvm_list(self, rg='*'):
if not rg or rg == '*':
url = '/subscriptions/{subscriptionId}/providers/Microsoft.HybridCompute/machines'
Expand All @@ -324,6 +337,9 @@ def _get_hosts(self):
for vm_rg in self.get_option('include_vm_resource_groups'):
self._enqueue_vm_list(vm_rg)

for arc_rg in self.get_option('include_arc_resource_groups'):
self._enqueue_arc_list(arc_rg)

for vm_rg in self.get_option('include_hcivm_resource_groups'):
self._enqueue_arcvm_list(vm_rg)

Expand Down Expand Up @@ -434,6 +450,15 @@ def _on_vm_page_response(self, response, vmss=None, arcvm=None):
# FUTURE: add direct VM filtering by tag here (performance optimization)?
self._hosts.append(AzureHost(h, self, vmss=vmss, arcvm=arcvm, legacy_name=self._legacy_hostnames))

def _on_arc_page_response(self, response):
next_link = response.get('nextLink')

if next_link:
self._enqueue_get(url=next_link, api_version=self._hybridcompute_api_version, handler=self._on_arc_page_response)

for arcvm in response['value']:
self._hosts.append(ArcHost(arcvm, self, legacy_name=self._legacy_hostnames))

def _on_arcvm_page_response(self, response):
next_link = response.get('nextLink')

Expand All @@ -444,6 +469,7 @@ def _on_arcvm_page_response(self, response):
url = '{0}/providers/Microsoft.AzureStackHCI/virtualMachineInstances'.format(arcvm['id'])
# Stack HCI instances look close enough to regular VMs that we can share the handler impl...
self._enqueue_get(url=url, api_version=self._stackhci_api_version, handler=self._on_vm_page_response, handler_args=dict(arcvm=arcvm))
self._hosts.append(ArcHost(arcvm, self, legacy_name=self._legacy_hostnames))

def _on_vmss_page_response(self, response):
next_link = response.get('nextLink')
Expand Down Expand Up @@ -584,6 +610,69 @@ def _legacy_script_compatible_group_sanitization(name):
# VMSS VMs (all SS, N specific SS, N resource groups?): SS -> VM -> InstanceView, N NICs, N PublicIPAddress)


class ArcHost(object):
def __init__(self, arc_model, inventory_client, legacy_name=False):
self._inventory_client = inventory_client
self._arc_model = arc_model
self._instanceview = self._arc_model
self._status = self._arc_model['properties'].get('status', {}).lower() # 'Connected'
self._powerstate = self._status.replace('connected', 'running')

self._hostvars = {}

arc_name = self._arc_model['name']

if legacy_name:
self.default_inventory_hostname = arc_name
else:
# Azure often doesn't provide a globally-unique filename, so use resource name + a chunk of ID hash
self.default_inventory_hostname = '{0}_{1}'.format(arc_name, hashlib.sha1(to_bytes(arc_model['id'])).hexdigest()[0:4])

@property
def hostvars(self):
if self._hostvars != {}:
return self._hostvars

properties = self._arc_model.get('properties', {})
new_hostvars = dict(
network_interface=[],
mac_address=[],
ansible_all_ipv4_addresses=[],
ansible_all_ipv6_addresses=[],
public_ipv4_address=[],
private_ipv4_addresses=[],
public_dns_hostnames=[],
ansible_dns=[],
id=self._arc_model['id'],
location=self._arc_model['location'],
name=self._arc_model['name'],
default_inventory_hostname=self.default_inventory_hostname,
powerstate=self._powerstate,
status=self._status,
provisioning_state=properties.get('provisioningState', 'unknown').lower(),
os_profile=dict(
sku=properties.get('osSku', 'unknown'),
system=properties.get('osType', 'unknown'),
version=properties.get('osVersion', 'unknown'),
),
tags=self._arc_model.get('tags', {}),
resource_type=self._arc_model.get('type', "unknown"),
resource_group=parse_resource_id(self._arc_model['id']).get('resource_group').lower(),
)

for nic in properties.get('networkProfile', {}).get('networkInterfaces', []):
new_hostvars['mac_address'].append(nic.get('macAddress'))
new_hostvars['network_interface'].append(nic.get('name'))
for ipaddr in nic.get('ipAddresses', []):
ipAddressVersion = ipaddr.get('ipAddressVersion')
if ipAddressVersion == 'IPv4':
new_hostvars['ansible_all_ipv4_addresses'].append(ipaddr.get('address'))
if ipAddressVersion == 'IPv6':
new_hostvars['ansible_all_ipv6_addresses'].append(ipaddr.get('address'))
self._hostvars = new_hostvars
return self._hostvars


class AzureHost(object):
_powerstate_regex = re.compile('^PowerState/(?P<powerstate>.+)$')

Expand Down
Loading

0 comments on commit aeeee10

Please sign in to comment.