Add oslo.config-based cluster label registry with auto-docs
Introduces a [capi_helm_cluster_labels] oslo.config group that serves as the authoritative registry for all supported cluster labels. Each label is defined as a typed oslo.config option (BoolOpt/IntOpt/StrOpt) with a default value and help text, which operators can override site-wide in magnum.conf and users can override per-cluster via Magnum labels. The five csi_cinder_* options previously scattered in [capi_helm] are migrated to the new group with deprecated_opts so existing magnum.conf files remain compatible without changes. _label(), _get_label_bool(), _get_label_int() now raise ValueError for any key not registered in the new group. A new _get_label_csv() method handles the comma-separated api_master_lb_allowed_cidrs label. A new AST-based unit test (TestClusterLabelRegistry) walks driver.py at test time and asserts that every string-literal key passed to any _label* method is present in capi_helm_cluster_labels_opts, preventing unregistered labels from being silently introduced. Documentation is updated to drop the hand-maintained label table in favour of a cross-reference to the Configuration Reference page, which oslo_config.sphinxext now auto-generates from the new group alongside the existing [capi_helm] section. Generated-By: Claude Sonnet 4.6 Change-Id: I2eb36d4c9bddc9d378b05bce1640d8a6d479552b Signed-off-by: Michal Nasiadka <mnasiadka@gmail.com>
This commit is contained in:
@@ -41,31 +41,10 @@
|
||||
# Default StorageClass volume type for persistent volumes. (string value)
|
||||
#csi_cinder_default_volume_type = <None>
|
||||
|
||||
# Policy for reclaiming dynamically created persistent volumes. Can be 'Retain'
|
||||
# or 'Delete'. (string value)
|
||||
#csi_cinder_reclaim_policy = Retain
|
||||
|
||||
# Allows the users to resize the volume by editing the corresponding PVC object.
|
||||
# (boolean value)
|
||||
#csi_cinder_allow_volume_expansion = true
|
||||
|
||||
# Select the Nodes where the application Pods may be scheduled based on Node
|
||||
# labels. (list value)
|
||||
#csi_cinder_allowed_topologies =
|
||||
|
||||
# Filesystem type for persistent volumes. (string value)
|
||||
#csi_cinder_fstype = ext4
|
||||
|
||||
# The volumeBindingMode field controls when volume binding and dynamic
|
||||
# provisioning should occur. (string value)
|
||||
# Possible values:
|
||||
# WaitForFirstConsumer - <No description provided>
|
||||
# Immediate - <No description provided>
|
||||
#csi_cinder_volume_binding_mode = WaitForFirstConsumer
|
||||
|
||||
# The default availability zone to use for Cinder volumes. (string value)
|
||||
#csi_cinder_availability_zone = nova
|
||||
|
||||
# The value to use in the interface field of generated application credentials.
|
||||
# (string value)
|
||||
#app_cred_interface_type = public
|
||||
@@ -96,3 +75,122 @@
|
||||
# List of conditions to check for kubernetes control plane resource to consider
|
||||
# as ready. (list value)
|
||||
#k8s_control_plane_resource_conditions = MachinesReady,Ready,EtcdClusterHealthy,ControlPlaneComponentsHealthy
|
||||
|
||||
|
||||
[capi_helm_cluster_labels]
|
||||
|
||||
#
|
||||
# From capi_helm_cluster_labels
|
||||
#
|
||||
|
||||
# Size of the etcd block device in GB. 0 means use the default ephemeral disk.
|
||||
# (integer value)
|
||||
#etcd_blockdevice_size = 0
|
||||
|
||||
# Storage type for the etcd block device. 'volume' uses a Cinder volume; 'local'
|
||||
# uses an ephemeral disk. (string value)
|
||||
# Possible values:
|
||||
# volume - <No description provided>
|
||||
# local - <No description provided>
|
||||
#etcd_blockdevice_type = volume
|
||||
|
||||
# Cinder volume type for the etcd block device. (string value)
|
||||
#etcd_blockdevice_volume_type =
|
||||
|
||||
# Availability zone for the etcd Cinder volume. (string value)
|
||||
#etcd_blockdevice_volume_az =
|
||||
|
||||
# Deprecated: use etcd_blockdevice_size instead. (integer value)
|
||||
# Deprecated group/name - [capi_helm]/etcd_volume_size
|
||||
#etcd_volume_size = 0
|
||||
|
||||
# Deprecated: use etcd_blockdevice_volume_type instead. (string value)
|
||||
# Deprecated group/name - [capi_helm]/etcd_volume_type
|
||||
#etcd_volume_type =
|
||||
|
||||
# Enable the monitoring addon on the cluster. (boolean value)
|
||||
#monitoring_enabled = false
|
||||
|
||||
# Enable the Kubernetes Dashboard addon. (boolean value)
|
||||
#kube_dashboard_enabled = true
|
||||
|
||||
# Enable auto-healing for cluster nodes. (boolean value)
|
||||
#auto_healing_enabled = true
|
||||
|
||||
# Enable the cluster autoscaler. (boolean value)
|
||||
#auto_scaling_enabled = false
|
||||
|
||||
# Minimum node count for autoscaling. Defaults to the nodegroup node_count when
|
||||
# unset. (integer value)
|
||||
#min_node_count = <None>
|
||||
|
||||
# Maximum node count for autoscaling. Defaults to the nodegroup node_count when
|
||||
# unset. (integer value)
|
||||
#max_node_count = <None>
|
||||
|
||||
# Enable the Keystone authentication webhook. (boolean value)
|
||||
#keystone_auth_enabled = false
|
||||
|
||||
# Default availability zone for Cinder volumes. (string value)
|
||||
# Deprecated group/name - [capi_helm]/csi_cinder_availability_zone
|
||||
#csi_cinder_availability_zone = nova
|
||||
|
||||
# Reclaim policy for dynamically provisioned persistent volumes. Can be 'Retain'
|
||||
# or 'Delete'. (string value)
|
||||
# Possible values:
|
||||
# Retain - <No description provided>
|
||||
# Delete - <No description provided>
|
||||
# Deprecated group/name - [capi_helm]/csi_cinder_reclaim_policy
|
||||
#csi_cinder_reclaim_policy = Retain
|
||||
|
||||
# Controls when volume binding and dynamic provisioning occur. Can be
|
||||
# 'WaitForFirstConsumer' or 'Immediate'. (string value)
|
||||
# Possible values:
|
||||
# WaitForFirstConsumer - <No description provided>
|
||||
# Immediate - <No description provided>
|
||||
# Deprecated group/name - [capi_helm]/csi_cinder_volume_binding_mode
|
||||
#csi_cinder_volume_binding_mode = WaitForFirstConsumer
|
||||
|
||||
# Filesystem type for persistent volumes. (string value)
|
||||
# Deprecated group/name - [capi_helm]/csi_cinder_fstype
|
||||
#csi_cinder_fstype = ext4
|
||||
|
||||
# Allow users to resize persistent volumes by editing the PVC. (boolean value)
|
||||
# Deprecated group/name - [capi_helm]/csi_cinder_allow_volume_expansion
|
||||
#csi_cinder_allow_volume_expansion = true
|
||||
|
||||
# Octavia load balancer provider (e.g. 'amphora' or 'ovn'). (string value)
|
||||
#octavia_provider = amphora
|
||||
|
||||
# Load balancer algorithm. When unset, defaults to SOURCE_IP_PORT for the ovn
|
||||
# provider and ROUND_ROBIN for all others. (string value)
|
||||
#octavia_lb_algorithm =
|
||||
|
||||
# Enable health checks on the load balancer. (boolean value)
|
||||
#octavia_lb_healthcheck = true
|
||||
|
||||
# Associate a floating IP with the API load balancer. (boolean value)
|
||||
#master_lb_floating_ip_enabled = true
|
||||
|
||||
# CIDR for the node network when no fixed_subnet is specified. (string value)
|
||||
#fixed_subnet_cidr = 10.0.0.0/24
|
||||
|
||||
# Comma-separated list of CIDRs allowed to reach the API load balancer. Empty
|
||||
# means all CIDRs are allowed. (string value)
|
||||
#api_master_lb_allowed_cidrs =
|
||||
|
||||
# Name of an additional Neutron network to attach to each node. (string value)
|
||||
#extra_network_name =
|
||||
|
||||
# Root volume type. Falls back to the cinder config option
|
||||
# default_boot_volume_type when unset. (string value)
|
||||
#boot_volume_type = <None>
|
||||
|
||||
# Root volume size in GB. Falls back to the cinder config option
|
||||
# default_boot_volume_size when unset. (integer value)
|
||||
#boot_volume_size = <None>
|
||||
|
||||
# Helm chart version to use. Can only be set via the cluster template label, not
|
||||
# overridden per cluster. Falls back to capi_helm.default_helm_chart_version
|
||||
# when unset. (string value)
|
||||
#capi_helm_chart_version =
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
Configuration reference
|
||||
=======================
|
||||
|
||||
All configuration options listed bellow are used in
|
||||
[capi_helm] stanza in magnum.conf file.
|
||||
The ``[capi_helm]`` section controls driver-level settings (Helm chart
|
||||
location, flavour constraints, etc.).
|
||||
|
||||
The ``[capi_helm_cluster_labels]`` section controls the **default values**
|
||||
for all supported cluster labels. Every option here can be overridden
|
||||
per-cluster by the user via Magnum cluster (or template) labels.
|
||||
|
||||
.. show-options::
|
||||
:config-file: etc/oslo-config-generator/capi_helm.conf
|
||||
|
||||
@@ -20,78 +20,20 @@ as updates to node groups and their sizes.
|
||||
The Kubernetes versions against which the CAPI Helm charts are currently being
|
||||
tested can be found `here <https://github.com/azimuth-cloud/capi-helm-charts/blob/main/.github/workflows/ensure-capi-images.yaml#L9>`__.
|
||||
|
||||
The driver respects the following cluster and template properties:
|
||||
The driver supports cluster labels to customise cluster behaviour. The full
|
||||
list of supported labels, their types, defaults, and descriptions is
|
||||
maintained in the :doc:`Configuration Reference <config-reference>` under
|
||||
the ``[capi_helm_cluster_labels]`` section.
|
||||
|
||||
* image_id
|
||||
* keypair
|
||||
* fixed_network, fixed_subnet (if missing, a new one is created
|
||||
with CIDR: 10.0.0.0/24)
|
||||
* external_network_id
|
||||
* dns_nameserver
|
||||
Default values for all labels can be set operator-wide in ``magnum.conf``
|
||||
under ``[capi_helm_cluster_labels]`` and overridden per cluster or template
|
||||
by the user via Magnum labels.
|
||||
|
||||
The driver supports the following labels:
|
||||
.. note::
|
||||
|
||||
|
||||
|
||||
+-----------------------------------+---------------------+
|
||||
| Label | Default |
|
||||
+===================================+=====================+
|
||||
| monitoring_enabled | false |
|
||||
| | |
|
||||
+-----------------------------------+---------------------+
|
||||
| kube_dashboard_enabled | true |
|
||||
| | |
|
||||
+-----------------------------------+---------------------+
|
||||
| fixed_subnet_cidr | 10.0.0.0/24 |
|
||||
+-----------------------------------+---------------------+
|
||||
| extra_network_name | empty |
|
||||
+-----------------------------------+---------------------+
|
||||
| capi_helm_chart_version | 0.10.1 |
|
||||
| (see bellow for additional info) | |
|
||||
+-----------------------------------+---------------------+
|
||||
| etcd_blockdevice_size | 0 |
|
||||
+-----------------------------------+---------------------+
|
||||
| etcd_blockdevice_type | volume |
|
||||
| | |
|
||||
+-----------------------------------+---------------------+
|
||||
| etcd_blockdevice_volume_az | "" |
|
||||
+-----------------------------------+---------------------+
|
||||
| etcd_volume_size | 0 |
|
||||
+-----------------------------------+---------------------+
|
||||
| etcd_volume_type | "" |
|
||||
+-----------------------------------+---------------------+
|
||||
| csi_cinder_availability_zone | "" |
|
||||
+-----------------------------------+---------------------+
|
||||
| csi_cinder_reclaim_policy | Delete |
|
||||
| | |
|
||||
+-----------------------------------+---------------------+
|
||||
| csi_cinder_fstype | ext4 |
|
||||
+-----------------------------------+---------------------+
|
||||
| csi_cinder_allow_volume_expansion | True |
|
||||
| | |
|
||||
+-----------------------------------+---------------------+
|
||||
| octavia_provider | amphora |
|
||||
| | ovn |
|
||||
+-----------------------------------+---------------------+
|
||||
| octavia_lb_algorithm | ROUND_ROBIN, |
|
||||
| | SOURCE_IP_PORT if |
|
||||
| | octavia_provider is |
|
||||
| | set to "ovn". |
|
||||
+-----------------------------------+---------------------+
|
||||
| boot_volume_type | "" |
|
||||
+-----------------------------------+---------------------+
|
||||
| extra_network_name | "" |
|
||||
| | |
|
||||
+-----------------------------------+---------------------+
|
||||
|
||||
|
||||
|
||||
One config option requires a little bit more explanation:
|
||||
|
||||
|
||||
* capi_helm_chart_version: can only be set via template property
|
||||
and CAN'T be overridden by cli options. If not set in cluster template,
|
||||
value is taken from magnum.config option default_helm_chart_version.
|
||||
``capi_helm_chart_version`` can only be set via a cluster template label
|
||||
and cannot be overridden per cluster. When unset it falls back to
|
||||
``[capi_helm] default_helm_chart_version``.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[DEFAULT]
|
||||
wrap_width = 80
|
||||
output_file = etc/capi_helm.conf.sample
|
||||
namespace = capi_helm
|
||||
namespace = capi_helm
|
||||
namespace = capi_helm_cluster_labels
|
||||
+234
-35
@@ -80,22 +80,6 @@ capi_helm_opts = [
|
||||
"csi_cinder_default_volume_type",
|
||||
help=("Default StorageClass volume type for persistent volumes."),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"csi_cinder_reclaim_policy",
|
||||
default="Retain",
|
||||
help=(
|
||||
"Policy for reclaiming dynamically created "
|
||||
"persistent volumes. Can be 'Retain' or 'Delete'."
|
||||
),
|
||||
),
|
||||
cfg.BoolOpt(
|
||||
"csi_cinder_allow_volume_expansion",
|
||||
default=True,
|
||||
help=(
|
||||
"Allows the users to resize the volume by "
|
||||
"editing the corresponding PVC object."
|
||||
),
|
||||
),
|
||||
cfg.ListOpt(
|
||||
"csi_cinder_allowed_topologies",
|
||||
default=[],
|
||||
@@ -104,25 +88,6 @@ capi_helm_opts = [
|
||||
"Pods may be scheduled based on Node labels."
|
||||
),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"csi_cinder_fstype",
|
||||
default="ext4",
|
||||
help=("Filesystem type for persistent volumes."),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"csi_cinder_volume_binding_mode",
|
||||
default="WaitForFirstConsumer",
|
||||
choices=["WaitForFirstConsumer", "Immediate"],
|
||||
help=(
|
||||
"The volumeBindingMode field controls when "
|
||||
"volume binding and dynamic provisioning should occur."
|
||||
),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"csi_cinder_availability_zone",
|
||||
default="nova",
|
||||
help=("The default availability zone to use for Cinder volumes."),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"app_cred_interface_type",
|
||||
default="public",
|
||||
@@ -172,10 +137,244 @@ capi_helm_opts = [
|
||||
),
|
||||
]
|
||||
|
||||
capi_helm_cluster_labels_group = cfg.OptGroup(
|
||||
name="capi_helm_cluster_labels",
|
||||
title="Default values for cluster labels",
|
||||
)
|
||||
|
||||
capi_helm_cluster_labels_opts = [
|
||||
# etcd
|
||||
cfg.IntOpt(
|
||||
"etcd_blockdevice_size",
|
||||
default=0,
|
||||
help=(
|
||||
"Size of the etcd block device in GB. "
|
||||
"0 means use the default ephemeral disk."
|
||||
),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"etcd_blockdevice_type",
|
||||
default="volume",
|
||||
choices=["volume", "local"],
|
||||
help=(
|
||||
"Storage type for the etcd block device. "
|
||||
"'volume' uses a Cinder volume; 'local' uses an ephemeral disk."
|
||||
),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"etcd_blockdevice_volume_type",
|
||||
default="",
|
||||
help="Cinder volume type for the etcd block device.",
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"etcd_blockdevice_volume_az",
|
||||
default="",
|
||||
help="Availability zone for the etcd Cinder volume.",
|
||||
),
|
||||
cfg.IntOpt(
|
||||
"etcd_volume_size",
|
||||
default=0,
|
||||
deprecated_opts=[
|
||||
cfg.DeprecatedOpt("etcd_volume_size", group="capi_helm")
|
||||
],
|
||||
help="Deprecated: use etcd_blockdevice_size instead.",
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"etcd_volume_type",
|
||||
default="",
|
||||
deprecated_opts=[
|
||||
cfg.DeprecatedOpt("etcd_volume_type", group="capi_helm")
|
||||
],
|
||||
help="Deprecated: use etcd_blockdevice_volume_type instead.",
|
||||
),
|
||||
# addons
|
||||
cfg.BoolOpt(
|
||||
"monitoring_enabled",
|
||||
default=False,
|
||||
help="Enable the monitoring addon on the cluster.",
|
||||
),
|
||||
cfg.BoolOpt(
|
||||
"kube_dashboard_enabled",
|
||||
default=True,
|
||||
help="Enable the Kubernetes Dashboard addon.",
|
||||
),
|
||||
cfg.BoolOpt(
|
||||
"auto_healing_enabled",
|
||||
default=True,
|
||||
help="Enable auto-healing for cluster nodes.",
|
||||
),
|
||||
cfg.BoolOpt(
|
||||
"auto_scaling_enabled",
|
||||
default=False,
|
||||
help="Enable the cluster autoscaler.",
|
||||
),
|
||||
cfg.IntOpt(
|
||||
"min_node_count",
|
||||
default=None,
|
||||
help=(
|
||||
"Minimum node count for autoscaling. "
|
||||
"Defaults to the nodegroup node_count when unset."
|
||||
),
|
||||
),
|
||||
cfg.IntOpt(
|
||||
"max_node_count",
|
||||
default=None,
|
||||
help=(
|
||||
"Maximum node count for autoscaling. "
|
||||
"Defaults to the nodegroup node_count when unset."
|
||||
),
|
||||
),
|
||||
# auth
|
||||
cfg.BoolOpt(
|
||||
"keystone_auth_enabled",
|
||||
default=False,
|
||||
help="Enable the Keystone authentication webhook.",
|
||||
),
|
||||
# CSI Cinder — moved from [capi_helm], old location still accepted
|
||||
cfg.StrOpt(
|
||||
"csi_cinder_availability_zone",
|
||||
default="nova",
|
||||
deprecated_opts=[
|
||||
cfg.DeprecatedOpt(
|
||||
"csi_cinder_availability_zone", group="capi_helm"
|
||||
)
|
||||
],
|
||||
help="Default availability zone for Cinder volumes.",
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"csi_cinder_reclaim_policy",
|
||||
default="Retain",
|
||||
choices=["Retain", "Delete"],
|
||||
deprecated_opts=[
|
||||
cfg.DeprecatedOpt("csi_cinder_reclaim_policy", group="capi_helm")
|
||||
],
|
||||
help=(
|
||||
"Reclaim policy for dynamically provisioned persistent volumes. "
|
||||
"Can be 'Retain' or 'Delete'."
|
||||
),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"csi_cinder_volume_binding_mode",
|
||||
default="WaitForFirstConsumer",
|
||||
choices=["WaitForFirstConsumer", "Immediate"],
|
||||
deprecated_opts=[
|
||||
cfg.DeprecatedOpt(
|
||||
"csi_cinder_volume_binding_mode", group="capi_helm"
|
||||
)
|
||||
],
|
||||
help=(
|
||||
"Controls when volume binding and dynamic provisioning occur. "
|
||||
"Can be 'WaitForFirstConsumer' or 'Immediate'."
|
||||
),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"csi_cinder_fstype",
|
||||
default="ext4",
|
||||
deprecated_opts=[
|
||||
cfg.DeprecatedOpt("csi_cinder_fstype", group="capi_helm")
|
||||
],
|
||||
help="Filesystem type for persistent volumes.",
|
||||
),
|
||||
cfg.BoolOpt(
|
||||
"csi_cinder_allow_volume_expansion",
|
||||
default=True,
|
||||
deprecated_opts=[
|
||||
cfg.DeprecatedOpt(
|
||||
"csi_cinder_allow_volume_expansion", group="capi_helm"
|
||||
)
|
||||
],
|
||||
help="Allow users to resize persistent volumes by editing the PVC.",
|
||||
),
|
||||
# Octavia load balancer
|
||||
cfg.StrOpt(
|
||||
"octavia_provider",
|
||||
default="amphora",
|
||||
help="Octavia load balancer provider (e.g. 'amphora' or 'ovn').",
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"octavia_lb_algorithm",
|
||||
default="",
|
||||
help=(
|
||||
"Load balancer algorithm. "
|
||||
"When unset, defaults to SOURCE_IP_PORT for the ovn provider "
|
||||
"and ROUND_ROBIN for all others."
|
||||
),
|
||||
),
|
||||
cfg.BoolOpt(
|
||||
"octavia_lb_healthcheck",
|
||||
default=True,
|
||||
help="Enable health checks on the load balancer.",
|
||||
),
|
||||
# networking
|
||||
cfg.BoolOpt(
|
||||
"master_lb_floating_ip_enabled",
|
||||
default=True,
|
||||
help="Associate a floating IP with the API load balancer.",
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"fixed_subnet_cidr",
|
||||
default="10.0.0.0/24",
|
||||
help="CIDR for the node network when no fixed_subnet is specified.",
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"api_master_lb_allowed_cidrs",
|
||||
default="",
|
||||
help=(
|
||||
"Comma-separated list of CIDRs allowed to reach the API load "
|
||||
"balancer. Empty means all CIDRs are allowed."
|
||||
),
|
||||
),
|
||||
cfg.StrOpt(
|
||||
"extra_network_name",
|
||||
default="",
|
||||
help="Name of an additional Neutron network to attach to each node.",
|
||||
),
|
||||
# boot volume
|
||||
cfg.StrOpt(
|
||||
"boot_volume_type",
|
||||
default=None,
|
||||
help=(
|
||||
"Root volume type. "
|
||||
"Falls back to the cinder config option "
|
||||
"default_boot_volume_type when unset."
|
||||
),
|
||||
),
|
||||
cfg.IntOpt(
|
||||
"boot_volume_size",
|
||||
default=None,
|
||||
help=(
|
||||
"Root volume size in GB. "
|
||||
"Falls back to the cinder config option "
|
||||
"default_boot_volume_size when unset."
|
||||
),
|
||||
),
|
||||
# chart version (template-only — cannot be overridden per cluster)
|
||||
cfg.StrOpt(
|
||||
"capi_helm_chart_version",
|
||||
default="",
|
||||
help=(
|
||||
"Helm chart version to use. Can only be set via the cluster "
|
||||
"template label, not overridden per cluster. Falls back to "
|
||||
"capi_helm.default_helm_chart_version when unset."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
CONF = cfg.CONF
|
||||
CONF.register_group(capi_helm_group)
|
||||
CONF.register_opts(capi_helm_opts, group=capi_helm_group)
|
||||
CONF.register_group(capi_helm_cluster_labels_group)
|
||||
CONF.register_opts(
|
||||
capi_helm_cluster_labels_opts, group=capi_helm_cluster_labels_group
|
||||
)
|
||||
|
||||
|
||||
def list_capi_opts():
|
||||
return [(capi_helm_group, [o]) for o in capi_helm_opts]
|
||||
|
||||
|
||||
def list_capi_cluster_label_opts():
|
||||
return [
|
||||
(capi_helm_cluster_labels_group, [o])
|
||||
for o in capi_helm_cluster_labels_opts
|
||||
]
|
||||
|
||||
+124
-32
@@ -38,6 +38,10 @@ LOG = logging.getLogger(__name__)
|
||||
CONF = conf.CONF
|
||||
NODE_GROUP_ROLE_CONTROLLER = "master"
|
||||
|
||||
_REGISTERED_LABEL_NAMES = frozenset(
|
||||
opt.name for opt in conf.capi_helm_cluster_labels_opts
|
||||
)
|
||||
|
||||
|
||||
class NodeGroupState(enum.Enum):
|
||||
NOT_PRESENT = 1
|
||||
@@ -487,6 +491,11 @@ class Driver(driver.Driver):
|
||||
)
|
||||
|
||||
def _label(self, cluster, key, default):
|
||||
if key not in _REGISTERED_LABEL_NAMES:
|
||||
raise ValueError(
|
||||
f"Cluster label '{key}' is not registered in "
|
||||
"capi_helm_cluster_labels. Add it to conf.py first."
|
||||
)
|
||||
all_labels = helm.mergeconcat(
|
||||
cluster.cluster_template.labels, cluster.labels
|
||||
)
|
||||
@@ -509,6 +518,25 @@ class Driver(driver.Driver):
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def _get_label_csv(self, cluster, label, default):
|
||||
"""Return a cluster label as a filtered list split on commas."""
|
||||
if label not in _REGISTERED_LABEL_NAMES:
|
||||
raise ValueError(
|
||||
f"Cluster label '{label}' is not registered in "
|
||||
"capi_helm_cluster_labels. Add it to conf.py first."
|
||||
)
|
||||
all_labels = helm.mergeconcat(
|
||||
cluster.cluster_template.labels, cluster.labels
|
||||
)
|
||||
raw = (all_labels or {}).get(label, "") or default
|
||||
if not raw:
|
||||
return []
|
||||
return [
|
||||
re.sub(r"[^a-zA-Z0-9\.\-\/\:]+", "", entry.strip())
|
||||
for entry in str(raw).split(",")
|
||||
if entry.strip()
|
||||
]
|
||||
|
||||
def _get_chart_version(self, cluster):
|
||||
version = cluster.cluster_template.labels.get(
|
||||
"capi_helm_chart_version",
|
||||
@@ -592,18 +620,25 @@ class Driver(driver.Driver):
|
||||
)
|
||||
|
||||
def _get_etcd_config(self, cluster):
|
||||
lconf = CONF.capi_helm_cluster_labels
|
||||
# Support new-style and legacy labels for volume size and type, with
|
||||
# new-style labels taking precedence
|
||||
etcd_size = self._get_label_int(
|
||||
cluster,
|
||||
"etcd_blockdevice_size",
|
||||
self._get_label_int(cluster, "etcd_volume_size", 0),
|
||||
self._get_label_int(
|
||||
cluster,
|
||||
"etcd_volume_size",
|
||||
lconf.etcd_blockdevice_size,
|
||||
),
|
||||
)
|
||||
if etcd_size > 0:
|
||||
etcd_block_device = {"size": etcd_size}
|
||||
# The block device type can be either local or volume
|
||||
etcd_bd_type = self._label(
|
||||
cluster, "etcd_blockdevice_type", "volume"
|
||||
cluster,
|
||||
"etcd_blockdevice_type",
|
||||
lconf.etcd_blockdevice_type,
|
||||
)
|
||||
if etcd_bd_type == "local":
|
||||
etcd_block_device["type"] = "Local"
|
||||
@@ -613,13 +648,19 @@ class Driver(driver.Driver):
|
||||
etcd_volume_type = self._label(
|
||||
cluster,
|
||||
"etcd_blockdevice_volume_type",
|
||||
self._label(cluster, "etcd_volume_type", ""),
|
||||
self._label(
|
||||
cluster,
|
||||
"etcd_volume_type",
|
||||
lconf.etcd_blockdevice_volume_type,
|
||||
),
|
||||
)
|
||||
if etcd_volume_type:
|
||||
etcd_block_device["volumeType"] = etcd_volume_type
|
||||
|
||||
etcd_volume_az = self._label(
|
||||
cluster, "etcd_blockdevice_volume_az", ""
|
||||
cluster,
|
||||
"etcd_blockdevice_volume_az",
|
||||
lconf.etcd_blockdevice_volume_az,
|
||||
)
|
||||
if etcd_volume_az:
|
||||
etcd_block_device["availabilityZone"] = etcd_volume_az
|
||||
@@ -635,19 +676,32 @@ class Driver(driver.Driver):
|
||||
return None
|
||||
|
||||
def _get_monitoring_enabled(self, cluster):
|
||||
# NOTE(mkjpryor) default off, like heat driver,
|
||||
# as requires cinder and takes a while
|
||||
return self._get_label_bool(cluster, "monitoring_enabled", False)
|
||||
return self._get_label_bool(
|
||||
cluster,
|
||||
"monitoring_enabled",
|
||||
CONF.capi_helm_cluster_labels.monitoring_enabled,
|
||||
)
|
||||
|
||||
def _get_kube_dash_enabled(self, cluster):
|
||||
# NOTE(mkjpryor) default on, like the heat driver
|
||||
return self._get_label_bool(cluster, "kube_dashboard_enabled", True)
|
||||
return self._get_label_bool(
|
||||
cluster,
|
||||
"kube_dashboard_enabled",
|
||||
CONF.capi_helm_cluster_labels.kube_dashboard_enabled,
|
||||
)
|
||||
|
||||
def _get_autoheal_enabled(self, cluster):
|
||||
return self._get_label_bool(cluster, "auto_healing_enabled", True)
|
||||
return self._get_label_bool(
|
||||
cluster,
|
||||
"auto_healing_enabled",
|
||||
CONF.capi_helm_cluster_labels.auto_healing_enabled,
|
||||
)
|
||||
|
||||
def _get_autoscale_enabled(self, cluster):
|
||||
return self._get_label_bool(cluster, "auto_scaling_enabled", False)
|
||||
return self._get_label_bool(
|
||||
cluster,
|
||||
"auto_scaling_enabled",
|
||||
CONF.capi_helm_cluster_labels.auto_scaling_enabled,
|
||||
)
|
||||
|
||||
def _get_autoscale_values(self, cluster, nodegroup):
|
||||
auto_scale = self._get_autoscale_enabled(cluster)
|
||||
@@ -662,7 +716,11 @@ class Driver(driver.Driver):
|
||||
return auto_scale_args
|
||||
|
||||
def _get_k8s_keystone_auth_enabled(self, cluster):
|
||||
return self._get_label_bool(cluster, "keystone_auth_enabled", False)
|
||||
return self._get_label_bool(
|
||||
cluster,
|
||||
"keystone_auth_enabled",
|
||||
CONF.capi_helm_cluster_labels.keystone_auth_enabled,
|
||||
)
|
||||
|
||||
def _get_fixed_network_id(self, context, cluster):
|
||||
network = cluster.fixed_network
|
||||
@@ -737,11 +795,17 @@ class Driver(driver.Driver):
|
||||
# We still want to be able to override the default node
|
||||
# group values with labels for consistent behaviour with
|
||||
# Magnum Heat driver.
|
||||
conf_min = CONF.capi_helm_cluster_labels.min_node_count
|
||||
conf_max = CONF.capi_helm_cluster_labels.max_node_count
|
||||
min_nodes = self._get_label_int(
|
||||
cluster, "min_node_count", min_nodes
|
||||
cluster,
|
||||
"min_node_count",
|
||||
conf_min if conf_min is not None else min_nodes,
|
||||
)
|
||||
max_nodes = self._get_label_int(
|
||||
cluster, "max_node_count", max_nodes
|
||||
cluster,
|
||||
"max_node_count",
|
||||
conf_max if conf_max is not None else max_nodes,
|
||||
)
|
||||
|
||||
return min_nodes, max_nodes
|
||||
@@ -780,54 +844,64 @@ class Driver(driver.Driver):
|
||||
return self._label(
|
||||
cluster,
|
||||
"csi_cinder_availability_zone",
|
||||
CONF.capi_helm.csi_cinder_availability_zone,
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_availability_zone,
|
||||
)
|
||||
|
||||
def _get_csi_cinder_reclaim_policy(self, cluster):
|
||||
return self._label(
|
||||
cluster,
|
||||
"csi_cinder_reclaim_policy",
|
||||
CONF.capi_helm.csi_cinder_reclaim_policy,
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_reclaim_policy,
|
||||
)
|
||||
|
||||
def _get_csi_cinder_volume_binding_mode(self, cluster):
|
||||
return self._label(
|
||||
cluster,
|
||||
"csi_cinder_volume_binding_mode",
|
||||
CONF.capi_helm.csi_cinder_volume_binding_mode,
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_volume_binding_mode,
|
||||
)
|
||||
|
||||
def _get_csi_cinder_fstype(self, cluster):
|
||||
return self._label(
|
||||
cluster,
|
||||
"csi_cinder_fstype",
|
||||
CONF.capi_helm.csi_cinder_fstype,
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_fstype,
|
||||
)
|
||||
|
||||
def _get_csi_cinder_allow_volume_expansion(self, cluster):
|
||||
return self._get_label_bool(
|
||||
cluster,
|
||||
"csi_cinder_allow_volume_expansion",
|
||||
CONF.capi_helm.csi_cinder_allow_volume_expansion,
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_allow_volume_expansion,
|
||||
)
|
||||
|
||||
def _get_octavia_provider(self, cluster):
|
||||
return self._label(cluster, "octavia_provider", "amphora")
|
||||
return self._label(
|
||||
cluster,
|
||||
"octavia_provider",
|
||||
CONF.capi_helm_cluster_labels.octavia_provider,
|
||||
)
|
||||
|
||||
def _get_octavia_lb_algorithm(self, cluster):
|
||||
provider = self._get_octavia_provider(cluster)
|
||||
conf_default = CONF.capi_helm_cluster_labels.octavia_lb_algorithm
|
||||
dynamic_default = (
|
||||
"SOURCE_IP_PORT" if provider.lower() == "ovn" else "ROUND_ROBIN"
|
||||
)
|
||||
return self._label(
|
||||
cluster,
|
||||
"octavia_lb_algorithm",
|
||||
"SOURCE_IP_PORT" if provider.lower() == "ovn" else "ROUND_ROBIN",
|
||||
conf_default or dynamic_default,
|
||||
)
|
||||
|
||||
def _get_allowed_cidrs(self, cluster):
|
||||
cidr_list = cluster.labels.get("api_master_lb_allowed_cidrs", "")
|
||||
LOG.debug(f"CIDR list {cidr_list}")
|
||||
if isinstance(cidr_list, str) and cidr_list != "":
|
||||
return cidr_list.split(",")
|
||||
return False
|
||||
cidrs = self._get_label_csv(
|
||||
cluster,
|
||||
"api_master_lb_allowed_cidrs",
|
||||
CONF.capi_helm_cluster_labels.api_master_lb_allowed_cidrs,
|
||||
)
|
||||
LOG.debug(f"CIDR list {cidrs}")
|
||||
return cidrs or False
|
||||
|
||||
def _storageclass_definitions(self, context, cluster):
|
||||
"""Query cinder API to retrieve list of available volume types.
|
||||
@@ -908,6 +982,7 @@ class Driver(driver.Driver):
|
||||
return nodegroup_set
|
||||
|
||||
def _update_helm_release(self, context, cluster, nodegroups=None):
|
||||
lconf = CONF.capi_helm_cluster_labels
|
||||
if nodegroups is None:
|
||||
nodegroups = cluster.nodegroups
|
||||
|
||||
@@ -930,7 +1005,7 @@ class Driver(driver.Driver):
|
||||
"associateFloatingIP": self._get_label_bool(
|
||||
cluster,
|
||||
"master_lb_floating_ip_enabled",
|
||||
True,
|
||||
lconf.master_lb_floating_ip_enabled,
|
||||
),
|
||||
"enableLoadBalancer": True,
|
||||
"loadBalancerProvider": self._get_octavia_provider(cluster),
|
||||
@@ -947,7 +1022,9 @@ class Driver(driver.Driver):
|
||||
"subnetFilter": ({"id": subnet_id} if subnet_id else None),
|
||||
# This is only used if a fixed network is not specified
|
||||
"nodeCidr": self._label(
|
||||
cluster, "fixed_subnet_cidr", "10.0.0.0/24"
|
||||
cluster,
|
||||
"fixed_subnet_cidr",
|
||||
lconf.fixed_subnet_cidr,
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -977,7 +1054,9 @@ class Driver(driver.Driver):
|
||||
cluster
|
||||
),
|
||||
"create-monitor": self._get_label_bool(
|
||||
cluster, "octavia_lb_healthcheck", True
|
||||
cluster,
|
||||
"octavia_lb_healthcheck",
|
||||
lconf.octavia_lb_healthcheck,
|
||||
),
|
||||
}
|
||||
},
|
||||
@@ -997,7 +1076,9 @@ class Driver(driver.Driver):
|
||||
# Add boot disk details, if defined in config file.
|
||||
# Helm chart defaults to ephemeral disks, if unset.
|
||||
boot_volume_type = self._label(
|
||||
cluster, "boot_volume_type", CONF.cinder.default_boot_volume_type
|
||||
cluster,
|
||||
"boot_volume_type",
|
||||
lconf.boot_volume_type or CONF.cinder.default_boot_volume_type,
|
||||
)
|
||||
if boot_volume_type:
|
||||
disk_type_details = {
|
||||
@@ -1014,8 +1095,15 @@ class Driver(driver.Driver):
|
||||
}
|
||||
values = helm.mergeconcat(values, disk_type_details)
|
||||
|
||||
conf_boot_size = lconf.boot_volume_size
|
||||
boot_volume_size_gb = self._get_label_int(
|
||||
cluster, "boot_volume_size", CONF.cinder.default_boot_volume_size
|
||||
cluster,
|
||||
"boot_volume_size",
|
||||
(
|
||||
conf_boot_size
|
||||
if conf_boot_size is not None
|
||||
else CONF.cinder.default_boot_volume_size
|
||||
),
|
||||
)
|
||||
if boot_volume_size_gb:
|
||||
disk_size_details = {
|
||||
@@ -1034,7 +1122,11 @@ class Driver(driver.Driver):
|
||||
|
||||
# Sometimes you need to add an extra network
|
||||
# for things like Cinder CSI CephFS Native
|
||||
extra_network_name = self._label(cluster, "extra_network_name", "")
|
||||
extra_network_name = self._label(
|
||||
cluster,
|
||||
"extra_network_name",
|
||||
lconf.extra_network_name,
|
||||
)
|
||||
if extra_network_name:
|
||||
network_details = {
|
||||
"nodeGroupDefaults": {
|
||||
|
||||
@@ -967,25 +967,40 @@ class ClusterAPIDriverTest(base.DbTestCase):
|
||||
self.cluster_obj.labels = dict()
|
||||
self.cluster_obj.cluster_template.labels = dict()
|
||||
|
||||
result = self.driver._label(self.cluster_obj, "foo", "bar")
|
||||
result = self.driver._label(
|
||||
self.cluster_obj, "monitoring_enabled", "bar"
|
||||
)
|
||||
|
||||
self.assertEqual("bar", result)
|
||||
|
||||
def test_label_return_template(self):
|
||||
self.cluster_obj.cluster_template.labels = dict(foo=42)
|
||||
self.cluster_obj.cluster_template.labels = dict(monitoring_enabled=42)
|
||||
|
||||
result = self.driver._label(self.cluster_obj, "foo", "bar")
|
||||
result = self.driver._label(
|
||||
self.cluster_obj, "monitoring_enabled", "bar"
|
||||
)
|
||||
|
||||
self.assertEqual("42", result)
|
||||
|
||||
def test_label_return_cluster(self):
|
||||
self.cluster_obj.labels = dict(foo=41)
|
||||
self.cluster_obj.cluster_template.labels = dict(foo=42)
|
||||
self.cluster_obj.labels = dict(monitoring_enabled=41)
|
||||
self.cluster_obj.cluster_template.labels = dict(monitoring_enabled=42)
|
||||
|
||||
result = self.driver._label(self.cluster_obj, "foo", "bar")
|
||||
result = self.driver._label(
|
||||
self.cluster_obj, "monitoring_enabled", "bar"
|
||||
)
|
||||
|
||||
self.assertEqual("41", result)
|
||||
|
||||
def test_label_unregistered_key_raises(self):
|
||||
self.assertRaises(
|
||||
ValueError,
|
||||
self.driver._label,
|
||||
self.cluster_obj,
|
||||
"not_a_real_label",
|
||||
"default",
|
||||
)
|
||||
|
||||
def test_sanitized_name_no_suffix(self):
|
||||
self.assertEqual(
|
||||
"123-456fab", driver_utils.sanitized_name("123-456Fab")
|
||||
@@ -2020,7 +2035,9 @@ class ClusterAPIDriverTest(base.DbTestCase):
|
||||
@mock.patch("magnum.common.clients.OpenStackClients.cinder")
|
||||
def test_get_storage_classes(self, mock_cinder):
|
||||
CONF.capi_helm.csi_cinder_default_volume_type = "type3"
|
||||
CONF.capi_helm.csi_cinder_availability_zone = "middle_earth_east"
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_availability_zone = (
|
||||
"middle_earth_east"
|
||||
)
|
||||
mock_vol_type_1 = mock.MagicMock()
|
||||
mock_vol_type_1.name = "type1"
|
||||
mock_vol_type_2 = mock.MagicMock()
|
||||
@@ -2055,7 +2072,9 @@ class ClusterAPIDriverTest(base.DbTestCase):
|
||||
@mock.patch("magnum.common.clients.OpenStackClients.cinder")
|
||||
def test_get_storage_class_volume_type_not_available(self, mock_cinder):
|
||||
CONF.capi_helm.csi_cinder_default_volume_type = "type4"
|
||||
CONF.capi_helm.csi_cinder_availability_zone = "middle_earth_east"
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_availability_zone = (
|
||||
"middle_earth_east"
|
||||
)
|
||||
mock_vol_type_1 = mock.MagicMock()
|
||||
mock_vol_type_1.name = "type1"
|
||||
mock_vol_type_2 = mock.MagicMock()
|
||||
@@ -2082,7 +2101,9 @@ class ClusterAPIDriverTest(base.DbTestCase):
|
||||
@mock.patch("magnum.common.clients.OpenStackClients.cinder")
|
||||
def test_get_storage_class_volume_type_not_defined(self, mock_cinder):
|
||||
CONF.capi_helm.csi_cinder_default_volume_type = None
|
||||
CONF.capi_helm.csi_cinder_availability_zone = "middle_earth_east"
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_availability_zone = (
|
||||
"middle_earth_east"
|
||||
)
|
||||
mock_vol_type_1 = mock.MagicMock()
|
||||
mock_vol_type_1.name = "__TYPE1__"
|
||||
mock_vol_type_2 = mock.MagicMock()
|
||||
@@ -2108,7 +2129,9 @@ class ClusterAPIDriverTest(base.DbTestCase):
|
||||
@mock.patch("magnum.common.clients.OpenStackClients.cinder")
|
||||
def test_get_storage_classes_openstacksdk(self, mock_cinder):
|
||||
CONF.capi_helm.csi_cinder_default_volume_type = "type3"
|
||||
CONF.capi_helm.csi_cinder_availability_zone = "middle_earth_east"
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_availability_zone = (
|
||||
"middle_earth_east"
|
||||
)
|
||||
mock_vol_type_1 = mock.MagicMock()
|
||||
mock_vol_type_1.name = "type1"
|
||||
mock_vol_type_2 = mock.MagicMock()
|
||||
@@ -2145,7 +2168,9 @@ class ClusterAPIDriverTest(base.DbTestCase):
|
||||
self, mock_cinder
|
||||
):
|
||||
CONF.capi_helm.csi_cinder_default_volume_type = "type4"
|
||||
CONF.capi_helm.csi_cinder_availability_zone = "middle_earth_east"
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_availability_zone = (
|
||||
"middle_earth_east"
|
||||
)
|
||||
mock_vol_type_1 = mock.MagicMock()
|
||||
mock_vol_type_1.name = "type1"
|
||||
mock_vol_type_2 = mock.MagicMock()
|
||||
@@ -2174,7 +2199,9 @@ class ClusterAPIDriverTest(base.DbTestCase):
|
||||
self, mock_cinder
|
||||
):
|
||||
CONF.capi_helm.csi_cinder_default_volume_type = None
|
||||
CONF.capi_helm.csi_cinder_availability_zone = "middle_earth_east"
|
||||
CONF.capi_helm_cluster_labels.csi_cinder_availability_zone = (
|
||||
"middle_earth_east"
|
||||
)
|
||||
mock_vol_type_1 = mock.MagicMock()
|
||||
mock_vol_type_1.name = "__TYPE1__"
|
||||
mock_vol_type_2 = mock.MagicMock()
|
||||
@@ -3696,3 +3723,50 @@ class ClusterAPIDriverTest(base.DbTestCase):
|
||||
fields.ClusterStatus.UPDATE_FAILED, self.cluster_obj.status
|
||||
)
|
||||
self.assertIsNotNone(self.cluster_obj.status_reason)
|
||||
|
||||
|
||||
class TestClusterLabelRegistry(base.DbTestCase):
|
||||
"""Enforce that every label key used in the driver is registered."""
|
||||
|
||||
def test_all_label_calls_use_registered_keys(self):
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
from magnum_capi_helm import conf as capi_conf
|
||||
from magnum_capi_helm import driver as capi_driver
|
||||
|
||||
registered = frozenset(
|
||||
opt.name for opt in capi_conf.capi_helm_cluster_labels_opts
|
||||
)
|
||||
source = inspect.getsource(capi_driver)
|
||||
tree = ast.parse(source)
|
||||
|
||||
label_methods = {
|
||||
"_label",
|
||||
"_get_label_bool",
|
||||
"_get_label_int",
|
||||
"_get_label_csv",
|
||||
}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not (
|
||||
isinstance(func, ast.Attribute) and func.attr in label_methods
|
||||
):
|
||||
continue
|
||||
# second positional arg (index 1) is the label key
|
||||
if len(node.args) < 2:
|
||||
continue
|
||||
key_node = node.args[1]
|
||||
# Variable keys (e.g. inside _get_label_bool forwarding to
|
||||
# _label) are validated at runtime; only check string literals.
|
||||
if not isinstance(key_node, ast.Constant):
|
||||
continue
|
||||
self.assertIn(
|
||||
key_node.value,
|
||||
registered,
|
||||
f"Label '{key_node.value}' used at line {node.lineno} "
|
||||
"is not registered in capi_helm_cluster_labels_opts. "
|
||||
"Add it to conf.py before using it in the driver.",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
All cluster label defaults are now configurable site-wide via a new
|
||||
``[capi_helm_cluster_labels]`` section in ``magnum.conf``. Each supported
|
||||
label has a typed option with a default value that users can still override
|
||||
per cluster or template via Magnum labels.
|
||||
deprecations:
|
||||
- |
|
||||
The ``csi_cinder_availability_zone``, ``csi_cinder_reclaim_policy``,
|
||||
``csi_cinder_volume_binding_mode``, ``csi_cinder_fstype``, and
|
||||
``csi_cinder_allow_volume_expansion`` options have moved from the
|
||||
``[capi_helm]`` section to the new ``[capi_helm_cluster_labels]`` section.
|
||||
The old locations are still accepted but will produce a deprecation
|
||||
warning. Please update your ``magnum.conf`` accordingly.
|
||||
Reference in New Issue
Block a user