New module - meraki_mx_l7_firewall (#55485)

* Initial commit for meraki_mx_l7_firewall module

* Add argument checking

* Sanity fixes

* Fix crash and improve integration tests

* Improved integration tests and coverage

* Reformat response data
- Module does not match argument structure of API
- PR reformats the response to match Ansible arg spec
- Improved integration tests

* Fix reformatting for diffs

* Create rename_* functions
- is_update_required() ignores the key id
- Meraki expects one of the keys to be id
- Function temporarily renames key to appId to get around this

* Tweak documentation and tests
- Tests now do an include so each task doesn't need delegate_to

* Fix which files I'm editing
pull/59651/head
Kevin Breit 5 years ago committed by Paul Belanger
parent f8f3986871
commit 3cdc667888

@ -0,0 +1,506 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: (c) 2019, Kevin Breit (@kbreit) <kevin.breit@kevinbreit.net>
# 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
ANSIBLE_METADATA = {
'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'
}
DOCUMENTATION = r'''
---
module: meraki_mx_l7_firewall
short_description: Manage MX appliance layer 7 firewalls in the Meraki cloud
version_added: "2.9"
description:
- Allows for creation, management, and visibility into layer 7 firewalls implemented on Meraki MX firewalls.
notes:
- Module assumes a complete list of firewall rules are passed as a parameter.
- If there is interest in this module allowing manipulation of a single firewall rule, please submit an issue against this module.
options:
state:
description:
- Query or modify a firewall rule.
choices: ['present', 'query']
default: present
type: str
net_name:
description:
- Name of network which MX firewall is in.
type: str
net_id:
description:
- ID of network which MX firewall is in.
type: str
rules:
description:
- List of layer 7 firewall rules.
type: list
suboptions:
policy:
description:
- Policy to apply if rule is hit.
choices: [deny]
default: deny
type: str
type:
description:
- Type of policy to apply.
choices: [application,
application_category,
blacklisted_countries,
host,
ip_range,
port,
whitelisted_countries]
type: str
application:
description:
- Application to filter.
suboptions:
name:
description:
- Name of application to filter as defined by Meraki.
type: str
id:
description:
- URI of application as defined by Meraki.
type: str
application_category:
description:
- Category of applications to filter.
suboptions:
name:
description:
- Name of application category to filter as defined by Meraki.
type: str
id:
description:
- URI of application category as defined by Meraki.
type: str
host:
description:
- FQDN of host to filter.
type: str
ip_range:
description:
- CIDR notation range of IP addresses to apply rule to.
- Port can be appended to range with a C(":").
type: str
port:
description:
- TCP or UDP based port to filter.
type: str
countries:
description:
- List of countries to whitelist or blacklist.
- The countries follow the two-letter ISO 3166-1 alpha-2 format.
type: list
categories:
description:
- When C(True), specifies that applications and application categories should be queried instead of firewall rules.
type: bool
author:
- Kevin Breit (@kbreit)
extends_documentation_fragment: meraki
'''
EXAMPLES = r'''
- name: Query firewall rules
meraki_mx_l7_firewall:
auth_key: abc123
org_name: YourOrg
net_name: YourNet
state: query
delegate_to: localhost
- name: Query applications and application categories
meraki_mx_l7_firewall:
auth_key: abc123
org_name: YourOrg
net_name: YourNet
categories: yes
state: query
delegate_to: localhost
- name: Set firewall rules
meraki_mx_l7_firewall:
auth_key: abc123
org_name: YourOrg
net_name: YourNet
state: present
rules:
- type: whitelisted_countries
countries:
- US
- FR
- type: blacklisted_countries
countries:
- CN
- policy: deny
type: port
port: 8080
- type: port
port: 1234
- type: host
host: asdf.com
- type: application
application:
id: meraki:layer7/application/205
- type: application_category
application:
id: meraki:layer7/category/24
delegate_to: localhost
'''
RETURN = r'''
data:
description: Firewall rules associated to network.
returned: success
type: complex
contains:
rules:
description: Ordered list of firewall rules.
returned: success, when not querying applications
type: list
contains:
policy:
description: Action to apply when rule is hit.
returned: success
type: string
sample: deny
type:
description: Type of rule category.
returned: success
type: string
sample: applications
applications:
description: List of applications within a category.
type: list
contains:
id:
description: URI of application.
returned: success
type: string
sample: Gmail
name:
description: Descriptive name of application.
returned: success
type: string
sample: meraki:layer7/application/4
applicationCategory:
description: List of application categories within a category.
type: list
contains:
id:
description: URI of application.
returned: success
type: string
sample: Gmail
name:
description: Descriptive name of application.
returned: success
type: string
sample: meraki:layer7/application/4
port:
description: Port number in rule.
returned: success
type: string
sample: 23
ipRange:
description: Range of IP addresses in rule.
returned: success
type: string
sample: 1.1.1.0/23
whitelistedCountries:
description: Countries to be whitelisted.
returned: success
type: string
sample: CA
blacklistedCountries:
description: Countries to be blacklisted.
returned: success
type: string
sample: RU
application_categories:
description: List of application categories and applications.
type: list
returned: success, when querying applications
contains:
applications:
description: List of applications within a category.
type: list
contains:
id:
description: URI of application.
returned: success
type: string
sample: Gmail
name:
description: Descriptive name of application.
returned: success
type: string
sample: meraki:layer7/application/4
id:
description: URI of application category.
returned: success
type: string
sample: Email
name:
description: Descriptive name of application category.
returned: success
type: string
sample: layer7/category/1
'''
import copy
import os
from ansible.module_utils.basic import AnsibleModule, json, env_fallback
from ansible.module_utils.common.dict_transformations import recursive_diff
from ansible.module_utils.network.meraki.meraki import MerakiModule, meraki_argument_spec
def get_applications(meraki, net_id):
path = meraki.construct_path('get_categories', net_id=net_id)
return meraki.request(path, method='GET')
def lookup_application(meraki, net_id, application):
response = get_applications(meraki, net_id)
for category in response['applicationCategories']:
if category['name'].lower() == application.lower():
return category['id']
for app in category['applications']:
if app['name'].lower() == application.lower():
return app['id']
meraki.fail_json(msg="No application or category named {0} found".format(application))
def assemble_payload(meraki, net_id, rule):
if rule['type'] == 'application':
new_rule = {'policy': rule['policy'],
'type': 'application',
}
if rule['application']['id']:
new_rule['value'] = {'id': rule['application']['id']}
elif rule['application']['name']:
new_rule['value'] = {'id': lookup_application(meraki, net_id, rule['application']['name'])}
elif rule['type'] == 'application_category':
new_rule = {'policy': rule['policy'],
'type': 'applicationCategory',
}
if rule['application']['id']:
new_rule['value'] = {'id': rule['application']['id']}
elif rule['application']['name']:
new_rule['value'] = {'id': lookup_application(meraki, net_id, rule['application']['name'])}
elif rule['type'] == 'ip_range':
new_rule = {'policy': rule['policy'],
'type': 'ipRange',
'value': rule['ip_range']}
elif rule['type'] == 'host':
new_rule = {'policy': rule['policy'],
'type': rule['type'],
'value': rule['host']}
elif rule['type'] == 'port':
new_rule = {'policy': rule['policy'],
'type': rule['type'],
'value': rule['port']}
elif rule['type'] == 'blacklisted_countries':
new_rule = {'policy': rule['policy'],
'type': 'blacklistedCountries',
'value': rule['countries']
}
elif rule['type'] == 'whitelisted_countries':
new_rule = {'policy': rule['policy'],
'type': 'whitelistedCountries',
'value': rule['countries']
}
return new_rule
def restructure_response(rules):
for rule in rules['rules']:
type = rule['type']
rule[type] = copy.deepcopy(rule['value'])
del rule['value']
return rules
def get_rules(meraki, net_id):
path = meraki.construct_path('get_all', net_id=net_id)
response = meraki.request(path, method='GET')
if meraki.status == 200:
return response
def rename_id_to_appid(rules):
for rule in rules['rules']:
print(rule['type'])
if rule['type'] == 'application' or rule['type'] == 'applicationCategory':
rule['value']['appId'] = rule['value'].pop('id')
return rules
def rename_appid_to_id(rules):
for rule in rules['rules']:
if rule['type'] == 'application' or rule['type'] == 'applicationCategory':
rule['value']['id'] = rule['value'].pop('appId')
return rules
def main():
# define the available arguments/parameters that a user can pass to
# the module
application_arg_spec = dict(id=dict(type='str'),
name=dict(type='str'),
)
rule_arg_spec = dict(policy=dict(type='str', choices=['deny'], default='deny'),
type=dict(type='str', choices=['application',
'application_category',
'blacklisted_countries',
'host',
'ip_range',
'port',
'whitelisted_countries']),
ip_range=dict(type='str'),
application=dict(type='dict', default=None, options=application_arg_spec),
host=dict(type='str'),
port=dict(type='str'),
countries=dict(type='list'),
)
argument_spec = meraki_argument_spec()
argument_spec.update(state=dict(type='str', choices=['present', 'query'], default='present'),
net_name=dict(type='str'),
net_id=dict(type='str'),
rules=dict(type='list', default=None, elements='dict', options=rule_arg_spec),
categories=dict(type='bool'),
)
# seed the result dict in the object
# we primarily care about changed and state
# change is if this module effectively modified the target
# state will include any data that you want your module to pass back
# for consumption, for example, in a subsequent task
result = dict(
changed=False,
)
# the AnsibleModule object will be our abstraction working with Ansible
# this includes instantiation, a couple of common attr would be the
# args/params passed to the execution, as well as if the module
# supports check mode
module = AnsibleModule(argument_spec=argument_spec,
supports_check_mode=True,
)
meraki = MerakiModule(module, function='mx_l7_firewall')
# check for argument completeness
if meraki.params['rules']:
for rule in meraki.params['rules']:
if rule['type'] == 'application' and rule['application'] is None:
meraki.fail_json(msg="application argument is required when type is application.")
elif rule['type'] == 'application_category' and rule['application'] is None:
meraki.fail_json(msg="application argument is required when type is application_category.")
elif rule['type'] == 'blacklisted_countries' and rule['countries'] is None:
meraki.fail_json(msg="countries argument is required when type is blacklisted_countries.")
elif rule['type'] == 'host' and rule['host'] is None:
meraki.fail_json(msg="host argument is required when type is host.")
elif rule['type'] == 'port' and rule['port'] is None:
meraki.fail_json(msg="port argument is required when type is port.")
elif rule['type'] == 'whitelisted_countries' and rule['countries'] is None:
meraki.fail_json(msg="countries argument is required when type is whitelisted_countries.")
meraki.params['follow_redirects'] = 'all'
query_urls = {'mx_l7_firewall': '/networks/{net_id}/l7FirewallRules/'}
query_category_urls = {'mx_l7_firewall': '/networks/{net_id}/l7FirewallRules/applicationCategories'}
update_urls = {'mx_l7_firewall': '/networks/{net_id}/l7FirewallRules/'}
meraki.url_catalog['get_all'].update(query_urls)
meraki.url_catalog['get_categories'] = (query_category_urls)
meraki.url_catalog['update'] = update_urls
payload = None
# manipulate or modify the state as needed (this is going to be the
# part where your module will do what it needs to do)
org_id = meraki.params['org_id']
orgs = None
if org_id is None:
orgs = meraki.get_orgs()
for org in orgs:
if org['name'] == meraki.params['org_name']:
org_id = org['id']
net_id = meraki.params['net_id']
if net_id is None:
if orgs is None:
orgs = meraki.get_orgs()
net_id = meraki.get_net_id(net_name=meraki.params['net_name'],
data=meraki.get_nets(org_id=org_id))
if meraki.params['state'] == 'query':
if meraki.params['categories'] is True: # Output only applications
meraki.result['data'] = get_applications(meraki, net_id)
else:
meraki.result['data'] = restructure_response(get_rules(meraki, net_id))
elif meraki.params['state'] == 'present':
rules = get_rules(meraki, net_id)
path = meraki.construct_path('get_all', net_id=net_id)
if meraki.params['rules']:
payload = {'rules': []}
for rule in meraki.params['rules']:
payload['rules'].append(assemble_payload(meraki, net_id, rule))
else:
payload = dict()
'''
The rename_* functions are needed because the key is id and
is_update_required() by default ignores id.
'''
rules = rename_id_to_appid(rules)
payload = rename_id_to_appid(payload)
if meraki.is_update_required(rules, payload):
rules = rename_appid_to_id(rules)
payload = rename_appid_to_id(payload)
if meraki.module.check_mode is True:
response = restructure_response(payload)
diff = recursive_diff(restructure_response(rules), response)
meraki.result['diff'] = {'before': diff[0],
'after': diff[1],
}
meraki.result['data'] = response
meraki.result['changed'] = True
meraki.exit_json(**meraki.result)
response = meraki.request(path, method='PUT', payload=json.dumps(payload))
response = restructure_response(response)
if meraki.status == 200:
diff = recursive_diff(restructure_response(rules), response)
meraki.result['diff'] = {'before': diff[0],
'after': diff[1],
}
meraki.result['data'] = response
meraki.result['changed'] = True
else:
rules = rename_appid_to_id(rules)
payload = rename_appid_to_id(payload)
if meraki.module.check_mode is True:
meraki.result['data'] = rules
meraki.result['changed'] = False
meraki.exit_json(**meraki.result)
meraki.result['data'] = payload
# in the event of a successful module execution, you will want to
# simple AnsibleModule.exit_json(), passing the key/value results
meraki.exit_json(**meraki.result)
if __name__ == '__main__':
main()

@ -0,0 +1,7 @@
# Test code for the Meraki Organization module
# Copyright: (c) 2018, Kevin Breit (@kbreit)
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
---
- name: Run test cases
include: tests.yml ansible_connection=local

@ -0,0 +1,494 @@
# Test code for the Meraki Organization module
# Copyright: (c) 2018, Kevin Breit (@kbreit)
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
---
- block:
- name: Test an API key is provided
fail:
msg: Please define an API key
when: auth_key is not defined
- name: Create network
meraki_network:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
type: appliance
- name: Query firewall rules
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: query
register: query
- debug:
var: query
- assert:
that:
- query.data is defined
- name: Query firewall application categories
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: query
categories: yes
register: query_categories
- assert:
that:
- query_categories.data is defined
- name: Create firewall rule for IP range in check mode
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: ip_range
ip_range: 10.11.12.0/24
register: create_ip_range_check
check_mode: yes
- debug:
var: create_ip_range_check
- assert:
that:
- create_ip_range_check is changed
- name: Create firewall rule for IP range
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: ip_range
ip_range: 10.11.12.0/24
register: create_ip_range
- debug:
var: create_ip_range
- assert:
that:
- create_ip_range is changed
- create_ip_range.data.rules | length == 1
- name: Create firewall rule for IP range with idempotency with check mode
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: ip_range
ip_range: 10.11.12.0/24
register: create_ip_range_idempotent_check
check_mode: yes
- assert:
that:
- create_ip_range_idempotent_check is not changed
- name: Create firewall rule for IP range with idempotency
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: ip_range
ip_range: 10.11.12.0/24
register: create_ip_range_idempotent
- assert:
that:
- create_ip_range_idempotent is not changed
- name: Create firewall rule for IP and port
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: ip_range
ip_range: 10.11.12.1:23
register: create_ip_range_port
- debug:
var: create_ip_range_port
- assert:
that:
- create_ip_range_port is changed
- name: Create firewall rule for IP range
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: ip_range
ip_range: 10.11.12.0/24
register: create_ip_range
- debug:
var: create_ip_range
- assert:
that:
- create_ip_range is changed
- create_ip_range.data.rules | length == 1
- name: Create firewall rule for IP range with idempotency with check mode
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: ip_range
ip_range: 10.11.12.0/24
register: create_ip_range_idempotent_check
check_mode: yes
- assert:
that:
- create_ip_range_idempotent_check is not changed
- name: Create firewall rule for IP range with idempotency
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: ip_range
ip_range: 10.11.12.0/24
register: create_ip_range_idempotent
- assert:
that:
- create_ip_range_idempotent is not changed
- name: Create firewall rule for application
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application
application:
name: facebook
register: application_rule
- assert:
that:
- application_rule is changed
- application_rule.data.rules is defined
- name: Create firewall rule for application via ID
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application
application:
id: meraki:layer7/application/205
register: application_rule_id
- assert:
that:
- application_rule_id is changed
- name: Create firewall rule for invalid application
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application
application:
name: ansible
register: application_rule_invalid
ignore_errors: yes
- name: Create firewall rule for application category
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application_category
application:
name: Advertising
register: application_category_rule
- debug:
var: application_category_rule
- assert:
that:
- application_category_rule is changed
- name: Create firewall rule for application category with ID and conflict
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application_category
application:
id: meraki:layer7/category/27
register: application_category_rule_id_conflict
- assert:
that:
- application_category_rule_id_conflict is not changed
- name: Create firewall rule for application category with ID
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application_category
application:
id: meraki:layer7/category/24
register: application_category_rule_id
- assert:
that:
- application_category_rule_id is changed
- name: Create firewall rule for host
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: host
host: asdf.com
register: host_rule
- assert:
that:
- host_rule is changed
- name: Create firewall rule for port
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: port
port: 1234
register: port_rule
- assert:
that:
- port_rule is changed
- name: Create firewall rule for blacklisted countries
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: blacklisted_countries
countries:
- CA
- AX
register: blacklist_countries
- assert:
that:
- blacklist_countries is changed
- name: Create firewall rule for whitelisted countries
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: whitelisted_countries
countries:
- US
- FR
register: whitelist_countries
- assert:
that:
- whitelist_countries is changed
- name: Create firewall rule for whitelisted countries with idempotency
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: whitelisted_countries
countries:
- US
- FR
register: whitelist_countries_idempotent
- assert:
that:
- whitelist_countries_idempotent is not changed
- name: Create multiple firewall rules
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application_category
application:
id: meraki:layer7/category/27
- type: blacklisted_countries
countries:
- CN
- policy: deny
type: port
port: 8080
register: multiple_rules
- debug:
var: multiple_rules
- assert:
that:
- multiple_rules.data.rules | length == 3
- multiple_rules is changed
#########################################
## Tests for argument completeness ##
#########################################
- name: Test whitelisted_countries incomplete arguments
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: whitelisted_countries
register: error_whitelist
ignore_errors: yes
- assert:
that:
- 'error_whitelist.msg == "countries argument is required when type is whitelisted_countries."'
- name: Test blacklisted_countries incomplete arguments
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: blacklisted_countries
register: error_blacklist
ignore_errors: yes
- assert:
that:
- 'error_blacklist.msg == "countries argument is required when type is blacklisted_countries."'
- name: Test application_category incomplete arguments
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application_category
register: error_app_cat
ignore_errors: yes
- assert:
that:
- 'error_app_cat.msg == "application argument is required when type is application_category."'
- name: Test application incomplete arguments
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: application
register: error_app_cat
ignore_errors: yes
- assert:
that:
- 'error_app_cat.msg == "application argument is required when type is application."'
- name: Test host incomplete arguments
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: host
register: error_app_cat
ignore_errors: yes
- assert:
that:
- 'error_app_cat.msg == "host argument is required when type is host."'
- name: Test port incomplete arguments
meraki_mx_l7_firewall:
auth_key: '{{ auth_key }}'
org_name: '{{test_org_name}}'
net_name: TestNetAppliance
state: present
rules:
- type: port
register: error_app_cat
ignore_errors: yes
- assert:
that:
- 'error_app_cat.msg == "port argument is required when type is port."'
#################
## Cleanup ##
#################
# always:
# - name: Delete network
# meraki_network:
# auth_key: '{{ auth_key }}'
# org_name: '{{test_org_name}}'
# net_name: TestNetAppliance
# state: absent
# delegate_to: localhost
Loading…
Cancel
Save