1024 lines
41 KiB
Python
1024 lines
41 KiB
Python
# Copyright 2019 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.
|
|
|
|
import collections
|
|
import operator
|
|
import os
|
|
import subprocess
|
|
import time
|
|
|
|
import charmhelpers.core as ch_core
|
|
import charmhelpers.contrib.charmsupport.nrpe as nrpe
|
|
import charmhelpers.contrib.network.ovs.ovn as ch_ovn
|
|
import charmhelpers.contrib.network.ovs.ovsdb as ch_ovsdb
|
|
from charmhelpers.contrib.network import ufw as ch_ufw
|
|
import charmhelpers.contrib.openstack.deferred_events as deferred_events
|
|
import charmhelpers.fetch as ch_fetch
|
|
|
|
import charms.reactive as reactive
|
|
|
|
import charms_openstack.adapters
|
|
import charms_openstack.charm
|
|
|
|
from charms.layer import snap
|
|
|
|
# Release selection need to happen here for correct determination during
|
|
# bus discovery and action exection
|
|
charms_openstack.charm.use_defaults('charm.default-select-release')
|
|
|
|
|
|
PEER_RELATION = 'ovsdb-peer'
|
|
CERT_RELATION = 'certificates'
|
|
|
|
|
|
# NOTE(fnordahl): We should split the ``OVNConfigurationAdapter`` in
|
|
# ``layer-ovn`` into common and chassis specific parts so we can re-use the
|
|
# common parts here.
|
|
class OVNCentralConfigurationAdapter(
|
|
charms_openstack.adapters.ConfigurationAdapter):
|
|
"""Provide a configuration adapter for OVN Central."""
|
|
|
|
@property
|
|
def ovn_key(self):
|
|
return os.path.join(self.charm_instance.ovn_sysconfdir(), 'key_host')
|
|
|
|
@property
|
|
def ovn_cert(self):
|
|
return os.path.join(self.charm_instance.ovn_sysconfdir(), 'cert_host')
|
|
|
|
@property
|
|
def ovn_ca_cert(self):
|
|
return os.path.join(self.charm_instance.ovn_sysconfdir(),
|
|
'{}.crt'.format(self.charm_instance.name))
|
|
|
|
@property
|
|
def is_charm_leader(self):
|
|
return reactive.is_flag_set('leadership.is_leader')
|
|
|
|
@property
|
|
def _ovn_source(self):
|
|
if (not self.ovn_source
|
|
and reactive.is_flag_set('leadership.set.install_stamp')
|
|
and ch_core.host.lsb_release()['DISTRIB_CODENAME'] == 'focal'):
|
|
return 'cloud:focal-ovn-22.03'
|
|
return self.ovn_source
|
|
|
|
@property
|
|
def ovn_exporter_snap_channel(self):
|
|
"""Validate a provided snap channel and return it
|
|
|
|
Any prefix is ignored ('0.10' in '0.10/stable' for example). If
|
|
a config value is empty it means that the snap does not need to
|
|
be installed.
|
|
"""
|
|
channel = self.ovn_exporter_channel
|
|
if not channel:
|
|
return None
|
|
|
|
channel_suffix = channel.split('/')[-1]
|
|
if channel_suffix not in ('stable', 'candidate', 'beta', 'edge'):
|
|
return 'stable'
|
|
return channel_suffix
|
|
|
|
|
|
class BaseOVNCentralCharm(charms_openstack.charm.OpenStackCharm):
|
|
abstract_class = True
|
|
# Note that we currently do not support pivoting between release specific
|
|
# charm classes in the OVN charms. We still need this set to ensure the
|
|
# default methods are happy.
|
|
#
|
|
# Also see docstring in the `upgrade_if_available` method.
|
|
package_codenames = {
|
|
'ovn-central': collections.OrderedDict([
|
|
('2', 'train'),
|
|
('20', 'ussuri'),
|
|
]),
|
|
}
|
|
name = 'ovn-central'
|
|
packages = ['ovn-central']
|
|
services = ['ovn-central']
|
|
nrpe_check_services = []
|
|
release_pkg = 'ovn-central'
|
|
configuration_class = OVNCentralConfigurationAdapter
|
|
required_relations = [PEER_RELATION, CERT_RELATION]
|
|
python_version = 3
|
|
source_config_key = 'source'
|
|
min_election_timer = 1
|
|
max_election_timer = 60
|
|
|
|
def __init__(self, **kwargs):
|
|
"""Override class init to populate restart map with instance method."""
|
|
self.restart_map = {
|
|
'/etc/default/ovn-central': self.services,
|
|
os.path.join(self.ovn_sysconfdir(),
|
|
'ovn-northd-db-params.conf'): ['ovn-northd'],
|
|
}
|
|
super().__init__(**kwargs)
|
|
|
|
def restart_on_change(self):
|
|
"""Restart the services in the self.restart_map{} attribute if any of
|
|
the files identified by the keys changes for the wrapped call.
|
|
|
|
Usage:
|
|
|
|
with restart_on_change(restart_map, ...):
|
|
do_stuff_that_might_trigger_a_restart()
|
|
...
|
|
"""
|
|
return ch_core.host.restart_on_change(
|
|
self.full_restart_map,
|
|
stopstart=True,
|
|
restart_functions=getattr(self, 'restart_functions', None),
|
|
can_restart_now_f=deferred_events.check_and_record_restart_request,
|
|
post_svc_restart_f=deferred_events.process_svc_restart)
|
|
|
|
@property
|
|
def deferable_services(self):
|
|
"""Services which should be stopped from restarting.
|
|
|
|
All services from self.services are deferable. But the charm may
|
|
install a package which install a service that the charm does not add
|
|
to its restart_map. In that case it will be missing from
|
|
self.services. However one of the jobs of deferred events is to ensure
|
|
that packages updates outside of charms also do not restart services.
|
|
To ensure there is a complete list take the services from self.services
|
|
and also add in a known list of networking services.
|
|
|
|
NOTE: It does not matter if one of the services in the list is not
|
|
installed on the system.
|
|
"""
|
|
svcs = self.services[:]
|
|
svcs.extend(['ovn-ovsdb-server-nb', 'ovn-ovsdb-server-nb',
|
|
'ovn-northd', 'ovn-central'])
|
|
return list(set(svcs))
|
|
|
|
def configure_ovn_source(self):
|
|
"""Configure the OVN overlay archive."""
|
|
if self.options.ovn_source:
|
|
# The end user has added configuration which may require full
|
|
# processing including key extraction.
|
|
self.configure_source(config_key='ovn-source')
|
|
elif self.options._ovn_source:
|
|
# The end user has not added configuration and we want to use the
|
|
# runtime determined default value.
|
|
#
|
|
# We cannot use the default `configure_source` method here as it
|
|
# attempts to access charm config directly.
|
|
ch_fetch.add_source(self.options._ovn_source)
|
|
ch_fetch.apt_update(fatal=True)
|
|
|
|
def configure_sources(self):
|
|
"""Configure package sources for OVN charms.
|
|
|
|
The principal charms provide both a `source` and a `ovn-source`
|
|
configuration option, and the subordinate charms only provide the
|
|
`ovn-source` configuration option.
|
|
|
|
The `source` configuration option is tied into the charms.openstack
|
|
`source_config_key` class variable and is inteded to be used with a
|
|
full UCA archive. The default methods and functions will apply special
|
|
meaning to the name used for further processing, and as such the
|
|
`source` configuration option is not suitable for use with an overlay
|
|
archive.
|
|
|
|
The `ovn-source` configuration option is intended to be used with a
|
|
slim overlay archive containing only OVN and its dependencies.
|
|
|
|
The two configuration options can be used simultaneously, and the
|
|
underlying charm-helpers code will write the configuration out into
|
|
separate files depending on the value of the options.
|
|
|
|
Ref: https://github.com/juju/charm-helpers/commit/982319b136b
|
|
"""
|
|
self.configure_ovn_source()
|
|
if self.source_config_key:
|
|
self.configure_source()
|
|
|
|
def install(self, service_masks=None):
|
|
"""Extend the default install method.
|
|
|
|
Mask services before initial installation.
|
|
|
|
This is done to prevent extraneous standalone DB initialization and
|
|
subsequent upgrade to clustered DB when configuration is rendered.
|
|
|
|
We need to manually create the symlink as the package is not installed
|
|
yet and subsequently systemctl(1) has no knowledge of it.
|
|
|
|
We also configure source before installing as OpenvSwitch and OVN
|
|
packages are distributed as part of the UCA.
|
|
"""
|
|
# NOTE(fnordahl): The actual masks are provided by the release specific
|
|
# classes.
|
|
service_masks = service_masks or []
|
|
for service_file in service_masks:
|
|
abs_path_svc = os.path.join('/etc/systemd/system', service_file)
|
|
if not os.path.islink(abs_path_svc):
|
|
os.symlink('/dev/null', abs_path_svc)
|
|
self.configure_sources()
|
|
super().install()
|
|
|
|
def upgrade_charm(self):
|
|
"""Extend the default upgrade_charm method."""
|
|
super().upgrade_charm()
|
|
|
|
# Ensure that `config.changed.ovn-source` flag is not set on charm
|
|
# upgrade. When upgrading from an older charm, this flag will be
|
|
# set even though the config has not changed.
|
|
reactive.clear_flag('config.changed.ovn-source')
|
|
|
|
def ovn_upgrade_available(self, package=None, snap=None):
|
|
"""Determine whether an OVN upgrade is available.
|
|
|
|
Make use of the installed package version and the package version
|
|
available in the apt cache to determine availability of new version.
|
|
"""
|
|
self.configure_sources()
|
|
cur_vers = self.get_package_version(self.release_pkg,
|
|
apt_cache_sufficient=False)
|
|
avail_vers = self.get_package_version(self.release_pkg,
|
|
apt_cache_sufficient=True)
|
|
ch_fetch.apt_pkg.init()
|
|
return ch_fetch.apt_pkg.version_compare(avail_vers, cur_vers) == 1
|
|
|
|
def upgrade_if_available(self, interfaces_list):
|
|
"""Upgrade OVN if an upgrade is available.
|
|
|
|
At present there is no need to pivot to a release specific charm class
|
|
when upgrading OVN. As such we override the default method to keep
|
|
this simpler, given OVN versions are not fully represented in the
|
|
OpenStack version machinery that the default method relies on.
|
|
|
|
:param interfaces_list: List of instances of interface classes
|
|
:returns: None
|
|
"""
|
|
if self.ovn_upgrade_available(self.release_pkg):
|
|
ch_core.hookenv.status_set('maintenance', 'Rolling upgrade')
|
|
self.do_openstack_pkg_upgrade(upgrade_openstack=False)
|
|
self.render_with_interfaces(interfaces_list)
|
|
|
|
def configure_deferred_restarts(self):
|
|
if 'enable-auto-restarts' in ch_core.hookenv.config().keys():
|
|
deferred_events.configure_deferred_restarts(
|
|
self.deferable_services)
|
|
# Reactive charms execute perm missing.
|
|
os.chmod(
|
|
'/var/lib/charm/{}/policy-rc.d'.format(
|
|
ch_core.hookenv.service_name()),
|
|
0o755)
|
|
|
|
def states_to_check(self, required_relations=None):
|
|
"""Override parent method to add custom messaging.
|
|
|
|
Note that this method will only override the messaging for certain
|
|
relations, any relations we don't know about will get the default
|
|
treatment from the parent method.
|
|
|
|
:param required_relations: Override `required_relations` class instance
|
|
variable.
|
|
:type required_relations: Optional[List[str]]
|
|
:returns: Map of relation name to flags to check presence of
|
|
accompanied by status and message.
|
|
:rtype: collections.OrderedDict[str, List[Tuple[str, str, str]]]
|
|
"""
|
|
# Retrieve default state map
|
|
states_to_check = super().states_to_check(
|
|
required_relations=required_relations)
|
|
|
|
# The parent method will always return a OrderedDict
|
|
if PEER_RELATION in states_to_check:
|
|
# for the peer relation we want default messaging for all states
|
|
# but connected.
|
|
states_to_check[PEER_RELATION] = [
|
|
('{}.connected'.format(PEER_RELATION),
|
|
'blocked',
|
|
'Charm requires peers to operate, add more units. A minimum '
|
|
'of 3 is required for HA')
|
|
] + [
|
|
states for states in states_to_check[PEER_RELATION]
|
|
if 'connected' not in states[0]
|
|
]
|
|
|
|
if CERT_RELATION in states_to_check:
|
|
# for the certificates relation we want to replace all messaging
|
|
states_to_check[CERT_RELATION] = [
|
|
# the certificates relation has no connected state
|
|
('{}.available'.format(CERT_RELATION),
|
|
'blocked',
|
|
"'{}' missing".format(CERT_RELATION)),
|
|
# we cannot proceed until Vault have provided server
|
|
# certificates
|
|
('{}.server.certs.available'.format(CERT_RELATION),
|
|
'waiting',
|
|
"'{}' awaiting server certificate data"
|
|
.format(CERT_RELATION)),
|
|
]
|
|
|
|
return states_to_check
|
|
|
|
@staticmethod
|
|
def ovn_sysconfdir():
|
|
return '/etc/ovn'
|
|
|
|
@staticmethod
|
|
def ovn_rundir():
|
|
return '/var/run/ovn'
|
|
|
|
def _default_port_list(self, *_):
|
|
"""Return list of ports the payload listens to.
|
|
|
|
The api_ports class attribute can not be used as it does not allow
|
|
one service to listen to multiple ports.
|
|
|
|
:returns: port numbers the payload listens to.
|
|
:rtype: List[int]
|
|
"""
|
|
# NOTE(fnordahl): the port check does not appear to cope with
|
|
# ports bound to a specific interface LP: #1843434
|
|
return [6641, 6642]
|
|
|
|
def ports_to_check(self, *_):
|
|
"""Return list of ports to check the payload listens too.
|
|
|
|
The api_ports class attribute can not be used as it does not allow
|
|
one service to listen to multiple ports.
|
|
|
|
:returns: ports numbers the payload listens to.
|
|
:rtype List[int]
|
|
"""
|
|
return self._default_port_list()
|
|
|
|
def validate_config(self):
|
|
"""Validate configuration and inform user of any issues.
|
|
|
|
:returns: Tuple with status and message describing configuration issue.
|
|
:rtype: Tuple[Optional[str],Optional[str]]
|
|
"""
|
|
tgt_timer = self.config['ovsdb-server-election-timer']
|
|
if (tgt_timer > self.max_election_timer or
|
|
tgt_timer < self.min_election_timer):
|
|
return (
|
|
'blocked',
|
|
"Invalid configuration: 'ovsdb-server-election-timer' must be "
|
|
"> {} < {}."
|
|
.format(self.min_election_timer, self.max_election_timer))
|
|
return None, None
|
|
|
|
def custom_assess_status_last_check(self):
|
|
"""Customize charm status output.
|
|
|
|
Checks and notifies for invalid config and adds clustered DB status to
|
|
status message.
|
|
|
|
:returns: Tuple with workload status and message.
|
|
:rtype: Tuple[Optional[str],Optional[str]]
|
|
"""
|
|
invalid_config = self.validate_config()
|
|
if invalid_config != (None, None):
|
|
return invalid_config
|
|
|
|
cluster_str = self.cluster_status_message()
|
|
if cluster_str:
|
|
return ('active', 'Unit is ready ({})'.format(cluster_str))
|
|
return None, None
|
|
|
|
def enable_services(self):
|
|
"""Enable services.
|
|
|
|
:returns: True on success, False on failure.
|
|
:rtype: bool
|
|
"""
|
|
if self.check_if_paused() != (None, None):
|
|
return False
|
|
for service in self.services:
|
|
ch_core.host.service_resume(service)
|
|
return True
|
|
|
|
def cluster_status(self, db):
|
|
"""OVN version agnostic cluster_status helper.
|
|
|
|
:param db: Database to operate on
|
|
:type db: str
|
|
:returns: Object describing the cluster status or None
|
|
:rtype: Optional[ch_ovn.OVNClusterStatus]
|
|
"""
|
|
try:
|
|
# The charm will attempt to retrieve cluster status before OVN
|
|
# is clustered and while units are paused, so we need to handle
|
|
# errors from this call gracefully.
|
|
return ch_ovn.cluster_status(db, rundir=self.ovn_rundir(),
|
|
use_ovs_appctl=(
|
|
self.release == 'train'))
|
|
except (ValueError, subprocess.CalledProcessError) as e:
|
|
ch_core.hookenv.log('Unable to get cluster status, ovsdb-server '
|
|
'not ready yet?: {}'.format(e),
|
|
level=ch_core.hookenv.DEBUG)
|
|
return
|
|
|
|
def cluster_status_message(self):
|
|
"""Get cluster status message suitable for use as workload message.
|
|
|
|
:returns: Textual representation of local unit db and northd status.
|
|
:rtype: str
|
|
"""
|
|
db_leader = []
|
|
for db in ('ovnnb_db', 'ovnsb_db',):
|
|
status = self.cluster_status(db)
|
|
if status and status.is_cluster_leader:
|
|
db_leader.append(db)
|
|
|
|
msg = []
|
|
if db_leader:
|
|
msg.append('leader: {}'.format(', '.join(db_leader)))
|
|
if self.is_northd_active():
|
|
msg.append('northd: active')
|
|
return ' '.join(msg)
|
|
|
|
def is_northd_active(self):
|
|
"""OVN version agnostic is_northd_active helper.
|
|
|
|
:returns: True if northd is active, False if not, None if not supported
|
|
:rtype: Optional[bool]
|
|
"""
|
|
if self.release != 'train':
|
|
return ch_ovn.is_northd_active()
|
|
|
|
def run(self, *args):
|
|
"""Fork off a proc and run commands, collect output and return code.
|
|
|
|
:param args: Arguments
|
|
:type args: Union
|
|
:returns: subprocess.CompletedProcess object
|
|
:rtype: subprocess.CompletedProcess
|
|
:raises: subprocess.CalledProcessError
|
|
"""
|
|
cp = subprocess.run(
|
|
args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=True,
|
|
universal_newlines=True)
|
|
ch_core.hookenv.log(cp, level=ch_core.hookenv.INFO)
|
|
|
|
def join_cluster(self, db_file, schema_name, local_conn, remote_conn):
|
|
"""Maybe create a OVSDB file with remote peer connection information.
|
|
|
|
This function will return immediately if the database file already
|
|
exists.
|
|
|
|
Because of a shortcoming in the ``ovn-ctl`` script used to start the
|
|
OVN databases we call to ``ovsdb-tool join-cluster`` ourself.
|
|
|
|
That will create a database file on disk with the required information
|
|
and the ``ovn-ctl`` script will not touch it.
|
|
|
|
The ``ovn-ctl`` ``db-nb-cluster-remote-addr`` and
|
|
``db-sb-cluster-remote-addr`` configuration options only take one
|
|
remote and one must be provided for correct startup, but the values in
|
|
the on-disk database file will be used by the ``ovsdb-server`` process.
|
|
|
|
:param db_file: Full path to OVSDB file
|
|
:type db_file: str
|
|
:param schema_name: OVSDB Schema [OVN_Northbound, OVN_Southbound]
|
|
:type schema_name: str
|
|
:param local_conn: Connection string for local unit
|
|
:type local_conn: Union[str, ...]
|
|
:param remote_conn: Connection string for remote unit(s)
|
|
:type remote_conn: Union[str, ...]
|
|
:raises: subprocess.CalledProcessError
|
|
"""
|
|
if self.release == 'train':
|
|
absolute_path = os.path.join('/var/lib/openvswitch', db_file)
|
|
else:
|
|
absolute_path = os.path.join('/var/lib/ovn', db_file)
|
|
if os.path.exists(absolute_path):
|
|
ch_core.hookenv.log('OVN database "{}" exists on disk, not '
|
|
'creating a new one joining cluster',
|
|
level=ch_core.hookenv.DEBUG)
|
|
return
|
|
cmd = ['ovsdb-tool', 'join-cluster', absolute_path, schema_name]
|
|
cmd.extend(list(local_conn))
|
|
cmd.extend(list(remote_conn))
|
|
ch_core.hookenv.log(cmd, level=ch_core.hookenv.INFO)
|
|
self.run(*cmd)
|
|
|
|
def configure_tls(self, certificates_interface=None):
|
|
"""Override default handler prepare certs per OVNs taste.
|
|
|
|
:param certificates_interface: Certificates interface if present
|
|
:type certificates_interface: Optional[reactive.Endpoint]
|
|
:raises: subprocess.CalledProcessError
|
|
"""
|
|
tls_objects = self.get_certs_and_keys(
|
|
certificates_interface=certificates_interface)
|
|
|
|
for tls_object in tls_objects:
|
|
with open(
|
|
self.options.ovn_ca_cert, 'w') as crt:
|
|
chain = tls_object.get('chain')
|
|
if chain:
|
|
crt.write(tls_object['ca'] + os.linesep + chain)
|
|
else:
|
|
crt.write(tls_object['ca'])
|
|
|
|
self.configure_cert(self.ovn_sysconfdir(),
|
|
tls_object['cert'],
|
|
tls_object['key'],
|
|
cn='host')
|
|
break
|
|
|
|
def configure_ovn_listener(self, db, port_map):
|
|
"""Create or update OVN listener configuration.
|
|
|
|
:param db: Database to operate on, 'nb' or 'sb'
|
|
:type db: str
|
|
:param port_map: Dictionary with port number and associated settings
|
|
:type port_map: Dict[int,Dict[str,str]]
|
|
:raises: ValueError
|
|
"""
|
|
if db not in ('nb', 'sb'):
|
|
raise ValueError
|
|
# NOTE: There is one individual OVSDB cluster leader for each
|
|
# of the OVSDB databases and throughout a deployment lifetime
|
|
# they are not necessarilly the same as the charm leader.
|
|
#
|
|
# However, at bootstrap time the OVSDB cluster leaders will
|
|
# coincide with the charm leader.
|
|
status = self.cluster_status('ovn{}_db'.format(db))
|
|
if status and status.is_cluster_leader:
|
|
ch_core.hookenv.log('is_cluster_leader {}'.format(db),
|
|
level=ch_core.hookenv.DEBUG)
|
|
connections = ch_ovsdb.SimpleOVSDB(
|
|
'ovn-{}ctl'.format(db)).connection
|
|
for port, settings in port_map.items():
|
|
ch_core.hookenv.log('port {} {}'.format(port, settings),
|
|
level=ch_core.hookenv.DEBUG)
|
|
# discover and create any non-existing listeners first
|
|
for connection in connections.find(
|
|
'target="pssl:{}"'.format(port)):
|
|
break
|
|
else:
|
|
ch_core.hookenv.log('create port {}'.format(port),
|
|
level=ch_core.hookenv.DEBUG)
|
|
# NOTE(fnordahl) the listener configuration is written to
|
|
# the database and used by all units, so we cannot bind to
|
|
# specific space/address here. We might consider not
|
|
# using listener configuration from DB, but that is
|
|
# currently not supported by ``ovn-ctl`` script.
|
|
self.run('ovn-{}ctl'.format(db),
|
|
'--',
|
|
'--id=@connection',
|
|
'create', 'connection',
|
|
'target="pssl:{}"'.format(port),
|
|
'--',
|
|
'add', '{}_Global'.format(db.upper()),
|
|
'.', 'connections', '@connection')
|
|
# set/update connection settings
|
|
for connection in connections.find(
|
|
'target="pssl:{}"'.format(port)):
|
|
for k, v in settings.items():
|
|
ch_core.hookenv.log(
|
|
'set {} {} {}'
|
|
.format(str(connection['_uuid']), k, v),
|
|
level=ch_core.hookenv.DEBUG)
|
|
connections.set(str(connection['_uuid']), k, v)
|
|
|
|
def configure_ovsdb_election_timer(self, db, tgt_timer):
|
|
"""Set the OVSDB cluster Raft election timer.
|
|
|
|
Note that the OVSDB Server will refuse to decrease or increase this
|
|
value more than 2x the current value, however we should let the end
|
|
user of the charm set this to whatever they want. Paper over the
|
|
reality by iteratively decreasing / increasing the value in a safe
|
|
pace.
|
|
|
|
:param db: Database to operate on, 'nb' or 'sb'
|
|
:type db: str
|
|
:param tgt_timer: Target value for election timer in seconds
|
|
:type tgt_timer: int
|
|
:raises: ValueError
|
|
"""
|
|
if db not in ('nb', 'sb'):
|
|
raise ValueError
|
|
if (tgt_timer > self.max_election_timer or
|
|
tgt_timer < self.min_election_timer):
|
|
# Invalid target timer, log error as well as inform user through
|
|
# workload status+message, please refer to
|
|
# `custom_assess_status_last_check` for implementation detail.
|
|
ch_core.hookenv.log('Attempt to set election timer to invalid '
|
|
'value: {} (min {}, max {})'
|
|
.format(
|
|
tgt_timer,
|
|
self.min_election_timer,
|
|
self.max_election_timer),
|
|
level=ch_core.hookenv.ERROR)
|
|
return
|
|
# OVN uses ms as unit for the election timer
|
|
tgt_timer = tgt_timer * 1000
|
|
|
|
ovn_db = 'ovn{}_db'.format(db)
|
|
ovn_schema = 'OVN_Northbound' if db == 'nb' else 'OVN_Southbound'
|
|
status = self.cluster_status(ovn_db)
|
|
if status and status.is_cluster_leader:
|
|
ch_core.hookenv.log('is_cluster_leader {}'.format(db),
|
|
level=ch_core.hookenv.DEBUG)
|
|
cur_timer = status.election_timer
|
|
if tgt_timer == cur_timer:
|
|
ch_core.hookenv.log('Election timer already set to target '
|
|
'value: {} == {}'
|
|
.format(tgt_timer, cur_timer),
|
|
level=ch_core.hookenv.DEBUG)
|
|
return
|
|
# to be able to reuse the change loop to both increase and decrease
|
|
# the timer we assign the operators used to variables
|
|
if tgt_timer > cur_timer:
|
|
# when increasing timer, we will multiply the value
|
|
change_op = operator.mul
|
|
# when increasing timer, we want the smaller between target
|
|
# value and current value multiplied with 2
|
|
change_select = min
|
|
else:
|
|
# when decreasing timer, we will divide the value and do not
|
|
# want fractional values
|
|
change_op = operator.floordiv
|
|
# when decreasing timer, we want the larger of target value and
|
|
# current value divided by 2
|
|
change_select = max
|
|
while status and status.is_cluster_leader and (
|
|
status.election_timer != tgt_timer):
|
|
# election timer decrease/increase cannot be more than 2x
|
|
# current value per iteration
|
|
change_timer = change_select(
|
|
change_op(cur_timer, 2), tgt_timer)
|
|
ch_core.hookenv.status_set(
|
|
'maintenance',
|
|
'change {} election timer {}ms -> {}ms'
|
|
.format(ovn_schema, cur_timer, change_timer))
|
|
ch_ovn.ovn_appctl(
|
|
ovn_db, (
|
|
'cluster/change-election-timer',
|
|
ovn_schema,
|
|
str(change_timer),
|
|
),
|
|
rundir=self.ovn_rundir(),
|
|
use_ovs_appctl=(self.release == 'train'))
|
|
# wait for an election window to pass before changing the value
|
|
# again
|
|
time.sleep((cur_timer + change_timer) / 1000)
|
|
cur_timer = change_timer
|
|
status = self.cluster_status(ovn_db)
|
|
|
|
def configure_ovn(self, nb_port, sb_port, sb_admin_port):
|
|
"""Create or update OVN listener configuration.
|
|
|
|
:param nb_port: Port for Northbound DB listener
|
|
:type nb_port: int
|
|
:param sb_port: Port for Southbound DB listener
|
|
:type sb_port: int
|
|
:param sb_admin_port: Port for cluster private Southbound DB listener
|
|
:type sb_admin_port: int
|
|
"""
|
|
inactivity_probe = int(
|
|
self.config['ovsdb-server-inactivity-probe']) * 1000
|
|
|
|
self.configure_ovn_listener(
|
|
'nb', {
|
|
nb_port: {
|
|
'inactivity_probe': inactivity_probe,
|
|
},
|
|
})
|
|
self.configure_ovn_listener(
|
|
'sb', {
|
|
sb_port: {
|
|
'role': 'ovn-controller',
|
|
'inactivity_probe': inactivity_probe,
|
|
},
|
|
})
|
|
self.configure_ovn_listener(
|
|
'sb', {
|
|
sb_admin_port: {
|
|
'inactivity_probe': inactivity_probe,
|
|
},
|
|
})
|
|
|
|
election_timer = self.config['ovsdb-server-election-timer']
|
|
self.configure_ovsdb_election_timer('nb', election_timer)
|
|
self.configure_ovsdb_election_timer('sb', election_timer)
|
|
|
|
@staticmethod
|
|
def initialize_firewall():
|
|
"""Initialize firewall.
|
|
|
|
Note that this function is disruptive to active connections and should
|
|
only be called when necessary.
|
|
"""
|
|
# set default allow
|
|
ch_ufw.enable()
|
|
ch_ufw.default_policy('allow', 'incoming')
|
|
ch_ufw.default_policy('allow', 'outgoing')
|
|
ch_ufw.default_policy('allow', 'routed')
|
|
|
|
def configure_firewall(self, port_addr_map):
|
|
"""Configure firewall.
|
|
|
|
Lock down access to ports not protected by OVN RBAC.
|
|
|
|
:param port_addr_map: Map of ports to addresses to allow.
|
|
:type port_addr_map: Dict[Tuple[int, ...], Optional[Iterator]]
|
|
:param allowed_hosts: Hosts allowed to connect.
|
|
:type allowed_hosts: Iterator
|
|
"""
|
|
ufw_comment = 'charm-' + self.name
|
|
|
|
# reject connection to protected ports
|
|
for port in set().union(*port_addr_map.keys()):
|
|
ch_ufw.modify_access(src=None, dst='any', port=port,
|
|
proto='tcp', action='reject',
|
|
comment=ufw_comment)
|
|
# allow connections from provided addresses
|
|
allowed_addrs = {}
|
|
for ports, addrs in port_addr_map.items():
|
|
# store List copy of addrs to iterate over it multiple times
|
|
_addrs = list(addrs or [])
|
|
for port in ports:
|
|
for addr in _addrs:
|
|
ch_ufw.modify_access(addr, port=port, proto='tcp',
|
|
action='allow', prepend=True,
|
|
comment=ufw_comment)
|
|
allowed_addrs[addr] = 1
|
|
# delete any rules managed by us that do not match provided addresses
|
|
delete_rules = []
|
|
for num, rule in ch_ufw.status():
|
|
if 'comment' in rule and rule['comment'] == ufw_comment:
|
|
if (rule['action'] == 'allow in' and
|
|
rule['from'] not in allowed_addrs):
|
|
delete_rules.append(num)
|
|
for rule in sorted(delete_rules, reverse=True):
|
|
ch_ufw.modify_access(None, dst=None, action='delete', index=rule)
|
|
|
|
def render_nrpe(self):
|
|
"""Configure Nagios NRPE checks."""
|
|
hostname = nrpe.get_nagios_hostname()
|
|
current_unit = nrpe.get_nagios_unit_name()
|
|
charm_nrpe = nrpe.NRPE(hostname=hostname)
|
|
nrpe.add_init_service_checks(
|
|
charm_nrpe, self.nrpe_check_services, current_unit)
|
|
charm_nrpe.write()
|
|
|
|
def custom_assess_status_check(self):
|
|
"""Report deferred events in charm status message."""
|
|
state = None
|
|
message = None
|
|
deferred_events.check_restart_timestamps()
|
|
events = collections.defaultdict(set)
|
|
for e in deferred_events.get_deferred_events():
|
|
events[e.action].add(e.service)
|
|
for action, svcs in events.items():
|
|
svc_msg = "Services queued for {}: {}".format(
|
|
action, ', '.join(sorted(svcs)))
|
|
state = 'active'
|
|
if message:
|
|
message = "{}. {}".format(message, svc_msg)
|
|
else:
|
|
message = svc_msg
|
|
deferred_hooks = deferred_events.get_deferred_hooks()
|
|
if deferred_hooks:
|
|
state = 'active'
|
|
svc_msg = "Hooks skipped due to disabled auto restarts: {}".format(
|
|
', '.join(sorted(deferred_hooks)))
|
|
if message:
|
|
message = "{}. {}".format(message, svc_msg)
|
|
else:
|
|
message = svc_msg
|
|
return state, message
|
|
|
|
def assess_exporter(self):
|
|
is_installed = snap.is_installed('prometheus-ovn-exporter')
|
|
channel = None
|
|
channel = self.options.ovn_exporter_snap_channel
|
|
if channel is None:
|
|
if is_installed:
|
|
snap.remove('prometheus-ovn-exporter')
|
|
return
|
|
|
|
if is_installed:
|
|
snap.refresh('prometheus-ovn-exporter', channel=channel,
|
|
devmode=True)
|
|
else:
|
|
snap.install('prometheus-ovn-exporter', channel=channel,
|
|
devmode=True)
|
|
|
|
@staticmethod
|
|
def leave_cluster():
|
|
"""Run commands to remove servers running on this unit from cluster.
|
|
|
|
In case the commands fail, an ERROR message will be logged.
|
|
:return: None
|
|
:rtype: None
|
|
"""
|
|
try:
|
|
ch_core.hookenv.log(
|
|
"Removing self from Southbound cluster",
|
|
ch_core.hookenv.INFO
|
|
)
|
|
ch_ovn.ovn_appctl("ovnsb_db", ("cluster/leave", "OVN_Southbound"))
|
|
except subprocess.CalledProcessError:
|
|
ch_core.hookenv.log(
|
|
"Failed to leave Southbound cluster. You can use "
|
|
"'cluster-kick' juju action on remaining units to "
|
|
"remove lingering cluster members.",
|
|
ch_core.hookenv.ERROR
|
|
)
|
|
|
|
try:
|
|
ch_core.hookenv.log(
|
|
"Removing self from Northbound cluster",
|
|
ch_core.hookenv.INFO
|
|
)
|
|
ch_ovn.ovn_appctl("ovnnb_db", ("cluster/leave", "OVN_Northbound"))
|
|
except subprocess.CalledProcessError:
|
|
ch_core.hookenv.log(
|
|
"Failed to leave Northbound cluster. You can use "
|
|
"'cluster-kick' juju action on remaining units to "
|
|
"remove lingering cluster members.",
|
|
ch_core.hookenv.ERROR
|
|
)
|
|
|
|
@staticmethod
|
|
def is_server_in_cluster(server_ip, cluster_status):
|
|
"""Parse cluster status and find if server with given IP is part of it.
|
|
|
|
:param server_ip: IP of a server to search.
|
|
:type server_ip: str
|
|
:param cluster_status: Cluster status to parse.
|
|
:type cluster_status: ch_ovn.OVNClusterStatus
|
|
:return: True if server is part of the cluster. Otherwise, False.
|
|
:rtype: bool
|
|
"""
|
|
remote_unit_url = "ssl:{}:".format(server_ip)
|
|
return any(
|
|
list(server)[1].startswith(remote_unit_url)
|
|
for server in cluster_status.servers
|
|
)
|
|
|
|
def wait_for_server_leave(self, server_ip, timeout=30):
|
|
"""Wait for servers with specified IP to leave SB and NB clusters.
|
|
|
|
:param server_ip: IP of the server that should no longer be part of
|
|
the clusters.
|
|
:type server_ip: str
|
|
:param timeout: How many seconds should this function wait for the
|
|
servers to leave. The timeout should be an increment of 5.
|
|
:return: True if servers from selected unit departed within the
|
|
timeout window. Otherwise, it returns False.
|
|
:rtype: bool
|
|
"""
|
|
tick = 5
|
|
timer = 0
|
|
unit_in_sb_cluster = unit_in_nb_cluster = True
|
|
servers_left = False
|
|
wait_sb_msg = "Waiting for {} to leave Southbound cluster".format(
|
|
server_ip
|
|
)
|
|
wait_nb_msg = "Waiting for {} to leave Northbound cluster".format(
|
|
server_ip
|
|
)
|
|
while timer < timeout:
|
|
if unit_in_sb_cluster:
|
|
ch_core.hookenv.log(wait_sb_msg, ch_core.hookenv.INFO)
|
|
unit_in_sb_cluster = self.is_server_in_cluster(
|
|
server_ip,
|
|
self.cluster_status("ovnsb_db")
|
|
)
|
|
if unit_in_nb_cluster:
|
|
ch_core.hookenv.log(wait_nb_msg, ch_core.hookenv.INFO)
|
|
unit_in_nb_cluster = self.is_server_in_cluster(
|
|
server_ip,
|
|
self.cluster_status("ovnnb_db")
|
|
)
|
|
if not unit_in_sb_cluster and not unit_in_nb_cluster:
|
|
servers_left = True
|
|
ch_core.hookenv.log(
|
|
"{} servers left Northbound and Southbound OVN "
|
|
"clusters.".format(server_ip),
|
|
ch_core.hookenv.INFO
|
|
)
|
|
break
|
|
time.sleep(tick)
|
|
timer += tick
|
|
|
|
return servers_left
|
|
|
|
|
|
class TrainOVNCentralCharm(BaseOVNCentralCharm):
|
|
# OpenvSwitch and OVN is distributed as part of the Ubuntu Cloud Archive
|
|
# Pockets get their name from OpenStack releases
|
|
release = 'train'
|
|
|
|
# NOTE(fnordahl) we have to replace the package sysv init script with
|
|
# systemd service files, this should be removed from the charm when the
|
|
# systemd service files committed to Focal can be backported to the Train
|
|
# UCA.
|
|
#
|
|
# The issue that triggered this change is that to be able to pass the
|
|
# correct command line arguments to ``ovn-nortrhd`` we need to create
|
|
# a ``/etc/openvswitch/ovn-northd-db-params.conf`` which has the side
|
|
# effect of profoundly changing the behaviour of the ``ovn-ctl`` tool
|
|
# that the ``ovn-central`` init script makes use of.
|
|
#
|
|
# https://github.com/ovn-org/ovn/blob/dc0e10c068c20c4e59c9c86ecee26baf8ed50e90/utilities/ovn-ctl#L323
|
|
def __init__(self, **kwargs):
|
|
"""Override class init to adjust restart_map for Train.
|
|
|
|
NOTE(fnordahl): the restart_map functionality in charms.openstack
|
|
combines the process of writing a charm template to disk and
|
|
restarting a service whenever the target file changes.
|
|
|
|
In this instance we are only interested in getting the files written
|
|
to disk. The restart operation will be taken care of when
|
|
``/etc/default/ovn-central`` as defined in ``BaseOVNCentralCharm``.
|
|
"""
|
|
super().__init__(**kwargs)
|
|
self.restart_map.update({
|
|
'/lib/systemd/system/ovn-central.service': [],
|
|
'/lib/systemd/system/ovn-northd.service': [],
|
|
'/lib/systemd/system/ovn-nb-ovsdb.service': [],
|
|
'/lib/systemd/system/ovn-sb-ovsdb.service': [],
|
|
})
|
|
self.nrpe_check_services = [
|
|
'ovn-northd',
|
|
'ovn-nb-ovsdb',
|
|
'ovn-sb-ovsdb',
|
|
]
|
|
|
|
def install(self):
|
|
"""Override charm install method.
|
|
|
|
NOTE(fnordahl) At Train, the OVN central components is packaged with
|
|
a dependency on openvswitch-switch, but it does not need the switch
|
|
or stock ovsdb running.
|
|
"""
|
|
service_masks = [
|
|
'openvswitch-switch.service',
|
|
'ovs-vswitchd.service',
|
|
'ovsdb-server.service',
|
|
'ovn-central.service',
|
|
]
|
|
super().install(service_masks=service_masks)
|
|
|
|
@staticmethod
|
|
def ovn_sysconfdir():
|
|
return '/etc/openvswitch'
|
|
|
|
@staticmethod
|
|
def ovn_rundir():
|
|
return '/var/run/openvswitch'
|
|
|
|
|
|
class UssuriOVNCentralCharm(BaseOVNCentralCharm):
|
|
# OpenvSwitch and OVN is distributed as part of the Ubuntu Cloud Archive
|
|
# Pockets get their name from OpenStack releases
|
|
release = 'ussuri'
|
|
|
|
def __init__(self, **kwargs):
|
|
"""Override class init to adjust service map for Ussuri."""
|
|
super().__init__(**kwargs)
|
|
# We need to list the OVN ovsdb-server services explicitly so they get
|
|
# unmasked on render of ``ovn-central``.
|
|
self.services.extend([
|
|
'ovn-ovsdb-server-nb',
|
|
'ovn-ovsdb-server-sb',
|
|
])
|
|
self.nrpe_check_services = [
|
|
'ovn-northd',
|
|
'ovn-ovsdb-server-nb',
|
|
'ovn-ovsdb-server-sb',
|
|
]
|
|
|
|
def install(self):
|
|
"""Override charm install method."""
|
|
|
|
# This is done to prevent extraneous standalone DB initialization and
|
|
# subsequent upgrade to clustered DB when configuration is rendered.
|
|
service_masks = [
|
|
'ovn-central.service',
|
|
'ovn-ovsdb-server-nb.service',
|
|
'ovn-ovsdb-server-sb.service',
|
|
]
|
|
super().install(service_masks=service_masks)
|
|
|
|
|
|
class WallabyOVNCentralCharm(UssuriOVNCentralCharm):
|
|
# OpenvSwitch and OVN is distributed as part of the Ubuntu Cloud Archive
|
|
# Pockets get their name from OpenStack releases
|
|
release = 'wallaby'
|
|
packages = ['ovn-central', 'openstack-release']
|