forked from cerc-io/stack-orchestrator
Compare commits
12 Commits
main
...
telackey/w
Author | SHA1 | Date | |
---|---|---|---|
82785b6823 | |||
583774e32a | |||
f91329eef0 | |||
7f0b82b9a9 | |||
64b395f15a | |||
240db3ca4c | |||
d09fbc3ac4 | |||
3c7ff76a83 | |||
53b29ff69b | |||
4ec0e38b77 | |||
14ee4da1fc | |||
e81c95a920 |
@ -110,13 +110,32 @@ class ClusterInfo:
|
|||||||
http_proxy_info = http_proxy_info_list[0]
|
http_proxy_info = http_proxy_info_list[0]
|
||||||
if opts.o.debug:
|
if opts.o.debug:
|
||||||
print(f"http-proxy: {http_proxy_info}")
|
print(f"http-proxy: {http_proxy_info}")
|
||||||
# TODO: good enough parsing for webapp deployment for now
|
|
||||||
host_name = http_proxy_info["host-name"]
|
host_name = http_proxy_info["host-name"]
|
||||||
|
|
||||||
|
tls = None
|
||||||
|
tls_issuer = None
|
||||||
|
|
||||||
|
if use_tls:
|
||||||
|
tls_info = http_proxy_info.get("tls", {})
|
||||||
|
tls_hosts = tls_info.get("hosts", [host_name])
|
||||||
|
tls_issuer = tls_info.get("issuer", "letsencrypt-prod")
|
||||||
|
tls_secret_name = f"{self.app_name}-tls"
|
||||||
|
if "secret" in tls_info:
|
||||||
|
# If an existing secret is specified, unset the issuer so that we don't try to re-request it.
|
||||||
|
tls_secret_name = tls_info["secret"]
|
||||||
|
tls_issuer = None
|
||||||
|
|
||||||
|
if opts.o.debug:
|
||||||
|
print(f"TLS hosts/secret: {tls_hosts}/{tls_secret_name}")
|
||||||
|
|
||||||
|
tls = [client.V1IngressTLS(
|
||||||
|
hosts=tls_hosts,
|
||||||
|
secret_name=tls_secret_name
|
||||||
|
)]
|
||||||
|
|
||||||
|
# TODO: good enough parsing for webapp deployment for now
|
||||||
rules = []
|
rules = []
|
||||||
tls = [client.V1IngressTLS(
|
|
||||||
hosts=[host_name],
|
|
||||||
secret_name=f"{self.app_name}-tls"
|
|
||||||
)] if use_tls else None
|
|
||||||
paths = []
|
paths = []
|
||||||
for route in http_proxy_info["routes"]:
|
for route in http_proxy_info["routes"]:
|
||||||
path = route["path"]
|
path = route["path"]
|
||||||
@ -147,13 +166,15 @@ class ClusterInfo:
|
|||||||
tls=tls,
|
tls=tls,
|
||||||
rules=rules
|
rules=rules
|
||||||
)
|
)
|
||||||
|
annotations = {
|
||||||
|
"kubernetes.io/ingress.class": "nginx",
|
||||||
|
}
|
||||||
|
if tls_issuer:
|
||||||
|
annotations["cert-manager.io/cluster-issuer"] = tls_issuer
|
||||||
ingress = client.V1Ingress(
|
ingress = client.V1Ingress(
|
||||||
metadata=client.V1ObjectMeta(
|
metadata=client.V1ObjectMeta(
|
||||||
name=f"{self.app_name}-ingress",
|
name=f"{self.app_name}-ingress",
|
||||||
annotations={
|
annotations=annotations
|
||||||
"kubernetes.io/ingress.class": "nginx",
|
|
||||||
"cert-manager.io/cluster-issuer": "letsencrypt-prod"
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
spec=spec
|
spec=spec
|
||||||
)
|
)
|
||||||
|
@ -350,9 +350,10 @@ class K8sDeployer(Deployer):
|
|||||||
name=ingress.spec.tls[0].secret_name
|
name=ingress.spec.tls[0].secret_name
|
||||||
)
|
)
|
||||||
|
|
||||||
hostname = ingress.spec.tls[0].hosts[0]
|
hostname = ingress.spec.rules[0].host
|
||||||
ip = ingress.status.load_balancer.ingress[0].ip
|
ip = ingress.status.load_balancer.ingress[0].ip
|
||||||
tls = "notBefore: %s, notAfter: %s" % (cert["status"]["notBefore"], cert["status"]["notAfter"])
|
tls = "notBefore: %s; notAfter: %s; names: %s" % (
|
||||||
|
cert["status"]["notBefore"], cert["status"]["notAfter"], ", ".join(ingress.spec.tls[0].hosts))
|
||||||
except: # noqa: E722
|
except: # noqa: E722
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -27,7 +27,9 @@ class ResourceLimits:
|
|||||||
memory: int = None
|
memory: int = None
|
||||||
storage: int = None
|
storage: int = None
|
||||||
|
|
||||||
def __init__(self, obj={}):
|
def __init__(self, obj=None):
|
||||||
|
if obj is None:
|
||||||
|
obj = {}
|
||||||
if "cpus" in obj:
|
if "cpus" in obj:
|
||||||
self.cpus = float(obj["cpus"])
|
self.cpus = float(obj["cpus"])
|
||||||
if "memory" in obj:
|
if "memory" in obj:
|
||||||
@ -50,7 +52,9 @@ class Resources:
|
|||||||
limits: ResourceLimits = None
|
limits: ResourceLimits = None
|
||||||
reservations: ResourceLimits = None
|
reservations: ResourceLimits = None
|
||||||
|
|
||||||
def __init__(self, obj={}):
|
def __init__(self, obj=None):
|
||||||
|
if obj is None:
|
||||||
|
obj = {}
|
||||||
if "reservations" in obj:
|
if "reservations" in obj:
|
||||||
self.reservations = ResourceLimits(obj["reservations"])
|
self.reservations = ResourceLimits(obj["reservations"])
|
||||||
if "limits" in obj:
|
if "limits" in obj:
|
||||||
@ -72,7 +76,9 @@ class Spec:
|
|||||||
obj: typing.Any
|
obj: typing.Any
|
||||||
file_path: Path
|
file_path: Path
|
||||||
|
|
||||||
def __init__(self, file_path: Path = None, obj={}) -> None:
|
def __init__(self, file_path: Path = None, obj=None) -> None:
|
||||||
|
if obj is None:
|
||||||
|
obj = {}
|
||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
self.obj = obj
|
self.obj = obj
|
||||||
|
|
||||||
@ -91,49 +97,41 @@ class Spec:
|
|||||||
self.file_path = file_path
|
self.file_path = file_path
|
||||||
|
|
||||||
def get_image_registry(self):
|
def get_image_registry(self):
|
||||||
return (self.obj[constants.image_registry_key]
|
return self.obj.get(constants.image_registry_key)
|
||||||
if self.obj and constants.image_registry_key in self.obj
|
|
||||||
else None)
|
|
||||||
|
|
||||||
def get_volumes(self):
|
def get_volumes(self):
|
||||||
return (self.obj["volumes"]
|
return self.obj.get(constants.volumes_key, {})
|
||||||
if self.obj and "volumes" in self.obj
|
|
||||||
else {})
|
|
||||||
|
|
||||||
def get_configmaps(self):
|
def get_configmaps(self):
|
||||||
return (self.obj["configmaps"]
|
return self.obj.get(constants.configmaps_key, {})
|
||||||
if self.obj and "configmaps" in self.obj
|
|
||||||
else {})
|
|
||||||
|
|
||||||
def get_container_resources(self):
|
def get_container_resources(self):
|
||||||
return Resources(self.obj.get("resources", {}).get("containers", {}))
|
return Resources(self.obj.get(constants.resources_key, {}).get("containers", {}))
|
||||||
|
|
||||||
def get_volume_resources(self):
|
def get_volume_resources(self):
|
||||||
return Resources(self.obj.get("resources", {}).get("volumes", {}))
|
return Resources(self.obj.get(constants.resources_key, {}).get(constants.volumes_key, {}))
|
||||||
|
|
||||||
def get_http_proxy(self):
|
def get_http_proxy(self):
|
||||||
return (self.obj[constants.network_key][constants.http_proxy_key]
|
return self.obj.get(constants.network_key, {}).get(constants.http_proxy_key, [])
|
||||||
if self.obj and constants.network_key in self.obj
|
|
||||||
and constants.http_proxy_key in self.obj[constants.network_key]
|
|
||||||
else None)
|
|
||||||
|
|
||||||
def get_annotations(self):
|
def get_annotations(self):
|
||||||
return self.obj.get("annotations", {})
|
return self.obj.get(constants.annotations_key, {})
|
||||||
|
|
||||||
def get_labels(self):
|
def get_labels(self):
|
||||||
return self.obj.get("labels", {})
|
return self.obj.get(constants.labels_key, {})
|
||||||
|
|
||||||
def get_privileged(self):
|
def get_privileged(self):
|
||||||
return "true" == str(self.obj.get("security", {}).get("privileged", "false")).lower()
|
return "true" == str(self.obj.get(constants.security_key, {}).get("privileged", "false")).lower()
|
||||||
|
|
||||||
def get_capabilities(self):
|
def get_capabilities(self):
|
||||||
return self.obj.get("security", {}).get("capabilities", [])
|
return self.obj.get(constants.security_key, {}).get("capabilities", [])
|
||||||
|
|
||||||
def get_deployment_type(self):
|
def get_deployment_type(self):
|
||||||
return self.obj[constants.deploy_to_key]
|
return self.obj.get(constants.deploy_to_key)
|
||||||
|
|
||||||
def is_kubernetes_deployment(self):
|
def is_kubernetes_deployment(self):
|
||||||
return self.get_deployment_type() in [constants.k8s_kind_deploy_type, constants.k8s_deploy_type]
|
return self.get_deployment_type() in [constants.k8s_kind_deploy_type,
|
||||||
|
constants.k8s_deploy_type]
|
||||||
|
|
||||||
def is_kind_deployment(self):
|
def is_kind_deployment(self):
|
||||||
return self.get_deployment_type() in [constants.k8s_kind_deploy_type]
|
return self.get_deployment_type() in [constants.k8s_kind_deploy_type]
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
@ -23,6 +24,7 @@ from stack_orchestrator.util import error_exit, global_options2
|
|||||||
from stack_orchestrator.deploy.deployment_create import init_operation, create_operation
|
from stack_orchestrator.deploy.deployment_create import init_operation, create_operation
|
||||||
from stack_orchestrator.deploy.deploy import create_deploy_context
|
from stack_orchestrator.deploy.deploy import create_deploy_context
|
||||||
from stack_orchestrator.deploy.deploy_types import DeployCommandContext
|
from stack_orchestrator.deploy.deploy_types import DeployCommandContext
|
||||||
|
from stack_orchestrator.deploy.webapp.util import TlsDetails
|
||||||
|
|
||||||
|
|
||||||
def _fixup_container_tag(deployment_dir: str, image: str):
|
def _fixup_container_tag(deployment_dir: str, image: str):
|
||||||
@ -36,16 +38,16 @@ def _fixup_container_tag(deployment_dir: str, image: str):
|
|||||||
wfile.write(contents)
|
wfile.write(contents)
|
||||||
|
|
||||||
|
|
||||||
def _fixup_url_spec(spec_file_name: str, url: str):
|
def _fixup_url_spec(spec_file_name: str, url: str, tls_details: TlsDetails = TlsDetails()):
|
||||||
# url is like: https://example.com/path
|
# url is like: https://example.com/path
|
||||||
parsed_url = urlparse(url)
|
parsed_url = urlparse(url)
|
||||||
http_proxy_spec = f'''
|
http_proxy_spec = f''' http-proxy:
|
||||||
http-proxy:
|
|
||||||
- host-name: {parsed_url.hostname}
|
- host-name: {parsed_url.hostname}
|
||||||
routes:
|
routes:
|
||||||
- path: '{parsed_url.path if parsed_url.path else "/"}'
|
- path: '{parsed_url.path if parsed_url.path else "/"}'
|
||||||
proxy-to: webapp:80
|
proxy-to: webapp:80
|
||||||
'''
|
{tls_details.to_yaml(indent=6)}
|
||||||
|
'''
|
||||||
spec_file_path = Path(spec_file_name)
|
spec_file_path = Path(spec_file_name)
|
||||||
with open(spec_file_path) as rfile:
|
with open(spec_file_path) as rfile:
|
||||||
contents = rfile.read()
|
contents = rfile.read()
|
||||||
@ -54,7 +56,8 @@ def _fixup_url_spec(spec_file_name: str, url: str):
|
|||||||
wfile.write(contents)
|
wfile.write(contents)
|
||||||
|
|
||||||
|
|
||||||
def create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file):
|
def create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file,
|
||||||
|
tls_details: TlsDetails = None):
|
||||||
# Do the equivalent of:
|
# Do the equivalent of:
|
||||||
# 1. laconic-so --stack webapp-template deploy --deploy-to k8s init --output webapp-spec.yml
|
# 1. laconic-so --stack webapp-template deploy --deploy-to k8s init --output webapp-spec.yml
|
||||||
# --config (eqivalent of the contents of my-config.env)
|
# --config (eqivalent of the contents of my-config.env)
|
||||||
@ -86,7 +89,7 @@ def create_deployment(ctx, deployment_dir, image, url, kube_config, image_regist
|
|||||||
None
|
None
|
||||||
)
|
)
|
||||||
# Add the TLS and DNS spec
|
# Add the TLS and DNS spec
|
||||||
_fixup_url_spec(spec_file_name, url)
|
_fixup_url_spec(spec_file_name, url, tls_details)
|
||||||
create_operation(
|
create_operation(
|
||||||
deploy_command_context,
|
deploy_command_context,
|
||||||
spec_file_name,
|
spec_file_name,
|
||||||
@ -116,8 +119,16 @@ def command(ctx):
|
|||||||
@click.option("--image", help="image to deploy", required=True)
|
@click.option("--image", help="image to deploy", required=True)
|
||||||
@click.option("--url", help="url to serve", required=True)
|
@click.option("--url", help="url to serve", required=True)
|
||||||
@click.option("--env-file", help="environment file for webapp")
|
@click.option("--env-file", help="environment file for webapp")
|
||||||
|
@click.option("--tls-host", help="Override TLS hostname (eg, '*.mydomain.com')")
|
||||||
|
@click.option("--tls-secret", help="Override TLS secret name")
|
||||||
|
@click.option("--tls-issuer", help="TLS issuer to use (default: letsencrypt-prod)")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def create(ctx, deployment_dir, image, url, kube_config, image_registry, env_file):
|
def create(ctx, deployment_dir, image, url, kube_config, image_registry, env_file, tls_host, tls_secret, tls_issuer):
|
||||||
'''create a deployment for the specified webapp container'''
|
'''create a deployment for the specified webapp container'''
|
||||||
|
|
||||||
return create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file)
|
if (tls_secret and not tls_host) or (tls_host and not tls_secret):
|
||||||
|
print("Cannot specify --tls-host without --tls-secret", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
return create_deployment(ctx, deployment_dir, image, url, kube_config, image_registry, env_file,
|
||||||
|
TlsDetails(tls_host, tls_secret, tls_issuer))
|
||||||
|
@ -26,7 +26,7 @@ import click
|
|||||||
|
|
||||||
from stack_orchestrator.deploy.images import remote_image_exists, add_tags_to_image
|
from stack_orchestrator.deploy.images import remote_image_exists, add_tags_to_image
|
||||||
from stack_orchestrator.deploy.webapp import deploy_webapp
|
from stack_orchestrator.deploy.webapp import deploy_webapp
|
||||||
from stack_orchestrator.deploy.webapp.util import (LaconicRegistryClient, TimedLogger,
|
from stack_orchestrator.deploy.webapp.util import (LaconicRegistryClient, TimedLogger, TlsDetails,
|
||||||
build_container_image, push_container_image,
|
build_container_image, push_container_image,
|
||||||
file_hash, deploy_to_k8s, publish_deployment,
|
file_hash, deploy_to_k8s, publish_deployment,
|
||||||
hostname_for_deployment_request, generate_hostname_for_app,
|
hostname_for_deployment_request, generate_hostname_for_app,
|
||||||
@ -44,7 +44,8 @@ def process_app_deployment_request(
|
|||||||
kube_config,
|
kube_config,
|
||||||
image_registry,
|
image_registry,
|
||||||
force_rebuild,
|
force_rebuild,
|
||||||
logger
|
tls_details,
|
||||||
|
logger,
|
||||||
):
|
):
|
||||||
logger.log("BEGIN - process_app_deployment_request")
|
logger.log("BEGIN - process_app_deployment_request")
|
||||||
|
|
||||||
@ -106,7 +107,7 @@ def process_app_deployment_request(
|
|||||||
(app_deployment_crn, deployment_dir))
|
(app_deployment_crn, deployment_dir))
|
||||||
print("deploy_webapp", deployment_dir)
|
print("deploy_webapp", deployment_dir)
|
||||||
deploy_webapp.create_deployment(ctx, deployment_dir, deployment_container_tag,
|
deploy_webapp.create_deployment(ctx, deployment_dir, deployment_container_tag,
|
||||||
f"https://{fqdn}", kube_config, image_registry, env_filename)
|
f"https://{fqdn}", kube_config, image_registry, env_filename, tls_details)
|
||||||
elif env_filename:
|
elif env_filename:
|
||||||
shutil.copyfile(env_filename, deployment_config_file)
|
shutil.copyfile(env_filename, deployment_config_file)
|
||||||
|
|
||||||
@ -198,11 +199,14 @@ def dump_known_requests(filename, requests, status="SEEN"):
|
|||||||
@click.option("--exclude-tags", help="Exclude requests with matching tags (comma-separated).", default="")
|
@click.option("--exclude-tags", help="Exclude requests with matching tags (comma-separated).", default="")
|
||||||
@click.option("--force-rebuild", help="Rebuild even if the image already exists.", is_flag=True)
|
@click.option("--force-rebuild", help="Rebuild even if the image already exists.", is_flag=True)
|
||||||
@click.option("--log-dir", help="Output build/deployment logs to directory.", default=None)
|
@click.option("--log-dir", help="Output build/deployment logs to directory.", default=None)
|
||||||
|
@click.option("--tls-host", help="Override TLS hostname (eg, '*.mydomain.com')")
|
||||||
|
@click.option("--tls-secret", help="Override TLS secret name")
|
||||||
|
@click.option("--tls-issuer", help="TLS issuer to use (default: letsencrypt-prod)")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_dir, # noqa: C901
|
def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_dir, # noqa: C901
|
||||||
request_id, discover, state_file, only_update_state,
|
request_id, discover, state_file, only_update_state,
|
||||||
dns_suffix, record_namespace_dns, record_namespace_deployments, dry_run,
|
dns_suffix, record_namespace_dns, record_namespace_deployments, dry_run,
|
||||||
include_tags, exclude_tags, force_rebuild, log_dir):
|
include_tags, exclude_tags, force_rebuild, log_dir, tls_host, tls_secret, tls_issuer):
|
||||||
if request_id and discover:
|
if request_id and discover:
|
||||||
print("Cannot specify both --request-id and --discover", file=sys.stderr)
|
print("Cannot specify both --request-id and --discover", file=sys.stderr)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
@ -220,6 +224,10 @@ def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_
|
|||||||
print("--dns-suffix, --record-namespace-dns, and --record-namespace-deployments are all required", file=sys.stderr)
|
print("--dns-suffix, --record-namespace-dns, and --record-namespace-deployments are all required", file=sys.stderr)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
|
if (tls_secret and not tls_host) or (tls_host and not tls_secret):
|
||||||
|
print("Cannot specify --tls-host without --tls-secret", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
# Split CSV and clean up values.
|
# Split CSV and clean up values.
|
||||||
include_tags = [tag.strip() for tag in include_tags.split(",") if tag]
|
include_tags = [tag.strip() for tag in include_tags.split(",") if tag]
|
||||||
exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag]
|
exclude_tags = [tag.strip() for tag in exclude_tags.split(",") if tag]
|
||||||
@ -305,6 +313,7 @@ def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_
|
|||||||
print("Found %d unsatisfied request(s) to process." % len(requests_to_execute))
|
print("Found %d unsatisfied request(s) to process." % len(requests_to_execute))
|
||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
|
tls_details = TlsDetails(tls_host, tls_secret, tls_issuer)
|
||||||
for r in requests_to_execute:
|
for r in requests_to_execute:
|
||||||
dump_known_requests(state_file, [r], "DEPLOYING")
|
dump_known_requests(state_file, [r], "DEPLOYING")
|
||||||
status = "ERROR"
|
status = "ERROR"
|
||||||
@ -334,6 +343,7 @@ def command(ctx, kube_config, laconic_config, image_registry, deployment_parent_
|
|||||||
kube_config,
|
kube_config,
|
||||||
image_registry,
|
image_registry,
|
||||||
force_rebuild,
|
force_rebuild,
|
||||||
|
tls_details,
|
||||||
logger
|
logger
|
||||||
)
|
)
|
||||||
status = "DEPLOYED"
|
status = "DEPLOYED"
|
||||||
|
@ -26,6 +26,40 @@ import uuid
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
class TlsDetails:
|
||||||
|
def __init__(self, host_or_hosts=None, secret_name: str = None, issuer_name: str = None):
|
||||||
|
if host_or_hosts:
|
||||||
|
if isinstance(host_or_hosts, list):
|
||||||
|
self.hosts = host_or_hosts
|
||||||
|
else:
|
||||||
|
self.hosts = [host_or_hosts]
|
||||||
|
else:
|
||||||
|
self.hosts = None
|
||||||
|
self.secret_name = secret_name
|
||||||
|
self.issuer_name = issuer_name
|
||||||
|
|
||||||
|
def to_yaml(self, indent=6):
|
||||||
|
if not self.hosts and not self.secret_name and not self.issuer_name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
ret = " " * indent + "tls:\n"
|
||||||
|
indent += 2
|
||||||
|
|
||||||
|
if self.issuer_name:
|
||||||
|
ret += " " * indent + "issuer: '%s'\n" % self.issuer_name
|
||||||
|
|
||||||
|
if self.secret_name:
|
||||||
|
ret += " " * indent + "secret: '%s'\n" % self.secret_name
|
||||||
|
|
||||||
|
if self.hosts:
|
||||||
|
ret += " " * indent + "hosts:"
|
||||||
|
indent += 2
|
||||||
|
for h in self.hosts:
|
||||||
|
ret += "\n" + " " * indent + "- '%s'" % h
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
class AttrDict(dict):
|
class AttrDict(dict):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(AttrDict, self).__init__(*args, **kwargs)
|
super(AttrDict, self).__init__(*args, **kwargs)
|
||||||
|
Loading…
Reference in New Issue
Block a user