Skip to content
Snippets Groups Projects
Commit dc3171a1 authored by finn's avatar finn
Browse files

Added yml parser

parent 24c1fc58
No related branches found
No related tags found
No related merge requests found
Pipeline #29546792 failed
......@@ -174,7 +174,7 @@ class BuildGridCLI(click.MultiCommand):
mod = __import__(name='buildgrid._app.commands.cmd_{}'.format(name),
fromlist=['cli'])
except ImportError:
return None
raise
return mod.cli
......
......@@ -26,17 +26,15 @@ import sys
import click
from buildgrid.server import buildgrid_server
from buildgrid.server.execution.instance import ExecutionInstance
from buildgrid.server.actioncache.storage import ActionCache
from buildgrid.server.cas.instance import ByteStreamInstance, ContentAddressableStorageInstance
from buildgrid.server.cas.storage.disk import DiskStorage
from buildgrid.server.cas.storage.lru_memory_cache import LRUMemoryCache
from buildgrid.server.cas.storage.s3 import S3Storage
from buildgrid.server.cas.storage.with_cache import WithCacheStorage
from buildgrid.server.actioncache.storage import ActionCache
from ..cli import pass_context
_SIZE_PREFIXES = {'k': 2 ** 10, 'm': 2 ** 20, 'g': 2 ** 30, 't': 2 ** 40}
from ..settings import parser
from ..server import BuildGridServer
@click.group(name='server', short_help="Start a local server instance.")
@pass_context
......@@ -45,71 +43,23 @@ def cli(context):
@cli.command('start', short_help="Setup a new server instance.")
@click.argument('instances', nargs=-1, type=click.STRING)
@click.option('--port', type=click.INT, default='50051', show_default=True,
help="The port number to be listened.")
@click.option('--server-key', type=click.Path(exists=True, dir_okay=False), default=None,
help="Private server key for TLS (PEM-encoded)")
@click.option('--server-cert', type=click.Path(exists=True, dir_okay=False), default=None,
help="Public server certificate for TLS (PEM-encoded)")
@click.option('--client-certs', type=click.Path(exists=True, dir_okay=False), default=None,
help="Public client certificates for TLS (PEM-encoded, one single file)")
@click.option('--allow-insecure', type=click.BOOL, is_flag=True,
help="Whether or not to allow unencrypted connections.")
@click.option('--allow-update-action-result/--forbid-update-action-result',
'allow_uar', default=True, show_default=True,
help="Whether or not to allow clients to manually edit the action cache.")
@click.option('--max-cached-actions', type=click.INT, default=50, show_default=True,
help="Maximum number of actions to keep in the ActionCache.")
@click.option('--cas', type=click.Choice(('lru', 's3', 'disk', 'with-cache')),
help="The CAS storage type to use.")
@click.option('--cas-cache', type=click.Choice(('lru', 's3', 'disk')),
help="For --cas=with-cache, the CAS storage to use as the cache.")
@click.option('--cas-fallback', type=click.Choice(('lru', 's3', 'disk')),
help="For --cas=with-cache, the CAS storage to use as the fallback.")
@click.option('--cas-lru-size', type=click.STRING,
help="For --cas=lru, the LRU cache's memory limit.")
@click.option('--cas-s3-bucket', type=click.STRING,
help="For --cas=s3, the bucket name.")
@click.option('--cas-s3-endpoint', type=click.STRING,
help="For --cas=s3, the endpoint URI.")
@click.option('--cas-disk-directory', type=click.Path(file_okay=False, dir_okay=True, writable=True),
help="For --cas=disk, the folder to store CAS blobs in.")
@click.argument('yml', type=click.Path(file_okay=True, dir_okay=False, writable=False))
@pass_context
def start(context, port, allow_insecure, server_key, server_cert, client_certs,
instances, max_cached_actions, allow_uar, cas, **cas_args):
"""Setups a new server instance."""
credentials = None
if not allow_insecure:
credentials = context.load_server_credentials(server_key, server_cert, client_certs)
if not credentials and not allow_insecure:
click.echo("ERROR: no TLS keys were specified and no defaults could be found.\n" +
"Use --allow-insecure in order to deactivate TLS encryption.\n", err=True)
sys.exit(-1)
context.credentials = credentials
context.port = port
context.logger.info("BuildGrid server booting up")
context.logger.info("Starting on port {}".format(port))
cas_storage = _make_cas_storage(context, cas, cas_args)
if cas_storage is None:
context.logger.info("Running without CAS - action cache will be unavailable")
action_cache = None
else:
action_cache = ActionCache(cas_storage, max_cached_actions, allow_uar)
if instances is None:
instances = ['main']
server = buildgrid_server.BuildGridServer(port=context.port,
credentials=context.credentials,
instances=instances,
cas_storage=cas_storage,
action_cache=action_cache)
def start(context, yml):
with open(yml) as f:
settings = parser.get_parser().safe_load(f)
server_settings = settings['server']
instances = settings['instances']
execution_instances = _instance_maker(instances, ExecutionInstance)
execution_bots_interfaces = _instance_maker(instances, ExecutionInstance)
port = server_settings['port']
server = BuildGridServer(port=port,
execution_instances=execution_instances)
loop = asyncio.get_event_loop()
try:
server.start()
......@@ -123,53 +73,16 @@ def start(context, port, allow_insecure, server_key, server_cert, client_certs,
loop.close()
def _make_cas_storage(context, cas_type, cas_args):
"""Returns the storage provider corresponding to the given `cas_type`,
or None if the provider cannot be created.
"""
if cas_type == "lru":
if cas_args["cas_lru_size"] is None:
context.logger.error("--cas-lru-size is required for LRU CAS")
return None
try:
size = _parse_size(cas_args["cas_lru_size"])
except ValueError:
context.logger.error('Invalid LRU size "{0}"'.format(cas_args["cas_lru_size"]))
return None
return LRUMemoryCache(size)
elif cas_type == "s3":
if cas_args["cas_s3_bucket"] is None:
context.logger.error("--cas-s3-bucket is required for S3 CAS")
return None
if cas_args["cas_s3_endpoint"] is not None:
return S3Storage(cas_args["cas_s3_bucket"],
endpoint_url=cas_args["cas_s3_endpoint"])
return S3Storage(cas_args["cas_s3_bucket"])
elif cas_type == "disk":
if cas_args["cas_disk_directory"] is None:
context.logger.error("--cas-disk-directory is required for disk CAS")
return None
return DiskStorage(cas_args["cas_disk_directory"])
elif cas_type == "with-cache":
cache = _make_cas_storage(context, cas_args["cas_cache"], cas_args)
fallback = _make_cas_storage(context, cas_args["cas_fallback"], cas_args)
if cache is None:
context.logger.error("Missing cache provider for --cas=with-cache")
return None
elif fallback is None:
context.logger.error("Missing fallback provider for --cas=with-cache")
return None
return WithCacheStorage(cache, fallback)
elif cas_type is None:
return None
return None
def _parse_size(size):
"""Convert a string containing a size in bytes (e.g. '2GB') to a number."""
size = size.lower()
if size[-1] == 'b':
size = size[:-1]
if size[-1] in _SIZE_PREFIXES:
return int(size[:-1]) * _SIZE_PREFIXES[size[-1]]
return int(size)
# Turn away now if you want to keep your eyes
def _instance_maker(instances, service_type):
# I did warn you
# Really should map this properly
made = {}
for instance in instances:
services = instance['services']
instance_name = instance['name']
for service in services:
if type(service) == service_type:
made[instance_name] = service
return made
# Copyright (C) 2018 Bloomberg LP
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# <http://www.apache.org/licenses/LICENSE-2.0>
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
BuildGridServer
==============
Creates the user a local BuildGrid server.
"""
from concurrent import futures
import grpc
from buildgrid.server.cas.service import ByteStreamService, ContentAddressableStorageService
from buildgrid.server.actioncache.service import ActionCacheService
from buildgrid.server.execution.service import ExecutionService
from buildgrid.server.operations.service import OperationsService
from buildgrid.server.bots.service import BotsService
class BuildGridServer:
def __init__(self, port=50051, max_workers=10, credentials=None,
execution_instances=None, bots_interface_instances = None,
operations_instances=None, reference_storage_instances = None,
action_cache_instances=None, cas_instances=None,
bytestream_instances = None):
address = '[::]:{0}'.format(port)
server = grpc.server(futures.ThreadPoolExecutor(max_workers))
if credentials is not None:
server.add_secure_port(address, credentials)
else:
server.add_insecure_port(address)
if execution_instances:
ExecutionService(server, execution_instances)
if bots_interface_instances:
BotsService(server, bots_interface_instances)
if operations_instances:
OperationsService(server, operations_instances)
if reference_storage_instances:
ReferenceStorageService(server, reference_storage_instances)
if action_cache_instances:
ActionCacheService(server, action_cache_instances)
if cas_instances:
ContentAddressableStorageService(server, cas_instances)
if bytestream_instances:
ByteStreamService(server, bytestream_instances)
self._server = server
def start(self):
self._server.start()
def stop(self):
self._server.stop(0)
server:
port: 50001
tls-server-key: null
tls-server-cert: null
tls-client-certs: null
insecure-mode: true
description: |
A single default instance
instances:
- name: main
description: |
The main server
storages:
- !disk-storage &main-storage
path: ~/
- !lru-storage &main-lru-storage
size: 10mb
services:
- !action-cache &main-action
storage: *main-storage
max_cached_refs: 256
allow_updates: true
- !execution
storage: *main-storage
action_cache: *main-action
- !cas
storage: *main-storage
- !bytestream
storage: *main-storage
# Copyright (C) 2018 Bloomberg LP
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# <http://www.apache.org/licenses/LICENSE-2.0>
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import sys
import yaml
from buildgrid.server.controller import ExecutionController
from buildgrid.server.scheduler import Scheduler
from buildgrid.server.actioncache.storage import ActionCache
from buildgrid.server.cas.instance import ByteStreamInstance, ContentAddressableStorageInstance
from buildgrid.server.cas.storage.disk import DiskStorage
from buildgrid.server.cas.storage.lru_memory_cache import LRUMemoryCache
class YamlFactory(yaml.YAMLObject):
@classmethod
def from_yaml(cls, loader, node):
values = loader.construct_mapping(node, deep=True)
return cls(**values)
class Disk(YamlFactory):
yaml_tag = u'!disk-storage'
def __new__(cls, path):
return DiskStorage(path)
class LRU(YamlFactory):
yaml_tag = u'!lru-storage'
def __new__(cls, size):
return LRUMemoryCache(_parse_size(size))
class Execution(YamlFactory):
yaml_tag = u'!execution'
def __new__(cls, storage, action_cache=None):
scheduler = Scheduler(storage)
return ExecutionController(scheduler, storage)
class Action(YamlFactory):
yaml_tag = u'!action-cache'
def __new__(cls, storage, max_cached_refs=0, allow_updates=True):
return ActionCache(storage, max_cached_refs, allow_updates)
class CAS(YamlFactory):
yaml_tag = u'!cas'
def __new__(cls, storage):
return ContentAddressableStorageInstance(storage)
class ByteStream(YamlFactory):
yaml_tag = u'!bytestream'
def __new__(cls, storage):
return ByteStreamInstance(storage)
def _parse_size(size):
"""Convert a string containing a size in bytes (e.g. '2GB') to a number."""
_size_prefixes = {'k': 2 ** 10, 'm': 2 ** 20, 'g': 2 ** 30, 't': 2 ** 40}
size = size.lower()
if size[-1] == 'b':
size = size[:-1]
if size[-1] in _size_prefixes:
return int(size[:-1]) * _size_prefixes[size[-1]]
return int(size)
def get_parser():
yaml.SafeLoader.add_constructor(Execution.yaml_tag, Execution.from_yaml)
yaml.SafeLoader.add_constructor(Execution.yaml_tag, Execution.from_yaml)
yaml.SafeLoader.add_constructor(Action.yaml_tag, Action.from_yaml)
yaml.SafeLoader.add_constructor(Disk.yaml_tag, Disk.from_yaml)
yaml.SafeLoader.add_constructor(LRU.yaml_tag, LRU.from_yaml)
yaml.SafeLoader.add_constructor(CAS.yaml_tag, CAS.from_yaml)
yaml.SafeLoader.add_constructor(ByteStream.yaml_tag, ByteStream.from_yaml)
return yaml
......@@ -32,13 +32,23 @@ from .._exceptions import NotFoundError
class ActionCacheService(remote_execution_pb2_grpc.ActionCacheServicer):
def __init__(self, action_cache):
def __init__(self, server, instances):
self._action_cache = action_cache
self._instances = instances
self.logger = logging.getLogger(__name__)
remote_execution_pb2_grpc.add_ActionCacheServicer_to_server(self, server)
def GetActionResult(self, request, context):
try:
return self._action_cache.get_action_result(request.action_digest)
instance = self._get_instance(request.instance_name)
return instance.get_action_result(request.action_digest)
except InvalidArgumentError as e:
self.logger.error(e)
context.set_details(str(e))
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
except NotFoundError as e:
self.logger.error(e)
......@@ -48,11 +58,24 @@ class ActionCacheService(remote_execution_pb2_grpc.ActionCacheServicer):
def UpdateActionResult(self, request, context):
try:
self._action_cache.update_action_result(request.action_digest, request.action_result)
instance = self._get_instance(request.instance_name)
instance.update_action_result(request.action_digest, request.action_result)
return request.action_result
except InvalidArgumentError as e:
self.logger.error(e)
context.set_details(str(e))
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
except NotImplementedError as e:
self.logger.error(e)
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
return remote_execution_pb2.ActionResult()
def _get_instance(self, instance_name):
try:
return self._instances[instance_name]
except KeyError:
raise InvalidArgumentError("Invalid instance name: {}".format(instance_name))
......@@ -33,10 +33,12 @@ from .._exceptions import InvalidArgumentError, OutofSyncError
class BotsService(bots_pb2_grpc.BotsServicer):
def __init__(self, instances):
def __init__(self, server, instances):
self._instances = instances
self.logger = logging.getLogger(__name__)
bots_pb2_grpc.add_BotsServicer_to_server(self, server)
def CreateBotSession(self, request, context):
try:
parent = request.parent
......
# Copyright (C) 2018 Bloomberg LP
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# <http://www.apache.org/licenses/LICENSE-2.0>
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
BuildGridServer
==============
Creates the user a local server BuildGrid server.
"""
from concurrent import futures
import grpc
from buildgrid._protos.google.bytestream import bytestream_pb2_grpc
from buildgrid._protos.build.bazel.remote.execution.v2 import remote_execution_pb2_grpc
from buildgrid._protos.google.devtools.remoteworkers.v1test2 import bots_pb2_grpc
from buildgrid._protos.google.longrunning import operations_pb2_grpc
from .instance import BuildGridInstance
from .cas.service import ByteStreamService, ContentAddressableStorageService
from .actioncache.service import ActionCacheService
from .execution.service import ExecutionService
from .operations.service import OperationsService
from .bots.service import BotsService
class BuildGridServer:
def __init__(self, port=50051, credentials=None, instances=None,
max_workers=10, action_cache=None, cas_storage=None):
address = '[::]:{0}'.format(port)
self._server = grpc.server(futures.ThreadPoolExecutor(max_workers))
if credentials is not None:
self._server.add_secure_port(address, credentials)
else:
self._server.add_insecure_port(address)
if cas_storage is not None:
cas_service = ContentAddressableStorageService(cas_storage)
remote_execution_pb2_grpc.add_ContentAddressableStorageServicer_to_server(cas_service,
self._server)
bytestream_pb2_grpc.add_ByteStreamServicer_to_server(ByteStreamService(cas_storage),
self._server)
if action_cache is not None:
action_cache_service = ActionCacheService(action_cache)
remote_execution_pb2_grpc.add_ActionCacheServicer_to_server(action_cache_service,
self._server)
buildgrid_instances = {}
if not instances:
buildgrid_instances["main"] = BuildGridInstance(action_cache, cas_storage)
else:
for name in instances:
buildgrid_instances[name] = BuildGridInstance(action_cache, cas_storage)
bots_pb2_grpc.add_BotsServicer_to_server(BotsService(buildgrid_instances),
self._server)
remote_execution_pb2_grpc.add_ExecutionServicer_to_server(ExecutionService(buildgrid_instances),
self._server)
operations_pb2_grpc.add_OperationsServicer_to_server(OperationsService(buildgrid_instances),
self._server)
def start(self):
self._server.start()
def stop(self):
self._server.stop(0)
......@@ -27,18 +27,20 @@ import logging
import grpc
from buildgrid._protos.google.bytestream import bytestream_pb2, bytestream_pb2_grpc
from buildgrid._protos.build.bazel.remote.execution.v2 import remote_execution_pb2 as re_pb2
from buildgrid._protos.build.bazel.remote.execution.v2 import remote_execution_pb2_grpc as re_pb2_grpc
from buildgrid._protos.build.bazel.remote.execution.v2 import remote_execution_pb2
from buildgrid._protos.build.bazel.remote.execution.v2 import remote_execution_pb2_grpc
from .._exceptions import InvalidArgumentError, NotFoundError, OutOfRangeError
class ContentAddressableStorageService(re_pb2_grpc.ContentAddressableStorageServicer):
class ContentAddressableStorageService(remote_execution_pb2_grpc.ContentAddressableStorageServicer):
def __init__(self, instances):
def __init__(self, server, instances):
self.logger = logging.getLogger(__name__)
self._instances = instances
remote_execution_pb2_grpc.add_ContentAddressableStorageServicer_to_server(self, server)
def FindMissingBlobs(self, request, context):
try:
instance = self._get_instance(request.instance_name)
......@@ -49,7 +51,7 @@ class ContentAddressableStorageService(re_pb2_grpc.ContentAddressableStorageServ
context.set_details(str(e))
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
return re_pb2.FindMissingBlobsResponse()
return remote_execution_pb2.FindMissingBlobsResponse()
def BatchUpdateBlobs(self, request, context):
try:
......@@ -61,7 +63,7 @@ class ContentAddressableStorageService(re_pb2_grpc.ContentAddressableStorageServ
context.set_details(str(e))
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
return re_pb2.BatchReadBlobsResponse()
return remote_execution_pb2.BatchReadBlobsResponse()
def _get_instance(self, instance_name):
try:
......@@ -73,10 +75,12 @@ class ContentAddressableStorageService(re_pb2_grpc.ContentAddressableStorageServ
class ByteStreamService(bytestream_pb2_grpc.ByteStreamServicer):
def __init__(self, instances):
def __init__(self, server, instances):
self.logger = logging.getLogger(__name__)
self._instances = instances
bytestream_pb2_grpc.add_ByteStreamServicer_to_server(self, server)
def Read(self, request, context):
try:
path = request.resource_name.split("/")
......
......@@ -14,31 +14,36 @@
"""
BuildGrid Instance
Execution Controller
==================
An instance of the BuildGrid server.
An instance of the Execution controller.
Contains scheduler, execution instance and an interface to the bots.
All this stuff you need to make the execution service work.
Contains scheduler, execution instance, an interface to the bots
and an operations instance.
"""
import logging
from .execution.instance import ExecutionInstance
from .scheduler import Scheduler
from .bots.instance import BotsInterface
from .execution.instance import ExecutionInstance
from .operations.instance import OperationsInstance
class BuildGridInstance(ExecutionInstance, BotsInterface):
class ExecutionController(ExecutionInstance, BotsInterface, OperationsInstance):
def __init__(self, action_cache=None, cas_storage=None):
def __init__(self, action_cache=None, storage=None):
scheduler = Scheduler(action_cache)
self.logger = logging.getLogger(__name__)
ExecutionInstance.__init__(self, scheduler, cas_storage)
ExecutionInstance.__init__(self, scheduler, storage)
BotsInterface.__init__(self, scheduler)
OperationsInstance.__init__(self, scheduler)
def stream_operation_updates(self, message_queue, operation_name):
operation = message_queue.get()
......
......@@ -35,10 +35,12 @@ from .._exceptions import InvalidArgumentError
class ExecutionService(remote_execution_pb2_grpc.ExecutionServicer):
def __init__(self, instances):
def __init__(self, server, instances):
self.logger = logging.getLogger(__name__)
self._instances = instances
remote_execution_pb2_grpc.add_ExecutionServicer_to_server(self, server)
def Execute(self, request, context):
try:
message_queue = queue.Queue()
......
......@@ -29,13 +29,14 @@ from buildgrid._protos.google.longrunning import operations_pb2_grpc, operations
from .._exceptions import InvalidArgumentError
class OperationsService(operations_pb2_grpc.OperationsServicer):
def __init__(self, instances):
def __init__(self, server, instances):
self._instances = instances
self.logger = logging.getLogger(__name__)
operations_pb2_grpc.add_OperationsServicer_to_server(self, server)
def GetOperation(self, request, context):
try:
name = request.name
......
......@@ -25,29 +25,64 @@ from .._exceptions import NotFoundError
class ReferenceStorageService(buildstream_pb2_grpc.ReferenceStorageServicer):
def __init__(self, reference_cache):
def __init__(self, server, instances):
self._reference_cache = reference_cache
self.logger = logging.getLogger(__name__)
self._instances = instances
buildstream_pb2_grpc.add_ReferenceStorageServicer_to_server(self, server)
def GetReference(self, request, context):
try:
response = buildstream_pb2.GetReferenceResponse()
response.digest.CopyFrom(self._reference_cache.get_digest_reference(request.key))
return response
instance = self._get_instance(request.instance_name)
digest = instance.get_digest_reference(request.key)
return buildstream_pb2.GetReferenceResponse(digest)
except InvalidArgumentError as e:
self.logger.error(e)
context.set_details(str(e))
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
except NotFoundError:
context.set_code(grpc.StatusCode.NOT_FOUND)
return buildstream_pb2.GetReferenceResponse()
def UpdateReference(self, request, context):
try:
instance = self._get_instance(request.instance_name)
digest = request.digest
for key in request.keys:
self._reference_cache.update_reference(key, request.digest)
instance.update_reference(key, digest)
return buildstream_pb2.UpdateReferenceResponse()
except InvalidArgumentError as e:
self.logger.error(e)
context.set_details(str(e))
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
except NotImplementedError:
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
return buildstream_pb2.UpdateReferenceResponse()
def Status(self, request, context):
allow_updates = self._reference_cache.allow_updates
return buildstream_pb2.StatusResponse(allow_updates=allow_updates)
try:
instance = self._get_instance(request.instance_name)
allow_updates = instance.allow_updates
return buildstream_pb2.StatusResponse(allow_updates=allow_updates)
except InvalidArgumentError as e:
self.logger.error(e)
context.set_details(str(e))
context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
return buildstream_pb2.StatusResponse()
def _get_instance(self, instance_name):
try:
return self._instances[instance_name]
except KeyError:
raise InvalidArgumentError("Invalid instance name: {}".format(instance_name))
......@@ -114,6 +114,7 @@ setup(
'protobuf',
'grpcio',
'Click',
'pyaml',
'boto3 < 1.8.0',
'botocore < 1.11.0',
'xdg',
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment