clean up instance attribute modification and add base AMI stuff
This commit is contained in:
parent
301c23a499
commit
351aca3c68
@ -3,7 +3,7 @@ from collections import defaultdict
|
||||
from boto.ec2.instance import Instance, InstanceState, Reservation
|
||||
|
||||
from moto.core import BaseBackend
|
||||
from .utils import random_instance_id, random_reservation_id
|
||||
from .utils import random_instance_id, random_reservation_id, random_ami_id
|
||||
|
||||
|
||||
class InstanceBackend(object):
|
||||
@ -65,6 +65,16 @@ class InstanceBackend(object):
|
||||
|
||||
return rebooted_instances
|
||||
|
||||
def modify_instance_attribute(self, instance_id, key, value):
|
||||
instance = self.get_instance(instance_id)
|
||||
setattr(instance, key, value)
|
||||
return instance
|
||||
|
||||
def describe_instance_attribute(self, instance_id, key):
|
||||
instance = self.get_instance(instance_id)
|
||||
value = getattr(instance, key)
|
||||
return instance, value
|
||||
|
||||
def all_instances(self):
|
||||
instances = []
|
||||
for reservation in self.all_reservations():
|
||||
@ -104,7 +114,45 @@ class TagBackend(object):
|
||||
return results
|
||||
|
||||
|
||||
class EC2Backend(BaseBackend, InstanceBackend, TagBackend):
|
||||
class Ami(object):
|
||||
def __init__(self, ami_id, instance, name, description):
|
||||
self.id = ami_id
|
||||
self.instance = instance
|
||||
self.instance_id = instance.id
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
self.virtualization_type = instance.virtualization_type
|
||||
self.kernel_id = instance.kernel
|
||||
|
||||
class AmiBackend(object):
|
||||
def __init__(self):
|
||||
self.amis = {}
|
||||
super(AmiBackend, self).__init__()
|
||||
|
||||
def create_image(self, instance_id, name, description):
|
||||
# TODO: check that instance exists and pull info from it.
|
||||
ami_id = random_ami_id()
|
||||
instance = ec2_backend.get_instance(instance_id)
|
||||
if not instance:
|
||||
return None
|
||||
ami = Ami(ami_id, instance, name, description)
|
||||
self.amis[ami_id] = ami
|
||||
return ami
|
||||
|
||||
def describe_images(self):
|
||||
return self.amis.values()
|
||||
|
||||
def get_image(self, ami_id):
|
||||
return self.amis[ami_id]
|
||||
|
||||
def deregister_image(self, ami_id):
|
||||
if ami_id in self.amis:
|
||||
self.amis.pop(ami_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
class EC2Backend(BaseBackend, InstanceBackend, TagBackend, AmiBackend):
|
||||
pass
|
||||
|
||||
|
||||
@ -112,33 +160,33 @@ ec2_backend = EC2Backend()
|
||||
|
||||
|
||||
|
||||
{
|
||||
#'Instances': ['DescribeInstanceAttribute', 'DescribeInstances', '\n\t\t\tDescribeInstanceStatus\n\t\t', 'ImportInstance', 'ModifyInstanceAttribute', 'RebootInstances', 'ReportInstanceStatus', 'ResetInstanceAttribute', 'RunInstances', 'StartInstances', 'StopInstances', 'TerminateInstances'],
|
||||
#'Tags': ['CreateTags', 'DeleteTags', 'DescribeTags'],
|
||||
'IP Addresses': ['AssignPrivateIpAddresses', 'UnassignPrivateIpAddresses'],
|
||||
'Monitoring': ['MonitorInstances', 'UnmonitorInstances'],
|
||||
'Reserved Instances': ['CancelReservedInstancesListing', 'CreateReservedInstancesListing', 'DescribeReservedInstances', 'DescribeReservedInstancesListings', 'DescribeReservedInstancesOfferings', 'PurchaseReservedInstancesOffering'],
|
||||
'VPN Connections (Amazon VPC)': ['CreateVpnConnection', 'DeleteVpnConnection', 'DescribeVpnConnections'],
|
||||
'DHCP Options (Amazon VPC)': ['AssociateDhcpOptions', 'CreateDhcpOptions', 'DeleteDhcpOptions', 'DescribeDhcpOptions'],
|
||||
'Network ACLs (Amazon VPC)': ['CreateNetworkAcl', 'CreateNetworkAclEntry', 'DeleteNetworkAcl', 'DeleteNetworkAclEntry', 'DescribeNetworkAcls', 'ReplaceNetworkAclAssociation', 'ReplaceNetworkAclEntry'],
|
||||
'Elastic Block Store': ['AttachVolume', 'CopySnapshot', 'CreateSnapshot', 'CreateVolume', 'DeleteSnapshot', 'DeleteVolume', 'DescribeSnapshotAttribute', 'DescribeSnapshots', 'DescribeVolumes', 'DescribeVolumeAttribute', 'DescribeVolumeStatus', 'DetachVolume', 'EnableVolumeIO', 'ImportVolume', 'ModifySnapshotAttribute', 'ModifyVolumeAttribute', 'ResetSnapshotAttribute'],
|
||||
'Customer Gateways (Amazon VPC)': ['CreateCustomerGateway', 'DeleteCustomerGateway', 'DescribeCustomerGateways'],
|
||||
'Subnets (Amazon VPC)': ['CreateSubnet', 'DeleteSubnet', 'DescribeSubnets'],
|
||||
'AMIs': ['CreateImage', 'DeregisterImage', 'DescribeImageAttribute', 'DescribeImages', 'ModifyImageAttribute', 'RegisterImage', 'ResetImageAttribute'],
|
||||
'Virtual Private Gateways (Amazon VPC)': ['AttachVpnGateway', 'CreateVpnGateway', 'DeleteVpnGateway', 'DescribeVpnGateways', 'DetachVpnGateway'],
|
||||
'Availability Zones and Regions': ['DescribeAvailabilityZones', 'DescribeRegions'],
|
||||
'VPCs (Amazon VPC)': ['CreateVpc', 'DeleteVpc', 'DescribeVpcs'],
|
||||
'Windows': ['BundleInstance', 'CancelBundleTask', 'DescribeBundleTasks', 'GetPasswordData'],
|
||||
'VM Import': ['CancelConversionTask', 'DescribeConversionTasks', 'ImportInstance', 'ImportVolume'],
|
||||
'Placement Groups': ['CreatePlacementGroup', 'DeletePlacementGroup', 'DescribePlacementGroups'],
|
||||
'Key Pairs': ['CreateKeyPair', 'DeleteKeyPair', 'DescribeKeyPairs', 'ImportKeyPair'],
|
||||
'Amazon DevPay': ['ConfirmProductInstance'],
|
||||
'Internet Gateways (Amazon VPC)': ['AttachInternetGateway', 'CreateInternetGateway', 'DeleteInternetGateway', 'DescribeInternetGateways', 'DetachInternetGateway'],
|
||||
'Route Tables (Amazon VPC)': ['AssociateRouteTable', 'CreateRoute', 'CreateRouteTable', 'DeleteRoute', 'DeleteRouteTable', 'DescribeRouteTables', 'DisassociateRouteTable', 'ReplaceRoute', 'ReplaceRouteTableAssociation'],
|
||||
'Elastic Network Interfaces (Amazon VPC)': ['AttachNetworkInterface', 'CreateNetworkInterface', 'DeleteNetworkInterface', 'DescribeNetworkInterfaceAttribute', 'DescribeNetworkInterfaces', 'DetachNetworkInterface', 'ModifyNetworkInterfaceAttribute', 'ResetNetworkInterfaceAttribute'],
|
||||
'Elastic IP Addresses': ['AllocateAddress', 'AssociateAddress', 'DescribeAddresses', 'DisassociateAddress', 'ReleaseAddress'],
|
||||
'Security Groups': ['AuthorizeSecurityGroupEgress', 'AuthorizeSecurityGroupIngress', 'CreateSecurityGroup', 'DeleteSecurityGroup', 'DescribeSecurityGroups', 'RevokeSecurityGroupEgress', 'RevokeSecurityGroupIngress'],
|
||||
'General': ['GetConsoleOutput'],
|
||||
'VM Export': ['CancelExportTask', 'CreateInstanceExportTask', 'DescribeExportTasks'],
|
||||
'Spot Instances': ['CancelSpotInstanceRequests', 'CreateSpotDatafeedSubscription', 'DeleteSpotDatafeedSubscription', 'DescribeSpotDatafeedSubscription', 'DescribeSpotInstanceRequests', 'DescribeSpotPriceHistory', 'RequestSpotInstances']
|
||||
}
|
||||
# {
|
||||
# #'Instances': ['DescribeInstanceAttribute', 'DescribeInstances', '\n\t\t\tDescribeInstanceStatus\n\t\t', 'ImportInstance', 'ModifyInstanceAttribute', 'RebootInstances', 'ReportInstanceStatus', 'ResetInstanceAttribute', 'RunInstances', 'StartInstances', 'StopInstances', 'TerminateInstances'],
|
||||
# #'Tags': ['CreateTags', 'DeleteTags', 'DescribeTags'],
|
||||
# 'IP Addresses': ['AssignPrivateIpAddresses', 'UnassignPrivateIpAddresses'],
|
||||
# 'Monitoring': ['MonitorInstances', 'UnmonitorInstances'],
|
||||
# 'Reserved Instances': ['CancelReservedInstancesListing', 'CreateReservedInstancesListing', 'DescribeReservedInstances', 'DescribeReservedInstancesListings', 'DescribeReservedInstancesOfferings', 'PurchaseReservedInstancesOffering'],
|
||||
# 'VPN Connections (Amazon VPC)': ['CreateVpnConnection', 'DeleteVpnConnection', 'DescribeVpnConnections'],
|
||||
# 'DHCP Options (Amazon VPC)': ['AssociateDhcpOptions', 'CreateDhcpOptions', 'DeleteDhcpOptions', 'DescribeDhcpOptions'],
|
||||
# 'Network ACLs (Amazon VPC)': ['CreateNetworkAcl', 'CreateNetworkAclEntry', 'DeleteNetworkAcl', 'DeleteNetworkAclEntry', 'DescribeNetworkAcls', 'ReplaceNetworkAclAssociation', 'ReplaceNetworkAclEntry'],
|
||||
# 'Elastic Block Store': ['AttachVolume', 'CopySnapshot', 'CreateSnapshot', 'CreateVolume', 'DeleteSnapshot', 'DeleteVolume', 'DescribeSnapshotAttribute', 'DescribeSnapshots', 'DescribeVolumes', 'DescribeVolumeAttribute', 'DescribeVolumeStatus', 'DetachVolume', 'EnableVolumeIO', 'ImportVolume', 'ModifySnapshotAttribute', 'ModifyVolumeAttribute', 'ResetSnapshotAttribute'],
|
||||
# 'Customer Gateways (Amazon VPC)': ['CreateCustomerGateway', 'DeleteCustomerGateway', 'DescribeCustomerGateways'],
|
||||
# 'Subnets (Amazon VPC)': ['CreateSubnet', 'DeleteSubnet', 'DescribeSubnets'],
|
||||
# 'AMIs': ['CreateImage', 'DeregisterImage', 'DescribeImageAttribute', 'DescribeImages', 'ModifyImageAttribute', 'RegisterImage', 'ResetImageAttribute'],
|
||||
# 'Virtual Private Gateways (Amazon VPC)': ['AttachVpnGateway', 'CreateVpnGateway', 'DeleteVpnGateway', 'DescribeVpnGateways', 'DetachVpnGateway'],
|
||||
# 'Availability Zones and Regions': ['DescribeAvailabilityZones', 'DescribeRegions'],
|
||||
# 'VPCs (Amazon VPC)': ['CreateVpc', 'DeleteVpc', 'DescribeVpcs'],
|
||||
# 'Windows': ['BundleInstance', 'CancelBundleTask', 'DescribeBundleTasks', 'GetPasswordData'],
|
||||
# 'VM Import': ['CancelConversionTask', 'DescribeConversionTasks', 'ImportInstance', 'ImportVolume'],
|
||||
# 'Placement Groups': ['CreatePlacementGroup', 'DeletePlacementGroup', 'DescribePlacementGroups'],
|
||||
# 'Key Pairs': ['CreateKeyPair', 'DeleteKeyPair', 'DescribeKeyPairs', 'ImportKeyPair'],
|
||||
# 'Amazon DevPay': ['ConfirmProductInstance'],
|
||||
# 'Internet Gateways (Amazon VPC)': ['AttachInternetGateway', 'CreateInternetGateway', 'DeleteInternetGateway', 'DescribeInternetGateways', 'DetachInternetGateway'],
|
||||
# 'Route Tables (Amazon VPC)': ['AssociateRouteTable', 'CreateRoute', 'CreateRouteTable', 'DeleteRoute', 'DeleteRouteTable', 'DescribeRouteTables', 'DisassociateRouteTable', 'ReplaceRoute', 'ReplaceRouteTableAssociation'],
|
||||
# 'Elastic Network Interfaces (Amazon VPC)': ['AttachNetworkInterface', 'CreateNetworkInterface', 'DeleteNetworkInterface', 'DescribeNetworkInterfaceAttribute', 'DescribeNetworkInterfaces', 'DetachNetworkInterface', 'ModifyNetworkInterfaceAttribute', 'ResetNetworkInterfaceAttribute'],
|
||||
# 'Elastic IP Addresses': ['AllocateAddress', 'AssociateAddress', 'DescribeAddresses', 'DisassociateAddress', 'ReleaseAddress'],
|
||||
# 'Security Groups': ['AuthorizeSecurityGroupEgress', 'AuthorizeSecurityGroupIngress', 'CreateSecurityGroup', 'DeleteSecurityGroup', 'DescribeSecurityGroups', 'RevokeSecurityGroupEgress', 'RevokeSecurityGroupIngress'],
|
||||
# 'General': ['GetConsoleOutput'],
|
||||
# 'VM Export': ['CancelExportTask', 'CreateInstanceExportTask', 'DescribeExportTasks'],
|
||||
# 'Spot Instances': ['CancelSpotInstanceRequests', 'CreateSpotDatafeedSubscription', 'DeleteSpotDatafeedSubscription', 'DescribeSpotDatafeedSubscription', 'DescribeSpotInstanceRequests', 'DescribeSpotPriceHistory', 'RequestSpotInstances']
|
||||
# }
|
@ -3,7 +3,7 @@ from urlparse import parse_qs
|
||||
from moto.ec2.utils import camelcase_to_underscores, method_namess_from_class
|
||||
|
||||
from .amazon_dev_pay import AmazonDevPay
|
||||
from .amis import AMIs
|
||||
from .amis import AmisResponse
|
||||
from .availability_zonesand_regions import AvailabilityZonesandRegions
|
||||
from .customer_gateways import CustomerGateways
|
||||
from .dhcp_options import DHCPOptions
|
||||
@ -35,7 +35,7 @@ from .tags import TagResponse
|
||||
|
||||
class EC2Response(object):
|
||||
|
||||
sub_responses = [InstanceResponse, TagResponse]
|
||||
sub_responses = [InstanceResponse, TagResponse, AmisResponse]
|
||||
|
||||
def dispatch(self, uri, body, headers):
|
||||
if body:
|
||||
|
@ -1,21 +1,37 @@
|
||||
from jinja2 import Template
|
||||
|
||||
from moto.ec2.models import ec2_backend
|
||||
from moto.ec2.utils import resource_ids_from_querystring
|
||||
from moto.ec2.utils import instance_ids_from_querystring
|
||||
|
||||
|
||||
class AMIs(object):
|
||||
class AmisResponse(object):
|
||||
def __init__(self, querystring):
|
||||
self.querystring = querystring
|
||||
self.instance_ids = instance_ids_from_querystring(querystring)
|
||||
|
||||
def create_image(self):
|
||||
raise NotImplementedError('AMIs.create_image is not yet implemented')
|
||||
name = self.querystring.get('Name')[0]
|
||||
description = self.querystring.get('Description')[0]
|
||||
instance_id = self.instance_ids[0]
|
||||
image = ec2_backend.create_image(instance_id, name, description)
|
||||
if not image:
|
||||
return "There is not instance with id {}".format(instance_id), dict(status=404)
|
||||
template = Template(CREATE_IMAGE_RESPONSE)
|
||||
return template.render(image=image)
|
||||
|
||||
def deregister_image(self):
|
||||
raise NotImplementedError('AMIs.deregister_image is not yet implemented')
|
||||
ami_id = self.querystring.get('ImageId')[0]
|
||||
success = ec2_backend.deregister_image(ami_id)
|
||||
template = Template(DESCRIBE_IMAGES_RESPONSE)
|
||||
return template.render(success=str(success).lower())
|
||||
|
||||
def describe_image_attribute(self):
|
||||
raise NotImplementedError('AMIs.describe_image_attribute is not yet implemented')
|
||||
|
||||
def describe_images(self):
|
||||
raise NotImplementedError('AMIs.describe_images is not yet implemented')
|
||||
images = ec2_backend.describe_images()
|
||||
template = Template(DESCRIBE_IMAGES_RESPONSE)
|
||||
return template.render(images=images)
|
||||
|
||||
def modify_image_attribute(self):
|
||||
raise NotImplementedError('AMIs.modify_image_attribute is not yet implemented')
|
||||
@ -26,3 +42,61 @@ class AMIs(object):
|
||||
def reset_image_attribute(self):
|
||||
raise NotImplementedError('AMIs.reset_image_attribute is not yet implemented')
|
||||
|
||||
|
||||
CREATE_IMAGE_RESPONSE = """<CreateImageResponse xmlns="http://ec2.amazonaws.com/doc/2012-12-01/">
|
||||
<requestId>59dbff89-35bd-4eac-99ed-be587EXAMPLE</requestId>
|
||||
<imageId>{{ image.id }}</imageId>
|
||||
</CreateImageResponse>"""
|
||||
|
||||
# TODO almost all of these params should actually be templated based on the ec2 image
|
||||
DESCRIBE_IMAGES_RESPONSE = """<DescribeImagesResponse xmlns="http://ec2.amazonaws.com/doc/2012-12-01/">
|
||||
<requestId>59dbff89-35bd-4eac-99ed-be587EXAMPLE</requestId>
|
||||
<imagesSet>
|
||||
{% for image in images %}
|
||||
<item>
|
||||
<imageId>{{ image.id }}</imageId>
|
||||
<imageLocation>amazon/getting-started</imageLocation>
|
||||
<imageState>available</imageState>
|
||||
<imageOwnerId>111122223333</imageOwnerId>
|
||||
<isPublic>true</isPublic>
|
||||
<architecture>i386</architecture>
|
||||
<imageType>machine</imageType>
|
||||
<kernelId>{{ image.kernel_id }}</kernelId>
|
||||
<ramdiskId>ari-1a2b3c4d</ramdiskId>
|
||||
<imageOwnerAlias>amazon</imageOwnerAlias>
|
||||
<name>{{ image.name }}</name>
|
||||
<description>{{ image.description }}</description>
|
||||
<rootDeviceType>ebs</rootDeviceType>
|
||||
<rootDeviceName>/dev/sda</rootDeviceName>
|
||||
<blockDeviceMapping>
|
||||
<item>
|
||||
<deviceName>/dev/sda1</deviceName>
|
||||
<ebs>
|
||||
<snapshotId>snap-1a2b3c4d</snapshotId>
|
||||
<volumeSize>15</volumeSize>
|
||||
<deleteOnTermination>false</deleteOnTermination>
|
||||
<volumeType>standard</volumeType>
|
||||
</ebs>
|
||||
</item>
|
||||
</blockDeviceMapping>
|
||||
<virtualizationType>{{ image.virtualization_type }}</virtualizationType>
|
||||
<tagSet/>
|
||||
<hypervisor>xen</hypervisor>
|
||||
</item>
|
||||
{% endfor %}
|
||||
</imagesSet>
|
||||
</DescribeImagesResponse>"""
|
||||
|
||||
DESCRIBE_IMAGE_RESPONSE = """<DescribeImageAttributeResponse xmlns="http://ec2.amazonaws.com/doc/2012-12-01/">
|
||||
<requestId>59dbff89-35bd-4eac-99ed-be587EXAMPLE</requestId>
|
||||
<imageId>{{ image.id }}</imageId>
|
||||
<{{ key }}>
|
||||
<value>{{ value }}</value>
|
||||
</{{key }}>
|
||||
</DescribeImageAttributeResponse>"""
|
||||
|
||||
DEREGISTER_IMAGE_RESPONSE = """<DeregisterImageResponse xmlns="http://ec2.amazonaws.com/doc/2012-12-01/">
|
||||
<requestId>59dbff89-35bd-4eac-99ed-be587EXAMPLE</requestId>
|
||||
<return>{{ success }}</return>
|
||||
</DeregisterImageResponse>"""
|
||||
|
||||
|
@ -42,10 +42,9 @@ class InstanceResponse(object):
|
||||
def describe_instance_attribute(self):
|
||||
# TODO this and modify below should raise IncorrectInstanceState if instance not in stopped state
|
||||
attribute = self.querystring.get("Attribute")[0]
|
||||
normalized_attribute = camelcase_to_underscores(attribute)
|
||||
key = camelcase_to_underscores(attribute)
|
||||
instance_id = self.instance_ids[0]
|
||||
instance = ec2_backend.get_instance(instance_id)
|
||||
value = getattr(instance, normalized_attribute)
|
||||
instance, value = ec2_backend.describe_instance_attribute(instance_id, key)
|
||||
template = Template(EC2_DESCRIBE_INSTANCE_ATTRIBUTE)
|
||||
return template.render(instance=instance, attribute=attribute, value=value)
|
||||
|
||||
@ -57,8 +56,7 @@ class InstanceResponse(object):
|
||||
value = self.querystring.get(key)[0]
|
||||
normalized_attribute = camelcase_to_underscores(key.split(".")[0])
|
||||
instance_id = self.instance_ids[0]
|
||||
instance = ec2_backend.get_instance(instance_id)
|
||||
setattr(instance, normalized_attribute, value)
|
||||
instance = ec2_backend.modify_instance_attribute(instance_id, normalized_attribute, value)
|
||||
return EC2_MODIFY_INSTANCE_ATTRIBUTE
|
||||
|
||||
|
||||
|
@ -18,6 +18,10 @@ def random_reservation_id():
|
||||
return random_id(prefix='r')
|
||||
|
||||
|
||||
def random_ami_id():
|
||||
return random_id(prefix='ami')
|
||||
|
||||
|
||||
def instance_ids_from_querystring(querystring_dict):
|
||||
instance_ids = []
|
||||
for key, value in querystring_dict.iteritems():
|
||||
|
@ -1,9 +1,49 @@
|
||||
import boto
|
||||
from boto.exception import EC2ResponseError
|
||||
|
||||
from sure import expect
|
||||
|
||||
from moto import mock_ec2
|
||||
|
||||
|
||||
@mock_ec2
|
||||
def test_amis():
|
||||
pass
|
||||
def test_ami_create_and_delete():
|
||||
conn = boto.connect_ec2('the_key', 'the_secret')
|
||||
reservation = conn.run_instances('<ami-image-id>')
|
||||
instance = reservation.instances[0]
|
||||
image = instance.create_image("test-ami", "this is a test ami")
|
||||
|
||||
all_images = conn.get_all_images()
|
||||
all_images[0].id.should.equal(image)
|
||||
|
||||
success = conn.deregister_image(image)
|
||||
success.should.be.true
|
||||
|
||||
|
||||
@mock_ec2
|
||||
def test_ami_create_from_missing_instance():
|
||||
conn = boto.connect_ec2('the_key', 'the_secret')
|
||||
conn.create_image.when.called_with("i-abcdefg", "test-ami", "this is a test ami").should.throw(EC2ResponseError)
|
||||
|
||||
|
||||
@mock_ec2
|
||||
def test_ami_pulls_attributes_from_instance():
|
||||
conn = boto.connect_ec2('the_key', 'the_secret')
|
||||
reservation = conn.run_instances('<ami-image-id>')
|
||||
instance = reservation.instances[0]
|
||||
instance.modify_attribute("kernel", "test-kernel")
|
||||
|
||||
image_id = instance.create_image("test-ami", "this is a test ami")
|
||||
image = conn.get_image(image_id)
|
||||
image.kernel_id.should.equal('test-kernel')
|
||||
|
||||
|
||||
# @mock_ec2
|
||||
# def test_ami_attributes():
|
||||
# conn = boto.connect_ec2('the_key', 'the_secret')
|
||||
# reservation = conn.run_instances('<ami-image-id>')
|
||||
# instance = reservation.instances[0]
|
||||
# image = instance.create_image("test-ami", "this is a test ami")
|
||||
|
||||
# launch_permission = conn.get_image_attribute(image, 'description')
|
||||
# expect(launch_permission.description).should.equal("this is a test ami")
|
||||
|
Loading…
Reference in New Issue
Block a user