VMware: Fix DVPG idempotency issue (#35837)

This fixes, cloning operation where template or existing VM
does not have network or DVPG. Also, adds some strict type checking in
network parameters.

Signed-off-by: Abhijeet Kasurde <akasurde@redhat.com>
pull/36287/head
Abhijeet Kasurde 7 years ago committed by GitHub
parent 75a34f6668
commit a377302d6b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -502,23 +502,23 @@ class PyVmomiDeviceHelper(object):
return diskspec
def create_nic(self, device_type, device_label, device_infos):
nic = vim.vm.device.VirtualDeviceSpec()
if device_type == 'pcnet32':
nic.device = vim.vm.device.VirtualPCNet32()
elif device_type == 'vmxnet2':
nic.device = vim.vm.device.VirtualVmxnet2()
elif device_type == 'vmxnet3':
nic.device = vim.vm.device.VirtualVmxnet3()
elif device_type == 'e1000':
nic.device = vim.vm.device.VirtualE1000()
elif device_type == 'e1000e':
nic.device = vim.vm.device.VirtualE1000e()
elif device_type == 'sriov':
nic.device = vim.vm.device.VirtualSriovEthernetCard()
def get_device(self, device_type, name):
nic_dict = dict(pcnet32=vim.vm.device.VirtualPCNet32(),
vmxnet2=vim.vm.device.VirtualVmxnet2(),
vmxnet3=vim.vm.device.VirtualVmxnet3(),
e1000=vim.vm.device.VirtualE1000(),
e1000e=vim.vm.device.VirtualE1000e(),
sriov=vim.vm.device.VirtualSriovEthernetCard(),
)
if device_type in nic_dict:
return nic_dict[device_type]
else:
self.module.fail_json(msg='Invalid device_type "%s" for network "%s"' % (device_type, device_infos['name']))
self.module.fail_json(msg='Invalid device_type "%s"'
' for network "%s"' % (device_type, name))
def create_nic(self, device_type, device_label, device_infos):
nic = vim.vm.device.VirtualDeviceSpec()
nic.device = self.get_device(device_type, device_infos['name'])
nic.device.wakeOnLanEnabled = bool(device_infos.get('wake_on_lan', True))
nic.device.deviceInfo = vim.Description()
nic.device.deviceInfo.label = device_label
@ -544,11 +544,8 @@ class PyVmomiDeviceHelper(object):
Returns: (Boolean) True if string is valid MAC address, otherwise False
"""
ret = False
mac_addr_regex = re.compile('[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$')
if mac_addr_regex.match(mac_addr):
ret = True
return ret
return bool(mac_addr_regex.match(mac_addr))
class PyVmomiCache(object):
@ -897,58 +894,93 @@ class PyVmomiHelper(PyVmomi):
return device_list
def configure_network(self, vm_obj):
# Ignore empty networks, this permits to keep networks when deploying a template/cloning a VM
if len(self.params['networks']) == 0:
return
def sanitize_network_params(self):
"""
Function to sanitize user provided network provided params
Returns: A sanitized list of network params, else fails
"""
network_devices = list()
# Clean up user data here
for network in self.params['networks']:
if 'ip' in network or 'netmask' in network:
if 'ip' not in network or 'netmask' not in network:
self.module.fail_json(msg="Both 'ip' and 'netmask' are required together.")
if 'name' not in network and 'vlan' not in network:
self.module.fail_json(msg="Please specify at least a network name or"
" a VLAN name under VM network list.")
if 'name' in network:
if find_obj(self.content, [vim.Network], network['name']) is None:
if 'name' in network and find_obj(self.content, [vim.Network], network['name']) is None:
self.module.fail_json(msg="Network '%(name)s' does not exists" % network)
elif 'vlan' in network:
dvps = self.cache.get_all_objs(self.content, [vim.dvs.DistributedVirtualPortgroup])
for dvp in dvps:
if hasattr(dvp.config.defaultPortConfig, 'vlan') and dvp.config.defaultPortConfig.vlan.vlanId == network['vlan']:
if hasattr(dvp.config.defaultPortConfig, 'vlan') and \
dvp.config.defaultPortConfig.vlan.vlanId == network['vlan']:
network['name'] = dvp.config.name
break
if dvp.config.name == network['vlan']:
network['name'] = dvp.config.name
break
else:
self.module.fail_json(msg="VLAN '%(vlan)s' does not exist" % network)
self.module.fail_json(msg="VLAN '%(vlan)s' does not exist." % network)
if 'type' in network:
if network['type'] not in ['dhcp', 'static']:
self.module.fail_json(msg="Network type '%(type)s' is not a valid parameter."
" Valid parameters are ['dhcp', 'static']." % network)
if network['type'] != 'static' and ('ip' in network or 'netmask' in network):
self.module.fail_json(msg='Static IP information provided for network "%(name)s",'
' but "type" is set to "%(type)s".' % network)
else:
self.module.fail_json(msg="You need to define a network name or a vlan")
# Type is optional parameter, if user provided IP or Subnet assume
# network type as 'static'
if 'ip' in network or 'netmask' in network:
network['type'] = 'static'
if network.get('type') == 'static':
if 'ip' in network and 'netmask' not in network:
self.module.fail_json(msg="'netmask' is required if 'ip' is"
" specified under VM network list.")
if 'ip' not in network and 'netmask' in network:
self.module.fail_json(msg="'ip' is required if 'netmask' is"
" specified under VM network list.")
validate_device_types = ['pcnet32', 'vmxnet2', 'vmxnet3', 'e1000', 'e1000e', 'sriov']
if 'device_type' in network and network['device_type'] not in validate_device_types:
self.module.fail_json(msg="Device type specified '%s' is not valid."
" Please specify correct device"
" type from ['%s']." % (network['device_type'],
"', '".join(validate_device_types)))
if 'mac' in network and not PyVmomiDeviceHelper.is_valid_mac_addr(network['mac']):
self.module.fail_json(msg="Device MAC address '%s' is invalid."
" Please provide correct MAC address." % network['mac'])
network_devices.append(network)
return network_devices
def configure_network(self, vm_obj):
# Ignore empty networks, this permits to keep networks when deploying a template/cloning a VM
if len(self.params['networks']) == 0:
return
network_devices = self.sanitize_network_params()
# List current device for Clone or Idempotency
current_net_devices = self.get_vm_network_interfaces(vm=vm_obj)
if len(network_devices) < len(current_net_devices):
self.module.fail_json(msg="given network device list is lesser than current VM device list (%d < %d). "
self.module.fail_json(msg="Given network device list is lesser than current VM device list (%d < %d). "
"Removing interfaces is not allowed"
% (len(network_devices), len(current_net_devices)))
for key in range(0, len(network_devices)):
# Default device type is vmxnet3, VMWare best practice
device_type = network_devices[key].get('device_type', 'vmxnet3')
nic = self.device_helper.create_nic(device_type,
'Network Adapter %s' % (key + 1),
network_devices[key])
nic_change_detected = False
network_name = network_devices[key]['name']
if key < len(current_net_devices) and (vm_obj or self.params['template']):
# We are editing existing network devices, this is either when
# are cloning from VM or Template
nic = vim.vm.device.VirtualDeviceSpec()
nic.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit
# Changing mac address has no effect when editing interface
if 'mac' in network_devices[key] and nic.device.macAddress != current_net_devices[key].macAddress:
self.module.fail_json(msg="Changing MAC address has not effect when interface is already present. "
"The failing new MAC address is %s" % nic.device.macAddress)
nic.device = current_net_devices[key]
if ('wake_on_lan' in network_devices[key] and
@ -964,37 +996,56 @@ class PyVmomiHelper(PyVmomi):
nic.device.connectable.allowGuestControl = network_devices[key].get('allow_guest_control')
nic_change_detected = True
nic.device.deviceInfo = vim.Description()
if nic.device.deviceInfo.summary != network_name:
nic.device.deviceInfo.summary = network_name
nic_change_detected = True
if 'device_type' in network_devices[key]:
device = self.device_helper.get_device(network_devices[key]['device_type'], network_name)
if nic.device != device:
self.module.fail_json(msg="Changing the device type is not possible when interface is already present. "
"The failing device type is %s" % network_devices[key]['device_type'])
# Changing mac address has no effect when editing interface
if 'mac' in network_devices[key] and nic.device.macAddress != current_net_devices[key].macAddress:
self.module.fail_json(msg="Changing MAC address has not effect when interface is already present. "
"The failing new MAC address is %s" % nic.device.macAddress)
else:
# Default device type is vmxnet3, VMWare best practice
device_type = network_devices[key].get('device_type', 'vmxnet3')
nic = self.device_helper.create_nic(device_type,
'Network Adapter %s' % (key + 1),
network_devices[key])
nic.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
nic_change_detected = True
if hasattr(self.cache.get_network(network_devices[key]['name']), 'portKeys'):
if hasattr(self.cache.get_network(network_name), 'portKeys'):
# VDS switch
pg_obj = find_obj(self.content, [vim.dvs.DistributedVirtualPortgroup], network_devices[key]['name'])
pg_obj = find_obj(self.content, [vim.dvs.DistributedVirtualPortgroup], network_name)
if (nic.device.backing and
(not hasattr(nic.device.backing, 'port') or
(nic.device.backing.port.portgroupKey != pg_obj.key or
nic.device.backing.port.switchUuid != pg_obj.config.distributedVirtualSwitch.uuid))):
nic_change_detected = True
if vm_obj is None or (nic.device.backing and not hasattr(nic.device.backing, 'port')) or \
(nic.device.backing and (nic.device.backing.port.portgroupKey != pg_obj.key or
nic.device.backing.port.switchUuid != pg_obj.config.distributedVirtualSwitch.uuid)):
dvs_port_connection = vim.dvs.PortConnection()
dvs_port_connection.portgroupKey = pg_obj.key
dvs_port_connection.switchUuid = pg_obj.config.distributedVirtualSwitch.uuid
nic.device.backing = vim.vm.device.VirtualEthernetCard.DistributedVirtualPortBackingInfo()
nic.device.backing.port = dvs_port_connection
nic_change_detected = True
else:
# vSwitch
if not isinstance(nic.device.backing, vim.vm.device.VirtualEthernetCard.NetworkBackingInfo):
nic.device.backing = vim.vm.device.VirtualEthernetCard.NetworkBackingInfo()
nic_change_detected = True
net_obj = self.cache.get_network(network_devices[key]['name'])
net_obj = self.cache.get_network(network_name)
if nic.device.backing.network != net_obj:
nic.device.backing.network = net_obj
nic_change_detected = True
if nic.device.backing.deviceName != network_devices[key]['name']:
nic.device.backing.deviceName = network_devices[key]['name']
if nic.device.backing.deviceName != network_name:
nic.device.backing.deviceName = network_name
nic_change_detected = True
if nic_change_detected:
@ -1035,17 +1086,11 @@ class PyVmomiHelper(PyVmomi):
guest_map.adapter = vim.vm.customization.IPSettings()
if 'ip' in network and 'netmask' in network:
if 'type' in network and network['type'] != 'static':
self.module.fail_json(msg='Static IP information provided for network "%(name)s", but "type" is set to "%(type)s".' % network)
guest_map.adapter.ip = vim.vm.customization.FixedIp()
guest_map.adapter.ip.ipAddress = str(network['ip'])
guest_map.adapter.subnetMask = str(network['netmask'])
elif 'type' in network and network['type'] == 'static':
self.module.fail_json(msg='Network "%(name)s" was set to type "%(type)s", but "ip" and "netmask" are missing.' % network)
elif 'type' in network and network['type'] == 'dhcp':
guest_map.adapter.ip = vim.vm.customization.DhcpIpGenerator()
else:
self.module.fail_json(msg='Network "%(name)s" was set to unknown type "%(type)s".' % network)
if 'gateway' in network:
guest_map.adapter.gateway = network['gateway']

@ -27,4 +27,7 @@
- include: create_nw_d1_c1_f0.yml
- include: delete_vm.yml
- include: non_existent_vm_ops.yml
- include: network_negative_test.yml
# Currently, VCSIM doesn't support DVPG (as portkeys are not available) so commenting this test
#- include: network_with_dvpg.yml
#- include: template_d1_c1_f0.yml

@ -0,0 +1,341 @@
# Test code for the vmware_guest module.
# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
- name: Wait for Flask controller to come up online
wait_for:
host: "{{ vcsim }}"
port: 5000
state: started
- name: kill vcsim
uri:
url: http://{{ vcsim }}:5000/killall
- name: start vcsim with no folders
uri:
url: http://{{ vcsim }}:5000/spawn?datacenter=1&cluster=1&folder=0
register: vcsim_instance
- name: Wait for Flask controller to come up online
wait_for:
host: "{{ vcsim }}"
port: 443
state: started
- name: get a list of VMS from vcsim
uri:
url: http://{{ vcsim }}:5000/govc_find?filter=VM
register: vmlist
- debug: var=vcsim_instance
- debug: var=vmlist
- set_fact:
vm1: "{{ vmlist['json'][0] }}"
- debug: var=vm1
- name: create new VMs with non-existent network
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- name: "Non existent VM"
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: non_existent_network
ignore_errors: yes
- debug: var=non_existent_network
- name: assert that no changes were made
assert:
that:
- "not non_existent_network.changed"
- "\"Network 'Non existent VM' does not exists\" in non_existent_network.msg"
- name: create new VMs with network and with only IP
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- name: "VM Network"
type: static
ip: 10.10.10.10
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: no_netmask
ignore_errors: yes
- debug: var=no_netmask
- name: assert that no changes were made
assert:
that:
- "not no_netmask.changed"
- "\"'netmask' is required if 'ip' is specified under VM network list.\" in no_netmask.msg"
- name: create new VMs with network and with only netmask
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- name: "VM Network"
type: static
netmask: 255.255.255.0
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: no_ip
ignore_errors: yes
- debug: var=no_ip
- name: assert that changes were made
assert:
that:
- "not no_ip.changed"
- "\"'ip' is required if 'netmask' is specified under VM network list.\" in no_ip.msg"
- name: create new VMs with network and without network name
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- ip: 10.10.10.10
netmask: 255.255.255
type: static
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: no_network_name
ignore_errors: yes
- debug: var=no_network_name
- name: assert that no changes were made
assert:
that:
- "not no_network_name.changed"
- "\"Please specify at least a network name or a VLAN name under VM network list.\" in no_network_name.msg"
- name: create new VMs with network and without network name
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- vlan: non_existing_vlan
ip: 10.10.10.10
netmask: 255.255.255
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: no_network
ignore_errors: yes
- debug: var=no_network
- name: assert that changes were made
assert:
that:
- "not no_network.changed"
- "\"VLAN 'non_existing_vlan' does not exist.\" in no_network.msg"
- name: create new VMs with invalid device type
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- name: "VM Network"
ip: 10.10.10.10
netmask: 255.255.255
device_type: abc
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: invalid_device_type
ignore_errors: yes
- debug: var=invalid_device_type
- name: assert that changes were made
assert:
that:
- "not invalid_device_type.changed"
- "\"Device type specified 'abc' is not valid.\" in invalid_device_type.msg"
- name: create new VMs with invalid device MAC address
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- name: "VM Network"
ip: 10.10.10.10
netmask: 255.255.255
device_type: e1000
mac: abcdef
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: invalid_mac
ignore_errors: yes
- debug: var=invalid_mac
- name: assert that changes were made
assert:
that:
- "not invalid_mac.changed"
- "\"Device MAC address 'abcdef' is invalid.\" in invalid_mac.msg"
- name: create new VMs with invalid network type
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- name: "VM Network"
ip: 10.10.10.10
netmask: 255.255.255
device_type: e1000
mac: 01:23:45:67:89:ab
type: aaaaa
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: invalid_network_type
ignore_errors: yes
- debug: var=invalid_network_type
- name: assert that changes were made
assert:
that:
- "not invalid_network_type.changed"
- "\"Network type 'aaaaa' is not a valid parameter.\" in invalid_network_type.msg"
- name: create new VMs with IP, netmask and network type as "DHCP"
vmware_guest:
validate_certs: False
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
name: new_vm
guest_id: centos64Guest
datacenter: "{{ (vm1 | basename).split('_')[0] }}"
disk:
- size: 3mb
type: thin
autoselect_datastore: yes
networks:
- name: "VM Network"
ip: 10.10.10.10
netmask: 255.255.255
device_type: e1000
mac: 01:23:45:67:89:ab
type: dhcp
hardware:
num_cpus: 3
memory_mb: 512
state: poweredoff
folder: "{{ vm1 | dirname }}"
register: invalid_dhcp_network_type
ignore_errors: yes
- debug: var=invalid_dhcp_network_type
- name: assert that changes were made
assert:
that:
- "not invalid_dhcp_network_type.changed"
- "\"Static IP information provided for network\" in invalid_dhcp_network_type.msg"

@ -0,0 +1,129 @@
# Test code for the vmware_guest module.
# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
- name: Wait for Flask controller to come up online
wait_for:
host: "{{ vcsim }}"
port: 5000
state: started
- name: kill vcsim
uri:
url: http://{{ vcsim }}:5000/killall
- name: start vcsim with no folders
uri:
url: http://{{ vcsim }}:5000/spawn?datacenter=1&cluster=1&folder=0
register: vcsim_instance
- name: Wait for Flask controller to come up online
wait_for:
host: "{{ vcsim }}"
port: 443
state: started
- name: get a list of VMS from vcsim
uri:
url: http://{{ vcsim }}:5000/govc_find?filter=VM
register: vmlist
- debug: var=vcsim_instance
- debug: var=vmlist
- set_fact:
vm1: "{{ vmlist['json'][0] }}"
- debug: var=vm1
- set_fact:
vm_name: "VM_{{ 10000 | random }}"
# Clone from existing VM with DVPG
- name: Deploy VM from template {{ vm1 | basename }}
vmware_guest:
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
validate_certs: no
datacenter: "{{ (vm1|basename).split('_')[0] }}"
state: poweredon
folder: "{{ vm1 | dirname }}"
template: "{{ vm1 | basename }}"
name: "{{ vm_name }}"
disk:
- size: 10mb
autoselect_datastore: yes
guest_id: rhel7_64guest
hardware:
memory_mb: 512
num_cpus: 1
networks:
- name: "DC0_DVPG0"
register: no_vm_result
- debug: var=no_vm_result
- assert:
that:
- "no_vm_result.changed"
# New clone with DVPG
- set_fact:
vm_name: "VM_{{ 10000 | random }}"
- debug: var=vm_name
- name: Deploy new VM with DVPG
vmware_guest:
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
validate_certs: no
datacenter: "{{ (vm1|basename).split('_')[0] }}"
state: poweredon
folder: "{{ vm1 | dirname }}"
name: "{{ vm_name }}"
disk:
- size: 10mb
autoselect_datastore: yes
guest_id: rhel7_64guest
hardware:
memory_mb: 512
num_cpus: 1
networks:
- name: "DC0_DVPG0"
register: no_vm_result
- debug: var=no_vm_result
- assert:
that:
- "no_vm_result.changed"
- name: Deploy same {{ vm_name }} VM again
vmware_guest:
hostname: "{{ vcsim }}"
username: "{{ vcsim_instance['json']['username'] }}"
password: "{{ vcsim_instance['json']['password'] }}"
validate_certs: no
datacenter: "{{ (vm1|basename).split('_')[0] }}"
state: poweredon
folder: "{{ vm1 | dirname }}"
name: "{{ vm_name }}"
disk:
- size: 10mb
autoselect_datastore: yes
guest_id: rhel7_64guest
hardware:
memory_mb: 512
num_cpus: 1
networks:
- name: "DC0_DVPG0"
register: no_vm_result
- debug: var=no_vm_result
- assert:
that:
- "not no_vm_result.changed"
Loading…
Cancel
Save