Merge "Implement the sandbox proposal - Part 1"
This commit is contained in:
commit
b4368be180
@ -25,7 +25,7 @@
|
|||||||
# userrc for us if nova service is not enabled, check
|
# userrc for us if nova service is not enabled, check
|
||||||
# https://github.com/openstack-dev/devstack/blob/master/stack.sh#L1310
|
# https://github.com/openstack-dev/devstack/blob/master/stack.sh#L1310
|
||||||
|
|
||||||
OVERRIDE_ENABLED_SERVICES="dstat,key,mysql,rabbit,n-api,n-cond,n-cpu,n-crt,n-obj,n-sch,tempest"
|
OVERRIDE_ENABLED_SERVICES="dstat,key,mysql,rabbit,n-api,n-cond,n-cpu,n-crt,n-obj,n-sch,g-api,g-reg,tempest"
|
||||||
export OVERRIDE_ENABLED_SERVICES
|
export OVERRIDE_ENABLED_SERVICES
|
||||||
|
|
||||||
$BASE/new/devstack-gate/devstack-vm-gate.sh
|
$BASE/new/devstack-gate/devstack-vm-gate.sh
|
||||||
|
@ -64,8 +64,8 @@ else
|
|||||||
ZUN_BIN_DIR=$(get_python_exec_prefix)
|
ZUN_BIN_DIR=$(get_python_exec_prefix)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DOCKER_GROUP=docker
|
DOCKER_GROUP=${DOCKER_GROUP:-docker}
|
||||||
DEFAULT_CONTAINER_DRIVER=docker
|
ZUN_DRIVER=${DEFAULT_ZUN_DRIVER:-docker}
|
||||||
|
|
||||||
ETCD_VERSION=v3.0.13
|
ETCD_VERSION=v3.0.13
|
||||||
if is_ubuntu; then
|
if is_ubuntu; then
|
||||||
@ -109,11 +109,19 @@ function configure_zun {
|
|||||||
|
|
||||||
create_api_paste_conf
|
create_api_paste_conf
|
||||||
|
|
||||||
if [[ ${DEFAULT_CONTAINER_DRIVER} == "docker" ]]; then
|
if [[ ${ZUN_DRIVER} == "docker" ]]; then
|
||||||
check_docker || install_docker
|
check_docker || install_docker
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# upload_sandbox_image() - Upload sandbox image to glance
|
||||||
|
function upload_sandbox_image {
|
||||||
|
if [[ ${ZUN_DRIVER} == "docker" ]]; then
|
||||||
|
sg docker "docker pull kubernetes/pause"
|
||||||
|
sg docker "docker save kubernetes/pause" | openstack image create kubernetes/pause --public --container-format docker --disk-format raw
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# create_zun_accounts() - Set up common required ZUN accounts
|
# create_zun_accounts() - Set up common required ZUN accounts
|
||||||
#
|
#
|
||||||
# Project User Roles
|
# Project User Roles
|
||||||
@ -314,7 +322,11 @@ function start_zun_api {
|
|||||||
# start_zun_compute() - Start Zun compute agent
|
# start_zun_compute() - Start Zun compute agent
|
||||||
function start_zun_compute {
|
function start_zun_compute {
|
||||||
echo "Start zun compute..."
|
echo "Start zun compute..."
|
||||||
run_process zun-compute "$ZUN_BIN_DIR/zun-compute" ${DOCKER_GROUP}
|
if [[ ${ZUN_DRIVER} == "docker" ]]; then
|
||||||
|
run_process zun-compute "$ZUN_BIN_DIR/zun-compute" ${DOCKER_GROUP}
|
||||||
|
else
|
||||||
|
run_process zun-compute "$ZUN_BIN_DIR/zun-compute"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
function start_zun_etcd {
|
function start_zun_etcd {
|
||||||
|
@ -32,6 +32,7 @@ if is_service_enabled zun-api zun-compute; then
|
|||||||
# Start the zun API and zun compute
|
# Start the zun API and zun compute
|
||||||
echo_summary "Starting zun"
|
echo_summary "Starting zun"
|
||||||
start_zun
|
start_zun
|
||||||
|
upload_sandbox_image
|
||||||
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@ -256,6 +256,20 @@ class Dict(object):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class Json(object):
|
||||||
|
type_name = 'Json'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(self, value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise exception.InvalidValue(value=value, type=self.type_name)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class DateTime(object):
|
class DateTime(object):
|
||||||
type_name = "DateTime"
|
type_name = "DateTime"
|
||||||
|
|
||||||
|
@ -120,13 +120,16 @@ class Container(base.APIBase):
|
|||||||
'labels': {
|
'labels': {
|
||||||
'validate': types.Dict(types.String, types.String).validate,
|
'validate': types.Dict(types.String, types.String).validate,
|
||||||
},
|
},
|
||||||
|
'addresses': {
|
||||||
|
'validate': types.Json.validate,
|
||||||
|
},
|
||||||
'image_pull_policy': {
|
'image_pull_policy': {
|
||||||
'validate': types.EnumType.validate,
|
'validate': types.EnumType.validate,
|
||||||
'validate_args': {
|
'validate_args': {
|
||||||
'name': 'image_pull_policy',
|
'name': 'image_pull_policy',
|
||||||
'values': ['never', 'always', 'ifnotpresent']
|
'values': ['never', 'always', 'ifnotpresent']
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
@ -138,7 +141,7 @@ class Container(base.APIBase):
|
|||||||
container.unset_fields_except([
|
container.unset_fields_except([
|
||||||
'uuid', 'name', 'image', 'command', 'status', 'cpu', 'memory',
|
'uuid', 'name', 'image', 'command', 'status', 'cpu', 'memory',
|
||||||
'environment', 'task_state', 'workdir', 'ports', 'hostname',
|
'environment', 'task_state', 'workdir', 'ports', 'hostname',
|
||||||
'labels', 'image_pull_policy', 'status_reason'])
|
'labels', 'addresses', 'image_pull_policy', 'status_reason'])
|
||||||
|
|
||||||
container.links = [link.Link.make_link(
|
container.links = [link.Link.make_link(
|
||||||
'self', url,
|
'self', url,
|
||||||
@ -171,6 +174,14 @@ class Container(base.APIBase):
|
|||||||
ports=[80, 443],
|
ports=[80, 443],
|
||||||
hostname='testhost',
|
hostname='testhost',
|
||||||
labels={'key1': 'val1', 'key2': 'val2'},
|
labels={'key1': 'val1', 'key2': 'val2'},
|
||||||
|
addresses={
|
||||||
|
'private': [
|
||||||
|
{'OS-EXT-IPS-MAC:mac_addr': 'fa:16:3e:04:da:76',
|
||||||
|
'version': 4,
|
||||||
|
'addr': '10.0.0.12',
|
||||||
|
'OS-EXT-IPS:type': 'fixed'},
|
||||||
|
],
|
||||||
|
},
|
||||||
created_at=timeutils.utcnow(),
|
created_at=timeutils.utcnow(),
|
||||||
updated_at=timeutils.utcnow())
|
updated_at=timeutils.utcnow())
|
||||||
return cls._convert_with_links(sample, 'http://localhost:9517', expand)
|
return cls._convert_with_links(sample, 'http://localhost:9517', expand)
|
||||||
|
@ -118,9 +118,9 @@ def wrap_controller_exception(func, func_server_error, func_client_error):
|
|||||||
# log the error message with its associated
|
# log the error message with its associated
|
||||||
# correlation id
|
# correlation id
|
||||||
log_correlation_id = uuidutils.generate_uuid()
|
log_correlation_id = uuidutils.generate_uuid()
|
||||||
LOG.error(_LE("%(correlation_id)s:%(excp)s") %
|
LOG.exception(_LE("%(correlation_id)s:%(excp)s") %
|
||||||
{'correlation_id': log_correlation_id,
|
{'correlation_id': log_correlation_id,
|
||||||
'excp': str(excp)})
|
'excp': str(excp)})
|
||||||
# raise a client error with an obfuscated message
|
# raise a client error with an obfuscated message
|
||||||
return func_server_error(log_correlation_id, http_error_code)
|
return func_server_error(log_correlation_id, http_error_code)
|
||||||
else:
|
else:
|
||||||
@ -364,3 +364,7 @@ class InvalidStateException(ZunException):
|
|||||||
|
|
||||||
class DockerError(ZunException):
|
class DockerError(ZunException):
|
||||||
message = _("Docker internal error: %(error_msg)s.")
|
message = _("Docker internal error: %(error_msg)s.")
|
||||||
|
|
||||||
|
|
||||||
|
class PollTimeOut(ZunException):
|
||||||
|
message = _("Polling request timed out.")
|
||||||
|
@ -23,6 +23,7 @@ import zun.conf
|
|||||||
|
|
||||||
CONF = zun.conf.CONF
|
CONF = zun.conf.CONF
|
||||||
CFG_GROUP = 'keystone_auth'
|
CFG_GROUP = 'keystone_auth'
|
||||||
|
CFG_LEGACY_GROUP = 'keystone_authtoken'
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
keystone_auth_opts = (ka_loading.get_auth_common_conf_options() +
|
keystone_auth_opts = (ka_loading.get_auth_common_conf_options() +
|
||||||
@ -46,7 +47,9 @@ class KeystoneClientV3(object):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_url(self):
|
def auth_url(self):
|
||||||
return CONF.keystone_authtoken.auth_uri.replace('v2.0', 'v3')
|
# FIXME(pauloewerton): auth_url should be retrieved from keystone_auth
|
||||||
|
# section by default
|
||||||
|
return CONF[CFG_LEGACY_GROUP].auth_uri.replace('v2.0', 'v3')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_token(self):
|
def auth_token(self):
|
||||||
|
@ -77,6 +77,23 @@ class Manager(object):
|
|||||||
LOG.debug('Creating container...', context=context,
|
LOG.debug('Creating container...', context=context,
|
||||||
container=container)
|
container=container)
|
||||||
|
|
||||||
|
container.task_state = fields.TaskState.SANDBOX_CREATING
|
||||||
|
container.save()
|
||||||
|
sandbox_id = None
|
||||||
|
sandbox_image = 'kubernetes/pause'
|
||||||
|
repo, tag = utils.parse_image_name(sandbox_image)
|
||||||
|
try:
|
||||||
|
image = image_driver.pull_image(context, repo, tag, 'ifnotpresent')
|
||||||
|
sandbox_id = self.driver.create_sandbox(context, container,
|
||||||
|
image=sandbox_image)
|
||||||
|
except Exception as e:
|
||||||
|
with excutils.save_and_reraise_exception(reraise=reraise):
|
||||||
|
LOG.exception(_LE("Unexpected exception: %s"),
|
||||||
|
six.text_type(e))
|
||||||
|
self._fail_container(container, six.text_type(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.driver.set_sandbox_id(container, sandbox_id)
|
||||||
container.task_state = fields.TaskState.IMAGE_PULLING
|
container.task_state = fields.TaskState.IMAGE_PULLING
|
||||||
container.save()
|
container.save()
|
||||||
repo, tag = utils.parse_image_name(container.image)
|
repo, tag = utils.parse_image_name(container.image)
|
||||||
@ -107,7 +124,9 @@ class Manager(object):
|
|||||||
container.task_state = fields.TaskState.CONTAINER_CREATING
|
container.task_state = fields.TaskState.CONTAINER_CREATING
|
||||||
container.save()
|
container.save()
|
||||||
try:
|
try:
|
||||||
container = self.driver.create(container, image)
|
container = self.driver.create(container, sandbox_id, image)
|
||||||
|
container.addresses = self._get_container_addresses(context,
|
||||||
|
container)
|
||||||
container.task_state = None
|
container.task_state = None
|
||||||
container.save()
|
container.save()
|
||||||
return container
|
return container
|
||||||
@ -154,7 +173,6 @@ class Manager(object):
|
|||||||
if not force:
|
if not force:
|
||||||
self._validate_container_state(container, 'delete')
|
self._validate_container_state(container, 'delete')
|
||||||
self.driver.delete(container, force)
|
self.driver.delete(container, force)
|
||||||
return container
|
|
||||||
except exception.DockerError as e:
|
except exception.DockerError as e:
|
||||||
LOG.error(_LE("Error occured while calling docker delete API: %s"),
|
LOG.error(_LE("Error occured while calling docker delete API: %s"),
|
||||||
six.text_type(e))
|
six.text_type(e))
|
||||||
@ -163,6 +181,16 @@ class Manager(object):
|
|||||||
LOG.exception(_LE("Unexpected exception: %s"), str(e))
|
LOG.exception(_LE("Unexpected exception: %s"), str(e))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
sandbox_id = self.driver.get_sandbox_id(container)
|
||||||
|
if sandbox_id:
|
||||||
|
try:
|
||||||
|
self.driver.delete_sandbox(context, sandbox_id)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.exception(_LE("Unexpected exception: %s"), str(e))
|
||||||
|
raise
|
||||||
|
|
||||||
|
return container
|
||||||
|
|
||||||
@translate_exception
|
@translate_exception
|
||||||
def container_list(self, context):
|
def container_list(self, context):
|
||||||
LOG.debug('Listing container...', context=context)
|
LOG.debug('Listing container...', context=context)
|
||||||
@ -344,3 +372,16 @@ class Manager(object):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(_LE("Unexpected exception: %s"), str(e))
|
LOG.exception(_LE("Unexpected exception: %s"), str(e))
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
def _get_container_addresses(self, context, container):
|
||||||
|
LOG.debug('Showing container IP addresses...', context=context,
|
||||||
|
container=container)
|
||||||
|
try:
|
||||||
|
return self.driver.get_addresses(context, container)
|
||||||
|
except exception.DockerError as e:
|
||||||
|
LOG.error(_LE("Error occured while calling docker API: %s"),
|
||||||
|
six.text_type(e))
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
LOG.exception(_LE("Unexpected exception: %s"), str(e))
|
||||||
|
raise e
|
||||||
|
@ -28,7 +28,9 @@ Services which consume this:
|
|||||||
Interdependencies to other options:
|
Interdependencies to other options:
|
||||||
|
|
||||||
* None
|
* None
|
||||||
""")
|
"""),
|
||||||
|
cfg.IntOpt('default_timeout', default=60 * 10,
|
||||||
|
help='Maximum time (in seconds) to wait for an event.'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -11,17 +11,20 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
from docker import errors
|
|
||||||
import six
|
import six
|
||||||
|
|
||||||
|
from docker import errors
|
||||||
from oslo_log import log as logging
|
from oslo_log import log as logging
|
||||||
|
|
||||||
|
from zun.common.i18n import _LW
|
||||||
from zun.common.utils import check_container_id
|
from zun.common.utils import check_container_id
|
||||||
|
import zun.conf
|
||||||
from zun.container.docker import utils as docker_utils
|
from zun.container.docker import utils as docker_utils
|
||||||
from zun.container import driver
|
from zun.container import driver
|
||||||
from zun.objects import fields
|
from zun.objects import fields
|
||||||
|
|
||||||
|
|
||||||
|
CONF = zun.conf.CONF
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -46,7 +49,7 @@ class DockerDriver(driver.ContainerDriver):
|
|||||||
response = docker.images(repo, quiet)
|
response = docker.images(repo, quiet)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def create(self, container, image):
|
def create(self, container, sandbox_id, image):
|
||||||
with docker_utils.docker_client() as docker:
|
with docker_utils.docker_client() as docker:
|
||||||
name = container.name
|
name = container.name
|
||||||
if image['path']:
|
if image['path']:
|
||||||
@ -67,14 +70,18 @@ class DockerDriver(driver.ContainerDriver):
|
|||||||
}
|
}
|
||||||
|
|
||||||
host_config = {}
|
host_config = {}
|
||||||
host_config['publish_all_ports'] = True
|
host_config['network_mode'] = 'container:%s' % sandbox_id
|
||||||
|
# TODO(hongbin): Uncomment this after docker-py add support for
|
||||||
|
# container mode for pid namespace.
|
||||||
|
# host_config['pid_mode'] = 'container:%s' % sandbox_id
|
||||||
|
host_config['ipc_mode'] = 'container:%s' % sandbox_id
|
||||||
|
host_config['volumes_from'] = sandbox_id
|
||||||
if container.memory is not None:
|
if container.memory is not None:
|
||||||
host_config['mem_limit'] = container.memory
|
host_config['mem_limit'] = container.memory
|
||||||
if container.cpu is not None:
|
if container.cpu is not None:
|
||||||
host_config['cpu_quota'] = int(100000 * container.cpu)
|
host_config['cpu_quota'] = int(100000 * container.cpu)
|
||||||
host_config['cpu_period'] = 100000
|
host_config['cpu_period'] = 100000
|
||||||
kwargs['host_config'] = \
|
kwargs['host_config'] = docker.create_host_config(**host_config)
|
||||||
docker.create_host_config(**host_config)
|
|
||||||
|
|
||||||
response = docker.create_container(image, **kwargs)
|
response = docker.create_container(image, **kwargs)
|
||||||
container.container_id = response['Id']
|
container.container_id = response['Id']
|
||||||
@ -204,3 +211,45 @@ class DockerDriver(driver.ContainerDriver):
|
|||||||
if six.PY2 and not isinstance(value, unicode):
|
if six.PY2 and not isinstance(value, unicode):
|
||||||
value = unicode(value)
|
value = unicode(value)
|
||||||
return value.encode('utf-8')
|
return value.encode('utf-8')
|
||||||
|
|
||||||
|
def create_sandbox(self, context, container, image='kubernetes/pause'):
|
||||||
|
with docker_utils.docker_client() as docker:
|
||||||
|
name = self.get_sandbox_name(container)
|
||||||
|
response = docker.create_container(image, name=name)
|
||||||
|
sandbox_id = response['Id']
|
||||||
|
docker.start(sandbox_id)
|
||||||
|
return sandbox_id
|
||||||
|
|
||||||
|
def delete_sandbox(self, context, sandbox_id):
|
||||||
|
with docker_utils.docker_client() as docker:
|
||||||
|
docker.remove_container(sandbox_id, force=True)
|
||||||
|
|
||||||
|
def get_sandbox_id(self, container):
|
||||||
|
if container.meta:
|
||||||
|
return container.meta.get('sandbox_id', None)
|
||||||
|
else:
|
||||||
|
LOG.warning(_LW("Unexpected missing of sandbox_id"))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_sandbox_id(self, container, id):
|
||||||
|
if container.meta is None:
|
||||||
|
container.meta = {'sandbox_id': id}
|
||||||
|
else:
|
||||||
|
container.meta['sandbox_id'] = id
|
||||||
|
|
||||||
|
def get_sandbox_name(self, container):
|
||||||
|
return 'sandbox-' + container.uuid
|
||||||
|
|
||||||
|
def get_addresses(self, context, container):
|
||||||
|
sandbox_id = self.get_sandbox_id(container)
|
||||||
|
with docker_utils.docker_client() as docker:
|
||||||
|
response = docker.inspect_container(sandbox_id)
|
||||||
|
addr = response["NetworkSettings"]["IPAddress"]
|
||||||
|
addresses = {
|
||||||
|
'default': [
|
||||||
|
{
|
||||||
|
'addr': addr,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return addresses
|
||||||
|
@ -58,7 +58,7 @@ def load_container_driver(container_driver=None):
|
|||||||
class ContainerDriver(object):
|
class ContainerDriver(object):
|
||||||
'''Base class for container drivers.'''
|
'''Base class for container drivers.'''
|
||||||
|
|
||||||
def create(self, container):
|
def create(self, container, sandbox_name=None):
|
||||||
"""Create a container."""
|
"""Create a container."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@ -105,3 +105,27 @@ class ContainerDriver(object):
|
|||||||
def kill(self, container, signal):
|
def kill(self, container, signal):
|
||||||
"""kill signal to a container."""
|
"""kill signal to a container."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def create_sandbox(self, context, container, **kwargs):
|
||||||
|
"""Create a sandbox."""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def delete_sandbox(self, context, id):
|
||||||
|
"""Delete a sandbox."""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def get_sandbox_id(self, container):
|
||||||
|
"""Retrieve sandbox ID."""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def set_sandbox_id(self, container, id):
|
||||||
|
"""Set sandbox ID."""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def get_sandbox_name(self, container):
|
||||||
|
"""Retrieve sandbox name."""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def get_addresses(self, context, container):
|
||||||
|
"""Retrieve IP addresses of the container."""
|
||||||
|
raise NotImplementedError()
|
||||||
|
@ -0,0 +1,39 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
"""add meta addresses to container
|
||||||
|
|
||||||
|
Revision ID: 4a0c4f7a4a33
|
||||||
|
Revises: 43e1088c3389
|
||||||
|
Create Date: 2016-11-20 12:18:44.086036
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '4a0c4f7a4a33'
|
||||||
|
down_revision = '43e1088c3389'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from zun.db.sqlalchemy import models
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('container',
|
||||||
|
sa.Column('meta', models.JSONEncodedDict(),
|
||||||
|
nullable=True))
|
||||||
|
op.add_column('container',
|
||||||
|
sa.Column('addresses', models.JSONEncodedDict(),
|
||||||
|
nullable=True))
|
@ -142,6 +142,8 @@ class Container(Base):
|
|||||||
ports = Column(JSONEncodedList)
|
ports = Column(JSONEncodedList)
|
||||||
hostname = Column(String(255))
|
hostname = Column(String(255))
|
||||||
labels = Column(JSONEncodedDict)
|
labels = Column(JSONEncodedDict)
|
||||||
|
meta = Column(JSONEncodedDict)
|
||||||
|
addresses = Column(JSONEncodedDict)
|
||||||
image_pull_policy = Column(Text, nullable=True)
|
image_pull_policy = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,7 +25,9 @@ class Container(base.ZunPersistentObject, base.ZunObject,
|
|||||||
# Version 1.2: Add memory column
|
# Version 1.2: Add memory column
|
||||||
# Version 1.3: Add task_state column
|
# Version 1.3: Add task_state column
|
||||||
# Version 1.4: Add cpu, workdir, ports, hostname and labels columns
|
# Version 1.4: Add cpu, workdir, ports, hostname and labels columns
|
||||||
VERSION = '1.4'
|
# Version 1.5: Add meta column
|
||||||
|
# Version 1.6: Add addresses column
|
||||||
|
VERSION = '1.6'
|
||||||
|
|
||||||
fields = {
|
fields = {
|
||||||
'id': fields.IntegerField(),
|
'id': fields.IntegerField(),
|
||||||
@ -46,7 +48,9 @@ class Container(base.ZunPersistentObject, base.ZunObject,
|
|||||||
'ports': z_fields.ListOfIntegersField(nullable=True),
|
'ports': z_fields.ListOfIntegersField(nullable=True),
|
||||||
'hostname': fields.StringField(nullable=True),
|
'hostname': fields.StringField(nullable=True),
|
||||||
'labels': fields.DictOfStringsField(nullable=True),
|
'labels': fields.DictOfStringsField(nullable=True),
|
||||||
'image_pull_policy': fields.StringField(nullable=True)
|
'meta': fields.DictOfStringsField(nullable=True),
|
||||||
|
'addresses': z_fields.JsonField(nullable=True),
|
||||||
|
'image_pull_policy': fields.StringField(nullable=True),
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -10,6 +10,9 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
|
import six
|
||||||
|
|
||||||
|
from oslo_serialization import jsonutils as json
|
||||||
from oslo_versionedobjects import fields
|
from oslo_versionedobjects import fields
|
||||||
|
|
||||||
|
|
||||||
@ -31,9 +34,9 @@ class ContainerStatusField(fields.BaseEnumField):
|
|||||||
|
|
||||||
class TaskState(fields.Enum):
|
class TaskState(fields.Enum):
|
||||||
ALL = (
|
ALL = (
|
||||||
IMAGE_PULLING, CONTAINER_CREATING,
|
IMAGE_PULLING, CONTAINER_CREATING, SANDBOX_CREATING,
|
||||||
) = (
|
) = (
|
||||||
'image_pulling', 'container_creating',
|
'image_pulling', 'container_creating', 'sandbox_creating',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -47,3 +50,21 @@ class TaskStateField(fields.BaseEnumField):
|
|||||||
|
|
||||||
class ListOfIntegersField(fields.AutoTypedField):
|
class ListOfIntegersField(fields.AutoTypedField):
|
||||||
AUTO_TYPE = fields.List(fields.Integer())
|
AUTO_TYPE = fields.List(fields.Integer())
|
||||||
|
|
||||||
|
|
||||||
|
class Json(fields.FieldType):
|
||||||
|
def coerce(self, obj, attr, value):
|
||||||
|
if isinstance(value, six.string_types):
|
||||||
|
loaded = json.loads(value)
|
||||||
|
return loaded
|
||||||
|
return value
|
||||||
|
|
||||||
|
def from_primitive(self, obj, attr, value):
|
||||||
|
return self.coerce(obj, attr, value)
|
||||||
|
|
||||||
|
def to_primitive(self, obj, attr, value):
|
||||||
|
return json.dumps(value)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonField(fields.AutoTypedField):
|
||||||
|
AUTO_TYPE = Json()
|
||||||
|
@ -18,6 +18,7 @@ from tempest import manager
|
|||||||
|
|
||||||
from zun.tests.tempest.api.models import container_model
|
from zun.tests.tempest.api.models import container_model
|
||||||
from zun.tests.tempest.api.models import service_model
|
from zun.tests.tempest.api.models import service_model
|
||||||
|
from zun.tests.tempest import utils
|
||||||
|
|
||||||
|
|
||||||
CONF = config.CONF
|
CONF = config.CONF
|
||||||
@ -105,3 +106,13 @@ class ZunClient(rest_client.RestClient):
|
|||||||
resp, body = self.get(self.services_uri(filters), **kwargs)
|
resp, body = self.get(self.services_uri(filters), **kwargs)
|
||||||
return self.deserialize(resp, body,
|
return self.deserialize(resp, body,
|
||||||
service_model.ServiceCollection)
|
service_model.ServiceCollection)
|
||||||
|
|
||||||
|
def ensure_container_created(self, container_id):
|
||||||
|
def container_created():
|
||||||
|
_, container = self.get_container(container_id)
|
||||||
|
if container.status == 'Creating':
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
utils.wait_for_condition(container_created)
|
||||||
|
@ -10,8 +10,6 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from tempest.lib import decorators
|
from tempest.lib import decorators
|
||||||
|
|
||||||
from zun.tests.tempest.api import clients
|
from zun.tests.tempest.api import clients
|
||||||
@ -43,6 +41,14 @@ class TestContainer(base.BaseZunTest):
|
|||||||
|
|
||||||
super(TestContainer, cls).resource_setup()
|
super(TestContainer, cls).resource_setup()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
_, model = self.container_client.list_containers()
|
||||||
|
for c in model.containers:
|
||||||
|
self.container_client.ensure_container_created(c['uuid'])
|
||||||
|
self.container_client.delete_container(c['uuid'])
|
||||||
|
|
||||||
|
super(TestContainer, self).tearDown()
|
||||||
|
|
||||||
def _create_container(self, **kwargs):
|
def _create_container(self, **kwargs):
|
||||||
|
|
||||||
model = datagen.container_data(**kwargs)
|
model = datagen.container_data(**kwargs)
|
||||||
@ -52,26 +58,6 @@ class TestContainer(base.BaseZunTest):
|
|||||||
|
|
||||||
self.container_client.delete_container(container_id, **kwargs)
|
self.container_client.delete_container(container_id, **kwargs)
|
||||||
|
|
||||||
def _wait_on_creation(self, container_id, timeout=60):
|
|
||||||
def _check_status():
|
|
||||||
resp, model = self.container_client.get_container(container_id)
|
|
||||||
status = model.status
|
|
||||||
if status == 'Creating':
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
time.sleep(1)
|
|
||||||
start_time = time.time()
|
|
||||||
end_time = time.time() + timeout
|
|
||||||
while time.time() < end_time:
|
|
||||||
result = _check_status()
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
time.sleep(1)
|
|
||||||
raise Exception(("Timed out after %s seconds. Started " +
|
|
||||||
"on %s and ended on %s") % (timeout, start_time,
|
|
||||||
end_time))
|
|
||||||
|
|
||||||
@decorators.idempotent_id('a04f61f2-15ae-4200-83b7-1f311b101f35')
|
@decorators.idempotent_id('a04f61f2-15ae-4200-83b7-1f311b101f35')
|
||||||
def test_container_create_list_delete(self):
|
def test_container_create_list_delete(self):
|
||||||
|
|
||||||
@ -82,8 +68,7 @@ class TestContainer(base.BaseZunTest):
|
|||||||
self.assertEqual(200, resp.status)
|
self.assertEqual(200, resp.status)
|
||||||
self.assertGreater(len(model.containers), 0)
|
self.assertGreater(len(model.containers), 0)
|
||||||
# NOTE(mkrai): Check and wait for container creation to get over
|
# NOTE(mkrai): Check and wait for container creation to get over
|
||||||
self._wait_on_creation(container.uuid)
|
self.container_client.ensure_container_created(container.uuid)
|
||||||
|
|
||||||
self._delete_container(container.uuid, headers={'force': True})
|
self._delete_container(container.uuid, headers={'force': True})
|
||||||
|
|
||||||
resp, model = self.container_client.list_containers()
|
resp, model = self.container_client.list_containers()
|
||||||
|
25
zun/tests/tempest/utils.py
Normal file
25
zun/tests/tempest/utils.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 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 time
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_condition(condition, interval=1, timeout=60):
|
||||||
|
start_time = time.time()
|
||||||
|
end_time = time.time() + timeout
|
||||||
|
while time.time() < end_time:
|
||||||
|
result = condition()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
time.sleep(interval)
|
||||||
|
raise Exception(("Timed out after %s seconds. Started on %s and ended "
|
||||||
|
"on %s") % (timeout, start_time, end_time))
|
@ -62,56 +62,67 @@ class TestManager(base.TestCase):
|
|||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
@mock.patch('zun.image.driver.pull_image')
|
@mock.patch('zun.image.driver.pull_image')
|
||||||
@mock.patch.object(fake_driver, 'create')
|
@mock.patch.object(fake_driver, 'create')
|
||||||
def test_container_create(self, mock_create, mock_pull, mock_save):
|
@mock.patch.object(fake_driver, 'create_sandbox')
|
||||||
|
def test_container_create(self, mock_create_sandbox, mock_create,
|
||||||
|
mock_pull, mock_save):
|
||||||
container = Container(self.context, **utils.get_test_container())
|
container = Container(self.context, **utils.get_test_container())
|
||||||
mock_pull.return_value = 'fake_path'
|
mock_pull.return_value = 'fake_path'
|
||||||
|
mock_create_sandbox.return_value = 'fake_id'
|
||||||
self.compute_manager._do_container_create(self.context, container)
|
self.compute_manager._do_container_create(self.context, container)
|
||||||
mock_save.assert_called_with()
|
mock_save.assert_called_with()
|
||||||
mock_pull.assert_called_once_with(self.context,
|
mock_pull.assert_any_call(self.context, container.image, 'latest',
|
||||||
container.image,
|
'always')
|
||||||
'latest', 'always')
|
mock_create.assert_called_once_with(container, 'fake_id', 'fake_path')
|
||||||
mock_create.assert_called_once_with(container, 'fake_path')
|
|
||||||
|
|
||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
|
@mock.patch.object(fake_driver, 'create_sandbox')
|
||||||
@mock.patch('zun.image.driver.pull_image')
|
@mock.patch('zun.image.driver.pull_image')
|
||||||
@mock.patch.object(manager.Manager, '_fail_container')
|
@mock.patch.object(manager.Manager, '_fail_container')
|
||||||
def test_container_create_pull_image_failed_docker_error(
|
def test_container_create_pull_image_failed_docker_error(
|
||||||
self, mock_fail, mock_pull, mock_save):
|
self, mock_fail, mock_pull, mock_create_sandbox, mock_save):
|
||||||
container = Container(self.context, **utils.get_test_container())
|
container = Container(self.context, **utils.get_test_container())
|
||||||
mock_pull.side_effect = exception.DockerError("Pull Failed")
|
mock_pull.side_effect = exception.DockerError("Pull Failed")
|
||||||
|
mock_create_sandbox.return_value = mock.MagicMock()
|
||||||
self.compute_manager._do_container_create(self.context, container)
|
self.compute_manager._do_container_create(self.context, container)
|
||||||
mock_fail.assert_called_once_with(container, "Pull Failed")
|
mock_fail.assert_called_once_with(container, "Pull Failed")
|
||||||
|
|
||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
|
@mock.patch.object(fake_driver, 'create_sandbox')
|
||||||
@mock.patch('zun.image.driver.pull_image')
|
@mock.patch('zun.image.driver.pull_image')
|
||||||
@mock.patch.object(manager.Manager, '_fail_container')
|
@mock.patch.object(manager.Manager, '_fail_container')
|
||||||
def test_container_create_pull_image_failed_image_not_found(
|
def test_container_create_pull_image_failed_image_not_found(
|
||||||
self, mock_fail, mock_pull, mock_save):
|
self, mock_fail, mock_pull, mock_create_sandbox, mock_save):
|
||||||
container = Container(self.context, **utils.get_test_container())
|
container = Container(self.context, **utils.get_test_container())
|
||||||
mock_pull.side_effect = exception.ImageNotFound("Image Not Found")
|
mock_pull.side_effect = exception.ImageNotFound("Image Not Found")
|
||||||
|
mock_create_sandbox.return_value = mock.MagicMock()
|
||||||
self.compute_manager._do_container_create(self.context, container)
|
self.compute_manager._do_container_create(self.context, container)
|
||||||
mock_fail.assert_called_once_with(container, "Image Not Found")
|
mock_fail.assert_called_once_with(container, "Image Not Found")
|
||||||
|
|
||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
|
@mock.patch.object(fake_driver, 'create_sandbox')
|
||||||
@mock.patch('zun.image.driver.pull_image')
|
@mock.patch('zun.image.driver.pull_image')
|
||||||
@mock.patch.object(manager.Manager, '_fail_container')
|
@mock.patch.object(manager.Manager, '_fail_container')
|
||||||
def test_container_create_pull_image_failed_zun_exception(
|
def test_container_create_pull_image_failed_zun_exception(
|
||||||
self, mock_fail, mock_pull, mock_save):
|
self, mock_fail, mock_pull, mock_create_sandbox, mock_save):
|
||||||
container = Container(self.context, **utils.get_test_container())
|
container = Container(self.context, **utils.get_test_container())
|
||||||
mock_pull.side_effect = exception.ZunException(
|
mock_pull.side_effect = exception.ZunException(
|
||||||
message="Image Not Found")
|
message="Image Not Found")
|
||||||
|
mock_create_sandbox.return_value = mock.MagicMock()
|
||||||
self.compute_manager._do_container_create(self.context, container)
|
self.compute_manager._do_container_create(self.context, container)
|
||||||
mock_fail.assert_called_once_with(container, "Image Not Found")
|
mock_fail.assert_called_once_with(container, "Image Not Found")
|
||||||
|
|
||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
@mock.patch('zun.image.driver.pull_image')
|
@mock.patch('zun.image.driver.pull_image')
|
||||||
@mock.patch.object(fake_driver, 'create')
|
@mock.patch.object(fake_driver, 'create')
|
||||||
|
@mock.patch.object(fake_driver, 'create_sandbox')
|
||||||
@mock.patch.object(manager.Manager, '_fail_container')
|
@mock.patch.object(manager.Manager, '_fail_container')
|
||||||
def test_container_create_docker_create_failed(self, mock_fail,
|
def test_container_create_docker_create_failed(self, mock_fail,
|
||||||
|
mock_create_sandbox,
|
||||||
mock_create, mock_pull,
|
mock_create, mock_pull,
|
||||||
mock_save):
|
mock_save):
|
||||||
container = Container(self.context, **utils.get_test_container())
|
container = Container(self.context, **utils.get_test_container())
|
||||||
mock_create.side_effect = exception.DockerError("Creation Failed")
|
mock_create.side_effect = exception.DockerError("Creation Failed")
|
||||||
|
mock_create_sandbox.return_value = mock.MagicMock()
|
||||||
self.compute_manager._do_container_create(self.context, container)
|
self.compute_manager._do_container_create(self.context, container)
|
||||||
mock_fail.assert_called_once_with(container, "Creation Failed")
|
mock_fail.assert_called_once_with(container, "Creation Failed")
|
||||||
|
|
||||||
@ -127,10 +138,9 @@ class TestManager(base.TestCase):
|
|||||||
container.status = 'Stopped'
|
container.status = 'Stopped'
|
||||||
self.compute_manager.container_run(self.context, container)
|
self.compute_manager.container_run(self.context, container)
|
||||||
mock_save.assert_called_with()
|
mock_save.assert_called_with()
|
||||||
mock_pull.assert_called_once_with(self.context,
|
mock_pull.assert_any_call(self.context, container.image, 'latest',
|
||||||
container.image,
|
'always')
|
||||||
'latest', 'always')
|
mock_create.assert_called_once_with(container, None, 'fake_path')
|
||||||
mock_create.assert_called_once_with(container, 'fake_path')
|
|
||||||
mock_start.assert_called_once_with(container)
|
mock_start.assert_called_once_with(container)
|
||||||
|
|
||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
@ -147,9 +157,8 @@ class TestManager(base.TestCase):
|
|||||||
container)
|
container)
|
||||||
mock_save.assert_called_with()
|
mock_save.assert_called_with()
|
||||||
mock_fail.assert_called_with(container, 'Image Not Found')
|
mock_fail.assert_called_with(container, 'Image Not Found')
|
||||||
mock_pull.assert_called_once_with(self.context,
|
mock_pull.assert_called_once_with(self.context, 'kubernetes/pause',
|
||||||
container.image,
|
'latest', 'ifnotpresent')
|
||||||
'latest', 'always')
|
|
||||||
|
|
||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
@mock.patch('zun.image.driver.pull_image')
|
@mock.patch('zun.image.driver.pull_image')
|
||||||
@ -165,9 +174,8 @@ class TestManager(base.TestCase):
|
|||||||
container)
|
container)
|
||||||
mock_save.assert_called_with()
|
mock_save.assert_called_with()
|
||||||
mock_fail.assert_called_with(container, 'Image Not Found')
|
mock_fail.assert_called_with(container, 'Image Not Found')
|
||||||
mock_pull.assert_called_once_with(self.context,
|
mock_pull.assert_called_once_with(self.context, 'kubernetes/pause',
|
||||||
container.image,
|
'latest', 'ifnotpresent')
|
||||||
'latest', 'always')
|
|
||||||
|
|
||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
@mock.patch('zun.image.driver.pull_image')
|
@mock.patch('zun.image.driver.pull_image')
|
||||||
@ -183,9 +191,8 @@ class TestManager(base.TestCase):
|
|||||||
container)
|
container)
|
||||||
mock_save.assert_called_with()
|
mock_save.assert_called_with()
|
||||||
mock_fail.assert_called_with(container, 'Docker Error occurred')
|
mock_fail.assert_called_with(container, 'Docker Error occurred')
|
||||||
mock_pull.assert_called_once_with(self.context,
|
mock_pull.assert_called_once_with(self.context, 'kubernetes/pause',
|
||||||
container.image,
|
'latest', 'ifnotpresent')
|
||||||
'latest', 'always')
|
|
||||||
|
|
||||||
@mock.patch.object(Container, 'save')
|
@mock.patch.object(Container, 'save')
|
||||||
@mock.patch('zun.image.driver.pull_image')
|
@mock.patch('zun.image.driver.pull_image')
|
||||||
@ -204,10 +211,9 @@ class TestManager(base.TestCase):
|
|||||||
container)
|
container)
|
||||||
mock_save.assert_called_with()
|
mock_save.assert_called_with()
|
||||||
mock_fail.assert_called_with(container, 'Docker Error occurred')
|
mock_fail.assert_called_with(container, 'Docker Error occurred')
|
||||||
mock_pull.assert_called_once_with(self.context,
|
mock_pull.assert_any_call(self.context, container.image, 'latest',
|
||||||
container.image,
|
'always')
|
||||||
'latest', 'always')
|
mock_create.assert_called_once_with(container, None,
|
||||||
mock_create.assert_called_once_with(container,
|
|
||||||
{'name': 'nginx', 'path': None})
|
{'name': 'nginx', 'path': None})
|
||||||
|
|
||||||
@mock.patch.object(manager.Manager, '_validate_container_state')
|
@mock.patch.object(manager.Manager, '_validate_container_state')
|
||||||
|
@ -68,3 +68,18 @@ class FakeDriver(driver.ContainerDriver):
|
|||||||
@check_container_id
|
@check_container_id
|
||||||
def kill(self, container, signal=None):
|
def kill(self, container, signal=None):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def create_sandbox(self, context, name, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def delete_sandbox(self, context, id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_sandbox_id(self, container):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_sandbox_id(self, container, id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_addresses(self, context, container):
|
||||||
|
pass
|
||||||
|
@ -44,6 +44,17 @@ def get_test_container(**kw):
|
|||||||
'ports': kw.get('ports', [80, 443]),
|
'ports': kw.get('ports', [80, 443]),
|
||||||
'hostname': kw.get('hostname', 'testhost'),
|
'hostname': kw.get('hostname', 'testhost'),
|
||||||
'labels': kw.get('labels', {'key1': 'val1', 'key2': 'val2'}),
|
'labels': kw.get('labels', {'key1': 'val1', 'key2': 'val2'}),
|
||||||
|
'meta': kw.get('meta', {'key1': 'val1', 'key2': 'val2'}),
|
||||||
|
'addresses': kw.get('addresses', {
|
||||||
|
'private': [
|
||||||
|
{
|
||||||
|
'OS-EXT-IPS-MAC:mac_addr': 'fa:16:3e:04:da:76',
|
||||||
|
'version': 4,
|
||||||
|
'addr': '10.0.0.12',
|
||||||
|
'OS-EXT-IPS:type': 'fixed'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
'image_pull_policy': kw.get('image_pull_policy', 'always'),
|
'image_pull_policy': kw.get('image_pull_policy', 'always'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user