hostname - add macOS (#54439)
* Add DarwinStrategy class and integration tests macOS has three seprate hostname params that need to be set. One of those params, LocalHostName, has more stringent requirements than the other two, which accept special characters and spaces. Create a method to scrub the hostname to ensure it works well with the system requirements. * Update documentation * Account for virtualization type returned on Azure Pipelines * Do not be dependent on order of self.name_types Use the scrubbed name when the name type is LocalHostName
This commit is contained in:
parent
dd19c9f737
commit
7352457e7b
7 changed files with 288 additions and 89 deletions
2
changelogs/fragments/322214-hostname-macos-support.yml
Normal file
2
changelogs/fragments/322214-hostname-macos-support.yml
Normal file
|
@ -0,0 +1,2 @@
|
|||
bugfixes:
|
||||
- hostname - add macOS support (https://github.com/ansible/ansible/pull/54439)
|
|
@ -18,20 +18,25 @@ version_added: "1.4"
|
|||
short_description: Manage hostname
|
||||
requirements: [ hostname ]
|
||||
description:
|
||||
- Set system's hostname, supports most OSs/Distributions, including those using systemd.
|
||||
- Note, this module does *NOT* modify C(/etc/hosts). You need to modify it yourself using other modules like template or replace.
|
||||
- Windows, macOS, HP-UX and AIX are not currently supported.
|
||||
- Set system's hostname. Supports most OSs/Distributions including those using C(systemd).
|
||||
- Windows, HP-UX, and AIX are not currently supported.
|
||||
notes:
|
||||
- This module does B(NOT) modify C(/etc/hosts). You need to modify it yourself using other modules like M(template) or M(replace).
|
||||
- On macOS, this module uses C(scutil) to set C(HostName), C(ComputerName), and C(LocalHostName). Since C(LocalHostName)
|
||||
cannot contain spaces or most special characters, this module will replace characters when setting C(LocalHostName).
|
||||
options:
|
||||
name:
|
||||
description:
|
||||
- Name of the host
|
||||
- If the value is a fully qualified domain name that does not resolve from the given host,
|
||||
this will cause the module to hang for a few seconds while waiting for the name resolution attempt to timeout.
|
||||
type: str
|
||||
required: true
|
||||
use:
|
||||
description:
|
||||
- Which strategy to use to update the hostname.
|
||||
- If not set we try to autodetect, but this can be problematic, particularly with containers as they can present misleading information.
|
||||
choices: ['generic', 'debian', 'sles', 'redhat', 'alpine', 'systemd', 'openrc', 'openbsd', 'solaris', 'freebsd']
|
||||
choices: ['alpine', 'debian', 'freebsd', 'generic', 'macos', 'macosx', 'darwin', 'openbsd', 'openrc', 'redhat', 'sles', 'solaris', 'systemd']
|
||||
type: str
|
||||
version_added: '2.9'
|
||||
'''
|
||||
|
@ -40,6 +45,11 @@ EXAMPLES = '''
|
|||
- name: Set a hostname
|
||||
hostname:
|
||||
name: web01
|
||||
|
||||
- name: Set a hostname specifying strategy
|
||||
hostname:
|
||||
name: web01
|
||||
strategy: systemd
|
||||
'''
|
||||
|
||||
import os
|
||||
|
@ -54,10 +64,24 @@ from ansible.module_utils.basic import (
|
|||
)
|
||||
from ansible.module_utils.common.sys_info import get_platform_subclass
|
||||
from ansible.module_utils.facts.system.service_mgr import ServiceMgrFactCollector
|
||||
from ansible.module_utils._text import to_native
|
||||
from ansible.module_utils._text import to_native, to_text
|
||||
from ansible.module_utils.six import PY3, text_type
|
||||
|
||||
STRATS = {'generic': 'Generic', 'debian': 'Debian', 'sles': 'SLES', 'redhat': 'RedHat', 'alpine': 'Alpine',
|
||||
'systemd': 'Systemd', 'openrc': 'OpenRC', 'openbsd': 'OpenBSD', 'solaris': 'Solaris', 'freebsd': 'FreeBSD'}
|
||||
STRATS = {
|
||||
'alpine': 'Alpine',
|
||||
'debian': 'Debian',
|
||||
'freebsd': 'FreeBSD',
|
||||
'generic': 'Generic',
|
||||
'macos': 'Darwin',
|
||||
'macosx': 'Darwin',
|
||||
'darwin': 'Darwin',
|
||||
'openbsd': 'OpenBSD',
|
||||
'openrc': 'OpenRC',
|
||||
'redhat': 'RedHat',
|
||||
'sles': 'SLES',
|
||||
'solaris': 'Solaris',
|
||||
'systemd': 'Systemd',
|
||||
}
|
||||
|
||||
|
||||
class UnimplementedStrategy(object):
|
||||
|
@ -580,6 +604,116 @@ class FreeBSDStrategy(GenericStrategy):
|
|||
f.close()
|
||||
|
||||
|
||||
class DarwinStrategy(GenericStrategy):
|
||||
"""
|
||||
This is a macOS hostname manipulation strategy class. It uses
|
||||
/usr/sbin/scutil to set ComputerName, HostName, and LocalHostName.
|
||||
|
||||
HostName corresponds to what most platforms consider to be hostname.
|
||||
It controls the name used on the command line and SSH.
|
||||
|
||||
However, macOS also has LocalHostName and ComputerName settings.
|
||||
LocalHostName controls the Bonjour/ZeroConf name, used by services
|
||||
like AirDrop. This class implements a method, _scrub_hostname(), that mimics
|
||||
the transformations macOS makes on hostnames when enterened in the Sharing
|
||||
preference pane. It replaces spaces with dashes and removes all special
|
||||
characters.
|
||||
|
||||
ComputerName is the name used for user-facing GUI services, like the
|
||||
System Preferences/Sharing pane and when users connect to the Mac over the network.
|
||||
"""
|
||||
|
||||
def __init__(self, module):
|
||||
super(DarwinStrategy, self).__init__(module)
|
||||
self.scutil = self.module.get_bin_path('scutil', True)
|
||||
self.name_types = ('HostName', 'ComputerName', 'LocalHostName')
|
||||
self.scrubbed_name = self._scrub_hostname(self.module.params['name'])
|
||||
|
||||
def _make_translation(self, replace_chars, replacement_chars, delete_chars):
|
||||
if PY3:
|
||||
return str.maketrans(replace_chars, replacement_chars, delete_chars)
|
||||
|
||||
if not isinstance(replace_chars, text_type) or not isinstance(replacement_chars, text_type):
|
||||
raise ValueError('replace_chars and replacement_chars must both be strings')
|
||||
if len(replace_chars) != len(replacement_chars):
|
||||
raise ValueError('replacement_chars must be the same length as replace_chars')
|
||||
|
||||
table = dict(zip((ord(c) for c in replace_chars), replacement_chars))
|
||||
for char in delete_chars:
|
||||
table[ord(char)] = None
|
||||
|
||||
return table
|
||||
|
||||
def _scrub_hostname(self, name):
|
||||
"""
|
||||
LocalHostName only accepts valid DNS characters while HostName and ComputerName
|
||||
accept a much wider range of characters. This function aims to mimic how macOS
|
||||
translates a friendly name to the LocalHostName.
|
||||
"""
|
||||
|
||||
# Replace all these characters with a single dash
|
||||
name = to_text(name)
|
||||
replace_chars = u'\'"~`!@#$%^&*(){}[]/=?+\\|-_ '
|
||||
delete_chars = u".'"
|
||||
table = self._make_translation(replace_chars, u'-' * len(replace_chars), delete_chars)
|
||||
name = name.translate(table)
|
||||
|
||||
# Replace multiple dashes with a single dash
|
||||
while '-' * 2 in name:
|
||||
name = name.replace('-' * 2, '')
|
||||
|
||||
name = name.rstrip('-')
|
||||
return name
|
||||
|
||||
def get_current_hostname(self):
|
||||
cmd = [self.scutil, '--get', 'HostName']
|
||||
rc, out, err = self.module.run_command(cmd)
|
||||
if rc != 0 and 'HostName: not set' not in err:
|
||||
self.module.fail_json(msg="Failed to get current hostname rc=%d, out=%s, err=%s" % (rc, out, err))
|
||||
|
||||
return to_native(out).strip()
|
||||
|
||||
def get_permanent_hostname(self):
|
||||
cmd = [self.scutil, '--get', 'ComputerName']
|
||||
rc, out, err = self.module.run_command(cmd)
|
||||
if rc != 0:
|
||||
self.module.fail_json(msg="Failed to get permanent hostname rc=%d, out=%s, err=%s" % (rc, out, err))
|
||||
|
||||
return to_native(out).strip()
|
||||
|
||||
def set_permanent_hostname(self, name):
|
||||
for hostname_type in self.name_types:
|
||||
cmd = [self.scutil, '--set', hostname_type]
|
||||
if hostname_type == 'LocalHostName':
|
||||
cmd.append(to_native(self.scrubbed_name))
|
||||
else:
|
||||
cmd.append(to_native(name))
|
||||
rc, out, err = self.module.run_command(cmd)
|
||||
if rc != 0:
|
||||
self.module.fail_json(msg="Failed to set {3} to '{2}': {0} {1}".format(to_native(out), to_native(err), to_native(name), hostname_type))
|
||||
|
||||
def set_current_hostname(self, name):
|
||||
pass
|
||||
|
||||
def update_current_hostname(self):
|
||||
pass
|
||||
|
||||
def update_permanent_hostname(self):
|
||||
name = self.module.params['name']
|
||||
|
||||
# Get all the current host name values in the order of self.name_types
|
||||
all_names = tuple(self.module.run_command([self.scutil, '--get', name_type])[1].strip() for name_type in self.name_types)
|
||||
|
||||
# Get the expected host name values based on the order in self.name_types
|
||||
expected_names = tuple(self.scrubbed_name if n == 'LocalHostName' else name for n in self.name_types)
|
||||
|
||||
# Ensure all three names are updated
|
||||
if all_names != expected_names:
|
||||
if not self.module.check_mode:
|
||||
self.set_permanent_hostname(name)
|
||||
self.changed = True
|
||||
|
||||
|
||||
class FedoraHostname(Hostname):
|
||||
platform = 'Linux'
|
||||
distribution = 'Fedora'
|
||||
|
@ -822,6 +956,12 @@ class NeonHostname(Hostname):
|
|||
strategy_class = DebianStrategy
|
||||
|
||||
|
||||
class DarwinHostname(Hostname):
|
||||
platform = 'Darwin'
|
||||
distribution = None
|
||||
strategy_class = DarwinStrategy
|
||||
|
||||
|
||||
class OsmcHostname(Hostname):
|
||||
platform = 'Linux'
|
||||
distribution = 'Osmc'
|
||||
|
@ -867,7 +1007,11 @@ def main():
|
|||
name_before = current_hostname
|
||||
elif name != permanent_hostname:
|
||||
name_before = permanent_hostname
|
||||
else:
|
||||
name_before = permanent_hostname
|
||||
|
||||
# NOTE: socket.getfqdn() calls gethostbyaddr(socket.gethostname()), which can be
|
||||
# slow to return if the name does not resolve correctly.
|
||||
kw = dict(changed=changed, name=name,
|
||||
ansible_facts=dict(ansible_hostname=name.split('.')[0],
|
||||
ansible_nodename=name,
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
shippable/posix/group1
|
||||
destructive
|
||||
skip/aix # currently unsupported by hostname module
|
||||
skip/osx # same, see #54439, #32214
|
||||
skip/macos
|
||||
|
|
52
test/integration/targets/hostname/tasks/MacOSX.yml
Normal file
52
test/integration/targets/hostname/tasks/MacOSX.yml
Normal file
|
@ -0,0 +1,52 @@
|
|||
- name: macOS | Set hostname
|
||||
hostname:
|
||||
name: bugs.acme.example.com
|
||||
|
||||
# These tasks can be changed to a loop once https://github.com/ansible/ansible/issues/71031
|
||||
# is fixed
|
||||
- name: macOS | Set hostname specifiying macos strategy
|
||||
hostname:
|
||||
name: bugs.acme.example.com
|
||||
use: macos
|
||||
|
||||
- name: macOS | Set hostname specifiying macosx strategy
|
||||
hostname:
|
||||
name: bugs.acme.example.com
|
||||
use: macosx
|
||||
|
||||
- name: macOS | Set hostname specifiying darwin strategy
|
||||
hostname:
|
||||
name: bugs.acme.example.com
|
||||
use: darwin
|
||||
|
||||
- name: macOS | Get macOS hostname values
|
||||
command: scutil --get {{ item }}
|
||||
loop:
|
||||
- HostName
|
||||
- ComputerName
|
||||
- LocalHostName
|
||||
register: macos_scutil
|
||||
ignore_errors: yes
|
||||
|
||||
- name: macOS | Ensure all hostname values were set correctly
|
||||
assert:
|
||||
that:
|
||||
- "['bugs.acme.example.com', 'bugs.acme.example.com', 'bugsacmeexamplecom'] == macos_scutil.results | map(attribute='stdout') | list"
|
||||
|
||||
- name: macOS | Set to a hostname using spaces and punctuation
|
||||
hostname:
|
||||
name: The Dude's Computer
|
||||
|
||||
- name: macOS | Get macOS hostname values
|
||||
command: scutil --get {{ item }}
|
||||
loop:
|
||||
- HostName
|
||||
- ComputerName
|
||||
- LocalHostName
|
||||
register: macos_scutil_complex
|
||||
ignore_errors: yes
|
||||
|
||||
- name: macOS | Ensure all hostname values were set correctly
|
||||
assert:
|
||||
that:
|
||||
- "['The Dude\\'s Computer', 'The Dude\\'s Computer', 'The-Dudes-Computer'] == (macos_scutil_complex.results | map(attribute='stdout') | list)"
|
|
@ -16,80 +16,8 @@
|
|||
command: hostname
|
||||
register: original
|
||||
|
||||
- name: Run hostname module in check_mode
|
||||
hostname:
|
||||
name: crocodile.ansible.test.doesthiswork.net.example.com
|
||||
check_mode: true
|
||||
register: hn1
|
||||
|
||||
- name: Get current hostname again
|
||||
command: hostname
|
||||
register: after_hn
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- hn1 is changed
|
||||
- original.stdout == after_hn.stdout
|
||||
|
||||
- when: _hostname_file is defined and _hostname_file
|
||||
block:
|
||||
- name: See if current hostname file exists
|
||||
stat:
|
||||
path: "{{ _hostname_file }}"
|
||||
register: hn_stat
|
||||
|
||||
- name: Move the current hostname file if it exists
|
||||
command: mv {{ _hostname_file }} {{ _hostname_file }}.orig
|
||||
when: hn_stat.stat.exists
|
||||
|
||||
- name: Run hostname module in check_mode
|
||||
hostname:
|
||||
name: crocodile.ansible.test.doesthiswork.net.example.com
|
||||
check_mode: true
|
||||
register: hn
|
||||
|
||||
- stat:
|
||||
path: /etc/rc.conf.d/hostname
|
||||
register: hn_stat_checkmode
|
||||
|
||||
- assert:
|
||||
that:
|
||||
# TODO: This is a legitimate bug and will be fixed in another PR.
|
||||
# - not hn_stat_checkmode.stat.exists
|
||||
- hn is changed
|
||||
|
||||
- name: Get hostname again
|
||||
command: hostname
|
||||
register: current_after_cm
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- original.stdout == current_after_cm.stdout
|
||||
|
||||
- name: Run hostname module for real now
|
||||
hostname:
|
||||
name: crocodile.ansible.test.doesthiswork.net.example.com
|
||||
register: hn2
|
||||
|
||||
- name: Get hostname
|
||||
command: hostname
|
||||
register: current_after_hn2
|
||||
|
||||
- name: Run hostname again to ensure it is idempotent
|
||||
hostname:
|
||||
name: crocodile.ansible.test.doesthiswork.net.example.com
|
||||
register: hnidem
|
||||
|
||||
- name: Get hostname
|
||||
command: hostname
|
||||
register: current_after_hnidem
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- hn2 is changed
|
||||
- hnidem is not changed
|
||||
- current_after_hn2.stdout == 'crocodile.ansible.test.doesthiswork.net.example.com'
|
||||
- current_after_hn2.stdout == current_after_hnidem.stdout
|
||||
- import_tasks: test_check_mode.yml
|
||||
- import_tasks: test_normal.yml
|
||||
|
||||
- name: Include distribution specific tasks
|
||||
include_tasks:
|
||||
|
@ -98,24 +26,25 @@
|
|||
files:
|
||||
- "{{ ansible_facts.distribution }}.yml"
|
||||
- default.yml
|
||||
|
||||
always:
|
||||
# Reset back to original hostname
|
||||
- name: Move back original file if it existed
|
||||
command: mv -f {{ _hostname_file }}.orig {{ _hostname_file }}
|
||||
when: hn_stat is defined and hn_stat.stat.exists
|
||||
when: hn_stat.stat.exists | default(False)
|
||||
|
||||
- name: Delete the file if it never existed
|
||||
file:
|
||||
path: "{{ _hostname_file }}"
|
||||
state: absent
|
||||
when: hn_stat is defined and not hn_stat.stat.exists
|
||||
when: not hn_stat.stat.exists | default(True)
|
||||
|
||||
# Reset back to original hostname
|
||||
- hostname:
|
||||
- name: Reset back to original hostname
|
||||
hostname:
|
||||
name: "{{ original.stdout }}"
|
||||
register: revert
|
||||
|
||||
# And make sure we really do
|
||||
- assert:
|
||||
- name: Ensure original hostname was reset
|
||||
assert:
|
||||
that:
|
||||
- revert is changed
|
||||
|
|
50
test/integration/targets/hostname/tasks/test_check_mode.yml
Normal file
50
test/integration/targets/hostname/tasks/test_check_mode.yml
Normal file
|
@ -0,0 +1,50 @@
|
|||
- name: Run hostname module in check_mode
|
||||
hostname:
|
||||
name: crocodile.ansible.test.doesthiswork.net.example.com
|
||||
check_mode: true
|
||||
register: hn1
|
||||
|
||||
- name: Get current hostname again
|
||||
command: hostname
|
||||
register: after_hn
|
||||
|
||||
- name: Ensure hostname changed properly
|
||||
assert:
|
||||
that:
|
||||
- hn1 is changed
|
||||
- original.stdout == after_hn.stdout
|
||||
|
||||
- when: _hostname_file is defined and _hostname_file
|
||||
block:
|
||||
- name: See if current hostname file exists
|
||||
stat:
|
||||
path: "{{ _hostname_file }}"
|
||||
register: hn_stat
|
||||
|
||||
- name: Move the current hostname file if it exists
|
||||
command: mv {{ _hostname_file }} {{ _hostname_file }}.orig
|
||||
when: hn_stat.stat.exists
|
||||
|
||||
- name: Run hostname module in check_mode
|
||||
hostname:
|
||||
name: crocodile.ansible.test.doesthiswork.net.example.com
|
||||
check_mode: true
|
||||
register: hn
|
||||
|
||||
- stat:
|
||||
path: /etc/rc.conf.d/hostname
|
||||
register: hn_stat_checkmode
|
||||
|
||||
- assert:
|
||||
that:
|
||||
# TODO: This is a legitimate bug and will be fixed in another PR.
|
||||
# - not hn_stat_checkmode.stat.exists
|
||||
- hn is changed
|
||||
|
||||
- name: Get hostname again
|
||||
command: hostname
|
||||
register: current_after_cm
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- original.stdout == current_after_cm.stdout
|
24
test/integration/targets/hostname/tasks/test_normal.yml
Normal file
24
test/integration/targets/hostname/tasks/test_normal.yml
Normal file
|
@ -0,0 +1,24 @@
|
|||
- name: Run hostname module for real now
|
||||
hostname:
|
||||
name: crocodile.ansible.test.doesthiswork.net.example.com
|
||||
register: hn2
|
||||
|
||||
- name: Get hostname
|
||||
command: hostname
|
||||
register: current_after_hn2
|
||||
|
||||
- name: Run hostname again to ensure it does not change
|
||||
hostname:
|
||||
name: crocodile.ansible.test.doesthiswork.net.example.com
|
||||
register: hn3
|
||||
|
||||
- name: Get hostname
|
||||
command: hostname
|
||||
register: current_after_hn3
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- hn2 is changed
|
||||
- hn3 is not changed
|
||||
- current_after_hn2.stdout == 'crocodile.ansible.test.doesthiswork.net.example.com'
|
||||
- current_after_hn2.stdout == current_after_hn2.stdout
|
Loading…
Reference in a new issue