Misc fixes and additional testing
* Add functional tests. This is currently limited to checking the charm deploys and relates to mandatory relations. * Add heat-engine container * Add management of all 3 containers to charm. Previously only the heat-api container was managed and this was incorrectly done on the assumption is was a wsgi app * Add management for auth_encryption_key * Add ops.testing unit tests Change-Id: I57b24a01ed473c96648f78095dc5e4e87d240e66
This commit is contained in:
parent
0c20244384
commit
6ddf3c01ff
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,9 +1,11 @@
|
||||
venv/
|
||||
build/
|
||||
*.charm
|
||||
.tox/
|
||||
*.swp
|
||||
|
||||
.coverage
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.idea
|
||||
.vscode/
|
||||
.tox
|
||||
.stestr/
|
||||
tempest.log
|
||||
|
5
.gitreview
Normal file
5
.gitreview
Normal file
@ -0,0 +1,5 @@
|
||||
[gerrit]
|
||||
host=review.opendev.org
|
||||
port=29418
|
||||
project=openstack/charm-heat-k8s.git
|
||||
defaultbranch=main
|
3
.stestr.conf
Normal file
3
.stestr.conf
Normal file
@ -0,0 +1,3 @@
|
||||
[DEFAULT]
|
||||
test_path=./tests/unit
|
||||
top_dir=./tests
|
11
.zuul.yaml
Normal file
11
.zuul.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
- project:
|
||||
templates:
|
||||
- openstack-python3-charm-yoga-jobs
|
||||
- openstack-cover-jobs
|
||||
- microk8s-func-test
|
||||
vars:
|
||||
charm_build_name: heat-k8s
|
||||
juju_channel: 3.2/stable
|
||||
juju_classic_mode: false
|
||||
microk8s_channel: 1.26-strict/stable
|
||||
microk8s_classic_mode: false
|
3
TODO.txt
Normal file
3
TODO.txt
Normal file
@ -0,0 +1,3 @@
|
||||
* Register CFN endpoint with keystone traefik
|
||||
* Tempest tests
|
||||
* Switch to Antelope rocks
|
@ -8,15 +8,22 @@ description: |
|
||||
version: 3
|
||||
bases:
|
||||
- name: ubuntu
|
||||
channel: 20.04/stable
|
||||
channel: 22.04/stable
|
||||
assumes:
|
||||
- k8s-api
|
||||
- juju >= 3.1
|
||||
tags:
|
||||
- openstack
|
||||
source: https://opendev.org/openstack/charm-heat-k8s
|
||||
issues: https://bugs.launchpad.net/charm-heat-k8s
|
||||
|
||||
containers:
|
||||
heat-api:
|
||||
resource: heat-api-image
|
||||
heat-api-cfn:
|
||||
resource: heat-api-cfn-image
|
||||
heat-engine:
|
||||
resource: heat-engine-image
|
||||
|
||||
resources:
|
||||
heat-api-image:
|
||||
@ -29,6 +36,10 @@ resources:
|
||||
description: OCI image for OpenStack Heat CFN
|
||||
# docker.io/kolla/ubuntu-binary-heat-api-cfn:yoga
|
||||
upstream-source: docker.io/kolla/ubuntu-binary-heat-api-cfn@sha256:6eec5915066b55696414022c86c42360cdbd4b8b1250e06b470fee25af394b66
|
||||
heat-engine-image:
|
||||
type: oci-image
|
||||
description: OCI image for OpenStack Heat Engine
|
||||
upstream-source: docker.io/kolla/ubuntu-binary-heat-engine@sha256:a54491f7e09eedeaa42c046cedc478f8ba78fc455a6ba285a52a5d0f8ae1df84
|
||||
|
||||
requires:
|
||||
database:
|
||||
|
10
osci.yaml
Normal file
10
osci.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
- project:
|
||||
templates:
|
||||
- charm-publish-jobs
|
||||
vars:
|
||||
needs_charm_build: true
|
||||
charm_build_name: heat-k8s
|
||||
build_type: charmcraft
|
||||
publish_charm: true
|
||||
charmcraft_channel: 2.0/stable
|
||||
publish_channel: 2023.1/edge
|
@ -1,3 +1,6 @@
|
||||
# Copyright 2022 Canonical Ltd.
|
||||
# See LICENSE file for licensing details.
|
||||
|
||||
# Testing tools configuration
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
@ -11,29 +14,26 @@ log_cli_level = "INFO"
|
||||
|
||||
# Formatting tools configuration
|
||||
[tool.black]
|
||||
line-length = 99
|
||||
target-version = ["py38"]
|
||||
line-length = 79
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
multi_line_output = 3
|
||||
force_grid_wrap = true
|
||||
|
||||
# Linting tools configuration
|
||||
[tool.ruff]
|
||||
line-length = 99
|
||||
select = ["E", "W", "F", "C", "N", "D", "I001"]
|
||||
extend-ignore = [
|
||||
"D203",
|
||||
"D204",
|
||||
"D213",
|
||||
"D215",
|
||||
"D400",
|
||||
"D404",
|
||||
"D406",
|
||||
"D407",
|
||||
"D408",
|
||||
"D409",
|
||||
"D413",
|
||||
]
|
||||
ignore = ["E501", "D107"]
|
||||
extend-exclude = ["__pycache__", "*.egg_info"]
|
||||
per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]}
|
||||
|
||||
[tool.ruff.mccabe]
|
||||
[tool.flake8]
|
||||
max-line-length = 79
|
||||
max-doc-length = 99
|
||||
max-complexity = 10
|
||||
exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"]
|
||||
select = ["E", "W", "F", "C", "N", "R", "D", "H"]
|
||||
# Ignore W503, E501 because using black creates errors with this
|
||||
# Ignore D107 Missing docstring in __init__
|
||||
ignore = ["W503", "E501", "D107", "E402"]
|
||||
per-file-ignores = []
|
||||
docstring-convention = "google"
|
||||
# Check for properly formatted copyright header in each file
|
||||
copyright-check = "True"
|
||||
copyright-author = "Canonical Ltd."
|
||||
copyright-regexp = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+%(author)s"
|
||||
|
13
rename.sh
Executable file
13
rename.sh
Executable file
@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
charm=$(grep "charm_build_name" osci.yaml | awk '{print $2}')
|
||||
echo "renaming ${charm}_*.charm to ${charm}.charm"
|
||||
echo -n "pwd: "
|
||||
pwd
|
||||
ls -al
|
||||
echo "Removing bad downloaded charm maybe?"
|
||||
if [[ -e "${charm}.charm" ]];
|
||||
then
|
||||
rm "${charm}.charm"
|
||||
fi
|
||||
echo "Renaming charm here."
|
||||
mv ${charm}_*.charm ${charm}.charm
|
212
src/charm.py
212
src/charm.py
@ -1,56 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 Canonical Ltd.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Heat Operator Charm.
|
||||
|
||||
This charm provide Heat services as part of an OpenStack deployment
|
||||
This charm provide heat services as part of an OpenStack deployment
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from ops.framework import StoredState
|
||||
from ops.main import main
|
||||
import secrets
|
||||
import string
|
||||
from typing import (
|
||||
List,
|
||||
)
|
||||
|
||||
import ops_sunbeam.charm as sunbeam_charm
|
||||
import ops_sunbeam.container_handlers as sunbeam_chandlers
|
||||
import ops_sunbeam.core as sunbeam_core
|
||||
from ops.framework import (
|
||||
StoredState,
|
||||
)
|
||||
from ops.main import (
|
||||
main,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HEAT_API_CONTAINER = "heat-api"
|
||||
HEAT_API_CNF_CONTAINER = "heat-api-cfn"
|
||||
HEAT_ENGINE_CONTAINER = "heat-engine"
|
||||
|
||||
|
||||
class HeatAPIPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
|
||||
"""Pebble handler for Heat API container."""
|
||||
|
||||
def get_layer(self):
|
||||
"""Heat API service.
|
||||
|
||||
:returns: pebble service layer configuration for heat api service
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
"summary": "heat api layer",
|
||||
"description": "pebble configuration for heat api service",
|
||||
"services": {
|
||||
"heat-api": {
|
||||
"override": "replace",
|
||||
"summary": "Heat API",
|
||||
"command": "heat-api",
|
||||
"startup": "enabled",
|
||||
"user": "heat",
|
||||
"group": "heat",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class HeatAPICFNPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
|
||||
"""Pebble handler for Heat API CNF container."""
|
||||
|
||||
def get_layer(self):
|
||||
"""Heat API CNF service.
|
||||
|
||||
:returns: pebble service layer configuration for API CNF service
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
"summary": "heat api cfn layer",
|
||||
"description": "pebble configuration for heat api cfn service",
|
||||
"services": {
|
||||
"heat-api-cfn": {
|
||||
"override": "replace",
|
||||
"summary": "Heat API CNF",
|
||||
"command": "heat-api-cfn",
|
||||
"startup": "enabled",
|
||||
"user": "heat",
|
||||
"group": "heat",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class HeatEnginePebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
|
||||
"""Pebble handler for Heat engine container."""
|
||||
|
||||
def get_layer(self):
|
||||
"""Heat Engine service.
|
||||
|
||||
:returns: pebble service layer configuration for heat engine service
|
||||
:rtype: dict
|
||||
"""
|
||||
return {
|
||||
"summary": "heat engine layer",
|
||||
"description": "pebble configuration for heat engine service",
|
||||
"services": {
|
||||
"heat-engine": {
|
||||
"override": "replace",
|
||||
"summary": "Heat Engine",
|
||||
"command": "heat-engine",
|
||||
"startup": "enabled",
|
||||
"user": "heat",
|
||||
"group": "heat",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
||||
"""Charm the service."""
|
||||
|
||||
_state = StoredState()
|
||||
service_name = "heat-api"
|
||||
wsgi_admin_script = '/usr/bin/heat-wsgi-api'
|
||||
wsgi_public_script = '/usr/bin/heat-wsgi-api'
|
||||
wsgi_admin_script = "/usr/bin/heat-wsgi-api"
|
||||
wsgi_public_script = "/usr/bin/heat-wsgi-api"
|
||||
heat_auth_encryption_key = "auth_encryption_key"
|
||||
|
||||
db_sync_cmds = [
|
||||
['heat-manage', 'db_sync']
|
||||
]
|
||||
db_sync_cmds = [["heat-manage", "db_sync"]]
|
||||
|
||||
mandatory_relations = {
|
||||
"database",
|
||||
"amqp",
|
||||
"identity-service",
|
||||
"ingress-public",
|
||||
}
|
||||
|
||||
def get_pebble_handlers(
|
||||
self,
|
||||
) -> List[sunbeam_chandlers.ServicePebbleHandler]:
|
||||
"""Pebble handlers for operator."""
|
||||
pebble_handlers = [
|
||||
HeatAPIPebbleHandler(
|
||||
self,
|
||||
HEAT_API_CONTAINER,
|
||||
"heat-api",
|
||||
self.default_container_configs(),
|
||||
self.template_dir,
|
||||
self.configure_charm,
|
||||
),
|
||||
HeatAPICFNPebbleHandler(
|
||||
self,
|
||||
HEAT_API_CNF_CONTAINER,
|
||||
"heat-api-cfn",
|
||||
self.default_container_configs(),
|
||||
self.template_dir,
|
||||
self.configure_charm,
|
||||
),
|
||||
HeatEnginePebbleHandler(
|
||||
self,
|
||||
HEAT_ENGINE_CONTAINER,
|
||||
"heat-engine",
|
||||
self.default_container_configs(),
|
||||
self.template_dir,
|
||||
self.configure_charm,
|
||||
),
|
||||
]
|
||||
return pebble_handlers
|
||||
|
||||
def get_heat_auth_encryption_key(self):
|
||||
"""Return the shared metadata secret."""
|
||||
return self.leader_get(self.heat_auth_encryption_key)
|
||||
|
||||
def set_heat_auth_encryption_key(self):
|
||||
"""Store the shared metadata secret."""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
key = "".join(secrets.choice(alphabet) for i in range(32))
|
||||
self.leader_set({self.heat_auth_encryption_key: key})
|
||||
|
||||
def configure_charm(self, event):
|
||||
"""Configure charm.
|
||||
|
||||
Ensure setting the auth key is first as services in container need it
|
||||
to start.
|
||||
"""
|
||||
if self.unit.is_leader():
|
||||
auth_key = self.get_heat_auth_encryption_key()
|
||||
if auth_key:
|
||||
logger.debug("Found auth key in leader DB")
|
||||
else:
|
||||
logger.debug("Creating auth key")
|
||||
self.set_heat_auth_encryption_key()
|
||||
super().configure_charm(event)
|
||||
|
||||
@property
|
||||
def service_conf(self) -> str:
|
||||
"""Service default configuration file."""
|
||||
return f"/etc/heat/heat.conf"
|
||||
return "/etc/heat/heat.conf"
|
||||
|
||||
@property
|
||||
def service_user(self) -> str:
|
||||
"""Service user file and directory ownership."""
|
||||
return 'heat'
|
||||
return "heat"
|
||||
|
||||
@property
|
||||
def service_group(self) -> str:
|
||||
"""Service group file and directory ownership."""
|
||||
return 'heat'
|
||||
return "heat"
|
||||
|
||||
@property
|
||||
def service_endpoints(self):
|
||||
"""Return heat service endpoints."""
|
||||
return [
|
||||
{
|
||||
'service_name': 'heat',
|
||||
'type': 'heat',
|
||||
'description': "OpenStack Heat API",
|
||||
'internal_url': f'{self.internal_url}',
|
||||
'public_url': f'{self.public_url}',
|
||||
'admin_url': f'{self.admin_url}'}]
|
||||
"service_name": "heat",
|
||||
"type": "heat",
|
||||
"description": "OpenStack Heat API",
|
||||
"internal_url": f"{self.internal_url}",
|
||||
"public_url": f"{self.public_url}",
|
||||
"admin_url": f"{self.admin_url}",
|
||||
}
|
||||
]
|
||||
|
||||
def get_healthcheck_layer(self) -> dict:
|
||||
"""Health check pebble layer.
|
||||
@ -68,18 +237,19 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
|
||||
}
|
||||
|
||||
def default_container_configs(self):
|
||||
"""Base container configs."""
|
||||
"""Return base container configs."""
|
||||
return [
|
||||
sunbeam_core.ContainerConfigFile(
|
||||
"/etc/heat/heat-api.conf", "heat", "heat"
|
||||
"/etc/heat/heat.conf", "root", "heat"
|
||||
),
|
||||
sunbeam_core.ContainerConfigFile(
|
||||
"/etc/heat/api-paste.ini", "heat", "heat"
|
||||
"/etc/heat/api-paste.ini", "root", "heat"
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def default_public_ingress_port(self):
|
||||
"""Port for Heat AI service."""
|
||||
return 8004
|
||||
|
||||
|
||||
|
@ -1,10 +1,9 @@
|
||||
# heat-api composite
|
||||
[composite:heat-api]
|
||||
paste.composite_factory = heat.api:root_app_factory
|
||||
/: api
|
||||
/healthcheck: healthcheck
|
||||
|
||||
# heat-api composite for standalone heat
|
||||
# heat-api pipeline
|
||||
[pipeline:heat-api]
|
||||
pipeline = healthcheck cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authtoken context osprofiler apiv1app
|
||||
|
||||
# heat-api pipeline for standalone heat
|
||||
# ie. uses alternative auth backend that authenticates users against keystone
|
||||
# using username and password instead of validating token (which requires
|
||||
# an admin/service token).
|
||||
@ -12,54 +11,32 @@ paste.composite_factory = heat.api:root_app_factory
|
||||
# [paste_deploy]
|
||||
# flavor = standalone
|
||||
#
|
||||
[composite:heat-api-standalone]
|
||||
paste.composite_factory = heat.api:root_app_factory
|
||||
/: api
|
||||
/healthcheck: healthcheck
|
||||
[pipeline:heat-api-standalone]
|
||||
pipeline = healthcheck cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authpassword context apiv1app
|
||||
|
||||
# heat-api composite for custom cloud backends
|
||||
# heat-api pipeline for custom cloud backends
|
||||
# i.e. in heat.conf:
|
||||
# [paste_deploy]
|
||||
# flavor = custombackend
|
||||
#
|
||||
[composite:heat-api-custombackend]
|
||||
paste.composite_factory = heat.api:root_app_factory
|
||||
/: api
|
||||
/healthcheck: healthcheck
|
||||
[pipeline:heat-api-custombackend]
|
||||
pipeline = healthcheck cors request_id context faultwrap versionnegotiation custombackendauth apiv1app
|
||||
|
||||
# To enable, in heat.conf:
|
||||
# [paste_deploy]
|
||||
# flavor = noauth
|
||||
#
|
||||
[composite:heat-api-noauth]
|
||||
paste.composite_factory = heat.api:root_app_factory
|
||||
/: api
|
||||
/healthcheck: healthcheck
|
||||
[pipeline:heat-api-noauth]
|
||||
pipeline = healthcheck cors request_id faultwrap noauth context http_proxy_to_wsgi versionnegotiation apiv1app
|
||||
|
||||
# heat-api-cfn composite
|
||||
[composite:heat-api-cfn]
|
||||
paste.composite_factory = heat.api:root_app_factory
|
||||
/: api-cfn
|
||||
/healthcheck: healthcheck
|
||||
# heat-api-cfn pipeline
|
||||
[pipeline:heat-api-cfn]
|
||||
pipeline = healthcheck cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken authtoken context osprofiler apicfnv1app
|
||||
|
||||
# heat-api-cfn composite for standalone heat
|
||||
# heat-api-cfn pipeline for standalone heat
|
||||
# relies exclusively on authenticating with ec2 signed requests
|
||||
[composite:heat-api-cfn-standalone]
|
||||
paste.composite_factory = heat.api:root_app_factory
|
||||
/: api-cfn
|
||||
/healthcheck: healthcheck
|
||||
|
||||
[composite:api]
|
||||
paste.composite_factory = heat.api:pipeline_factory
|
||||
default = cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authtoken context osprofiler apiv1app
|
||||
standalone = cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authpassword context apiv1app
|
||||
custombackend = cors request_id context faultwrap versionnegotiation custombackendauth apiv1app
|
||||
noauth = cors request_id faultwrap noauth context http_proxy_to_wsgi versionnegotiation apiv1app
|
||||
|
||||
[composite:api-cfn]
|
||||
paste.composite_factory = heat.api:pipeline_factory
|
||||
default = cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken authtoken context osprofiler apicfnv1app
|
||||
standalone = cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken context apicfnv1app
|
||||
[pipeline:heat-api-cfn-standalone]
|
||||
pipeline = healthcheck cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken context apicfnv1app
|
||||
|
||||
[app:apiv1app]
|
||||
paste.app_factory = heat.common.wsgi:app_factory
|
||||
@ -69,9 +46,6 @@ heat.app_factory = heat.api.openstack.v1:API
|
||||
paste.app_factory = heat.common.wsgi:app_factory
|
||||
heat.app_factory = heat.api.cfn.v1:API
|
||||
|
||||
[app:healthcheck]
|
||||
paste.app_factory = oslo_middleware:Healthcheck.app_factory
|
||||
|
||||
[filter:versionnegotiation]
|
||||
paste.filter_factory = heat.common.wsgi:filter_factory
|
||||
heat.filter_factory = heat.api.openstack:version_negotiation_filter
|
||||
@ -126,3 +100,6 @@ paste.filter_factory = oslo_middleware.request_id:RequestId.factory
|
||||
|
||||
[filter:osprofiler]
|
||||
paste.filter_factory = osprofiler.web:WsgiMiddleware.factory
|
||||
|
||||
[filter:healthcheck]
|
||||
paste.filter_factory = oslo_middleware:Healthcheck.factory
|
||||
|
@ -7,7 +7,7 @@ plugin_dirs = /usr/lib64/heat,/usr/lib/heat
|
||||
environment_dir=/etc/heat/environment.d
|
||||
deferred_auth_method=password
|
||||
host=heat
|
||||
auth_encryption_key={{ encryption_key }}
|
||||
auth_encryption_key={{ peers.auth_encryption_key }}
|
||||
|
||||
transport_url = {{ amqp.transport_url }}
|
||||
|
||||
|
@ -1,17 +1,15 @@
|
||||
# This file is managed centrally. If you find the need to modify this as a
|
||||
# one-off, please don't. Intead, consult #openstack-charms and ask about
|
||||
# requirements management in charms via bot-control. Thank you.
|
||||
charm-tools>=2.4.4
|
||||
coverage>=3.6
|
||||
mock>=1.2
|
||||
flake8>=2.2.4,<=2.4.1
|
||||
pyflakes==2.1.1
|
||||
stestr>=2.2.0
|
||||
requests>=2.18.4
|
||||
psutil
|
||||
# oslo.i18n dropped py35 support
|
||||
oslo.i18n<4.0.0
|
||||
git+https://github.com/openstack-charmers/zaza.git#egg=zaza
|
||||
# This file is managed centrally by release-tools and should not be modified
|
||||
# within individual charm repos. See the 'global' dir contents for available
|
||||
# choices of *requirements.txt files for OpenStack Charms:
|
||||
# https://github.com/openstack-charmers/release-tools
|
||||
#
|
||||
|
||||
pwgen
|
||||
coverage
|
||||
mock
|
||||
flake8
|
||||
stestr
|
||||
git+https://github.com/openstack-charmers/zaza.git@libjuju-3.1#egg=zaza
|
||||
git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack
|
||||
pytz # workaround for 14.04 pip/tox
|
||||
pyudev # for ceph-* charm unit tests (not mocked?)
|
||||
git+https://opendev.org/openstack/tempest.git#egg=tempest
|
||||
ops
|
||||
|
71
tests/bundles/smoke.yaml
Normal file
71
tests/bundles/smoke.yaml
Normal file
@ -0,0 +1,71 @@
|
||||
bundle: kubernetes
|
||||
applications:
|
||||
|
||||
mysql:
|
||||
charm: ch:mysql-k8s
|
||||
channel: 8.0/stable
|
||||
scale: 1
|
||||
trust: false
|
||||
|
||||
# Currently traefik is required for networking things.
|
||||
# If this isn't present, the units will hang at "installing agent".
|
||||
traefik:
|
||||
charm: ch:traefik-k8s
|
||||
channel: 1.0/stable
|
||||
scale: 1
|
||||
trust: true
|
||||
|
||||
traefik-public:
|
||||
charm: ch:traefik-k8s
|
||||
channel: 1.0/stable
|
||||
scale: 1
|
||||
trust: true
|
||||
options:
|
||||
kubernetes-service-annotations: metallb.universe.tf/address-pool=public
|
||||
|
||||
rabbitmq:
|
||||
charm: ch:rabbitmq-k8s
|
||||
channel: 3.9/edge
|
||||
scale: 1
|
||||
trust: true
|
||||
|
||||
keystone:
|
||||
charm: ch:keystone-k8s
|
||||
channel: 2023.1/edge
|
||||
scale: 1
|
||||
trust: true
|
||||
options:
|
||||
admin-role: admin
|
||||
storage:
|
||||
fernet-keys: 5M
|
||||
credential-keys: 5M
|
||||
|
||||
heat:
|
||||
charm: ../../heat-k8s.charm
|
||||
scale: 1
|
||||
trust: true
|
||||
resources:
|
||||
heat-api-image: docker.io/kolla/ubuntu-binary-heat-api@sha256:ca80d57606525facb404d8b0374701c02609c2ade5cb7e28ba132e666dd85949
|
||||
heat-api-cfn-image: docker.io/kolla/ubuntu-binary-heat-api-cfn@sha256:6eec5915066b55696414022c86c42360cdbd4b8b1250e06b470fee25af394b66
|
||||
heat-engine-image: docker.io/kolla/ubuntu-binary-heat-engine@sha256:a54491f7e09eedeaa42c046cedc478f8ba78fc455a6ba285a52a5d0f8ae1df84
|
||||
|
||||
relations:
|
||||
- - traefik:ingress
|
||||
- keystone:ingress-internal
|
||||
- - traefik-public:ingress
|
||||
- keystone:ingress-public
|
||||
|
||||
- - mysql:database
|
||||
- keystone:database
|
||||
|
||||
- - mysql:database
|
||||
- heat:database
|
||||
- - keystone:identity-service
|
||||
- heat:identity-service
|
||||
- - traefik:ingress
|
||||
- heat:ingress-internal
|
||||
- - traefik-public:ingress
|
||||
- heat:ingress-public
|
||||
- - rabbitmq:amqp
|
||||
- heat:amqp
|
||||
|
1
tests/config.yaml
Symbolic link
1
tests/config.yaml
Symbolic link
@ -0,0 +1 @@
|
||||
../config.yaml
|
@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2023 Felipe Reyes <felipe.reyes@canonical.com>
|
||||
# See LICENSE file for licensing details.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pytest_operator.plugin import OpsTest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
METADATA = yaml.safe_load(Path("./metadata.yaml").read_text())
|
||||
APP_NAME = METADATA["name"]
|
||||
|
||||
|
||||
@pytest.mark.abort_on_fail
|
||||
async def test_build_and_deploy(ops_test: OpsTest):
|
||||
"""Build the charm-under-test and deploy it together with related charms.
|
||||
|
||||
Assert on the unit status before any relations/configurations take place.
|
||||
"""
|
||||
# Build and deploy charm from local source folder
|
||||
charm = await ops_test.build_charm(".")
|
||||
resources = {"httpbin-image": METADATA["resources"]["httpbin-image"]["upstream-source"]}
|
||||
|
||||
# Deploy the charm and wait for active/idle status
|
||||
await asyncio.gather(
|
||||
ops_test.model.deploy(charm, resources=resources, application_name=APP_NAME),
|
||||
ops_test.model.wait_for_idle(
|
||||
apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000
|
||||
),
|
||||
)
|
35
tests/tests.yaml
Normal file
35
tests/tests.yaml
Normal file
@ -0,0 +1,35 @@
|
||||
gate_bundles:
|
||||
- smoke
|
||||
smoke_bundles:
|
||||
- smoke
|
||||
# There is no storage provider at the moment so cannot run tests.
|
||||
configure:
|
||||
- zaza.charm_tests.noop.setup.basic_setup
|
||||
tests:
|
||||
- zaza.charm_tests.noop.tests.NoopTest
|
||||
tests_options:
|
||||
trust:
|
||||
- smoke
|
||||
ignore_hard_deploy_errors:
|
||||
- smoke
|
||||
|
||||
target_deploy_status:
|
||||
traefik:
|
||||
workload-status: active
|
||||
workload-status-message-regex: '^$'
|
||||
traefik-public:
|
||||
workload-status: active
|
||||
workload-status-message-regex: '^$'
|
||||
rabbitmq:
|
||||
workload-status: active
|
||||
workload-status-message-regex: '^$'
|
||||
keystone:
|
||||
workload-status: active
|
||||
workload-status-message-regex: '^$'
|
||||
mysql:
|
||||
workload-status: active
|
||||
workload-status-message-regex: '^.*$'
|
||||
heat:
|
||||
workload-status: active
|
||||
workload-status-message-regex: '^.*$'
|
||||
|
15
tests/unit/__init__.py
Normal file
15
tests/unit/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
# Copyright 2022 Canonical Ltd.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Unit tests for Heat operator."""
|
@ -1,74 +0,0 @@
|
||||
# Copyright 2023 Felipe Reyes <felipe.reyes@canonical.com>
|
||||
# See LICENSE file for licensing details.
|
||||
#
|
||||
# Learn more about testing at: https://juju.is/docs/sdk/testing
|
||||
|
||||
import unittest
|
||||
|
||||
import ops.testing
|
||||
from charm import HeatK8SCharm
|
||||
from ops.model import ActiveStatus, BlockedStatus, WaitingStatus
|
||||
from ops.testing import Harness
|
||||
|
||||
|
||||
class TestCharm(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Enable more accurate simulation of container networking.
|
||||
# For more information, see https://juju.is/docs/sdk/testing#heading--simulate-can-connect
|
||||
ops.testing.SIMULATE_CAN_CONNECT = True
|
||||
self.addCleanup(setattr, ops.testing, "SIMULATE_CAN_CONNECT", False)
|
||||
|
||||
self.harness = Harness(HeatK8SCharm)
|
||||
self.addCleanup(self.harness.cleanup)
|
||||
self.harness.begin()
|
||||
|
||||
def test_httpbin_pebble_ready(self):
|
||||
# Expected plan after Pebble ready with default config
|
||||
expected_plan = {
|
||||
"services": {
|
||||
"httpbin": {
|
||||
"override": "replace",
|
||||
"summary": "httpbin",
|
||||
"command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent",
|
||||
"startup": "enabled",
|
||||
"environment": {"GUNICORN_CMD_ARGS": "--log-level info"},
|
||||
}
|
||||
},
|
||||
}
|
||||
# Simulate the container coming up and emission of pebble-ready event
|
||||
self.harness.container_pebble_ready("httpbin")
|
||||
# Get the plan now we've run PebbleReady
|
||||
updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict()
|
||||
# Check we've got the plan we expected
|
||||
self.assertEqual(expected_plan, updated_plan)
|
||||
# Check the service was started
|
||||
service = self.harness.model.unit.get_container("httpbin").get_service("httpbin")
|
||||
self.assertTrue(service.is_running())
|
||||
# Ensure we set an ActiveStatus with no message
|
||||
self.assertEqual(self.harness.model.unit.status, ActiveStatus())
|
||||
|
||||
def test_config_changed_valid_can_connect(self):
|
||||
# Ensure the simulated Pebble API is reachable
|
||||
self.harness.set_can_connect("httpbin", True)
|
||||
# Trigger a config-changed event with an updated value
|
||||
self.harness.update_config({"log-level": "debug"})
|
||||
# Get the plan now we've run PebbleReady
|
||||
updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict()
|
||||
updated_env = updated_plan["services"]["httpbin"]["environment"]
|
||||
# Check the config change was effective
|
||||
self.assertEqual(updated_env, {"GUNICORN_CMD_ARGS": "--log-level debug"})
|
||||
self.assertEqual(self.harness.model.unit.status, ActiveStatus())
|
||||
|
||||
def test_config_changed_valid_cannot_connect(self):
|
||||
# Trigger a config-changed event with an updated value
|
||||
self.harness.update_config({"log-level": "debug"})
|
||||
# Check the charm is in WaitingStatus
|
||||
self.assertIsInstance(self.harness.model.unit.status, WaitingStatus)
|
||||
|
||||
def test_config_changed_invalid(self):
|
||||
# Ensure the simulated Pebble API is reachable
|
||||
self.harness.set_can_connect("httpbin", True)
|
||||
# Trigger a config-changed event with an updated value
|
||||
self.harness.update_config({"log-level": "foobar"})
|
||||
# Check the charm is in BlockedStatus
|
||||
self.assertIsInstance(self.harness.model.unit.status, BlockedStatus)
|
94
tests/unit/test_heat_charm.py
Normal file
94
tests/unit/test_heat_charm.py
Normal file
@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright 2021 Canonical Ltd.
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Unit tests for Heat operator."""
|
||||
|
||||
import ops_sunbeam.test_utils as test_utils
|
||||
|
||||
import charm
|
||||
|
||||
|
||||
class _HeatTestOperatorCharm(charm.HeatOperatorCharm):
|
||||
"""Test Operator Charm for Heat Operator."""
|
||||
|
||||
def __init__(self, framework):
|
||||
self.seen_events = []
|
||||
super().__init__(framework)
|
||||
|
||||
def _log_event(self, event):
|
||||
self.seen_events.append(type(event).__name__)
|
||||
|
||||
def configure_charm(self, event):
|
||||
super().configure_charm(event)
|
||||
self._log_event(event)
|
||||
|
||||
@property
|
||||
def public_ingress_address(self):
|
||||
return "heat.juju"
|
||||
|
||||
|
||||
class TestHeatOperatorCharm(test_utils.CharmTestCase):
|
||||
"""Unit tests for Heat Operator."""
|
||||
|
||||
PATCHES = []
|
||||
|
||||
def setUp(self):
|
||||
"""Run setup for unit tests."""
|
||||
super().setUp(charm, self.PATCHES)
|
||||
self.harness = test_utils.get_harness(
|
||||
_HeatTestOperatorCharm, container_calls=self.container_calls
|
||||
)
|
||||
|
||||
# clean up events that were dynamically defined,
|
||||
# otherwise we get issues because they'll be redefined,
|
||||
# which is not allowed.
|
||||
from charms.data_platform_libs.v0.database_requires import (
|
||||
DatabaseEvents,
|
||||
)
|
||||
|
||||
for attr in (
|
||||
"database_database_created",
|
||||
"database_endpoints_changed",
|
||||
"database_read_only_endpoints_changed",
|
||||
):
|
||||
try:
|
||||
delattr(DatabaseEvents, attr)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
self.addCleanup(self.harness.cleanup)
|
||||
self.harness.begin()
|
||||
|
||||
def test_pebble_ready_handler(self):
|
||||
"""Test pebble ready handler."""
|
||||
self.assertEqual(self.harness.charm.seen_events, [])
|
||||
test_utils.set_all_pebbles_ready(self.harness)
|
||||
self.assertEqual(len(self.harness.charm.seen_events), 3)
|
||||
|
||||
def test_all_relations(self):
|
||||
"""Test all integrations for operator."""
|
||||
self.harness.set_leader()
|
||||
test_utils.set_all_pebbles_ready(self.harness)
|
||||
# this adds all the default/common relations
|
||||
test_utils.add_all_relations(self.harness)
|
||||
test_utils.add_complete_ingress_relation(self.harness)
|
||||
|
||||
setup_cmds = [["heat-manage", "db_sync"]]
|
||||
for cmd in setup_cmds:
|
||||
self.assertIn(cmd, self.container_calls.execute["heat-api"])
|
||||
config_files = ["/etc/heat/heat.conf", "/etc/heat/api-paste.ini"]
|
||||
for f in config_files:
|
||||
self.check_file("heat-api", f)
|
217
tox.ini
217
tox.ini
@ -1,77 +1,84 @@
|
||||
# Operator charm (with zaza): tox.ini
|
||||
# Source charm: ./tox.ini
|
||||
# This file is managed centrally by release-tools and should not be modified
|
||||
# within individual charm repos. See the 'global' dir contents for available
|
||||
# choices of tox.ini for OpenStack Charms:
|
||||
# https://github.com/openstack-charmers/release-tools
|
||||
|
||||
[tox]
|
||||
envlist = pep8,py3
|
||||
skipsdist = True
|
||||
# NOTE: Avoid build/test env pollution by not enabling sitepackages.
|
||||
envlist = pep8,py3
|
||||
sitepackages = False
|
||||
# NOTE: Avoid false positives by not skipping missing interpreters.
|
||||
skip_missing_interpreters = False
|
||||
# NOTES:
|
||||
# * We avoid the new dependency resolver by pinning pip < 20.3, see
|
||||
# https://github.com/pypa/pip/issues/9187
|
||||
# * Pinning dependencies requires tox >= 3.2.0, see
|
||||
# https://tox.readthedocs.io/en/latest/config.html#conf-requires
|
||||
# * It is also necessary to pin virtualenv as a newer virtualenv would still
|
||||
# lead to fetching the latest pip in the func* tox targets, see
|
||||
# https://stackoverflow.com/a/38133283
|
||||
requires = pip < 20.3
|
||||
virtualenv < 20.0
|
||||
# NOTE: https://wiki.canonical.com/engineering/OpenStack/InstallLatestToxOnOsci
|
||||
minversion = 3.2.0
|
||||
minversion = 3.18.0
|
||||
|
||||
[vars]
|
||||
src_path = {toxinidir}/src/
|
||||
tst_path = {toxinidir}/tests/
|
||||
lib_path = {toxinidir}/lib/
|
||||
pyproject_toml = {toxinidir}/pyproject.toml
|
||||
all_path = {[vars]src_path} {[vars]tst_path}
|
||||
|
||||
[testenv]
|
||||
setenv = VIRTUAL_ENV={envdir}
|
||||
PYTHONHASHSEED=0
|
||||
CHARM_DIR={envdir}
|
||||
basepython = python3
|
||||
setenv =
|
||||
PYTHONPATH = {toxinidir}:{[vars]lib_path}:{[vars]src_path}
|
||||
passenv =
|
||||
HOME
|
||||
PYTHONPATH
|
||||
install_command =
|
||||
pip install {opts} {packages}
|
||||
commands = stestr run --slowest {posargs}
|
||||
whitelist_externals =
|
||||
git
|
||||
add-to-archive.py
|
||||
bash
|
||||
charmcraft
|
||||
passenv = HOME TERM CS_* OS_* TEST_*
|
||||
deps = -r{toxinidir}/test-requirements.txt
|
||||
allowlist_externals =
|
||||
git
|
||||
charmcraft
|
||||
{toxinidir}/fetch-libs.sh
|
||||
{toxinidir}/rename.sh
|
||||
deps =
|
||||
-r{toxinidir}/test-requirements.txt
|
||||
|
||||
[testenv:py35]
|
||||
basepython = python3.5
|
||||
# python3.5 is irrelevant on a focal+ charm.
|
||||
commands = /bin/true
|
||||
[testenv:fmt]
|
||||
description = Apply coding style standards to code
|
||||
deps =
|
||||
black
|
||||
isort
|
||||
commands =
|
||||
isort {[vars]all_path} --skip-glob {[vars]lib_path} --skip {toxinidir}/.tox
|
||||
black --config {[vars]pyproject_toml} {[vars]all_path} --exclude {[vars]lib_path}
|
||||
|
||||
[testenv:py36]
|
||||
basepython = python3.6
|
||||
deps = -r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/test-requirements.txt
|
||||
[testenv:build]
|
||||
basepython = python3
|
||||
deps =
|
||||
commands =
|
||||
charmcraft -v pack
|
||||
{toxinidir}/rename.sh
|
||||
|
||||
[testenv:py37]
|
||||
basepython = python3.7
|
||||
deps = -r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/test-requirements.txt
|
||||
|
||||
[testenv:py38]
|
||||
basepython = python3.8
|
||||
deps = -r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/test-requirements.txt
|
||||
[testenv:fetch]
|
||||
basepython = python3
|
||||
deps =
|
||||
commands =
|
||||
{toxinidir}/fetch-libs.sh
|
||||
|
||||
[testenv:py3]
|
||||
basepython = python3
|
||||
deps = -r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/test-requirements.txt
|
||||
deps =
|
||||
{[testenv]deps}
|
||||
-r{toxinidir}/requirements.txt
|
||||
|
||||
[testenv:pep8]
|
||||
basepython = python3
|
||||
deps = -r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/test-requirements.txt
|
||||
commands = flake8 {posargs} src unit_tests tests
|
||||
[testenv:py38]
|
||||
basepython = python3.8
|
||||
deps = {[testenv:py3]deps}
|
||||
|
||||
[testenv:py39]
|
||||
basepython = python3.9
|
||||
deps = {[testenv:py3]deps}
|
||||
|
||||
[testenv:py310]
|
||||
basepython = python3.10
|
||||
deps = {[testenv:py3]deps}
|
||||
|
||||
[testenv:cover]
|
||||
# Technique based heavily upon
|
||||
# https://github.com/openstack/nova/blob/master/tox.ini
|
||||
basepython = python3
|
||||
deps = -r{toxinidir}/requirements.txt
|
||||
-r{toxinidir}/test-requirements.txt
|
||||
deps = {[testenv:py3]deps}
|
||||
setenv =
|
||||
{[testenv]setenv}
|
||||
PYTHON=coverage run
|
||||
@ -83,6 +90,66 @@ commands =
|
||||
coverage xml -o cover/coverage.xml
|
||||
coverage report
|
||||
|
||||
[testenv:pep8]
|
||||
description = Alias for lint
|
||||
deps = {[testenv:lint]deps}
|
||||
commands = {[testenv:lint]commands}
|
||||
|
||||
[testenv:lint]
|
||||
description = Check code against coding style standards
|
||||
deps =
|
||||
black
|
||||
flake8<6 # Pin version until https://github.com/savoirfairelinux/flake8-copyright/issues/19 is merged
|
||||
flake8-docstrings
|
||||
flake8-copyright
|
||||
flake8-builtins
|
||||
pyproject-flake8
|
||||
pep8-naming
|
||||
isort
|
||||
codespell
|
||||
commands =
|
||||
codespell {[vars]all_path}
|
||||
# pflake8 wrapper supports config from pyproject.toml
|
||||
pflake8 --exclude {[vars]lib_path} --config {toxinidir}/pyproject.toml {[vars]all_path}
|
||||
isort --check-only --diff {[vars]all_path} --skip-glob {[vars]lib_path}
|
||||
black --config {[vars]pyproject_toml} --check --diff {[vars]all_path} --exclude {[vars]lib_path}
|
||||
|
||||
[testenv:func-noop]
|
||||
basepython = python3
|
||||
deps =
|
||||
git+https://github.com/openstack-charmers/zaza.git@libjuju-3.1#egg=zaza
|
||||
git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack
|
||||
git+https://opendev.org/openstack/tempest.git#egg=tempest
|
||||
commands =
|
||||
functest-run-suite --help
|
||||
|
||||
[testenv:func]
|
||||
basepython = python3
|
||||
deps = {[testenv:func-noop]deps}
|
||||
commands =
|
||||
functest-run-suite --keep-model
|
||||
|
||||
[testenv:func-smoke]
|
||||
basepython = python3
|
||||
deps = {[testenv:func-noop]deps}
|
||||
setenv =
|
||||
TEST_MODEL_SETTINGS = automatically-retry-hooks=true
|
||||
TEST_MAX_RESOLVE_COUNT = 5
|
||||
commands =
|
||||
functest-run-suite --keep-model --smoke
|
||||
|
||||
[testenv:func-dev]
|
||||
basepython = python3
|
||||
deps = {[testenv:func-noop]deps}
|
||||
commands =
|
||||
functest-run-suite --keep-model --dev
|
||||
|
||||
[testenv:func-target]
|
||||
basepython = python3
|
||||
deps = {[testenv:func-noop]deps}
|
||||
commands =
|
||||
functest-run-suite --keep-model --bundle {posargs}
|
||||
|
||||
[coverage:run]
|
||||
branch = True
|
||||
concurrency = multiprocessing
|
||||
@ -91,44 +158,8 @@ source =
|
||||
.
|
||||
omit =
|
||||
.tox/*
|
||||
*/charmhelpers/*
|
||||
unit_tests/*
|
||||
|
||||
[testenv:venv]
|
||||
basepython = python3
|
||||
commands = {posargs}
|
||||
|
||||
[testenv:build]
|
||||
basepython = python3
|
||||
deps = -r{toxinidir}/build-requirements.txt
|
||||
commands =
|
||||
charmcraft build
|
||||
|
||||
[testenv:func-noop]
|
||||
basepython = python3
|
||||
commands =
|
||||
functest-run-suite --help
|
||||
|
||||
[testenv:func]
|
||||
basepython = python3
|
||||
commands =
|
||||
functest-run-suite --keep-model
|
||||
|
||||
[testenv:func-smoke]
|
||||
basepython = python3
|
||||
commands =
|
||||
functest-run-suite --keep-model --smoke
|
||||
|
||||
[testenv:func-dev]
|
||||
basepython = python3
|
||||
commands =
|
||||
functest-run-suite --keep-model --dev
|
||||
|
||||
[testenv:func-target]
|
||||
basepython = python3
|
||||
commands =
|
||||
functest-run-suite --keep-model --bundle {posargs}
|
||||
tests/*
|
||||
src/templates/*
|
||||
|
||||
[flake8]
|
||||
# Ignore E902 because the unit_tests directory is missing in the built charm.
|
||||
ignore = E402,E226,E902
|
||||
ignore=E226,W504
|
||||
|
Loading…
Reference in New Issue
Block a user