New AWS module for managing VPC Network ACLs (#1502)
* New AWS module for managing VPC Networks ACLs Moved return outside of try block botocore.exceptions to support python 2.5 For some reason Travis is using Python V2.4 to run the tests - My code is valid duplicate file * Fixed NameError Exception- module not being passed when calling some boto3 client methods * Fixes a bug reported by @dennisconrad, where the nacl is not created when subnets list is empty * nacl property changed to name and fixes a bug where nacl is not deleted when subnets list is empty * Updates to version and requirements * Fix 'vpc' param to 'vpc_id' to match documentation and convention
This commit is contained in:
parent
0ba34435cf
commit
6d52d84af7
1 changed files with 546 additions and 0 deletions
546
cloud/amazon/ec2_vpc_nacl.py
Normal file
546
cloud/amazon/ec2_vpc_nacl.py
Normal file
|
@ -0,0 +1,546 @@
|
|||
#!/usr/bin/python
|
||||
# This file is part of Ansible
|
||||
#
|
||||
# Ansible is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Ansible is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||
DOCUMENTATION = '''
|
||||
module: ec2_vpc_nacl
|
||||
short_description: create and delete Network ACLs.
|
||||
description:
|
||||
- Read the AWS documentation for Network ACLS
|
||||
U(http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_ACLs.html)
|
||||
version_added: "2.2"
|
||||
options:
|
||||
name:
|
||||
description:
|
||||
- Tagged name identifying a network ACL.
|
||||
required: true
|
||||
vpc_id:
|
||||
description:
|
||||
- VPC id of the requesting VPC.
|
||||
required: true
|
||||
subnets:
|
||||
description:
|
||||
- The list of subnets that should be associated with the network ACL.
|
||||
- Must be specified as a list
|
||||
- Each subnet can be specified as subnet ID, or its tagged name.
|
||||
required: false
|
||||
egress:
|
||||
description:
|
||||
- A list of rules for outgoing traffic.
|
||||
- Each rule must be specified as a list.
|
||||
required: false
|
||||
ingress:
|
||||
description:
|
||||
- List of rules for incoming traffic.
|
||||
- Each rule must be specified as a list.
|
||||
required: false
|
||||
tags:
|
||||
description:
|
||||
- Dictionary of tags to look for and apply when creating a network ACL.
|
||||
required: false
|
||||
state:
|
||||
description:
|
||||
- Creates or modifies an existing NACL
|
||||
- Deletes a NACL and reassociates subnets to the default NACL
|
||||
required: false
|
||||
choices: ['present', 'absent']
|
||||
default: present
|
||||
author: Mike Mochan(@mmochan)
|
||||
extends_documentation_fragment: aws
|
||||
requirements: [ botocore, boto3, json ]
|
||||
'''
|
||||
|
||||
EXAMPLES = '''
|
||||
|
||||
# Complete example to create and delete a network ACL
|
||||
# that allows SSH, HTTP and ICMP in, and all traffic out.
|
||||
- name: "Create and associate production DMZ network ACL with DMZ subnets"
|
||||
ec2_vpc_nacl:
|
||||
vpc_id: vpc-12345678
|
||||
name: prod-dmz-nacl
|
||||
region: ap-southeast-2
|
||||
subnets: ['prod-dmz-1', 'prod-dmz-2']
|
||||
tags:
|
||||
CostCode: CC1234
|
||||
Project: phoenix
|
||||
Description: production DMZ
|
||||
ingress: [
|
||||
# rule no, protocol, allow/deny, cidr, icmp_code, icmp_type,
|
||||
# port from, port to
|
||||
[100, 'tcp', 'allow', '0.0.0.0/0', null, null, 22, 22],
|
||||
[200, 'tcp', 'allow', '0.0.0.0/0', null, null, 80, 80],
|
||||
[300, 'icmp', 'allow', '0.0.0.0/0', 0, 8],
|
||||
]
|
||||
egress: [
|
||||
[100, 'all', 'allow', '0.0.0.0/0', null, null, null, null]
|
||||
]
|
||||
state: 'present'
|
||||
|
||||
- name: "Remove the ingress and egress rules - defaults to deny all"
|
||||
ec2_vpc_nacl:
|
||||
vpc_id: vpc-12345678
|
||||
name: prod-dmz-nacl
|
||||
region: ap-southeast-2
|
||||
subnets:
|
||||
- prod-dmz-1
|
||||
- prod-dmz-2
|
||||
tags:
|
||||
CostCode: CC1234
|
||||
Project: phoenix
|
||||
Description: production DMZ
|
||||
state: present
|
||||
|
||||
- name: "Remove the NACL subnet associations and tags"
|
||||
ec2_vpc_nacl:
|
||||
vpc_id: 'vpc-12345678'
|
||||
name: prod-dmz-nacl
|
||||
region: ap-southeast-2
|
||||
state: present
|
||||
|
||||
- name: "Delete nacl and subnet associations"
|
||||
ec2_vpc_nacl:
|
||||
vpc_id: vpc-12345678
|
||||
name: prod-dmz-nacl
|
||||
state: absent
|
||||
'''
|
||||
RETURN = '''
|
||||
task:
|
||||
description: The result of the create, or delete action.
|
||||
returned: success
|
||||
type: dictionary
|
||||
'''
|
||||
|
||||
try:
|
||||
import json
|
||||
import botocore
|
||||
import boto3
|
||||
HAS_BOTO3 = True
|
||||
except ImportError:
|
||||
HAS_BOTO3 = False
|
||||
|
||||
|
||||
# Common fields for the default rule that is contained within every VPC NACL.
|
||||
DEFAULT_RULE_FIELDS = {
|
||||
'RuleNumber': 32767,
|
||||
'RuleAction': 'deny',
|
||||
'CidrBlock': '0.0.0.0/0',
|
||||
'Protocol': '-1'
|
||||
}
|
||||
|
||||
DEFAULT_INGRESS = dict(DEFAULT_RULE_FIELDS.items() + [('Egress', False)])
|
||||
DEFAULT_EGRESS = dict(DEFAULT_RULE_FIELDS.items() + [('Egress', True)])
|
||||
|
||||
# VPC-supported IANA protocol numbers
|
||||
# http://www.iana.org/assignments/protocol-numbers/protocol-numbers.xhtml
|
||||
PROTOCOL_NUMBERS = {'all': -1, 'icmp': 1, 'tcp': 6, 'udp': 17, }
|
||||
|
||||
|
||||
#Utility methods
|
||||
def icmp_present(entry):
|
||||
if len(entry) == 6 and entry[1] == 'icmp' or entry[1] == 1:
|
||||
return True
|
||||
|
||||
|
||||
def load_tags(module):
|
||||
tags = []
|
||||
if module.params.get('tags'):
|
||||
for name, value in module.params.get('tags').iteritems():
|
||||
tags.append({'Key': name, 'Value': str(value)})
|
||||
tags.append({'Key': "Name", 'Value': module.params.get('name')})
|
||||
else:
|
||||
tags.append({'Key': "Name", 'Value': module.params.get('name')})
|
||||
return tags
|
||||
|
||||
|
||||
def subnets_removed(nacl_id, subnets, client, module):
|
||||
results = find_acl_by_id(nacl_id, client, module)
|
||||
associations = results['NetworkAcls'][0]['Associations']
|
||||
subnet_ids = [assoc['SubnetId'] for assoc in associations]
|
||||
return [subnet for subnet in subnet_ids if subnet not in subnets]
|
||||
|
||||
|
||||
def subnets_added(nacl_id, subnets, client, module):
|
||||
results = find_acl_by_id(nacl_id, client, module)
|
||||
associations = results['NetworkAcls'][0]['Associations']
|
||||
subnet_ids = [assoc['SubnetId'] for assoc in associations]
|
||||
return [subnet for subnet in subnets if subnet not in subnet_ids]
|
||||
|
||||
|
||||
def subnets_changed(nacl, client, module):
|
||||
changed = False
|
||||
response = {}
|
||||
vpc_id = module.params.get('vpc_id')
|
||||
nacl_id = nacl['NetworkAcls'][0]['NetworkAclId']
|
||||
subnets = subnets_to_associate(nacl, client, module)
|
||||
if not subnets:
|
||||
default_nacl_id = find_default_vpc_nacl(vpc_id, client, module)[0]
|
||||
subnets = find_subnet_ids_by_nacl_id(nacl_id, client, module)
|
||||
if subnets:
|
||||
replace_network_acl_association(default_nacl_id, subnets, client, module)
|
||||
changed = True
|
||||
return changed
|
||||
changed = False
|
||||
return changed
|
||||
subs_added = subnets_added(nacl_id, subnets, client, module)
|
||||
if subs_added:
|
||||
replace_network_acl_association(nacl_id, subs_added, client, module)
|
||||
changed = True
|
||||
subs_removed = subnets_removed(nacl_id, subnets, client, module)
|
||||
if subs_removed:
|
||||
default_nacl_id = find_default_vpc_nacl(vpc_id, client, module)[0]
|
||||
replace_network_acl_association(default_nacl_id, subs_removed, client, module)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def nacls_changed(nacl, client, module):
|
||||
changed = False
|
||||
params = dict()
|
||||
params['egress'] = module.params.get('egress')
|
||||
params['ingress'] = module.params.get('ingress')
|
||||
|
||||
nacl_id = nacl['NetworkAcls'][0]['NetworkAclId']
|
||||
nacl = describe_network_acl(client, module)
|
||||
entries = nacl['NetworkAcls'][0]['Entries']
|
||||
tmp_egress = [entry for entry in entries if entry['Egress'] is True and DEFAULT_EGRESS !=entry]
|
||||
tmp_ingress = [entry for entry in entries if entry['Egress'] is False]
|
||||
egress = [rule for rule in tmp_egress if DEFAULT_EGRESS != rule]
|
||||
ingress = [rule for rule in tmp_ingress if DEFAULT_INGRESS != rule]
|
||||
if rules_changed(egress, params['egress'], True, nacl_id, client, module):
|
||||
changed = True
|
||||
if rules_changed(ingress, params['ingress'], False, nacl_id, client, module):
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def tags_changed(nacl_id, client, module):
|
||||
changed = False
|
||||
tags = dict()
|
||||
if module.params.get('tags'):
|
||||
tags = module.params.get('tags')
|
||||
tags['Name'] = module.params.get('name')
|
||||
nacl = find_acl_by_id(nacl_id, client, module)
|
||||
if nacl['NetworkAcls']:
|
||||
nacl_values = [t.values() for t in nacl['NetworkAcls'][0]['Tags']]
|
||||
nacl_tags = [item for sublist in nacl_values for item in sublist]
|
||||
tag_values = [[key, str(value)] for key, value in tags.iteritems()]
|
||||
tags = [item for sublist in tag_values for item in sublist]
|
||||
if sorted(nacl_tags) == sorted(tags):
|
||||
changed = False
|
||||
return changed
|
||||
else:
|
||||
delete_tags(nacl_id, client, module)
|
||||
create_tags(nacl_id, client, module)
|
||||
changed = True
|
||||
return changed
|
||||
return changed
|
||||
|
||||
|
||||
def rules_changed(aws_rules, param_rules, Egress, nacl_id, client, module):
|
||||
changed = False
|
||||
rules = list()
|
||||
for entry in param_rules:
|
||||
rules.append(process_rule_entry(entry, Egress))
|
||||
if rules == aws_rules:
|
||||
return changed
|
||||
else:
|
||||
removed_rules = [x for x in aws_rules if x not in rules]
|
||||
if removed_rules:
|
||||
params = dict()
|
||||
for rule in removed_rules:
|
||||
params['NetworkAclId'] = nacl_id
|
||||
params['RuleNumber'] = rule['RuleNumber']
|
||||
params['Egress'] = Egress
|
||||
delete_network_acl_entry(params, client, module)
|
||||
changed = True
|
||||
added_rules = [x for x in rules if x not in aws_rules]
|
||||
if added_rules:
|
||||
for rule in added_rules:
|
||||
rule['NetworkAclId'] = nacl_id
|
||||
create_network_acl_entry(rule, client, module)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def process_rule_entry(entry, Egress):
|
||||
params = dict()
|
||||
params['RuleNumber'] = entry[0]
|
||||
params['Protocol'] = str(PROTOCOL_NUMBERS[entry[1]])
|
||||
params['RuleAction'] = entry[2]
|
||||
params['Egress'] = Egress
|
||||
params['CidrBlock'] = entry[3]
|
||||
if icmp_present(entry):
|
||||
params['IcmpTypeCode'] = {"Type": int(entry[4]), "Code": int(entry[5])}
|
||||
else:
|
||||
if entry[6] or entry[7]:
|
||||
params['PortRange'] = {"From": entry[6], 'To': entry[7]}
|
||||
return params
|
||||
|
||||
|
||||
def restore_default_associations(assoc_ids, default_nacl_id, client, module):
|
||||
if assoc_ids:
|
||||
params = dict()
|
||||
params['NetworkAclId'] = default_nacl_id[0]
|
||||
for assoc_id in assoc_ids:
|
||||
params['AssociationId'] = assoc_id
|
||||
restore_default_acl_association(params, client, module)
|
||||
return True
|
||||
|
||||
|
||||
def construct_acl_entries(nacl, client, module):
|
||||
for entry in module.params.get('ingress'):
|
||||
params = process_rule_entry(entry, Egress=False)
|
||||
params['NetworkAclId'] = nacl['NetworkAcl']['NetworkAclId']
|
||||
create_network_acl_entry(params, client, module)
|
||||
for rule in module.params.get('egress'):
|
||||
params = process_rule_entry(rule, Egress=True)
|
||||
params['NetworkAclId'] = nacl['NetworkAcl']['NetworkAclId']
|
||||
create_network_acl_entry(params, client, module)
|
||||
|
||||
|
||||
## Module invocations
|
||||
def setup_network_acl(client, module):
|
||||
changed = False
|
||||
nacl = describe_network_acl(client, module)
|
||||
if not nacl['NetworkAcls']:
|
||||
nacl = create_network_acl(module.params.get('vpc_id'), client, module)
|
||||
nacl_id = nacl['NetworkAcl']['NetworkAclId']
|
||||
create_tags(nacl_id, client, module)
|
||||
subnets = subnets_to_associate(nacl, client, module)
|
||||
replace_network_acl_association(nacl_id, subnets, client, module)
|
||||
construct_acl_entries(nacl, client, module)
|
||||
changed = True
|
||||
return(changed, nacl['NetworkAcl']['NetworkAclId'])
|
||||
else:
|
||||
changed = False
|
||||
nacl_id = nacl['NetworkAcls'][0]['NetworkAclId']
|
||||
subnet_result = subnets_changed(nacl, client, module)
|
||||
nacl_result = nacls_changed(nacl, client, module)
|
||||
tag_result = tags_changed(nacl_id, client, module)
|
||||
if subnet_result is True or nacl_result is True or tag_result is True:
|
||||
changed = True
|
||||
return(changed, nacl_id)
|
||||
return (changed, nacl_id)
|
||||
|
||||
|
||||
def remove_network_acl(client, module):
|
||||
changed = False
|
||||
result = dict()
|
||||
vpc_id = module.params.get('vpc_id')
|
||||
nacl = describe_network_acl(client, module)
|
||||
if nacl['NetworkAcls']:
|
||||
nacl_id = nacl['NetworkAcls'][0]['NetworkAclId']
|
||||
associations = nacl['NetworkAcls'][0]['Associations']
|
||||
assoc_ids = [a['NetworkAclAssociationId'] for a in associations]
|
||||
default_nacl_id = find_default_vpc_nacl(vpc_id, client, module)
|
||||
if not default_nacl_id:
|
||||
result = {vpc_id: "Default NACL ID not found - Check the VPC ID"}
|
||||
return changed, result
|
||||
if restore_default_associations(assoc_ids, default_nacl_id, client, module):
|
||||
delete_network_acl(nacl_id, client, module)
|
||||
changed = True
|
||||
result[nacl_id] = "Successfully deleted"
|
||||
return changed, result
|
||||
if not assoc_ids:
|
||||
delete_network_acl(nacl_id, client, module)
|
||||
changed = True
|
||||
result[nacl_id] = "Successfully deleted"
|
||||
return changed, result
|
||||
return changed, result
|
||||
|
||||
|
||||
#Boto3 client methods
|
||||
def create_network_acl(vpc_id, client, module):
|
||||
try:
|
||||
nacl = client.create_network_acl(VpcId=vpc_id)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
return nacl
|
||||
|
||||
|
||||
def create_network_acl_entry(params, client, module):
|
||||
try:
|
||||
result = client.create_network_acl_entry(**params)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
return result
|
||||
|
||||
|
||||
def create_tags(nacl_id, client, module):
|
||||
try:
|
||||
delete_tags(nacl_id, client, module)
|
||||
client.create_tags(Resources=[nacl_id], Tags=load_tags(module))
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
|
||||
def delete_network_acl(nacl_id, client, module):
|
||||
try:
|
||||
client.delete_network_acl(NetworkAclId=nacl_id)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
|
||||
def delete_network_acl_entry(params, client, module):
|
||||
try:
|
||||
client.delete_network_acl_entry(**params)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
|
||||
def delete_tags(nacl_id, client, module):
|
||||
try:
|
||||
client.delete_tags(Resources=[nacl_id])
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
|
||||
def describe_acl_associations(subnets, client, module):
|
||||
if not subnets:
|
||||
return []
|
||||
try:
|
||||
results = client.describe_network_acls(Filters=[
|
||||
{'Name': 'association.subnet-id', 'Values': subnets}
|
||||
])
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
associations = results['NetworkAcls'][0]['Associations']
|
||||
return [a['NetworkAclAssociationId'] for a in associations if a['SubnetId'] in subnets]
|
||||
|
||||
|
||||
def describe_network_acl(client, module):
|
||||
try:
|
||||
nacl = client.describe_network_acls(Filters=[
|
||||
{'Name': 'tag:Name', 'Values': [module.params.get('name')]}
|
||||
])
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
return nacl
|
||||
|
||||
|
||||
def find_acl_by_id(nacl_id, client, module):
|
||||
try:
|
||||
return client.describe_network_acls(NetworkAclIds=[nacl_id])
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
|
||||
def find_default_vpc_nacl(vpc_id, client, module):
|
||||
try:
|
||||
response = client.describe_network_acls(Filters=[
|
||||
{'Name': 'vpc-id', 'Values': [vpc_id]}])
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
nacls = response['NetworkAcls']
|
||||
return [n['NetworkAclId'] for n in nacls if n['IsDefault'] == True]
|
||||
|
||||
|
||||
def find_subnet_ids_by_nacl_id(nacl_id, client, module):
|
||||
try:
|
||||
results = client.describe_network_acls(Filters=[
|
||||
{'Name': 'association.network-acl-id', 'Values': [nacl_id]}
|
||||
])
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
if results['NetworkAcls']:
|
||||
associations = results['NetworkAcls'][0]['Associations']
|
||||
return [s['SubnetId'] for s in associations if s['SubnetId']]
|
||||
else:
|
||||
return []
|
||||
|
||||
|
||||
def replace_network_acl_association(nacl_id, subnets, client, module):
|
||||
params = dict()
|
||||
params['NetworkAclId'] = nacl_id
|
||||
for association in describe_acl_associations(subnets, client, module):
|
||||
params['AssociationId'] = association
|
||||
try:
|
||||
client.replace_network_acl_association(**params)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
|
||||
def replace_network_acl_entry(entries, Egress, nacl_id, client, module):
|
||||
params = dict()
|
||||
for entry in entries:
|
||||
params = entry
|
||||
params['NetworkAclId'] = nacl_id
|
||||
try:
|
||||
client.replace_network_acl_entry(**params)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
|
||||
def restore_default_acl_association(params, client, module):
|
||||
try:
|
||||
client.replace_network_acl_association(**params)
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
|
||||
|
||||
def subnets_to_associate(nacl, client, module):
|
||||
params = list(module.params.get('subnets'))
|
||||
if not params:
|
||||
return []
|
||||
if params[0].startswith("subnet-"):
|
||||
try:
|
||||
subnets = client.describe_subnets(Filters=[
|
||||
{'Name': 'subnet-id', 'Values': params}])
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
else:
|
||||
try:
|
||||
subnets = client.describe_subnets(Filters=[
|
||||
{'Name': 'tag:Name', 'Values': params}])
|
||||
except botocore.exceptions.ClientError as e:
|
||||
module.fail_json(msg=str(e))
|
||||
return [s['SubnetId'] for s in subnets['Subnets'] if s['SubnetId']]
|
||||
|
||||
|
||||
def main():
|
||||
argument_spec = ec2_argument_spec()
|
||||
argument_spec.update(dict(
|
||||
vpc_id=dict(required=True),
|
||||
name=dict(required=True),
|
||||
subnets=dict(required=False, type='list', default=list()),
|
||||
tags=dict(required=False, type='dict'),
|
||||
ingress=dict(required=False, type='list', default=list()),
|
||||
egress=dict(required=False, type='list', default=list(),),
|
||||
state=dict(default='present', choices=['present', 'absent']),
|
||||
),
|
||||
)
|
||||
module = AnsibleModule(argument_spec=argument_spec)
|
||||
|
||||
if not HAS_BOTO3:
|
||||
module.fail_json(msg='json, botocore and boto3 are required.')
|
||||
state = module.params.get('state').lower()
|
||||
try:
|
||||
region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True)
|
||||
client = boto3_conn(module, conn_type='client', resource='ec2', region=region, endpoint=ec2_url, **aws_connect_kwargs)
|
||||
except botocore.exceptions.NoCredentialsError, e:
|
||||
module.fail_json(msg="Can't authorize connection - "+str(e))
|
||||
|
||||
invocations = {
|
||||
"present": setup_network_acl,
|
||||
"absent": remove_network_acl
|
||||
}
|
||||
(changed, results) = invocations[state](client, module)
|
||||
module.exit_json(changed=changed, nacl_id=results)
|
||||
|
||||
# import module snippets
|
||||
from ansible.module_utils.basic import *
|
||||
from ansible.module_utils.ec2 import *
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
Loading…
Reference in a new issue