Merged branch refactor onto main
- implemented basic charm functionality and configuration - unit-tests - pep8
This commit is contained in:
parent
00989d8d79
commit
2ab26e69f5
15
README.md
15
README.md
@ -1,17 +1,16 @@
|
||||
infinidat2 Storage Backend for Cinder
|
||||
-------------------------------
|
||||
Infinidat Tools charm
|
||||
---------------------
|
||||
|
||||
Overview
|
||||
========
|
||||
|
||||
This charm provides a infinidat2 storage backend for use with the Cinder
|
||||
charm.
|
||||
This charm provides configuration and tools for principal charms, such as cinder and nova-compute charms.
|
||||
|
||||
To use:
|
||||
To use as a nova-compute subordinate:
|
||||
|
||||
juju deploy cinder
|
||||
juju deploy cinder-infinidat2
|
||||
juju add-relation cinder-infinidat2 cinder
|
||||
juju deploy nova
|
||||
juju deploy infinidat-tools
|
||||
juju add-relation infinidat-tools:storage-backend nova:storage-backend
|
||||
|
||||
Configuration
|
||||
=============
|
||||
|
9
actions.yaml
Normal file
9
actions.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
run-infinidat-settings-check:
|
||||
description: |
|
||||
Manually trigger environment re-validation with infinihost tool.
|
||||
params:
|
||||
auto-fix:
|
||||
type: string
|
||||
description: |
|
||||
If true, infinihost updates environment multipath,
|
||||
iscsi and lvm configuration to recommended defaults.
|
52
config.yaml
52
config.yaml
@ -1,38 +1,42 @@
|
||||
options:
|
||||
driver-source:
|
||||
install_sources:
|
||||
type: string
|
||||
default:
|
||||
default: "deb https://repo.infinidat.com/packages/main-stable/apt/linux-ubuntu {distrib_codename} main"
|
||||
description: |
|
||||
Optional configuration to support use of additional sources such as:
|
||||
- ppa:myteam/ppa
|
||||
- cloud:trusty-proposed/kilo
|
||||
- http://my.archive.com/ubuntu main
|
||||
The last option should be used in conjunction with the key configuration
|
||||
option.
|
||||
driver-key:
|
||||
option. See https://repo.infinidat.com/home/main-stable for details.
|
||||
The charm also supports templating of the distribution codename via
|
||||
automatic expansion of {distrib_codename} depending on the host system
|
||||
install_keys:
|
||||
type: string
|
||||
default:
|
||||
default: |
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: GnuPG v1.4.11 (GNU/Linux)
|
||||
|
||||
mQENBFESDRIBCADMR7MQMbH4GdCQqfrOMt35MhBwwH4wv9kb1WRSTxa0CmuzYaBB
|
||||
1nJ0nLaMAwHsEr9CytPWDpMngm/3nt+4F2hJcsOEkQkqeJ31gScJewM+AOUV3DEl
|
||||
qOeXXYLcP+jUY6pPjlZpOw0p7moUQPXHn+7amVrk7cXGQ8O3B+5a5wjN86LT2hlX
|
||||
DlBlV5bX/DYluiPUbvQLOknmwO53KpaeDeZc4a8iIOCYWu2ntuAMddBkTps0El5n
|
||||
JJZMTf6os2ZzngWMZRMDiVJgqVRi2b+8SgFQlQy0cAmne/mpgPrRq0ZMX3DokGG5
|
||||
hnIg1mF82laTxd+9qtiOxupzJqf8mncQHdaTABEBAAG0IWFwcF9yZXBvIChDb21t
|
||||
ZW50KSA8bm9AZW1haWwuY29tPokBOAQTAQIAIgUCURINEgIbLwYLCQgHAwIGFQgC
|
||||
CQoLBBYCAwECHgECF4AACgkQem2D/j05RYSrcggAsCc4KppV/SZX5XI/CWFXIAXw
|
||||
+HaNsh2EwYKf9DhtoGbTOuwePvrPGcgFYM3Tu+m+rziPnnFl0bs0xwQyNEVQ9yDw
|
||||
t465pSgmXwEHbBkoISV1e4WYtZAsnTNne9ieJ49Ob/WY4w3AkdPRK/41UP5Ct6lR
|
||||
HHRXrSWJYHVq5Rh6BakRuMJyJLz/KvcJAaPkA4U6VrPD7PFtSecMTaONPjGCcomq
|
||||
b7q84G5ZfeJWb742PWBTS8fJdC+Jd4y5fFdJS9fQwIo52Ff9In2QBpJt5Wdc02SI
|
||||
fvQnuh37D2P8OcIfMxMfoFXpAMWjrMYc5veyQY1GXD/EOkfjjLne6qWPLfNojA==
|
||||
=w5Os
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
description: |
|
||||
Key ID to import to the apt keyring to support use with arbitary source
|
||||
configuration from outside of Launchpad archives or PPA's.
|
||||
use-multipath:
|
||||
type: boolean
|
||||
default: True
|
||||
description: |
|
||||
Whether to use a multipath connection for iSCSI or FC in Cinder
|
||||
volume service. Enabling multipath for VMs is managed by the
|
||||
"use-multipath" option in the nova-compute charm.
|
||||
protocol:
|
||||
type: string
|
||||
default:
|
||||
description: |
|
||||
SAN protocol to use. Choose between iscsi or fc.
|
||||
volume-backend-name:
|
||||
lvm_global_filter:
|
||||
type: string
|
||||
description: |
|
||||
Volume backend name for the backend. The default value is the
|
||||
application name in the Juju model, e.g. "cinder-mybackend"
|
||||
if it's deployed as `juju deploy cinder-infinidat2 cinder-mybackend`.
|
||||
A common backend name can be set to multiple backends with the
|
||||
same characters so that those can be treated as a single virtual
|
||||
backend associated with a single volume type.
|
||||
Value for global_filter to specify in lvm.conf
|
||||
default: "[ \"a|^/dev/sd.*|\", \"a|^/dev/vd.*|\", \"r|.*|\" ]"
|
||||
|
@ -1,10 +1,11 @@
|
||||
name: cinder-infinidat2
|
||||
summary: infinidat2 integration for OpenStack Block Storage
|
||||
name: infinidat-tools
|
||||
summary: Inifnidat integration tools for OpenStack Block Storage
|
||||
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
|
||||
description: |
|
||||
Cinder is the block storage service for the Openstack project.
|
||||
.
|
||||
This charm provides a infinidat2 backend for Cinder
|
||||
This charm is intended to be used as a subordinate charm to any machine which will access Infinidat Infinibox storage.
|
||||
Infinidat host powertools is installed and is used to configure the machine (akin to infinihost settings check --auto-fix).
|
||||
Compatible with bare-metal and KVM machines.
|
||||
NOT compatible with lxd containers as they can't run multipathd on bionic.
|
||||
tags:
|
||||
- openstack
|
||||
- storage
|
||||
@ -15,7 +16,7 @@ series:
|
||||
subordinate: true
|
||||
provides:
|
||||
storage-backend:
|
||||
interface: cinder-backend
|
||||
interface: storage-backend
|
||||
scope: container
|
||||
requires:
|
||||
juju-info:
|
||||
|
@ -1,2 +1,2 @@
|
||||
ops
|
||||
git+https://opendev.org/openstack/charm-ops-openstack#egg=ops_openstack
|
||||
git+https://github.com/nikolayvinogradov/charm-ops-openstack@nickv#egg=ops_openstack
|
||||
|
293
src/charm.py
293
src/charm.py
@ -1,6 +1,6 @@
|
||||
#! /usr/bin/env python3
|
||||
|
||||
# Copyright 2021 Canonical Ltd
|
||||
# Copyright 2022 Canonical Ltd
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -14,46 +14,291 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import subprocess
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ops_openstack.plugins.classes import CinderStoragePluginCharm
|
||||
from ops_openstack.core import charm_class, get_charm_class_for_release
|
||||
from ops_openstack.core import OSBaseCharm
|
||||
from ops.main import main
|
||||
|
||||
from ops.model import (
|
||||
ActiveStatus,
|
||||
BlockedStatus,
|
||||
)
|
||||
|
||||
class CinderCharmBase(CinderStoragePluginCharm):
|
||||
from charmhelpers.core.host import (
|
||||
lsb_release,
|
||||
service_restart,
|
||||
)
|
||||
|
||||
PACKAGES = ['cinder-common']
|
||||
MANDATORY_CONFIG = ['protocol']
|
||||
from charmhelpers.fetch import (
|
||||
apt_install,
|
||||
apt_update,
|
||||
add_source,
|
||||
)
|
||||
|
||||
from charmhelpers.fetch.archiveurl import ArchiveUrlFetchHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INFINIHOST_RESULTS_DIR = '/home/ubuntu/infinihost-results'
|
||||
MULTIPATH_CONF = '/etc/multipath.conf'
|
||||
DEFAULT_REPO_KEY_URL = 'https://repo.infinidat.com/packages/gpg.key'
|
||||
|
||||
|
||||
class InfinidatToolsCharm(OSBaseCharm):
|
||||
|
||||
# multipath-tools package should be installed by nova-compute
|
||||
PACKAGES = [
|
||||
'host-power-tools',
|
||||
'scsitools',
|
||||
'multipath-tools-boot'
|
||||
]
|
||||
|
||||
MANDATORY_CONFIG = ['install_sources']
|
||||
# Overriden from the parent. May be set depending on the charm's properties
|
||||
|
||||
RESTART_MAP = {
|
||||
MULTIPATH_CONF: ['multipathd']
|
||||
}
|
||||
|
||||
DEFAULT_REPO_BASEURL = \
|
||||
'https://repo.infinidat.com/packages/main-stable/apt/linux-ubuntu'
|
||||
|
||||
stateless = True
|
||||
active_active = True
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def cinder_configuration(self, config):
|
||||
# Return the configuration to be set by the principal.
|
||||
backend_name = config.get('volume-backend-name',
|
||||
self.framework.model.app.name)
|
||||
volume_driver = ''
|
||||
options = [
|
||||
('volume_driver', volume_driver),
|
||||
('volume_backend_name', backend_name),
|
||||
self.framework.observe(self.on.start, self.on_start)
|
||||
|
||||
self.framework.observe(self.on.run_infinidat_settings_check_action,
|
||||
self.on_run_infinidat_settings_check_action)
|
||||
|
||||
def _run_infinihost_check(self, auto_fix=True, stdout=subprocess.PIPE):
|
||||
"""
|
||||
host-power-tool utility checks environment for
|
||||
the recommended settings and if anything is missing
|
||||
(and --auto-fix is provided) then it:
|
||||
- generates default multipath.conf with recommended defaults
|
||||
- excludes local disks from multipath configuration
|
||||
- registers itself as a udev handler
|
||||
- regenerates initramfs, so that multipath configuration
|
||||
is applied to it as well, in case root fs is on SAN
|
||||
"""
|
||||
p = None
|
||||
|
||||
cmd = [
|
||||
'infinihost',
|
||||
'settings',
|
||||
'check'
|
||||
]
|
||||
|
||||
if config.get('use-multipath'):
|
||||
options.extend([
|
||||
('use_multipath_for_image_xfer', True),
|
||||
('enforce_multipath_for_image_xfer', True)
|
||||
])
|
||||
if auto_fix:
|
||||
cmd.append('--auto-fix')
|
||||
|
||||
return options
|
||||
logging.debug("Executing: {0}".format(' '.join(cmd)))
|
||||
|
||||
try:
|
||||
# FIXME: Without this there is a dependency conflict:
|
||||
# pkg_resources in charm's venv pythonpath takes precedence over
|
||||
# the system-wide one, and because of the version difference,
|
||||
# it breaks infinihost with "ImportError: cannot import name 'six'"
|
||||
env = os.environ.copy()
|
||||
if 'PYTHONPATH' in env:
|
||||
env.pop('PYTHONPATH')
|
||||
|
||||
@charm_class
|
||||
class Cinderinfinidat2Charm(CinderCharmBase):
|
||||
release = 'ussuri'
|
||||
p = subprocess.Popen(cmd, shell=False, stdout=stdout, env=env)
|
||||
except FileNotFoundError:
|
||||
logging.fatal(
|
||||
"Failed to run 'infinihost': is host-power-tools installed?")
|
||||
raise
|
||||
|
||||
if stdout == subprocess.PIPE:
|
||||
stdout, _ = p.communicate()
|
||||
logger.debug("infinihost output: {0}".format(stdout))
|
||||
|
||||
code = p.wait()
|
||||
|
||||
logger.info('infinihost exit code: {0}'
|
||||
.format(code))
|
||||
|
||||
return code
|
||||
|
||||
def _update_multipath_conf(self, restart=True):
|
||||
|
||||
replacements = (
|
||||
# Ensure that multipathd config file has skip_kpartx set to yes
|
||||
# (causes issues with volumes detaching if set to no)
|
||||
(re.compile(
|
||||
r'(skip_kpartx[ \t]+).*$', re.MULTILINE), 'yes'),
|
||||
(re.compile(
|
||||
r'(user_friendly_names[ \t]+).*$', re.MULTILINE), 'no'),
|
||||
)
|
||||
|
||||
with open(MULTIPATH_CONF, 'r') as f:
|
||||
data = f.read()
|
||||
for pat, new_value in replacements:
|
||||
data = re.sub(
|
||||
pat,
|
||||
lambda m: '{0}"{1}"'.format(str(m.group(1)), new_value),
|
||||
data
|
||||
)
|
||||
|
||||
logger.info("Updating multipath.conf")
|
||||
with open(MULTIPATH_CONF, 'w') as f:
|
||||
f.write(data)
|
||||
|
||||
logger.info("Restarting multipathd")
|
||||
if restart:
|
||||
service_restart('multipathd')
|
||||
|
||||
def _set_lvm_conf_global_filter(self, lvm_global_filter,
|
||||
lvm_conf='/etc/lvm/lvm.conf'):
|
||||
# Ensure we have a lvm.conf filter in place to stop lvm groups
|
||||
# present on instance/VM being discovered by the host
|
||||
|
||||
logging.info('Setting lvm.conf global_filter')
|
||||
|
||||
with open(lvm_conf, 'r') as file:
|
||||
d = file.read()
|
||||
|
||||
p = re.compile(r'(# global_filter = \[[^\n]*\n)')
|
||||
|
||||
p_split = p.split(d, maxsplit=1)
|
||||
if len(p_split) == 3:
|
||||
the_rest = p_split.pop(2)
|
||||
|
||||
the_next_line, nl, the_rest = the_rest.partition('\n')
|
||||
tmp = []
|
||||
if lvm_global_filter:
|
||||
tmp.append('global_filter = {0} # __infinidat_tools__'
|
||||
.format(lvm_global_filter))
|
||||
|
||||
if '__infinidat_tools__' not in the_next_line:
|
||||
tmp.append(the_next_line + nl + the_rest)
|
||||
else:
|
||||
tmp.append(the_rest)
|
||||
|
||||
p_split.append('\n'.join(tmp))
|
||||
|
||||
with open(lvm_conf, 'w') as file:
|
||||
file.write(''.join(p_split))
|
||||
else:
|
||||
logging.fatal(
|
||||
'Error while modifying lvm.conf: '
|
||||
'expected pattern not found'
|
||||
)
|
||||
self.unit.status = BlockedStatus('Failed to update lvm.conf')
|
||||
return
|
||||
|
||||
def on_start(self, event):
|
||||
self._stored.is_started = True
|
||||
|
||||
def _regenerate_initrd(self):
|
||||
# Regenerate initrd
|
||||
try:
|
||||
logging.info('Regenerating initrd')
|
||||
subprocess.check_call("update-initramfs -u -k all", shell=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logging.fatal('Error regenerating initrd: {0}'.format(e))
|
||||
self.unit.status = BlockedStatus('error regenerating initrd')
|
||||
return
|
||||
|
||||
def on_install(self, event):
|
||||
logging.info('Preparing Infinidat tools package installation')
|
||||
|
||||
# initial installation, this is not called after reboot
|
||||
try:
|
||||
self.install_pkgs()
|
||||
code = self._run_infinihost_check(auto_fix=True)
|
||||
self._update_multipath_conf()
|
||||
self._regenerate_initrd()
|
||||
except Exception as e:
|
||||
logger.fatal("Failed to install packages: {0}".format(str(e)))
|
||||
# something failed, attempt rerunning the hook later
|
||||
self.unit.status = BlockedStatus("Installation failed")
|
||||
event.defer()
|
||||
return
|
||||
|
||||
self._set_lvm_conf_global_filter(
|
||||
self.config.get('lvm_global_filter'))
|
||||
|
||||
if code != 0:
|
||||
self.unit.status = ActiveStatus(
|
||||
"review infinihost settings status")
|
||||
else:
|
||||
self.unit.status = ActiveStatus()
|
||||
|
||||
def _get_default_repo_key(self):
|
||||
url_fetcher = ArchiveUrlFetchHandler()
|
||||
with tempfile.NamedTemporaryFile() as outfile:
|
||||
outfile.close()
|
||||
dest_path = os.path.join(tempfile.gettempdir(), outfile.name)
|
||||
|
||||
url_fetcher.download(DEFAULT_REPO_KEY_URL,
|
||||
dest_path)
|
||||
with open(dest_path) as f:
|
||||
return f.read()
|
||||
|
||||
def install_pkgs(self):
|
||||
install_keys = self.model.config.get('install_keys')
|
||||
if not install_keys:
|
||||
install_keys = self._get_default_repo_key()
|
||||
|
||||
# we implement $codename expansion here
|
||||
# see the default value for 'source' in config.yaml
|
||||
if self.model.config.get('install_sources'):
|
||||
distrib_codename = lsb_release()['DISTRIB_CODENAME'].lower()
|
||||
add_source(
|
||||
self.model.config['install_sources']
|
||||
.format(distrib_codename=distrib_codename),
|
||||
self.model.config.get('install_keys'))
|
||||
apt_update(fatal=True)
|
||||
apt_install(self.PACKAGES, fatal=True)
|
||||
|
||||
def on_config(self, event):
|
||||
self._set_lvm_conf_global_filter(
|
||||
self.config.get('lvm_global_filter'))
|
||||
|
||||
self.unit.status = ActiveStatus()
|
||||
|
||||
def on_run_infinidat_settings_check_action(self, event):
|
||||
Path(INFINIHOST_RESULTS_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if event.params.get('auto-fix'):
|
||||
auto_fix = True
|
||||
else:
|
||||
auto_fix = False
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w+b", prefix='infinihost-out-',
|
||||
dir=INFINIHOST_RESULTS_DIR,
|
||||
delete=False) as outfile:
|
||||
try:
|
||||
event.log("Running 'infinihost settings check'")
|
||||
code = self._run_infinihost_check(auto_fix=auto_fix,
|
||||
stdout=outfile)
|
||||
Path(outfile.name).chmod(0o644)
|
||||
except subprocess.CalledProcessError as e:
|
||||
msg = "Failed to run infinihost: {0}".format(str(e))
|
||||
logger.fatal(msg)
|
||||
event.fail(msg)
|
||||
|
||||
if auto_fix:
|
||||
event.log("--auto-fix is enabled, updating multipath.conf")
|
||||
self._update_multipath_conf()
|
||||
|
||||
event.set_results({
|
||||
"result":
|
||||
"exit code={0}\n"
|
||||
"see 'juju ssh -m {1} {2} cat {3}' for more details"
|
||||
.format(code, self.unit.name, self.model.name,
|
||||
os.path.join(INFINIHOST_RESULTS_DIR, outfile.name))})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(get_charm_class_for_release())
|
||||
main(InfinidatToolsCharm)
|
||||
|
28
templates/multipath.conf.j2
Normal file
28
templates/multipath.conf.j2
Normal file
@ -0,0 +1,28 @@
|
||||
defaults {
|
||||
max_fds 8192
|
||||
queue_without_daemon no
|
||||
user_friendly_names no
|
||||
skip_kpartx yes
|
||||
find_multipaths yes
|
||||
}
|
||||
devices {
|
||||
device {
|
||||
vendor "NFINIDAT"
|
||||
product "InfiniBox.*"
|
||||
path_grouping_policy "group_by_prio"
|
||||
path_checker "tur"
|
||||
features "0"
|
||||
hardware_handler "1 alua"
|
||||
prio "alua"
|
||||
rr_weight "priorities"
|
||||
no_path_retry "queue"
|
||||
rr_min_io 1
|
||||
rr_min_io_rq 1
|
||||
flush_on_last_del "yes"
|
||||
fast_io_fail_tmo 15
|
||||
dev_loss_tmo "infinity"
|
||||
path_selector "round-robin 0"
|
||||
failback immediate
|
||||
detect_prio yes
|
||||
}
|
||||
}
|
@ -32,8 +32,8 @@ applications:
|
||||
openstack-origin: cloud:focal-ussuri
|
||||
to:
|
||||
- '2'
|
||||
cinder-infinidat2:
|
||||
charm: ../../cinder-infinidat2.charm
|
||||
infinidat-tools:
|
||||
charm: ../../infinidat-tools.charm
|
||||
# options:
|
||||
rabbitmq-server:
|
||||
charm: ch:rabbitmq-server
|
||||
@ -47,4 +47,4 @@ relations:
|
||||
- [ cinder:shared-db, mysql:shared-db ]
|
||||
- [ cinder:identity-service, keystone:identity-service ]
|
||||
- [ cinder:amqp, rabbitmq-server:amqp ]
|
||||
- [ cinder:storage-backend, cinder-infinidat2:storage-backend ]
|
||||
- [ cinder:juju-info, infinidat-tools:juju-info ]
|
||||
|
@ -14,17 +14,18 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Encapsulate cinder-infinidat2 testing."""
|
||||
"""Encapsulate infinidat-tools testing."""
|
||||
|
||||
from zaza.openstack.charm_tests.cinder_backend.tests import CinderBackendTest
|
||||
|
||||
|
||||
class Cinderinfinidat2Test(CinderBackendTest):
|
||||
"""Encapsulate infinidat2 tests."""
|
||||
class InfinidatToolsTest(CinderBackendTest):
|
||||
"""Encapsulate infinidat-tools tests."""
|
||||
|
||||
backend_name = 'infinidat2'
|
||||
backend_name = 'cinder-infinidat'
|
||||
|
||||
expected_config_content = {
|
||||
'infinidat2': {
|
||||
'volume-backend-name': ['infinidat2'],
|
||||
}}
|
||||
'infinidat-tools': {
|
||||
'volume-backend-name': ['cinder-infinidat'],
|
||||
}
|
||||
}
|
||||
|
77
unit_tests/infinibox-sample.txt
Normal file
77
unit_tests/infinibox-sample.txt
Normal file
@ -0,0 +1,77 @@
|
||||
SCSI: Checking that sg3-utils is installed ... [1m[32mok[22m[39m
|
||||
SCSI: Checking that scsitools is installed ... [1m[32mok[22m[39m
|
||||
SCSI: Checking that parted is installed ... [1m[32mok[22m[39m
|
||||
Multipath: Checking that multipath-tools is installed ... [1m[32mok[22m[39m
|
||||
Multipath: Checking that multipath-tools-boot is installed ... [1m[32mok[22m[39m
|
||||
Multipath: Checking that multipath-tools automatically loads on boot ... [1m[32mok[22m[39m
|
||||
Multipath: Checking that kernel module multipath is loaded. ... [1m[32mok[22m[39m
|
||||
Multipath: Checking that multipath-tools is running. ... [1m[31mfail[22m[39m [1m[34mapplying fix [22m[39m[1m[31mfail[22m[39m
|
||||
Multipath: Checking global parameters ... [1m[34mskip[22m[39m
|
||||
Multipath: Checking InfiniBox Device Settings ... [1m[34mskip[22m[39m
|
||||
Multipath: Checking for InfiniBox device exclusions ... [1m[34mskip[22m[39m
|
||||
Multipath: Checking for local device exclusions ... [1m[34mskip[22m[39m
|
||||
Performance: Checking that a udev rule exists for setting the 'noop' I/O scheduler for InfiniBox scsi-disks and 'none' for all InfiniBox dm-disks ... [1m[32mok[22m[39m
|
||||
Performance: Checking that the current I/O scheduler for all InfiniBox scsi-disks is 'noop' and for all InfiniBox dm-disks is 'none' ... [1m[32mok[22m[39m
|
||||
FC HBA: Checking Fibre Channel host bus adapters ... [1m[34mskip[22m[39m
|
||||
FC HBA: Checking versions of FC HBA drivers ... [1m[34mskip[22m[39m
|
||||
FC HBA: Checking FC HBA driver attributes ... [1m[32mok[22m[39m
|
||||
Boot From SAN: Checking if PowerTools has made some changes that require a new initrd image in boot-from-san environments ... [1m[34mskip[22m[39m
|
||||
Devices: Checking for volumes that don't have a multipath device ... [1m[32mok[22m[39m
|
||||
Devices: Checking that all reported InfiniBox LUNs have a block device ... [1m[32mok[22m[39m
|
||||
Devices: Checking the path count for all InfiniBox MPIO devices ... [1m[32mok[22m[39m
|
||||
Connectivity: Checking if initiator ports connected to InfiniBox are not connected to other target ports ... [1m[34mskip[22m[39m
|
||||
Connectivity: Checking that each InfiniBox is connected through more than one initiator ... [1m[34mskip[22m[39m
|
||||
Connectivity: Checking that the host is connected to at least one Node of each InfiniBox ... [1m[34mskip[22m[39m
|
||||
Connectivity: Checking that the host does not have more block devices than the recommended limit ... [1m[34mskip[22m[39m
|
||||
Connectivity: Checking that the host does not have more volumes than the recommended number ... [1m[32mok[22m[39m
|
||||
===============================================================================
|
||||
[1m[31mFail[22m[39m Multipath: Checking that multipath-tools is running.
|
||||
REASON: [1m[31mFAILURE: service multipath-tools is not running[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202404141
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Multipath: Checking global parameters
|
||||
REASON: [1m[34mSKIP: Multipath Daemon must be running for this test to pass[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202319232
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Multipath: Checking InfiniBox Device Settings
|
||||
REASON: [1m[34mSKIP: Multipath Daemon must be running for this test to pass[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202404151
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Multipath: Checking for InfiniBox device exclusions
|
||||
REASON: [1m[34mSKIP: Multipath Daemon must be running for this test to pass[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202319242
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Multipath: Checking for local device exclusions
|
||||
REASON: [1m[34mSKIP: Multipath Daemon must be running for this test to pass[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202404331
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m FC HBA: Checking Fibre Channel host bus adapters
|
||||
REASON: [1m[34mSKIP: no fiberchannel ports detected[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202404231
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m FC HBA: Checking versions of FC HBA drivers
|
||||
REASON: [1m[34mSKIP: no fiberchannel ports detected[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202319342
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Boot From SAN: Checking if PowerTools has made some changes that require a new initrd image in boot-from-san environments
|
||||
REASON: [1m[34mSKIP: no boot-related changes were performed[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202621702
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Connectivity: Checking if initiator ports connected to InfiniBox are not connected to other target ports
|
||||
REASON: [1m[34mSKIP: no fiberchannel ports connected to InfiniBox[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202319352
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Connectivity: Checking that each InfiniBox is connected through more than one initiator
|
||||
REASON: [1m[34mSKIP: no fiberchannel ports connected to InfiniBox[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202404261
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Connectivity: Checking that the host is connected to at least one Node of each InfiniBox
|
||||
REASON: [1m[34mSKIP: no fiberchannel ports connected to a physical port of InfiniBox[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202404271
|
||||
-------------------------------------------------------------------------------
|
||||
[1m[34mSkip[22m[39m Connectivity: Checking that the host does not have more block devices than the recommended limit
|
||||
REASON: [1m[34mSKIP: no initiator ports connected to InfiniBox[22m[39m
|
||||
INFO: For more information, see https://support.infinidat.com/hc/articles/202319172
|
||||
-------------------------------------------------------------------------------
|
||||
(failures=1, skips=11)
|
||||
[1m[35mThis host is NOT ready to work with the InfiniBox[22m[39m
|
@ -1,51 +0,0 @@
|
||||
# Copyright 2016 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 unittest
|
||||
from src.charm import CinderCharmBase
|
||||
from ops.model import ActiveStatus
|
||||
from ops.testing import Harness
|
||||
|
||||
|
||||
class TestCinderinfinidat2Charm(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.harness = Harness(CinderCharmBase)
|
||||
self.addCleanup(self.harness.cleanup)
|
||||
self.harness.begin()
|
||||
self.harness.set_leader(True)
|
||||
backend = self.harness.add_relation('storage-backend', 'cinder')
|
||||
self.harness.update_config({'volume-backend-name': 'test'})
|
||||
self.harness.add_relation_unit(backend, 'cinder/0')
|
||||
|
||||
def test_cinder_base(self):
|
||||
self.assertEqual(
|
||||
self.harness.framework.model.app.name,
|
||||
'cinder-infinidat2')
|
||||
# Test that charm is active upon installation.
|
||||
self.harness.update_config({})
|
||||
self.assertTrue(isinstance(
|
||||
self.harness.model.unit.status, ActiveStatus))
|
||||
|
||||
def test_multipath_config(self):
|
||||
self.harness.update_config({'use-multipath': True})
|
||||
conf = dict(self.harness.charm.cinder_configuration(
|
||||
dict(self.harness.model.config)))
|
||||
self.assertEqual(conf['volume_backend_name'], 'test')
|
||||
self.assertTrue(conf.get('use_multipath_for_image_xfer'))
|
||||
self.assertTrue(conf.get('enforce_multipath_for_image_xfer'))
|
||||
|
||||
def test_cinder_configuration(self):
|
||||
# Add check here that configuration is as expected.
|
||||
pass
|
327
unit_tests/test_infinidat_tools.py
Normal file
327
unit_tests/test_infinidat_tools.py
Normal file
@ -0,0 +1,327 @@
|
||||
# Copyright 2016 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 subprocess
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
from src.charm import InfinidatToolsCharm
|
||||
from ops.testing import Harness
|
||||
|
||||
from ops.model import (
|
||||
ActiveStatus,
|
||||
BlockedStatus,
|
||||
)
|
||||
|
||||
from charmhelpers.core.host_factory.ubuntu import UBUNTU_RELEASES
|
||||
|
||||
SOURCE = "deb https://repo.infinidat.com/packages/main-stable/apt/linux-ubuntu focal main" # noqa: E501
|
||||
KEY = """\
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
Version: GnuPG v1.4.11 (GNU/Linux)
|
||||
|
||||
mQENBFESDRIBCADMR7MQMbH4GdCQqfrOMt35MhBwwH4wv9kb1WRSTxa0CmuzYaBB
|
||||
1nJ0nLaMAwHsEr9CytPWDpMngm/3nt+4F2hJcsOEkQkqeJ31gScJewM+AOUV3DEl
|
||||
qOeXXYLcP+jUY6pPjlZpOw0p7moUQPXHn+7amVrk7cXGQ8O3B+5a5wjN86LT2hlX
|
||||
DlBlV5bX/DYluiPUbvQLOknmwO53KpaeDeZc4a8iIOCYWu2ntuAMddBkTps0El5n
|
||||
JJZMTf6os2ZzngWMZRMDiVJgqVRi2b+8SgFQlQy0cAmne/mpgPrRq0ZMX3DokGG5
|
||||
hnIg1mF82laTxd+9qtiOxupzJqf8mncQHdaTABEBAAG0IWFwcF9yZXBvIChDb21t
|
||||
ZW50KSA8bm9AZW1haWwuY29tPokBOAQTAQIAIgUCURINEgIbLwYLCQgHAwIGFQgC
|
||||
CQoLBBYCAwECHgECF4AACgkQem2D/j05RYSrcggAsCc4KppV/SZX5XI/CWFXIAXw
|
||||
+HaNsh2EwYKf9DhtoGbTOuwePvrPGcgFYM3Tu+m+rziPnnFl0bs0xwQyNEVQ9yDw
|
||||
t465pSgmXwEHbBkoISV1e4WYtZAsnTNne9ieJ49Ob/WY4w3AkdPRK/41UP5Ct6lR
|
||||
HHRXrSWJYHVq5Rh6BakRuMJyJLz/KvcJAaPkA4U6VrPD7PFtSecMTaONPjGCcomq
|
||||
b7q84G5ZfeJWb742PWBTS8fJdC+Jd4y5fFdJS9fQwIo52Ff9In2QBpJt5Wdc02SI
|
||||
fvQnuh37D2P8OcIfMxMfoFXpAMWjrMYc5veyQY1GXD/EOkfjjLne6qWPLfNojA==
|
||||
=w5Os
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
"""
|
||||
|
||||
|
||||
class TestInfinidatToolsCharm(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.harness = Harness(InfinidatToolsCharm)
|
||||
self.addCleanup(self.harness.cleanup)
|
||||
self.harness.begin()
|
||||
self.harness.set_leader(True)
|
||||
backend = self.harness.add_relation('juju-info', 'nova')
|
||||
self.harness.add_relation_unit(backend, 'infinidat-tools/0')
|
||||
|
||||
def _get_source(self, codename, pocket, baseurl=None):
|
||||
if baseurl is None:
|
||||
baseurl = self.harness.charm.DEFAULT_REPO_BASEURL
|
||||
return ' '.join((
|
||||
'deb',
|
||||
baseurl,
|
||||
codename,
|
||||
pocket))
|
||||
|
||||
@mock.patch('src.charm.add_source')
|
||||
@mock.patch('src.charm.apt_update')
|
||||
@mock.patch('src.charm.apt_install')
|
||||
@mock.patch('src.charm.lsb_release')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._get_default_repo_key')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._set_lvm_conf_global_filter')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._run_infinihost_check')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._update_multipath_conf')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._regenerate_initrd')
|
||||
def test_repo_management(self, _regenerate_initrd, _update_multipath_conf,
|
||||
_run_infinihost_check,
|
||||
_set_lvm_conf_global_filter,
|
||||
_get_default_repo_key, lsb_release, apt_install,
|
||||
apt_update, add_source):
|
||||
|
||||
dynamic_source = self._get_source('{distrib_codename}', 'main')
|
||||
|
||||
# generate test data for both 'source' values that need substituion
|
||||
# and for the static ones
|
||||
|
||||
test_data = []
|
||||
|
||||
for release in UBUNTU_RELEASES:
|
||||
static_source = self._get_source(release, 'main')
|
||||
test_data.append(
|
||||
(dynamic_source, release,
|
||||
self._get_source(release, 'main')),
|
||||
)
|
||||
test_data.append(
|
||||
(static_source, release, static_source),
|
||||
)
|
||||
|
||||
for i in test_data:
|
||||
# distro codename the charm runs on
|
||||
lsb_release.return_value = {'DISTRIB_CODENAME': i[1]}
|
||||
|
||||
# configure to use specific repo version
|
||||
self.harness.update_config({
|
||||
'install_sources': i[0],
|
||||
'install_keys': KEY,
|
||||
})
|
||||
|
||||
self.harness.charm.on.install.emit()
|
||||
|
||||
# make sure the repo management calls were correct
|
||||
add_source.assert_called_with(i[2], KEY)
|
||||
apt_install.assert_called_with(self.harness.charm.PACKAGES,
|
||||
fatal=True)
|
||||
|
||||
@mock.patch('subprocess.Popen')
|
||||
@mock.patch('subprocess.Popen.communicate')
|
||||
@mock.patch('subprocess.Popen.wait')
|
||||
def test_infinibox_settings_check(self, pwait, pcomm, popen):
|
||||
"""
|
||||
Test basic functionality of _run_infinihost:
|
||||
1. Check that method properly handles absense of infinihost
|
||||
2. Check subprocess.PIPE
|
||||
3. Check redirect to file
|
||||
4. Check return code 0 1 2
|
||||
"""
|
||||
with open('unit_tests/infinibox-sample.txt') as f:
|
||||
data = f.read()
|
||||
|
||||
pcomm.return_value = (data, '')
|
||||
pwait.return_value = 2
|
||||
|
||||
class p:
|
||||
def __init__(self):
|
||||
self.communicate = pcomm
|
||||
self.wait = pwait
|
||||
|
||||
popen.return_value = p()
|
||||
|
||||
code = self.harness.charm.\
|
||||
_run_infinihost_check(auto_fix=False, stdout=subprocess.PIPE)
|
||||
pwait.assert_called_with()
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
# test redirection to a file
|
||||
f = '123'
|
||||
self.harness.charm._run_infinihost_check(auto_fix=True, stdout=f)
|
||||
popen.assert_called_with([
|
||||
'infinihost', 'settings', 'check', '--auto-fix'],
|
||||
stdout=f, shell=mock.ANY, env=mock.ANY)
|
||||
|
||||
# make sure the missing infinihost is handled with re-raise
|
||||
popen.side_effect = FileNotFoundError(
|
||||
"[Errno 2] No such file or directory: 'infinihost'"
|
||||
)
|
||||
self.assertRaises(FileNotFoundError,
|
||||
self.harness.charm._run_infinihost_check)
|
||||
|
||||
@mock.patch('src.charm.add_source')
|
||||
@mock.patch('src.charm.apt_update')
|
||||
@mock.patch('src.charm.apt_install')
|
||||
@mock.patch('src.charm.lsb_release')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._get_default_repo_key')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._set_lvm_conf_global_filter')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._run_infinihost_check')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._update_multipath_conf')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._regenerate_initrd')
|
||||
def test_default_gpg_key(self, _regenerate_initrd, _update_multipath_conf,
|
||||
_run_infinihost_check,
|
||||
_set_lvm_conf_global_filter,
|
||||
_get_default_repo_key, lsb_release,
|
||||
apt_install, apt_update, add_source):
|
||||
_get_default_repo_key.return_value = KEY
|
||||
self.harness.update_config({
|
||||
'install_sources': self._get_source('focal', 'main')
|
||||
})
|
||||
self.harness.charm.on.install.emit()
|
||||
add_source.assert_called_with(self._get_source('focal', 'main'), KEY)
|
||||
|
||||
def test_multipath_config_patching(self):
|
||||
pass
|
||||
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._set_lvm_conf_global_filter')
|
||||
def test_on_config_changed(self, _set_lvm_conf_global_filter):
|
||||
"""
|
||||
Make sure that LVM configuration update is called
|
||||
in on_config() handler
|
||||
"""
|
||||
self.harness.update_config({
|
||||
'install_sources': self._get_source('focal', 'main')
|
||||
})
|
||||
_set_lvm_conf_global_filter.assert_called_with(
|
||||
self.harness.model.config.get('lvm_global_filter'))
|
||||
|
||||
self.assertTrue(isinstance(
|
||||
self.harness.model.unit.status, ActiveStatus
|
||||
))
|
||||
|
||||
@mock.patch('src.charm.add_source')
|
||||
@mock.patch('src.charm.apt_update')
|
||||
@mock.patch('src.charm.apt_install')
|
||||
@mock.patch('src.charm.lsb_release')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._get_default_repo_key')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._set_lvm_conf_global_filter')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._run_infinihost_check')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._update_multipath_conf')
|
||||
@mock.patch('src.charm.InfinidatToolsCharm._regenerate_initrd')
|
||||
def test_on_install_handler(self, _regenerate_initrd,
|
||||
_update_multipath_conf,
|
||||
_run_infinihost_check,
|
||||
_set_lvm_conf_global_filter,
|
||||
_get_default_repo_key, lsb_release,
|
||||
apt_install, apt_update, add_source):
|
||||
"""
|
||||
Make sure on_install() handler does all necessary stuff:
|
||||
1. Calls infinihost with autofix
|
||||
2. Updates multipath config
|
||||
3. Sets lvm_global_filter
|
||||
4. Sets status to Active if ok
|
||||
5. Sets status to Blocked if it fails
|
||||
"""
|
||||
self.harness.update_config({
|
||||
'install_sources': self._get_source('focal', 'main')
|
||||
})
|
||||
self.harness.charm.on.install.emit()
|
||||
|
||||
_run_infinihost_check.assert_called_with(auto_fix=True)
|
||||
_update_multipath_conf.assert_called_with()
|
||||
_set_lvm_conf_global_filter.assert_called_with(
|
||||
self.harness.model.config.get('lvm_global_filter'))
|
||||
|
||||
self.assertTrue(isinstance(
|
||||
self.harness.model.unit.status, ActiveStatus
|
||||
))
|
||||
_run_infinihost_check.side_effect = FileNotFoundError(
|
||||
"[Errno 2] No such file or directory: 'infinihost'"
|
||||
)
|
||||
|
||||
self.harness.charm.on.install.emit()
|
||||
|
||||
self.assertTrue(isinstance(
|
||||
self.harness.model.unit.status, BlockedStatus
|
||||
))
|
||||
|
||||
@mock.patch('builtins.open')
|
||||
def test_lvm_config_patching(self, _open):
|
||||
|
||||
tpl = r"""# Configuration option devices/global_filter.
|
||||
# Limit the block devices that are used by LVM system components.
|
||||
# Because devices/filter may be overridden from the command line, it is
|
||||
# not suitable for system-wide device filtering, e.g. udev.
|
||||
# Use global_filter to hide devices from these LVM system components.
|
||||
# The syntax is the same as devices/filter. Devices rejected by
|
||||
# global_filter are not opened by LVM.
|
||||
# This configuration option has an automatic default value.
|
||||
# global_filter = [ "a|.*|" ]{0}
|
||||
# other contents
|
||||
"""
|
||||
input_data = tpl.format('')
|
||||
|
||||
value = '[ "a|^/dev/sd.*|", "a|^/dev/vd.*|", "r|.*|" ]'
|
||||
updated_data = tpl.format(
|
||||
'\nglobal_filter = ' + value +
|
||||
' # __infinidat_tools__'
|
||||
)
|
||||
|
||||
self.maxDiff = 1000
|
||||
updated_data2 = tpl.format(
|
||||
'\nglobal_filter = ' + value +
|
||||
' 2 # __infinidat_tools__'
|
||||
)
|
||||
|
||||
class fake_fh:
|
||||
def __init__(self, expected):
|
||||
self.buf = StringIO(expected)
|
||||
self.newbuf = StringIO('')
|
||||
self.readlines = self.buf.readlines
|
||||
self.read = self.buf.read
|
||||
self.write = self.newbuf.write
|
||||
|
||||
def __enter__(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
d = fake_fh(input_data)
|
||||
_open.return_value = d
|
||||
|
||||
self.harness.charm._set_lvm_conf_global_filter(value)
|
||||
|
||||
self.assertEqual(d.newbuf.getvalue(), updated_data)
|
||||
|
||||
d = fake_fh(d.newbuf.getvalue())
|
||||
_open.return_value = d
|
||||
|
||||
self.harness.charm._set_lvm_conf_global_filter(value + ' 2')
|
||||
|
||||
self.assertEqual(d.newbuf.getvalue(), updated_data2)
|
||||
|
||||
d = fake_fh(updated_data)
|
||||
_open.return_value = d
|
||||
self.harness.charm._set_lvm_conf_global_filter('')
|
||||
|
||||
self.assertEqual(d.newbuf.getvalue(), input_data)
|
||||
|
||||
d = fake_fh(input_data)
|
||||
_open.return_value = d
|
||||
self.harness.charm._set_lvm_conf_global_filter('')
|
||||
|
||||
self.assertEqual(d.newbuf.getvalue(), input_data)
|
||||
|
||||
def check_contents(self):
|
||||
pass
|
||||
|
||||
def test_action_outfile_created(self):
|
||||
pass
|
||||
|
||||
def test_action_no_autofix(self):
|
||||
pass
|
||||
|
||||
def test_action_autofix(self):
|
||||
pass
|
Loading…
Reference in New Issue
Block a user