MSC: Various bugfixes and features enhancements (#50200)

* MSC: Various bugfixes and features enhancements

This PR includes:
- Lookups of roles, labels and domains
- Auto-create new labels
- Improvements to comparing complex datastructures
- Force removal of sites
- Support non top-level queries
- Document internal functions
- Add parameter types to modules
- Fix documentation examples
- Improvements to idempotency wrt. returning changed
- Support site locations
- Update permission list
- Various improvements to integration tests

* Fix Ci issues
pull/50211/head
Dag Wieers 6 years ago committed by GitHub
parent f55481863d
commit 14b03ac15f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -38,19 +38,30 @@ from ansible.module_utils._text import to_native, to_bytes
def issubset(subset, superset):
''' Recurse through nested dictionary and compare entries '''
# Both objects are the same object
if subset is superset:
return True
# Both objects are identical
if subset == superset:
return True
# Both objects have a different type
if type(subset) != type(superset):
return False
for key, value in subset.items():
# Item from subset is missing from superset
if key not in superset:
return False
elif isinstance(value, str):
if value != superset[key]:
return False
elif isinstance(value, dict):
# Item has different types in subset and superset
if type(superset[key]) != type(value):
return False
# Compare if item values are subset
if isinstance(value, dict):
if not issubset(superset[key], value):
return False
elif isinstance(value, list):
@ -62,9 +73,16 @@ def issubset(subset, superset):
else:
if not value == superset[key]:
return False
return True
def update_qs(params):
''' Append key-value pairs to self.filter_string '''
accepted_params = dict((k, v) for (k, v) in params.items() if v)
return '?' + '&'.join(['%s=%s' % (k, v) for (k, v) in accepted_params.items()])
def msc_argument_spec():
return dict(
host=dict(type='str', required=True, aliases=['hostname']),
@ -146,7 +164,7 @@ class MSCModule(object):
self.headers['Authorization'] = 'Bearer {token}'.format(**payload)
def request(self, path, method=None, data=None):
def request(self, path, method=None, data=None, qs=None):
''' Generic HTTP method for MSC requests. '''
self.path = path
@ -154,6 +172,10 @@ class MSCModule(object):
self.method = method
self.url = urljoin(self.baseuri, path)
if qs is not None:
self.url = self.url + update_qs(qs)
resp, info = fetch_url(self.module,
self.url,
headers=self.headers,
@ -168,8 +190,8 @@ class MSCModule(object):
# 200: OK, 201: Created, 202: Accepted, 204: No Content
if self.status in (200, 201, 202, 204):
output = resp.read()
if self.method in ('DELETE', 'PATCH', 'POST', 'PUT') and self.status in (200, 201, 204):
self.result['changed'] = True
# if self.method in ('DELETE', 'PATCH', 'POST', 'PUT') and self.status in (200, 201, 204):
# self.result['changed'] = True
if output:
return json.loads(output)
@ -192,20 +214,26 @@ class MSCModule(object):
return {}
def query_objs(self, path, **kwargs):
def query_objs(self, path, key=None, **kwargs):
''' Query the MSC REST API for objects in a path '''
found = []
objs = self.request(path, method='GET')
for obj in objs[path]:
for key in kwargs.keys():
if kwargs[key] is None:
if key is None:
key = path
for obj in objs[key]:
for kw_key, kw_value in kwargs.items():
if kw_value is None:
continue
if obj[key] != kwargs[key]:
if obj[kw_key] != kw_value:
break
else:
found.append(obj)
return found
def get_obj(self, path, **kwargs):
''' Get a specific object from a set of MSC REST objects '''
objs = self.query_objs(path, **kwargs)
if len(objs) == 0:
return {}
@ -213,7 +241,54 @@ class MSCModule(object):
self.fail_json(msg='More than one object matches unique filter: {0}'.format(kwargs))
return objs[0]
def lookup_domain(self, domain):
''' Look up a domain and return its id '''
if domain is None:
return domain
d = self.get_obj('auth/domains', key='domains', name=domain)
if not d:
self.module.fail_json(msg="Domain '%s' is not valid." % domain)
if 'id' not in d:
self.module.fail_json(msg="Domain lookup failed for '%s': %s" % (domain, d))
return d['id']
def lookup_roles(self, roles):
''' Look up roles and return their ids '''
if roles is None:
return roles
ids = []
for role in roles:
r = self.get_obj('roles', name=role)
if not r:
self.module.fail_json(msg="Role '%s' is not valid." % role)
if 'id' not in r:
self.module.fail_json(msg="Role lookup failed for '%s': %s" % (role, r))
ids.append(dict(roleId=r['id']))
return ids
def create_label(self, label, label_type):
''' Create a new label '''
return self.request('labels', method='POST', data=dict(displayName=label, type=label_type))
def lookup_labels(self, labels, label_type):
''' Look up labels and return their ids (create if necessary) '''
if labels is None:
return None
ids = []
for label in labels:
l = self.get_obj('labels', displayName=label)
if not l:
l = self.create_label(label, label_type)
if 'id' not in l:
self.module.fail_json(msg="Label lookup failed for '%s': %s" % (label, l))
ids.append(l['id'])
return ids
def sanitize(self, updates, collate=False, required_keys=None):
''' Clean up unset keys from a request payload '''
if required_keys is None:
required_keys = []
self.proposed = deepcopy(self.existing)

@ -23,21 +23,25 @@ options:
label_id:
description:
- The ID of the label.
required: yes
type: str
label:
description:
- The name of the label.
- Alternative to the name, you can use C(label_id).
type: str
required: yes
aliases: [ label_name, name ]
type:
description:
- The type of the label.
type: str
choices: [ site ]
default: site
state:
description:
- Use C(present) or C(absent) for adding or removing.
- Use C(query) for listing an object or multiple objects.
type: str
choices: [ absent, present, query ]
default: present
extends_documentation_fragment: msc
@ -49,9 +53,8 @@ EXAMPLES = r'''
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
label_id: 101
description: North European Datacenter
label: Belgium
type: site
state: present
delegate_to: localhost
@ -60,7 +63,7 @@ EXAMPLES = r'''
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
label: Belgium
state: absent
delegate_to: localhost
@ -69,7 +72,7 @@ EXAMPLES = r'''
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
label: Belgium
state: query
delegate_to: localhost
register: query_result
@ -151,11 +154,13 @@ def main():
elif state == 'present':
msc.previous = msc.existing
msc.sanitize(dict(
payload = dict(
id=label_id,
displayName=label,
type=label_type,
), collate=True)
)
msc.sanitize(payload, collate=True)
if msc.existing:
if not issubset(msc.sent, msc.existing):

@ -23,28 +23,39 @@ options:
role_id:
description:
- The ID of the role.
required: yes
type: str
role:
description:
- The name of the role.
- Alternative to the name, you can use C(role_id).
type: str
required: yes
aliases: [ name, role_name ]
display_name:
description:
- The name of the role to be displayed in the web UI.
type: str
description:
description:
- The description of the role.
type: str
permissions:
description:
- A list of permissions tied to this role.
type: list
choices:
- backup-db
- manage-audit-records
- manage-labels
- manage-roles
- manage-schemas
- manage-sites
- manage-tenants
- manage-tenant-schemas
- manage-users
- platform-logs
- view-all-audit-records
- view-labels
- view-roles
- view-schemas
- view-sites
@ -55,6 +66,7 @@ options:
description:
- Use C(present) or C(absent) for adding or removing.
- Use C(query) for listing an object or multiple objects.
type: str
choices: [ absent, present, query ]
default: present
extends_documentation_fragment: msc
@ -66,9 +78,16 @@ EXAMPLES = r'''
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
role_id: 101
description: North European Datacenter
role: readOnly
display_name: Read Only
description: Read-only access for troubleshooting
permissions:
- view-roles
- view-schemas
- view-sites
- view-tenants
- view-tenant-schemas
- view-users
state: present
delegate_to: localhost
@ -77,7 +96,7 @@ EXAMPLES = r'''
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
role: readOnly
state: absent
delegate_to: localhost
@ -86,7 +105,7 @@ EXAMPLES = r'''
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
role: readOnly
state: query
delegate_to: localhost
register: query_result
@ -116,11 +135,18 @@ def main():
display_name=dict(type='str'),
description=dict(type='str'),
permissions=dict(type='list', choices=[
'backup-db',
'manage-audit-records',
'manage-labels',
'manage-roles',
'manage-schemas',
'manage-sites',
'manage-tenants',
'manage-tenant-schemas',
'manage-users',
'platform-logs',
'view-all-audit-records',
'view-labels',
'view-roles',
'view-schemas',
'view-sites',
@ -183,13 +209,15 @@ def main():
elif state == 'present':
msc.previous = msc.existing
msc.sanitize(dict(
payload = dict(
id=role_id,
name=role,
displayName=role,
description=description,
permissions=permissions,
), collate=True)
)
msc.sanitize(payload, collate=True)
if msc.existing:
if not issubset(msc.sent, msc.existing):

@ -40,17 +40,30 @@ options:
description:
- The ID of the site.
type: str
required: yes
site:
description:
- The name of the site.
- Alternative to the name, you can use C(site_id).
type: str
required: yes
aliases: [ name, site_name ]
labels:
description:
- The labels for this site.
- Labels that do not already exist will be automatically created.
type: list
location:
description:
- Location of the site.
suboptions:
latitude:
description:
- The latitude of the location of the site.
type: float
longitude:
description:
- The longititude of the location of the site.
type: float
urls:
description:
- A list of URLs to reference the APICs.
@ -72,8 +85,21 @@ EXAMPLES = r'''
username: admin
password: SomeSecretPassword
site: north_europe
site_id: 101
description: North European Datacenter
apic_username: msc_admin
apic_password: AnotherSecretPassword
apic_site_id: 12
urls:
- 10.2.3.4
- 10.2.4.5
- 10.3.5.6
labels:
- NEDC
- Europe
- Diegem
location:
latitude: 50.887318
longitude: 4.447084
state: present
delegate_to: localhost
@ -114,12 +140,18 @@ from ansible.module_utils.network.aci.msc import MSCModule, msc_argument_spec, i
def main():
location_arg_spec = dict(
latitude=dict(type='float'),
longitude=dict(type='float'),
)
argument_spec = msc_argument_spec()
argument_spec.update(
apic_password=dict(type='str', no_log=True),
apic_site_id=dict(type='str'),
apic_username=dict(type='str', default='admin'),
labels=dict(type='list'),
location=dict(type='dict', options=location_arg_spec),
site=dict(type='str', required=False, aliases=['name', 'site_name']),
site_id=dict(type='str', required=False),
state=dict(type='str', default='present', choices=['absent', 'present', 'query']),
@ -140,6 +172,10 @@ def main():
apic_site_id = module.params['apic_site_id']
site = module.params['site']
site_id = module.params['site_id']
location = module.params['location']
if location is not None:
latitude = module.params['location']['latitude']
longitude = module.params['location']['longitude']
state = module.params['state']
urls = module.params['urls']
@ -147,6 +183,9 @@ def main():
path = 'sites'
# Convert labels
labels = msc.lookup_labels(module.params['labels'], 'site')
# Query for msc.existing object(s)
if site_id is None and site is None:
msc.existing = msc.query_objs(path)
@ -175,31 +214,43 @@ def main():
if module.check_mode:
msc.existing = {}
else:
msc.existing = msc.request(path, method='DELETE')
msc.existing = msc.request(path, method='DELETE', qs=dict(force='true'))
elif state == 'present':
msc.previous = msc.existing
msc.sanitize(dict(
payload = dict(
apicSiteId=apic_site_id,
id=site_id,
name=site,
urls=urls,
labels=labels,
username=apic_username,
password=apic_password,
), collate=True)
)
if location is not None:
payload['location'] = dict(
lat=latitude,
long=longitude,
)
msc.sanitize(payload, collate=True)
if msc.existing:
if not issubset(msc.sent, msc.existing):
if module.check_mode:
msc.existing = msc.proposed
else:
msc.request(path, method='PUT', data=msc.sent)
msc.existing = msc.request(path, method='PUT', data=msc.sent)
else:
if module.check_mode:
msc.existing = msc.proposed
else:
msc.request(path, method='POST', data=msc.sent)
msc.existing = msc.request(path, method='POST', data=msc.sent)
if 'password' in msc.existing:
msc.existing['password'] = '******'
msc.exit_json()

@ -24,10 +24,10 @@ options:
description:
- The ID of the tenant.
type: str
required: yes
tenant:
description:
- The name of the tenant.
- Alternative to the name, you can use C(tenant_id).
type: str
required: yes
aliases: [ name, tenant_name ]
@ -161,14 +161,20 @@ def main():
elif state == 'present':
msc.previous = msc.existing
msc.sanitize(dict(
payload = dict(
description=description,
id=tenant_id,
name=tenant,
displayName=display_name,
siteAssociations=[],
userAssociations=[dict(userId="0000ffff0000000000000020")],
), collate=True)
)
msc.sanitize(payload, collate=True)
# Ensure displayName is not undefined
if msc.sent.get('displayName') is None:
msc.sent['displayName'] = tenant
if msc.existing:
if not issubset(msc.sent, msc.existing):

@ -23,55 +23,87 @@ options:
user_id:
description:
- The ID of the user.
required: yes
type: str
user:
description:
- The name of the user.
- Alternative to the name, you can use C(user_id).
type: str
required: yes
aliases: [ name, user_name ]
user_password:
description:
- The password of the user.
type: str
first_name:
description:
- The first name of the user.
- This parameter is required when creating new users.
type: str
last_name:
description:
- The last name of the user.
- This parameter is required when creating new users.
type: str
email:
description:
- The email address of the user.
- This parameter is required when creating new users.
type: str
phone:
description:
- The phone number of the user.
- This parameter is required when creating new users.
type: str
account_status:
description:
- The status of the user account.
type: str
choices: [ active ]
domain:
description:
- The domain this user belongs to.
- When creating new users, this defaults to C(Local).
type: str
roles:
description:
- The roles this user has.
- The roles for this user.
type: list
state:
description:
- Use C(present) or C(absent) for adding or removing.
- Use C(query) for listing an object or multiple objects.
type: str
choices: [ absent, present, query ]
default: present
notes:
- A default installation of ACI Multi-Site ships with admin password 'we1come!' which requires a password change on first login.
See the examples of how to change the 'admin' password using Ansible.
extends_documentation_fragment: msc
'''
EXAMPLES = r'''
- name: Update initial admin password
msc_user:
host: msc_host
username: admin
password: we1come!
user_name: admin
user_password: SomeSecretPassword
state: present
delegate_to: localhost
- name: Add a new user
msc_user:
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
user_id: 101
description: North European Datacenter
user_name: dag
description: Test user
first_name: Dag
last_name: Wieers
email: dag@wieers.com
phone: +32 478 436 299
state: present
delegate_to: localhost
@ -80,7 +112,7 @@ EXAMPLES = r'''
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
user_name: dag
state: absent
delegate_to: localhost
@ -89,7 +121,7 @@ EXAMPLES = r'''
host: msc_host
username: admin
password: SomeSecretPassword
name: north_europe
user_name: dag
state: query
delegate_to: localhost
register: query_result
@ -120,7 +152,7 @@ def main():
last_name=dict(type='str'),
email=dict(type='str'),
phone=dict(type='str'),
# FIXME: What possible options do we have ?
# TODO: What possible options do we have ?
account_status=dict(type='str', choices=['active']),
domain=dict(type='str'),
roles=dict(type='list'),
@ -132,7 +164,7 @@ def main():
supports_check_mode=True,
required_if=[
['state', 'absent', ['user_name']],
['state', 'present', ['user_name', 'password', 'first_name', 'last_name', 'email', 'phone', 'account_status']],
['state', 'present', ['user_name']],
],
)
@ -144,18 +176,13 @@ def main():
email = module.params['email']
phone = module.params['phone']
account_status = module.params['account_status']
# FIXME: Look up domain
domain = module.params['domain']
# FIXME: Look up roles
roles = module.params['roles']
roles_dict = list()
if roles:
for role in roles:
roles_dict.append(dict(roleId=role))
state = module.params['state']
msc = MSCModule(module)
roles = msc.lookup_roles(module.params['roles'])
domain = msc.lookup_domain(module.params['domain'])
path = 'users'
# Query for existing object(s)
@ -191,7 +218,7 @@ def main():
elif state == 'present':
msc.previous = msc.existing
msc.sanitize(dict(
payload = dict(
id=user_id,
username=user_name,
password=user_password,
@ -199,19 +226,23 @@ def main():
lastName=last_name,
emailAddress=email,
phoneNumber=phone,
# accountStatus={},
accountStatus=account_status,
needsPasswordUpdate=False,
domainId=domain,
roles=roles_dict,
roles=roles,
# active=True,
# remote=True,
), collate=True)
)
msc.sanitize(payload, collate=True)
if msc.sent.get('accountStatus') is None:
msc.sent['accountStatus'] = 'active'
if msc.existing:
if not issubset(msc.sent, msc.existing):
# NOTE: Since MSC always returns '******' as password, we need to assume a change
if 'password' in msc.sent:
if 'password' in msc.proposed:
msc.module.warn("A password change is assumed, as the MSC REST API does not return passwords we do not know.")
msc.result['changed'] = True
if module.check_mode:

@ -48,18 +48,18 @@ options:
description:
- If C(no), it will not use a proxy, even if one is defined in an environment variable on the target hosts.
type: bool
default: 'yes'
default: yes
use_ssl:
description:
- If C(no), an HTTP connection will be used instead of the default HTTPS connection.
type: bool
default: 'yes'
default: yes
validate_certs:
description:
- If C(no), SSL certificates will not be validated.
- This should only set to C(no) when used on personally controlled sites using self-signed certificates.
type: bool
default: 'yes'
default: yes
notes:
- Please read the :ref:`aci_guide` for more detailed information on how to manage your ACI infrastructure using Ansible.
'''

@ -10,7 +10,7 @@
# CLEAN ENVIRONMENT
- name: Remove site ansible_test2
- name: Remove site 2
msc_site: &site_absent
host: '{{ msc_hostname }}'
username: '{{ msc_username }}'
@ -19,13 +19,13 @@
use_ssl: '{{ msc_use_ssl | default(true) }}'
use_proxy: '{{ msc_use_proxy | default(true) }}'
output_level: '{{ msc_output_level | default("info") }}'
site: ansible_test2
site: '{{ msc_site | default("ansible_test") }}_2'
state: absent
- name: Remove site ansible_test
- name: Remove site
msc_site:
<<: *site_absent
site: ansible_test
site: '{{ msc_site | default("ansible_test") }}'
register: cm_remove_site
@ -39,12 +39,19 @@
use_ssl: '{{ msc_use_ssl | default(true) }}'
use_proxy: '{{ msc_use_proxy | default(true) }}'
output_level: '{{ msc_output_level | default("info") }}'
site: ansible_test
site: '{{ msc_site | default("ansible_test") }}'
apic_username: admin
apic_password: '{{ apic_password }}'
apic_site_id: 101
urls:
- https://{{ apic_hostname }}/
- https://{{ apic_hostname }}
location:
latitude: 50.887318
longitude: 4.447084
labels:
- Diegem
- EMEA
- POD51
state: present
check_mode: yes
register: cm_add_site
@ -55,7 +62,7 @@
- cm_add_site is changed
- cm_add_site.previous == {}
- cm_add_site.current.id is not defined
- cm_add_site.current.name == 'ansible_test'
- cm_add_site.current.name == msc_site|default("ansible_test")
- name: Add site (normal mode)
msc_site: *site_present
@ -67,7 +74,7 @@
- nm_add_site is changed
- nm_add_site.previous == {}
- nm_add_site.current.id is defined
- nm_add_site.current.name == 'ansible_test'
- nm_add_site.current.name == msc_site|default("ansible_test")
- name: Add site again (check_mode)
msc_site: *site_present
@ -78,9 +85,9 @@
assert:
that:
- cm_add_site_again is not changed
- cm_add_site_again.previous.name == 'ansible_test'
- cm_add_site_again.previous.name == msc_site|default("ansible_test")
- cm_add_site_again.current.id == nm_add_site.current.id
- cm_add_site_again.current.name == 'ansible_test'
- cm_add_site_again.current.name == msc_site|default("ansible_test")
- name: Add site again (normal mode)
msc_site: *site_present
@ -90,9 +97,9 @@
assert:
that:
- nm_add_site_again is not changed
- nm_add_site_again.previous.name == 'ansible_test'
- nm_add_site_again.previous.name == msc_site|default("ansible_test")
- nm_add_site_again.current.id == nm_add_site.current.id
- nm_add_site_again.current.name == 'ansible_test'
- nm_add_site_again.current.name == msc_site|default("ansible_test")
# CHANGE SITE
@ -100,7 +107,7 @@
msc_site:
<<: *site_present
site_id: '{{ nm_add_site.current.id }}'
site: ansible_test2
site: '{{ msc_site | default("ansible_test") }}_2'
check_mode: yes
register: cm_change_site
@ -109,13 +116,13 @@
that:
- cm_change_site is changed
- cm_change_site.current.id == nm_add_site.current.id
- cm_change_site.current.name == 'ansible_test2'
- cm_change_site.current.name == '{{ msc_site | default("ansible_test") }}_2'
- name: Change site (normal mode)
msc_site:
<<: *site_present
site_id: '{{ nm_add_site.current.id }}'
site: ansible_test2
site: '{{ msc_site | default("ansible_test") }}_2'
output_level: debug
register: nm_change_site
@ -124,13 +131,13 @@
that:
- nm_change_site is changed
- nm_change_site.current.id == nm_add_site.current.id
- nm_change_site.current.name == 'ansible_test2'
- nm_change_site.current.name == '{{ msc_site | default("ansible_test") }}_2'
- name: Change site again (check_mode)
msc_site:
<<: *site_present
site_id: '{{ nm_add_site.current.id }}'
site: ansible_test2
site: '{{ msc_site | default("ansible_test") }}_2'
check_mode: yes
register: cm_change_site_again
@ -139,13 +146,13 @@
that:
- cm_change_site_again is not changed
- cm_change_site_again.current.id == nm_add_site.current.id
- cm_change_site_again.current.name == 'ansible_test2'
- cm_change_site_again.current.name == '{{ msc_site | default("ansible_test") }}_2'
- name: Change site again (normal mode)
msc_site:
<<: *site_present
site_id: '{{ nm_add_site.current.id }}'
site: ansible_test2
site: '{{ msc_site | default("ansible_test") }}_2'
register: nm_change_site_again
- name: Verify nm_change_site_again
@ -153,7 +160,7 @@
that:
- nm_change_site_again is not changed
- nm_change_site_again.current.id == nm_add_site.current.id
- nm_change_site_again.current.name == 'ansible_test2'
- nm_change_site_again.current.name == '{{ msc_site | default("ansible_test") }}_2'
# QUERY ALL SITES
@ -187,14 +194,14 @@
- name: Query our site
msc_site:
<<: *site_query
site: ansible_test2
site: '{{ msc_site | default("ansible_test") }}_2'
check_mode: yes
register: cm_query_site
- name: Query our site
msc_site:
<<: *site_query
site: ansible_test2
site: '{{ msc_site | default("ansible_test") }}_2'
register: nm_query_site
- name: Verify query_site
@ -202,10 +209,10 @@
that:
- cm_query_site is not changed
- cm_query_site.current.id == nm_add_site.current.id
- cm_query_site.current.name == 'ansible_test2'
- cm_query_site.current.name == '{{ msc_site | default("ansible_test") }}_2'
- nm_query_site is not changed
- nm_query_site.current.id == nm_add_site.current.id
- nm_query_site.current.name == 'ansible_test2'
- nm_query_site.current.name == '{{ msc_site | default("ansible_test") }}_2'
- cm_query_site == nm_query_site
@ -257,14 +264,14 @@
- name: Query non-existing site (check_mode)
msc_site:
<<: *site_query
site: ansible_test
site: '{{ msc_site | default("ansible_test") }}'
check_mode: yes
register: cm_query_non_site
- name: Query non-existing site (normal mode)
msc_site:
<<: *site_query
site: ansible_test
site: '{{ msc_site | default("ansible_test") }}'
register: nm_query_non_site
# TODO: Implement more tests

@ -48,7 +48,8 @@
phone: +32 478 436 299
account_status: active
roles:
- 0000ffff0000000000000031
- powerUser
domain: Local
state: present
check_mode: yes

Loading…
Cancel
Save