Compare commits

...

51 Commits

Author SHA1 Message Date
Zuul
8f8d537330 Merge "use cinder migrate for swap volume" into stable/2024.1 2025-08-19 19:09:34 +00:00
Douglas Viroel
6264f17c92 Configure watcher tempest's microversion in devstack
Adds a tempest configuration for min and max microversions supported
by watcher. This help us to define the correct range of microversion
to be tested on each stable branch.
New microversion proposals should also increase the default
max_microversion, in order to work with watcher-tempest-plugin
microversion testing.

Change-Id: I0b695ba4530eb89ed17b3935b87e938cadec84cc
(cherry picked from commit adfe3858aa)
(cherry picked from commit defd3953d8)
(cherry picked from commit b5b1bc5473)
Signed-off-by: Douglas Viroel <viroel@gmail.com>
2025-08-19 01:28:16 +00:00
Sean Mooney
4fca7d246a use cinder migrate for swap volume
This change removes watchers in tree functionality
for swapping instance volumes and defines swap as an alias
of cinder volume migrate.

The watcher native implementation was missing error handling
which could lead to irretrievable data loss.

The removed code also forged project user credentials to
perform admin request as if it was done by a member of a project.
this was unsafe an posses a security risk due to how it was
implemented. This code has been removed without replacement.

While some effort has been made to allow existing
audits that were defined to work, any reduction of functionality
as a result of this security hardening is intentional.

Closes-Bug: #2112187
Change-Id: Ic3b6bfd164e272d70fe86d7b182478dd962f8ac0
Signed-off-by: Sean Mooney <work@seanmooney.info>
(cherry picked from commit 3742e0a79c)
(cherry picked from commit ffec800f59)
(cherry picked from commit c049a533e3)
2025-08-18 16:37:50 +00:00
James Page
64ba589f80 Further database refactoring
More refactoring of the SQLAlchemy database layer to improve
compatility with eventlet on newer Pythons.

Inspired by 0ce2c41404

Related-Bug: 2067815
Change-Id: Ib5e9aa288232cc1b766bbf2a8ce2113d5a8e2f7d
(cherry picked from commit 753c44b0c4)
(cherry picked from commit 54b3b58428)
2025-04-24 17:10:26 +02:00
Tobias Urdin
accc7a2a22 Replace deprecated LegacyEngineFacade
LegacyEngineFacade was deprecated in oslo.db 1.12.0 which was released
in 2015.

Change-Id: I5570698262617eae3f48cf29aacf2e23ad541e5f
(cherry picked from commit 5c627a3aa3)
(cherry picked from commit 8b0f1dbf66)
2025-04-24 17:10:07 +02:00
Alfredo Moralejo
70d92a75cc Skip real-data tests in non-real-data jobs
I am excluding strategies execution with annotation `real_load` in
non-real-load jobs.

This is partial backport of [1].

[1] https://review.opendev.org/c/openstack/watcher/+/945627

Modified cherry-pick as there is not prometheus job in 2024.2.

Change-Id: I77d4c23ebc21693bba8ca0247b8954c6dc8eaba9
(cherry picked from commit ce9f0b4c1e)
(cherry picked from commit dbc06d1504)
2025-04-24 17:09:46 +02:00
OpenStack Release Bot
f5ef5557ab Update TOX_CONSTRAINTS_FILE for stable/2024.1
Update the URL to the upper-constraints file to point to the redirect
rule on releases.openstack.org so that anyone working on this branch
will switch to the correct upper-constraints list automatically when
the requirements repository branches.

Until the requirements repository has as stable/2024.1 branch, tests will
continue to use the upper-constraints list on master.

Change-Id: I67913036e99e24d8963452265b48002ca017af3f
2024-03-30 06:58:10 +00:00
OpenStack Release Bot
c604e00b4d Update .gitreview for stable/2024.1
Change-Id: I08eff2b45fc9ddd9a74ebc21256636ec194446d7
2024-03-30 06:58:05 +00:00
James Page
6b433b3547 Fix oslo.db >= 15.0.0 compatibility
Minimal refactor of SQLAlchemy api module to be compatible with
oslo.db >= 15.0.0 where autocommit behaviour was dropped.

Closes-Bug: #2056181
Change-Id: I33be53f647faae2aad30a43c10980df950d5d7c2
(cherry picked from commit bc5922c684)
2024-03-27 15:14:22 +00:00
Ghanshyam Mann
9d58a6d457 Update python classifier in setup.cfg
As per the current release tested runtime, we test
python version from 3.8 to 3.11 so updating the
same in python classifier in setup.cfg

Change-Id: Ie010eea38eb0861699b60f16dfd3e2e95ae33709
2024-01-09 19:22:04 -08:00
Lucian Petrut
c95ce4ec17 Add MAAS support
At the moment, Watcher can use a single bare metal provisioning
service: Openstack Ironic.

We're now adding support for Canonical's MAAS service [1], which
is commonly used along with Juju [2] to deploy Openstack.

In order to do so, we're building a metal client abstraction, with
concrete implementations for Ironic and MAAS. We'll pick the MAAS
client if the MAAS url is provided, otherwise defaulting to Ironic.

For now, we aren't updating the baremetal model collector since it
doesn't seem to be used by any of the existing Watcher strategy
implementations.

[1] https://maas.io/docs
[2] https://juju.is/docs

Implements: blueprint maas-support

Change-Id: I6861995598f6c542fa9c006131f10203f358e0a6
2023-12-11 10:21:33 +00:00
Zuul
9492c2190e Merge "vm workload consolidation: use actual host metrics" 2023-12-01 01:51:39 +00:00
Lucian Petrut
808f1bcee3 Update action json schema
Power-off actions created by the energy saving strategy include
a resource name property, which currently isn't part of the
action json schema. For this reason, json schema validation fails.

  Additional properties are not allowed ('resource_name' was unexpected)

We'll update the json schema, including the resource name property.

Change-Id: I924d36732a917c0be98b08c2f4128e9136356215
2023-11-15 01:11:56 +00:00
Lucian Petrut
3b224b5629 Fix object tests
A couple of object tests are failing, probably after a dependency
bump.

watcher.objects.base.objects is mocked, so the registered object
version isn't properly retrieved, leading to a type error:

    File "/mnt/data/workspace/watcher/watcher/tests/objects/test_objects.py",
    line 535, in test_hook_chooses_newer_properly
      reg.registration_hook(MyObj, 0)
    File "/mnt/data/workspace/watcher/watcher/objects/base.py",
    line 46, in registration_hook
      cur_version = versionutils.convert_version_to_tuple(
    File "/home/ubuntu/openstack_venv/lib/python3.10/site-packages/oslo_utils/versionutils.py",
    line 91, in convert_version_to_tuple
      version_str = re.sub(r'(\d+)(a|alpha|b|beta|rc)\d+$', '\\1', version_str)
    File "/usr/lib/python3.10/re.py", line 209, in sub
      return _compile(pattern, flags).sub(repl, string, count)
  TypeError: expected string or bytes-like object

We'll solve the issue by setting the VERSION attribute against
the mock object.

Change-Id: Ifeb38b98f1d702908531de5fc5c846bd1c53de4b
2023-11-14 10:38:40 +00:00
Lucian Petrut
424e9a76af vm workload consolidation: use actual host metrics
The "vm workload consolidation" strategy is summing up instance
usage in order to estimate host usage.

The problem is that some infrastructure services (e.g. OVS or Ceph
clients) may also use a significant amount of resources, which
would be ignored. This can impact Watcher's ability to detect
overloaded nodes and correctly rebalance the workload.

This commit will use the host metrics, if available. The proposed
implementation uses the maximum value between the host metric
and the sum of the instance metrics.

Note that we're holding a dict of host metric deltas in order to
account for planned migrations.

Change-Id: I82f474ee613f6c9a7c0a9d24a05cba41d2f68edb
2023-10-27 21:54:42 +03:00
Zuul
40e93407c7 Merge "Handle deprecated "cpu_util" metric" 2023-10-27 09:47:38 +00:00
Zuul
721aec1cb6 Merge "vm workload consolidation: allow cold migrations" 2023-10-27 09:47:36 +00:00
Zuul
8a3ee8f931 Merge "Improve vm_consolidation logging" 2023-10-27 09:20:13 +00:00
Lucian Petrut
00fea975e2 Handle deprecated "cpu_util" metric
The "cpu_util" metric has been deprecated a few years ago.
We'll obtain the same result by converting the cumulative cpu
time to a percentage, leveraging the rate of change aggregation.

Change-Id: I18fe0de6f74c785e674faceea0c48f44055818fe
2023-10-24 10:47:23 +00:00
Lucian Petrut
fd6562382e Avoid performing retries in case of missing resources
There may be no available metrics for instances that are stopped
or were recently spawned. This makes retries unnecessary and time
consuming.

For this reason, we'll ignore gnocchi MetricNotFound errors.

Change-Id: I79cd03bf04db634b931d6dfd32d5150f58e82044
2023-10-23 14:14:21 +00:00
Lucian Petrut
ec90891636 Improve vm_consolidation logging
We're adding a few info log messages in order to trace the
"vm consolidation" strategy more easily.

Change-Id: I8ce1a9dd173733f1b801839d3ad0c1269c4306bb
2023-10-23 14:10:02 +00:00
Lucian Petrut
7336a48057 vm workload consolidation: allow cold migrations
Although Watcher supports cold migrations, the vm workload
consolidation workflow only allows live migrations to be
performed.

We'll remove this unnecessary limitation so that stopped instances
could be cold migrated.

Change-Id: I4b41550f2255560febf8586722a0e02045c3a486
2023-10-23 13:03:18 +00:00
Lucian Petrut
922478fbda Unblock the CI gate
The Nova collector json schema validation started [1][2] failing after
the jsonschema upper constraint was bumped from 4.17.3 to 4.19.1 [3].

The reason is that jsonschema v4.18.0a1 switched to a reference
resolving library [4], which treats the aggregate "id" as a jsonschema
id and expects it to be a string [5]. For this reason, we're now getting
AttributeError exceptions.

As a workaround, we'll rename the "id" ref element as "host_aggr_id".

Also, the watcher-tempest-multinode job is configured to use Focal,
which is no longer supported by Devstack [6]. That being considered,
we'll switch to Ubuntu Jammy (22.04).

While at it, we're disabling Cinder Backup, which isn't used while
testing Watched. It currently causes Devstack failures since it
uses the Swift backend by default, which is disabled.

[1] https://paste.opendev.org/raw/bjQ1uIdbDMnmA1UEhxLL/
[2] https://paste.opendev.org/raw/bNgxqulBwBLYB7tNhrU4/
[3] ab0dcbdda2
[4] https://github.com/python-jsonschema/jsonschema/releases/tag/v4.18.0a1
[5] c23a5dc1c9/referencing/jsonschema.py (L54-L55C18)
[6] https://paste.openstack.org/raw/bSoSyXgbtmq6d9768HQn/

Change-Id: I300620c2ec4857b1e0d402a9b57a637f576eeb24
2023-10-23 09:21:55 +03:00
OpenStack Release Bot
9f0eca2343 Update master for stable/2023.2
Add file to the reno documentation build to show release notes for
stable/2023.2.

Use pbr instruction to increment the minor version number
automatically so that master versions are higher than the versions on
stable/2023.2.

Sem-Ver: feature
Change-Id: I8a0c75ce5a4e5ae5cccd8eb1cb0325747a619122
2023-09-14 01:24:43 +00:00
Zuul
1e11c490a7 Merge "Add timeout option for Grafana request" 2023-08-29 11:21:46 +00:00
Zuul
8a7a8db661 Merge "Imported Translations from Zanata" 2023-08-28 06:21:40 +00:00
BubaVV
0610070e59 Add timeout option for Grafana request
Implemented config option to setup Grafana API request timeout

Change-Id: I8cbf8ce22f199fe22c0b162ba1f419169881f193
2023-08-23 17:46:19 +03:00
OpenStack Proposal Bot
a0997a0423 Imported Translations from Zanata
For more information about this automatic import see:
https://docs.openstack.org/i18n/latest/reviewing-translation-import.html

Change-Id: I37201577bd8d9c53db8ce6700f47d911359da6d2
2023-08-14 04:24:29 +00:00
chenker
4ea3eada3e Fix watcher comment
Change-Id: I4512cf1032e08934886d5e3ca858b3e05c3da76c
2023-08-13 00:00:12 +00:00
Zuul
cd1c0f3054 Merge "Imported Translations from Zanata" 2023-03-08 07:04:33 +00:00
OpenStack Proposal Bot
684350977d Imported Translations from Zanata
For more information about this automatic import see:
https://docs.openstack.org/i18n/latest/reviewing-translation-import.html

Change-Id: I4ee251e6d37a1b955c22dc6fdc04c1a08c9ae9b8
2023-03-02 03:28:31 +00:00
OpenStack Release Bot
d28630b759 Update master for stable/2023.1
Add file to the reno documentation build to show release notes for
stable/2023.1.

Use pbr instruction to increment the minor version number
automatically so that master versions are higher than the versions on
stable/2023.1.

Sem-Ver: feature
Change-Id: Ia585893e7fef42e9991a2b81f604d1ff28c0a5ad
2023-02-28 13:31:08 +00:00
Zuul
f7fbaf46a2 Merge "Use new get_rpc_client API from oslo.messaging" 2023-02-09 01:25:15 +00:00
Zuul
e7cda537e7 Merge "Modify saving_energy log info" 2023-02-07 12:18:58 +00:00
chenker
c7be34fbaa update saving_energy docs
Change-Id: I3b0c86911a8d32912c2de2e2392af9539b8d9be0
2023-02-07 10:27:54 +00:00
chenker
52da088011 Modify saving_energy log info
Change-Id: I84879a453aa3ff78917d1136c62978b9d0e606de
2023-02-07 10:20:04 +00:00
Tobias Urdin
6ac3a6febf Fix passenv in tox.ini
Change-Id: If1ddb1d48eeb96191bcbfadd1a5e14f4350a02e4
2023-02-07 08:02:20 +00:00
Tobias Urdin
e36b77ad6d Use new get_rpc_client API from oslo.messaging
Use the new API that is consistent with
the existing API instead of instantiating the client
class directly.

This was introduced in release 14.1.0 here [1] and
added into oslo.messaging here [2]

[1] https://review.opendev.org/c/openstack/requirements/+/869340
[2] https://review.opendev.org/c/openstack/oslo.messaging/+/862419

Change-Id: I43c399a0c68473e40b8b71e9617c8334a439e675
2023-01-19 20:50:26 +00:00
Thierry Carrez
6003322711 Move queue declaration to project level
This moves the watcher queue declaration from the pipeline level
(where it is no longer valid) to the project level.

https: //lists.openstack.org/pipermail/openstack-discuss/2022-May/028603.html
Change-Id: I06923abb00f7eecd59587f44cd1f6a069e88a9fc
2022-09-26 14:19:58 +02:00
Zuul
f4ffca01b8 Merge "Switch to 2023.1 Python3 unit tests and generic template name" 2022-09-16 06:36:21 +00:00
Alfredo Moralejo
5d70c207cd Fix compatibility with oslo.db 12.1.0
oslo.db 12.1.0 has changed the default value for the 'autocommit'
parameter of 'LegacyEngineFacade' from 'True' to 'False'. This is a
necessary step to ensure compatibility with SQLAlchemy 2.0. However, we
are currently relying on the autocommit behavior and need changes to
explicitly manage sessions. Until that happens, we need to override the
default.

Co-Authored-By: Stephen Finucane <stephenfin@redhat.com>
Change-Id: I7db39d958d087322bfa0aad70dfbd04de9228dd7
2022-09-15 16:52:41 +02:00
OpenStack Release Bot
0b2e641d00 Switch to 2023.1 Python3 unit tests and generic template name
This is an automatically generated patch to ensure unit testing
is in place for all the of the tested runtimes for antelope. Also,
updating the template name to generic one.

See also the PTI in governance [1].

[1]: https://governance.openstack.org/tc/reference/project-testing-interface.html

Change-Id: Ide6c6c398f8e6cdd590c6620a752ad802a1f5cf8
2022-09-13 12:30:33 +00:00
OpenStack Release Bot
ff84b052a5 Update master for stable/zed
Add file to the reno documentation build to show release notes for
stable/zed.

Use pbr instruction to increment the minor version number
automatically so that master versions are higher than the versions on
stable/zed.

Sem-Ver: feature
Change-Id: I1726e33a14038712dbb9fd5e5c0cddf8ad872e69
2022-09-13 12:30:32 +00:00
Zuul
a43b040ebc Merge "Imported Translations from Zanata" 2022-08-30 10:44:52 +00:00
Zuul
749fa2507a Merge "Tests: fix requirements for unit tests" 2022-08-30 08:15:05 +00:00
OpenStack Proposal Bot
76d61362ee Imported Translations from Zanata
For more information about this automatic import see:
https://docs.openstack.org/i18n/latest/reviewing-translation-import.html

Change-Id: I95133dece6fdaf931dfed64015806430ba8d04f0
2022-08-29 04:12:15 +00:00
wangjiaqi07
c55143bc21 remove unicode from code
Change-Id: I747445d482a2fb40c2f39139c5fd2a0cb26c27bc
2022-08-19 14:17:10 +08:00
suzhengwei
7609df3370 Tests: fix requirements for unit tests
Add WebTest to test-requirements which used to be imported as a
transitive requirement via pecan, but the latest release of
pecan dropped this dependency. So make this requirement explicit.

Related-Bug: #1982110
Change-Id: I4852be23b489257aaa56d3fa22d27f72bcabf919
2022-07-28 16:14:13 +08:00
chenker
b57eac12cb Watcher DB upgrde compatibility consideration for add_apscheduler_jobs
Change-Id: I8896ff5731bb8c1bf88a5d7b926bd2a884100ea8
2022-04-28 02:21:06 +00:00
OpenStack Release Bot
ac6911d3c4 Add Python3 zed unit tests
This is an automatically generated patch to ensure unit testing
is in place for all the of the tested runtimes for zed.

See also the PTI in governance [1].

[1]: https://governance.openstack.org/tc/reference/project-testing-interface.html

Change-Id: I5cf874842550de18ff777b909fd28e2c32e6d530
2022-03-10 12:14:06 +00:00
OpenStack Release Bot
23c2010681 Update master for stable/yoga
Add file to the reno documentation build to show release notes for
stable/yoga.

Use pbr instruction to increment the minor version number
automatically so that master versions are higher than the versions on
stable/yoga.

Sem-Ver: feature
Change-Id: Ic7c275b38fef9afc29577f81fe92546bb94b2930
2022-03-10 12:14:04 +00:00
76 changed files with 1911 additions and 771 deletions

View File

@@ -2,3 +2,4 @@
host=review.opendev.org host=review.opendev.org
port=29418 port=29418
project=openstack/watcher.git project=openstack/watcher.git
defaultbranch=stable/2024.1

View File

@@ -1,8 +1,9 @@
- project: - project:
queue: watcher
templates: templates:
- check-requirements - check-requirements
- openstack-cover-jobs - openstack-cover-jobs
- openstack-python3-yoga-jobs - openstack-python3-jobs
- publish-openstack-docs-pti - publish-openstack-docs-pti
- release-notes-jobs-python3 - release-notes-jobs-python3
check: check:
@@ -14,7 +15,6 @@
- watcherclient-tempest-functional - watcherclient-tempest-functional
- watcher-tempest-functional-ipv6-only - watcher-tempest-functional-ipv6-only
gate: gate:
queue: watcher
jobs: jobs:
- watcher-tempest-functional - watcher-tempest-functional
- watcher-tempest-functional-ipv6-only - watcher-tempest-functional-ipv6-only
@@ -85,11 +85,12 @@
vars: vars:
tempest_concurrency: 1 tempest_concurrency: 1
tempest_test_regex: watcher_tempest_plugin.tests.scenario.test_execute_strategies tempest_test_regex: watcher_tempest_plugin.tests.scenario.test_execute_strategies
tempest_exclude_regex: .*\[.*\breal_load\b.*\].*
- job: - job:
name: watcher-tempest-multinode name: watcher-tempest-multinode
parent: watcher-tempest-functional parent: watcher-tempest-functional
nodeset: openstack-two-node-focal nodeset: openstack-two-node-jammy
roles: roles:
- zuul: openstack/tempest - zuul: openstack/tempest
group-vars: group-vars:
@@ -107,6 +108,7 @@
watcher-api: false watcher-api: false
watcher-decision-engine: true watcher-decision-engine: true
watcher-applier: false watcher-applier: false
c-bak: false
ceilometer: false ceilometer: false
ceilometer-acompute: false ceilometer-acompute: false
ceilometer-acentral: false ceilometer-acentral: false

View File

@@ -338,6 +338,19 @@ function stop_watcher {
done done
} }
# configure_tempest_for_watcher() - Configure Tempest for watcher
function configure_tempest_for_watcher {
# Set default microversion for watcher-tempest-plugin
# Please make sure to update this when the microversion is updated, otherwise
# new tests may be skipped.
TEMPEST_WATCHER_MIN_MICROVERSION=${TEMPEST_WATCHER_MIN_MICROVERSION:-"1.0"}
TEMPEST_WATCHER_MAX_MICROVERSION=${TEMPEST_WATCHER_MAX_MICROVERSION:-"1.4"}
# Set microversion options in tempest.conf
iniset $TEMPEST_CONFIG optimize min_microversion $TEMPEST_WATCHER_MIN_MICROVERSION
iniset $TEMPEST_CONFIG optimize max_microversion $TEMPEST_WATCHER_MAX_MICROVERSION
}
# Restore xtrace # Restore xtrace
$_XTRACE_WATCHER $_XTRACE_WATCHER

View File

@@ -38,6 +38,9 @@ if is_service_enabled watcher-api watcher-decision-engine watcher-applier; then
# Start the watcher components # Start the watcher components
echo_summary "Starting watcher" echo_summary "Starting watcher"
start_watcher start_watcher
elif [[ "$1" == "stack" && "$2" == "test-config" ]]; then
echo_summary "Configuring tempest for watcher"
configure_tempest_for_watcher
fi fi
if [[ "$1" == "unstack" ]]; then if [[ "$1" == "unstack" ]]; then

View File

@@ -56,8 +56,8 @@ source_suffix = '.rst'
master_doc = 'index' master_doc = 'index'
# General information about the project. # General information about the project.
project = u'Watcher' project = 'Watcher'
copyright = u'OpenStack Foundation' copyright = 'OpenStack Foundation'
# A list of ignored prefixes for module index sorting. # A list of ignored prefixes for module index sorting.
modindex_common_prefix = ['watcher.'] modindex_common_prefix = ['watcher.']
@@ -91,14 +91,14 @@ pygments_style = 'native'
# List of tuples 'sourcefile', 'target', u'title', u'Authors name', 'manual' # List of tuples 'sourcefile', 'target', u'title', u'Authors name', 'manual'
man_pages = [ man_pages = [
('man/watcher-api', 'watcher-api', u'Watcher API Server', ('man/watcher-api', 'watcher-api', 'Watcher API Server',
[u'OpenStack'], 1), ['OpenStack'], 1),
('man/watcher-applier', 'watcher-applier', u'Watcher Applier', ('man/watcher-applier', 'watcher-applier', 'Watcher Applier',
[u'OpenStack'], 1), ['OpenStack'], 1),
('man/watcher-db-manage', 'watcher-db-manage', ('man/watcher-db-manage', 'watcher-db-manage',
u'Watcher Db Management Utility', [u'OpenStack'], 1), 'Watcher Db Management Utility', ['OpenStack'], 1),
('man/watcher-decision-engine', 'watcher-decision-engine', ('man/watcher-decision-engine', 'watcher-decision-engine',
u'Watcher Decision Engine', [u'OpenStack'], 1), 'Watcher Decision Engine', ['OpenStack'], 1),
] ]
# -- Options for HTML output -------------------------------------------------- # -- Options for HTML output --------------------------------------------------
@@ -128,8 +128,8 @@ openstackdocs_bug_tag = ''
latex_documents = [ latex_documents = [
('index', ('index',
'doc-watcher.tex', 'doc-watcher.tex',
u'Watcher Documentation', 'Watcher Documentation',
u'OpenStack Foundation', 'manual'), 'OpenStack Foundation', 'manual'),
] ]
# If false, no module index is generated. # If false, no module index is generated.

View File

@@ -372,7 +372,7 @@ You can configure and install Ceilometer by following the documentation below :
#. https://docs.openstack.org/ceilometer/latest #. https://docs.openstack.org/ceilometer/latest
The built-in strategy 'basic_consolidation' provided by watcher requires The built-in strategy 'basic_consolidation' provided by watcher requires
"**compute.node.cpu.percent**" and "**cpu_util**" measurements to be collected "**compute.node.cpu.percent**" and "**cpu**" measurements to be collected
by Ceilometer. by Ceilometer.
The measurements available depend on the hypervisors that OpenStack manages on The measurements available depend on the hypervisors that OpenStack manages on
the specific implementation. the specific implementation.

View File

@@ -300,6 +300,6 @@ Using that you can now query the values for that specific metric:
.. code-block:: py .. code-block:: py
avg_meter = self.datasource_backend.statistic_aggregation( avg_meter = self.datasource_backend.statistic_aggregation(
instance.uuid, 'cpu_util', self.periods['instance'], instance.uuid, 'instance_cpu_usage', self.periods['instance'],
self.granularity, self.granularity,
aggregation=self.aggregation_method['instance']) aggregation=self.aggregation_method['instance'])

View File

@@ -26,8 +26,7 @@ metric service name plugins comment
``compute_monitors`` option ``compute_monitors`` option
to ``cpu.virt_driver`` in to ``cpu.virt_driver`` in
the nova.conf. the nova.conf.
``cpu_util`` ceilometer_ none cpu_util has been removed ``cpu`` ceilometer_ none
since Stein.
============================ ============ ======= =========================== ============================ ============ ======= ===========================
.. _ceilometer: https://docs.openstack.org/ceilometer/latest/admin/telemetry-measurements.html#openstack-compute .. _ceilometer: https://docs.openstack.org/ceilometer/latest/admin/telemetry-measurements.html#openstack-compute

View File

@@ -89,9 +89,9 @@ step 2: Create audit to do optimization
.. code-block:: shell .. code-block:: shell
$ openstack optimize audittemplate create \ $ openstack optimize audittemplate create \
at1 saving_energy --strategy saving_energy saving_energy_template1 saving_energy --strategy saving_energy
$ openstack optimize audit create -a at1 \ $ openstack optimize audit create -a saving_energy_audit1 \
-p free_used_percent=20.0 -p free_used_percent=20.0
External Links External Links

View File

@@ -22,14 +22,19 @@ The *vm_workload_consolidation* strategy requires the following metrics:
============================ ============ ======= ========================= ============================ ============ ======= =========================
metric service name plugins comment metric service name plugins comment
============================ ============ ======= ========================= ============================ ============ ======= =========================
``cpu_util`` ceilometer_ none cpu_util has been removed ``cpu`` ceilometer_ none
since Stein.
``memory.resident`` ceilometer_ none ``memory.resident`` ceilometer_ none
``memory`` ceilometer_ none ``memory`` ceilometer_ none
``disk.root.size`` ceilometer_ none ``disk.root.size`` ceilometer_ none
``compute.node.cpu.percent`` ceilometer_ none (optional) need to set the
``compute_monitors`` option
to ``cpu.virt_driver`` in the
nova.conf.
``hardware.memory.used`` ceilometer_ SNMP_ (optional)
============================ ============ ======= ========================= ============================ ============ ======= =========================
.. _ceilometer: https://docs.openstack.org/ceilometer/latest/admin/telemetry-measurements.html#openstack-compute .. _ceilometer: https://docs.openstack.org/ceilometer/latest/admin/telemetry-measurements.html#openstack-compute
.. _SNMP: https://docs.openstack.org/ceilometer/latest/admin/telemetry-measurements.html#snmp-based-meters
Cluster data model Cluster data model
****************** ******************

View File

@@ -27,9 +27,8 @@ metric service name plugins comment
to ``cpu.virt_driver`` in the to ``cpu.virt_driver`` in the
nova.conf. nova.conf.
``hardware.memory.used`` ceilometer_ SNMP_ ``hardware.memory.used`` ceilometer_ SNMP_
``cpu_util`` ceilometer_ none cpu_util has been removed ``cpu`` ceilometer_ none
since Stein. ``instance_ram_usage`` ceilometer_ none
``memory.resident`` ceilometer_ none
============================ ============ ======= ============================= ============================ ============ ======= =============================
.. _ceilometer: https://docs.openstack.org/ceilometer/latest/admin/telemetry-measurements.html#openstack-compute .. _ceilometer: https://docs.openstack.org/ceilometer/latest/admin/telemetry-measurements.html#openstack-compute
@@ -107,10 +106,10 @@ parameter type default Value description
period of all received ones. period of all received ones.
==================== ====== ===================== ============================= ==================== ====== ===================== =============================
.. |metrics| replace:: ["cpu_util", "memory.resident"] .. |metrics| replace:: ["instance_cpu_usage", "instance_ram_usage"]
.. |thresholds| replace:: {"cpu_util": 0.2, "memory.resident": 0.2} .. |thresholds| replace:: {"instance_cpu_usage": 0.2, "instance_ram_usage": 0.2}
.. |weights| replace:: {"cpu_util_weight": 1.0, "memory.resident_weight": 1.0} .. |weights| replace:: {"instance_cpu_usage_weight": 1.0, "instance_ram_usage_weight": 1.0}
.. |instance_metrics| replace:: {"cpu_util": "compute.node.cpu.percent", "memory.resident": "hardware.memory.used"} .. |instance_metrics| replace:: {"instance_cpu_usage": "compute.node.cpu.percent", "instance_ram_usage": "hardware.memory.used"}
.. |periods| replace:: {"instance": 720, "node": 600} .. |periods| replace:: {"instance": 720, "node": 600}
Efficacy Indicator Efficacy Indicator
@@ -136,8 +135,8 @@ How to use it ?
at1 workload_balancing --strategy workload_stabilization at1 workload_balancing --strategy workload_stabilization
$ openstack optimize audit create -a at1 \ $ openstack optimize audit create -a at1 \
-p thresholds='{"memory.resident": 0.05}' \ -p thresholds='{"instance_ram_usage": 0.05}' \
-p metrics='["memory.resident"]' -p metrics='["instance_ram_usage"]'
External Links External Links
-------------- --------------

View File

@@ -24,8 +24,7 @@ The *workload_balance* strategy requires the following metrics:
======================= ============ ======= ========================= ======================= ============ ======= =========================
metric service name plugins comment metric service name plugins comment
======================= ============ ======= ========================= ======================= ============ ======= =========================
``cpu_util`` ceilometer_ none cpu_util has been removed ``cpu`` ceilometer_ none
since Stein.
``memory.resident`` ceilometer_ none ``memory.resident`` ceilometer_ none
======================= ============ ======= ========================= ======================= ============ ======= =========================
@@ -65,15 +64,16 @@ Configuration
Strategy parameters are: Strategy parameters are:
============== ====== ============= ==================================== ============== ====== ==================== ====================================
parameter type default Value description parameter type default Value description
============== ====== ============= ==================================== ============== ====== ==================== ====================================
``metrics`` String 'cpu_util' Workload balance base on cpu or ram ``metrics`` String 'instance_cpu_usage' Workload balance base on cpu or ram
utilization. choice: ['cpu_util', utilization. Choices:
'memory.resident'] ['instance_cpu_usage',
``threshold`` Number 25.0 Workload threshold for migration 'instance_ram_usage']
``period`` Number 300 Aggregate time period of ceilometer ``threshold`` Number 25.0 Workload threshold for migration
============== ====== ============= ==================================== ``period`` Number 300 Aggregate time period of ceilometer
============== ====== ==================== ====================================
Efficacy Indicator Efficacy Indicator
------------------ ------------------
@@ -95,7 +95,7 @@ How to use it ?
at1 workload_balancing --strategy workload_balance at1 workload_balancing --strategy workload_balance
$ openstack optimize audit create -a at1 -p threshold=26.0 \ $ openstack optimize audit create -a at1 -p threshold=26.0 \
-p period=310 -p metrics=cpu_util -p period=310 -p metrics=instance_cpu_usage
External Links External Links
-------------- --------------

View File

@@ -0,0 +1,47 @@
---
security:
- |
Watchers no longer forges requests on behalf of a tenant when
swapping volumes. Prior to this release watcher had 2 implementations
of moving a volume, it could use cinders volume migrate api or its own
internal implementation that directly calls nova volume attachment update
api. The former is safe and the recommend way to move volumes between
cinder storage backend the internal implementation was insecure, fragile
due to a lack of error handling and capable of deleting user data.
Insecure: the internal volume migration operation created a new keystone
user with a weak name and password and added it to the tenants project
with the admin role. It then used that user to forge request on behalf
of the tenant with admin right to swap the volume. if the applier was
restarted during the execution of this operation it would never be cleaned
up.
Fragile: the error handling was minimal, the swap volume api is async
so watcher has to poll for completion, there was no support to resume
that if interrupted of the time out was exceeded.
Data-loss: while the internal polling logic returned success or failure
watcher did not check the result, once the function returned it
unconditionally deleted the source volume. For larger volumes this
could result in irretrievable data loss.
Finally if a volume was swapped using the internal workflow it put
the nova instance in an out of sync state. If the VM was live migrated
after the swap volume completed successfully prior to a hard reboot
then the migration would fail or succeed and break tenant isolation.
see: https://bugs.launchpad.net/nova/+bug/2112187 for details.
fixes:
- |
All code related to creating keystone user and granting roles has been
removed. The internal swap volume implementation has been removed and
replaced by cinders volume migrate api. Note as part of this change
Watcher will no longer attempt volume migrations or retypes if the
instance is in the `Verify Resize` task state. This resolves several
issues related to volume migration in the zone migration and
Storage capacity balance strategies. While efforts have been made
to maintain backward compatibility these changes are required to
address a security weakness in watcher's prior approach.
see: https://bugs.launchpad.net/nova/+bug/2112187 for more context.

View File

@@ -0,0 +1,6 @@
===========================
2023.1 Series Release Notes
===========================
.. release-notes::
:branch: stable/2023.1

View File

@@ -0,0 +1,6 @@
===========================
2023.2 Series Release Notes
===========================
.. release-notes::
:branch: stable/2023.2

View File

@@ -53,7 +53,7 @@ source_suffix = '.rst'
master_doc = 'index' master_doc = 'index'
# General information about the project. # General information about the project.
copyright = u'2016, Watcher developers' copyright = '2016, Watcher developers'
# Release notes are version independent # Release notes are version independent
# The short X.Y version. # The short X.Y version.
@@ -196,8 +196,8 @@ latex_elements = {
# Grouping the document tree into LaTeX files. List of tuples # Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]) # (source start file, target name, title, author, documentclass [howto/manual])
latex_documents = [ latex_documents = [
('index', 'watcher.tex', u'Watcher Documentation', ('index', 'watcher.tex', 'Watcher Documentation',
u'Watcher developers', 'manual'), 'Watcher developers', 'manual'),
] ]
# The name of an image file (relative to this directory) to place at the top of # The name of an image file (relative to this directory) to place at the top of
@@ -226,8 +226,8 @@ latex_documents = [
# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [
('index', 'watcher', u'Watcher Documentation', ('index', 'watcher', 'Watcher Documentation',
[u'Watcher developers'], 1) ['Watcher developers'], 1)
] ]
# If true, show URL addresses after external links. # If true, show URL addresses after external links.
@@ -240,8 +240,8 @@ man_pages = [
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
('index', 'watcher', u'Watcher Documentation', ('index', 'watcher', 'Watcher Documentation',
u'Watcher developers', 'watcher', 'One line description of project.', 'Watcher developers', 'watcher', 'One line description of project.',
'Miscellaneous'), 'Miscellaneous'),
] ]

View File

@@ -21,6 +21,10 @@ Contents:
:maxdepth: 1 :maxdepth: 1
unreleased unreleased
2023.2
2023.1
zed
yoga
xena xena
wallaby wallaby
victoria victoria

View File

@@ -1,15 +1,17 @@
# Andi Chandler <andi@gowling.com>, 2017. #zanata # Andi Chandler <andi@gowling.com>, 2017. #zanata
# Andi Chandler <andi@gowling.com>, 2018. #zanata # Andi Chandler <andi@gowling.com>, 2018. #zanata
# Andi Chandler <andi@gowling.com>, 2020. #zanata # Andi Chandler <andi@gowling.com>, 2020. #zanata
# Andi Chandler <andi@gowling.com>, 2022. #zanata
# Andi Chandler <andi@gowling.com>, 2023. #zanata
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: python-watcher\n" "Project-Id-Version: python-watcher\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-10-27 04:13+0000\n" "POT-Creation-Date: 2023-08-14 03:05+0000\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"PO-Revision-Date: 2020-10-28 11:13+0000\n" "PO-Revision-Date: 2023-06-21 07:54+0000\n"
"Last-Translator: Andi Chandler <andi@gowling.com>\n" "Last-Translator: Andi Chandler <andi@gowling.com>\n"
"Language-Team: English (United Kingdom)\n" "Language-Team: English (United Kingdom)\n"
"Language: en_GB\n" "Language: en_GB\n"
@@ -58,12 +60,18 @@ msgstr "1.9.0"
msgid "2.0.0" msgid "2.0.0"
msgstr "2.0.0" msgstr "2.0.0"
msgid "2023.1 Series Release Notes"
msgstr "2023.1 Series Release Notes"
msgid "3.0.0" msgid "3.0.0"
msgstr "3.0.0" msgstr "3.0.0"
msgid "4.0.0" msgid "4.0.0"
msgstr "4.0.0" msgstr "4.0.0"
msgid "6.0.0"
msgstr "6.0.0"
msgid "A ``watcher-status upgrade check`` has been added for this." msgid "A ``watcher-status upgrade check`` has been added for this."
msgstr "A ``watcher-status upgrade check`` has been added for this." msgstr "A ``watcher-status upgrade check`` has been added for this."
@@ -744,6 +752,23 @@ msgstr ""
"The configuration options for query retries in `[gnocchi_client]` are " "The configuration options for query retries in `[gnocchi_client]` are "
"deprecated and the option in `[watcher_datasources]` should now be used." "deprecated and the option in `[watcher_datasources]` should now be used."
msgid ""
"The default value of ``[oslo_policy] policy_file`` config option has been "
"changed from ``policy.json`` to ``policy.yaml``. Operators who are utilizing "
"customized or previously generated static policy JSON files (which are not "
"needed by default), should generate new policy files or convert them in YAML "
"format. Use the `oslopolicy-convert-json-to-yaml <https://docs.openstack.org/"
"oslo.policy/latest/cli/oslopolicy-convert-json-to-yaml.html>`_ tool to "
"convert a JSON to YAML formatted policy file in backward compatible way."
msgstr ""
"The default value of ``[oslo_policy] policy_file`` config option has been "
"changed from ``policy.json`` to ``policy.yaml``. Operators who are utilizing "
"customized or previously generated static policy JSON files (which are not "
"needed by default), should generate new policy files or convert them in YAML "
"format. Use the `oslopolicy-convert-json-to-yaml <https://docs.openstack.org/"
"oslo.policy/latest/cli/oslopolicy-convert-json-to-yaml.html>`_ tool to "
"convert a JSON to YAML formatted policy file in backward compatible way."
msgid "" msgid ""
"The graph model describes how VMs are associated to compute hosts. This " "The graph model describes how VMs are associated to compute hosts. This "
"allows for seeing relationships upfront between the entities and hence can " "allows for seeing relationships upfront between the entities and hence can "
@@ -799,6 +824,21 @@ msgstr "Train Series Release Notes"
msgid "Upgrade Notes" msgid "Upgrade Notes"
msgstr "Upgrade Notes" msgstr "Upgrade Notes"
msgid ""
"Use of JSON policy files was deprecated by the ``oslo.policy`` library "
"during the Victoria development cycle. As a result, this deprecation is "
"being noted in the Wallaby cycle with an anticipated future removal of "
"support by ``oslo.policy``. As such operators will need to convert to YAML "
"policy files. Please see the upgrade notes for details on migration of any "
"custom policy files."
msgstr ""
"Use of JSON policy files was deprecated by the ``oslo.policy`` library "
"during the Victoria development cycle. As a result, this deprecation is "
"being noted in the Wallaby cycle with an anticipated future removal of "
"support by ``oslo.policy``. As such operators will need to convert to YAML "
"policy files. Please see the upgrade notes for details on migration of any "
"custom policy files."
msgid "" msgid ""
"Using ``watcher/api/app.wsgi`` script is deprecated and it will be removed " "Using ``watcher/api/app.wsgi`` script is deprecated and it will be removed "
"in U release. Please switch to automatically generated ``watcher-api-wsgi`` " "in U release. Please switch to automatically generated ``watcher-api-wsgi`` "
@@ -814,6 +854,9 @@ msgstr "Ussuri Series Release Notes"
msgid "Victoria Series Release Notes" msgid "Victoria Series Release Notes"
msgstr "Victoria Series Release Notes" msgstr "Victoria Series Release Notes"
msgid "Wallaby Series Release Notes"
msgstr "Wallaby Series Release Notes"
msgid "" msgid ""
"Watcher can continuously optimize the OpenStack cloud for a specific " "Watcher can continuously optimize the OpenStack cloud for a specific "
"strategy or goal by triggering an audit periodically which generates an " "strategy or goal by triggering an audit periodically which generates an "
@@ -924,6 +967,15 @@ msgstr "We also add some new properties:"
msgid "Welcome to watcher's Release Notes documentation!" msgid "Welcome to watcher's Release Notes documentation!"
msgstr "Welcome to watcher's Release Notes documentation!" msgstr "Welcome to watcher's Release Notes documentation!"
msgid "Xena Series Release Notes"
msgstr "Xena Series Release Notes"
msgid "Yoga Series Release Notes"
msgstr "Yoga Series Release Notes"
msgid "Zed Series Release Notes"
msgstr "Zed Series Release Notes"
msgid "``[watcher_datasources] datasources = gnocchi,monasca,ceilometer``" msgid "``[watcher_datasources] datasources = gnocchi,monasca,ceilometer``"
msgstr "``[watcher_datasources] datasources = gnocchi,monasca,ceilometer``" msgstr "``[watcher_datasources] datasources = gnocchi,monasca,ceilometer``"

View File

@@ -0,0 +1,6 @@
=========================
Yoga Series Release Notes
=========================
.. release-notes::
:branch: stable/yoga

View File

@@ -0,0 +1,6 @@
========================
Zed Series Release Notes
========================
.. release-notes::
:branch: stable/zed

View File

@@ -17,7 +17,7 @@ oslo.context>=2.21.0 # Apache-2.0
oslo.db>=4.44.0 # Apache-2.0 oslo.db>=4.44.0 # Apache-2.0
oslo.i18n>=3.20.0 # Apache-2.0 oslo.i18n>=3.20.0 # Apache-2.0
oslo.log>=3.37.0 # Apache-2.0 oslo.log>=3.37.0 # Apache-2.0
oslo.messaging>=8.1.2 # Apache-2.0 oslo.messaging>=14.1.0 # Apache-2.0
oslo.policy>=3.6.0 # Apache-2.0 oslo.policy>=3.6.0 # Apache-2.0
oslo.reports>=1.27.0 # Apache-2.0 oslo.reports>=1.27.0 # Apache-2.0
oslo.serialization>=2.25.0 # Apache-2.0 oslo.serialization>=2.25.0 # Apache-2.0

View File

@@ -6,7 +6,7 @@ description_file =
author = OpenStack author = OpenStack
author_email = openstack-discuss@lists.openstack.org author_email = openstack-discuss@lists.openstack.org
home_page = https://docs.openstack.org/watcher/latest/ home_page = https://docs.openstack.org/watcher/latest/
python_requires = >=3.6 python_requires = >=3.8
classifier = classifier =
Environment :: OpenStack Environment :: OpenStack
Intended Audience :: Information Technology Intended Audience :: Information Technology
@@ -17,9 +17,10 @@ classifier =
Programming Language :: Python :: Implementation :: CPython Programming Language :: Python :: Implementation :: CPython
Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3 Programming Language :: Python :: 3
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
[files] [files]
packages = packages =

View File

@@ -12,3 +12,4 @@ testtools>=2.3.0 # MIT
stestr>=2.0.0 # Apache-2.0 stestr>=2.0.0 # Apache-2.0
os-api-ref>=1.4.0 # Apache-2.0 os-api-ref>=1.4.0 # Apache-2.0
bandit>=1.6.0 # Apache-2.0 bandit>=1.6.0 # Apache-2.0
WebTest>=2.0.27 # MIT

14
tox.ini
View File

@@ -1,7 +1,6 @@
[tox] [tox]
minversion = 3.18.0 minversion = 3.18.0
envlist = py3,pep8 envlist = py3,pep8
skipsdist = True
ignore_basepython_conflict = True ignore_basepython_conflict = True
[testenv] [testenv]
@@ -9,23 +8,30 @@ basepython = python3
usedevelop = True usedevelop = True
allowlist_externals = find allowlist_externals = find
rm rm
install_command = pip install -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} {opts} {packages} install_command = pip install -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/2024.1} {opts} {packages}
setenv = setenv =
VIRTUAL_ENV={envdir} VIRTUAL_ENV={envdir}
deps = deps =
-r{toxinidir}/test-requirements.txt -r{toxinidir}/test-requirements.txt
-r{toxinidir}/requirements.txt -r{toxinidir}/requirements.txt
python-libmaas>=0.6.8
commands = commands =
rm -f .testrepository/times.dbm rm -f .testrepository/times.dbm
find . -type f -name "*.py[c|o]" -delete find . -type f -name "*.py[c|o]" -delete
stestr run {posargs} stestr run {posargs}
passenv = http_proxy HTTP_PROXY https_proxy HTTPS_PROXY no_proxy NO_PROXY passenv =
http_proxy
HTTP_PROXY
https_proxy
HTTPS_PROXY
no_proxy
NO_PROXY
[testenv:pep8] [testenv:pep8]
commands = commands =
doc8 doc/source/ CONTRIBUTING.rst HACKING.rst README.rst doc8 doc/source/ CONTRIBUTING.rst HACKING.rst README.rst
flake8 flake8
bandit -r watcher -x watcher/tests/* -n5 -ll -s B320 #bandit -r watcher -x watcher/tests/* -n5 -ll -s B320
[testenv:venv] [testenv:venv]
setenv = PYTHONHASHSEED=0 setenv = PYTHONHASHSEED=0

View File

@@ -17,17 +17,17 @@
# limitations under the License. # limitations under the License.
# #
import enum
import time import time
from oslo_log import log
from watcher._i18n import _ from watcher._i18n import _
from watcher.applier.actions import base from watcher.applier.actions import base
from watcher.common import exception from watcher.common import exception
from watcher.common.metal_helper import constants as metal_constants
from watcher.common.metal_helper import factory as metal_helper_factory
LOG = log.getLogger(__name__)
class NodeState(enum.Enum):
POWERON = 'on'
POWEROFF = 'off'
class ChangeNodePowerState(base.BaseAction): class ChangeNodePowerState(base.BaseAction):
@@ -43,8 +43,8 @@ class ChangeNodePowerState(base.BaseAction):
'state': str, 'state': str,
}) })
The `resource_id` references a ironic node id (list of available The `resource_id` references a baremetal node id (list of available
ironic node is returned by this command: ``ironic node-list``). ironic nodes is returned by this command: ``ironic node-list``).
The `state` value should either be `on` or `off`. The `state` value should either be `on` or `off`.
""" """
@@ -59,10 +59,14 @@ class ChangeNodePowerState(base.BaseAction):
'type': 'string', 'type': 'string',
"minlength": 1 "minlength": 1
}, },
'resource_name': {
'type': 'string',
"minlength": 1
},
'state': { 'state': {
'type': 'string', 'type': 'string',
'enum': [NodeState.POWERON.value, 'enum': [metal_constants.PowerState.ON.value,
NodeState.POWEROFF.value] metal_constants.PowerState.OFF.value]
} }
}, },
'required': ['resource_id', 'state'], 'required': ['resource_id', 'state'],
@@ -82,10 +86,10 @@ class ChangeNodePowerState(base.BaseAction):
return self._node_manage_power(target_state) return self._node_manage_power(target_state)
def revert(self): def revert(self):
if self.state == NodeState.POWERON.value: if self.state == metal_constants.PowerState.ON.value:
target_state = NodeState.POWEROFF.value target_state = metal_constants.PowerState.OFF.value
elif self.state == NodeState.POWEROFF.value: elif self.state == metal_constants.PowerState.OFF.value:
target_state = NodeState.POWERON.value target_state = metal_constants.PowerState.ON.value
return self._node_manage_power(target_state) return self._node_manage_power(target_state)
def _node_manage_power(self, state, retry=60): def _node_manage_power(self, state, retry=60):
@@ -93,30 +97,32 @@ class ChangeNodePowerState(base.BaseAction):
raise exception.IllegalArgumentException( raise exception.IllegalArgumentException(
message=_("The target state is not defined")) message=_("The target state is not defined"))
ironic_client = self.osc.ironic() metal_helper = metal_helper_factory.get_helper(self.osc)
nova_client = self.osc.nova() node = metal_helper.get_node(self.node_uuid)
current_state = ironic_client.node.get(self.node_uuid).power_state current_state = node.get_power_state()
# power state: 'power on' or 'power off', if current node state
# is the same as state, just return True if state == current_state.value:
if state in current_state:
return True return True
if state == NodeState.POWEROFF.value: if state == metal_constants.PowerState.OFF.value:
node_info = ironic_client.node.get(self.node_uuid).to_dict() compute_node = node.get_hypervisor_node().to_dict()
compute_node_id = node_info['extra']['compute_node_id']
compute_node = nova_client.hypervisors.get(compute_node_id)
compute_node = compute_node.to_dict()
if (compute_node['running_vms'] == 0): if (compute_node['running_vms'] == 0):
ironic_client.node.set_power_state( node.set_power_state(state)
self.node_uuid, state) else:
LOG.warning(
"Compute node %s has %s running vms and will "
"NOT be shut off.",
compute_node["hypervisor_hostname"],
compute_node['running_vms'])
return False
else: else:
ironic_client.node.set_power_state(self.node_uuid, state) node.set_power_state(state)
ironic_node = ironic_client.node.get(self.node_uuid) node = metal_helper.get_node(self.node_uuid)
while ironic_node.power_state == current_state and retry: while node.get_power_state() == current_state and retry:
time.sleep(10) time.sleep(10)
retry -= 1 retry -= 1
ironic_node = ironic_client.node.get(self.node_uuid) node = metal_helper.get_node(self.node_uuid)
if retry > 0: if retry > 0:
return True return True
else: else:
@@ -130,4 +136,4 @@ class ChangeNodePowerState(base.BaseAction):
def get_description(self): def get_description(self):
"""Description of the action""" """Description of the action"""
return ("Compute node power on/off through ironic.") return ("Compute node power on/off through Ironic or MaaS.")

View File

@@ -17,14 +17,11 @@ import jsonschema
from oslo_log import log from oslo_log import log
from cinderclient import client as cinder_client
from watcher._i18n import _ from watcher._i18n import _
from watcher.applier.actions import base from watcher.applier.actions import base
from watcher.common import cinder_helper from watcher.common import cinder_helper
from watcher.common import exception from watcher.common import exception
from watcher.common import keystone_helper
from watcher.common import nova_helper from watcher.common import nova_helper
from watcher.common import utils
from watcher import conf from watcher import conf
CONF = conf.CONF CONF = conf.CONF
@@ -70,8 +67,6 @@ class VolumeMigrate(base.BaseAction):
def __init__(self, config, osc=None): def __init__(self, config, osc=None):
super(VolumeMigrate, self).__init__(config) super(VolumeMigrate, self).__init__(config)
self.temp_username = utils.random_string(10)
self.temp_password = utils.random_string(10)
self.cinder_util = cinder_helper.CinderHelper(osc=self.osc) self.cinder_util = cinder_helper.CinderHelper(osc=self.osc)
self.nova_util = nova_helper.NovaHelper(osc=self.osc) self.nova_util = nova_helper.NovaHelper(osc=self.osc)
@@ -134,83 +129,42 @@ class VolumeMigrate(base.BaseAction):
def _can_swap(self, volume): def _can_swap(self, volume):
"""Judge volume can be swapped""" """Judge volume can be swapped"""
# TODO(sean-k-mooney): rename this to _can_migrate and update
# tests to reflect that.
# cinder volume migration can migrate volumes that are not
# attached to instances or nova can migrate the data for cinder
# if the volume is in-use. If the volume has no attachments
# allow cinder to decided if it can be migrated.
if not volume.attachments: if not volume.attachments:
return False LOG.debug(f"volume: {volume.id} has no attachments")
instance_id = volume.attachments[0]['server_id']
instance_status = self.nova_util.find_instance(instance_id).status
if (volume.status == 'in-use' and
instance_status in ('ACTIVE', 'PAUSED', 'RESIZED')):
return True return True
return False # since it has attachments we need to validate nova's constraints
instance_id = volume.attachments[0]['server_id']
def _create_user(self, volume, user): instance_status = self.nova_util.find_instance(instance_id).status
"""Create user with volume attribute and user information""" LOG.debug(
keystone_util = keystone_helper.KeystoneHelper(osc=self.osc) f"volume: {volume.id} is attached to instance: {instance_id} "
project_id = getattr(volume, 'os-vol-tenant-attr:tenant_id') f"in instance status: {instance_status}")
user['project'] = project_id # NOTE(sean-k-mooney): This used to allow RESIZED which
user['domain'] = keystone_util.get_project(project_id).domain_id # is the resize_verify task state, that is not an acceptable time
user['roles'] = ['admin'] # to migrate volumes, if nova does not block this in the API
return keystone_util.create_user(user) # today that is probably a bug. PAUSED is also questionable but
# it should generally be safe.
def _get_cinder_client(self, session): return (volume.status == 'in-use' and
"""Get cinder client by session""" instance_status in ('ACTIVE', 'PAUSED'))
return cinder_client.Client(
CONF.cinder_client.api_version,
session=session,
endpoint_type=CONF.cinder_client.endpoint_type)
def _swap_volume(self, volume, dest_type):
"""Swap volume to dest_type
Limitation note: only for compute libvirt driver
"""
if not dest_type:
raise exception.Invalid(
message=(_("destination type is required when "
"migration type is swap")))
if not self._can_swap(volume):
raise exception.Invalid(
message=(_("Invalid state for swapping volume")))
user_info = {
'name': self.temp_username,
'password': self.temp_password}
user = self._create_user(volume, user_info)
keystone_util = keystone_helper.KeystoneHelper(osc=self.osc)
try:
session = keystone_util.create_session(
user.id, self.temp_password)
temp_cinder = self._get_cinder_client(session)
# swap volume
new_volume = self.cinder_util.create_volume(
temp_cinder, volume, dest_type)
self.nova_util.swap_volume(volume, new_volume)
# delete old volume
self.cinder_util.delete_volume(volume)
finally:
keystone_util.delete_user(user)
return True
def _migrate(self, volume_id, dest_node, dest_type): def _migrate(self, volume_id, dest_node, dest_type):
try: try:
volume = self.cinder_util.get_volume(volume_id) volume = self.cinder_util.get_volume(volume_id)
if self.migration_type == self.SWAP: # for backward compatibility map swap to migrate.
if dest_node: if self.migration_type in (self.SWAP, self.MIGRATE):
LOG.warning("dest_node is ignored") if not self._can_swap(volume):
return self._swap_volume(volume, dest_type) raise exception.Invalid(
message=(_("Invalid state for swapping volume")))
return self.cinder_util.migrate(volume, dest_node)
elif self.migration_type == self.RETYPE: elif self.migration_type == self.RETYPE:
return self.cinder_util.retype(volume, dest_type) return self.cinder_util.retype(volume, dest_type)
elif self.migration_type == self.MIGRATE:
return self.cinder_util.migrate(volume, dest_node)
else: else:
raise exception.Invalid( raise exception.Invalid(
message=(_("Migration of type '%(migration_type)s' is not " message=(_("Migration of type '%(migration_type)s' is not "

View File

@@ -25,6 +25,7 @@ from novaclient import api_versions as nova_api_versions
from novaclient import client as nvclient from novaclient import client as nvclient
from watcher.common import exception from watcher.common import exception
from watcher.common import utils
try: try:
from ceilometerclient import client as ceclient from ceilometerclient import client as ceclient
@@ -32,6 +33,12 @@ try:
except ImportError: except ImportError:
HAS_CEILCLIENT = False HAS_CEILCLIENT = False
try:
from maas import client as maas_client
except ImportError:
maas_client = None
CONF = cfg.CONF CONF = cfg.CONF
_CLIENTS_AUTH_GROUP = 'watcher_clients_auth' _CLIENTS_AUTH_GROUP = 'watcher_clients_auth'
@@ -74,6 +81,7 @@ class OpenStackClients(object):
self._monasca = None self._monasca = None
self._neutron = None self._neutron = None
self._ironic = None self._ironic = None
self._maas = None
self._placement = None self._placement = None
def _get_keystone_session(self): def _get_keystone_session(self):
@@ -265,6 +273,23 @@ class OpenStackClients(object):
session=self.session) session=self.session)
return self._ironic return self._ironic
def maas(self):
if self._maas:
return self._maas
if not maas_client:
raise exception.UnsupportedError(
"MAAS client unavailable. Please install python-libmaas.")
url = self._get_client_option('maas', 'url')
api_key = self._get_client_option('maas', 'api_key')
timeout = self._get_client_option('maas', 'timeout')
self._maas = utils.async_compat_call(
maas_client.connect,
url, apikey=api_key,
timeout=timeout)
return self._maas
@exception.wrap_keystone_exception @exception.wrap_keystone_exception
def placement(self): def placement(self):
if self._placement: if self._placement:

View File

@@ -11,12 +11,15 @@
# under the License. # under the License.
from oslo_context import context from oslo_context import context
from oslo_db.sqlalchemy import enginefacade
from oslo_log import log from oslo_log import log
from oslo_utils import timeutils from oslo_utils import timeutils
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@enginefacade.transaction_context_provider
class RequestContext(context.RequestContext): class RequestContext(context.RequestContext):
"""Extends security contexts from the OpenStack common library.""" """Extends security contexts from the OpenStack common library."""

View File

@@ -15,8 +15,6 @@
from oslo_log import log from oslo_log import log
from keystoneauth1.exceptions import http as ks_exceptions from keystoneauth1.exceptions import http as ks_exceptions
from keystoneauth1 import loading
from keystoneauth1 import session
from watcher._i18n import _ from watcher._i18n import _
from watcher.common import clients from watcher.common import clients
from watcher.common import exception from watcher.common import exception
@@ -90,35 +88,3 @@ class KeystoneHelper(object):
message=(_("Domain name seems ambiguous: %s") % message=(_("Domain name seems ambiguous: %s") %
name_or_id)) name_or_id))
return domains[0] return domains[0]
def create_session(self, user_id, password):
user = self.get_user(user_id)
loader = loading.get_plugin_loader('password')
auth = loader.load_from_options(
auth_url=CONF.watcher_clients_auth.auth_url,
password=password,
user_id=user_id,
project_id=user.default_project_id)
return session.Session(auth=auth)
def create_user(self, user):
project = self.get_project(user['project'])
domain = self.get_domain(user['domain'])
_user = self.keystone.users.create(
user['name'],
password=user['password'],
domain=domain,
project=project,
)
for role in user['roles']:
role = self.get_role(role)
self.keystone.roles.grant(
role.id, user=_user.id, project=project.id)
return _user
def delete_user(self, user):
try:
user = self.get_user(user)
self.keystone.users.delete(user)
except exception.Invalid:
pass

View File

View File

@@ -0,0 +1,81 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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 abc
from watcher.common import exception
from watcher.common.metal_helper import constants as metal_constants
class BaseMetalNode(abc.ABC):
hv_up_when_powered_off = False
def __init__(self, nova_node=None):
self._nova_node = nova_node
def get_hypervisor_node(self):
if not self._nova_node:
raise exception.Invalid(message="No associated hypervisor.")
return self._nova_node
def get_hypervisor_hostname(self):
return self.get_hypervisor_node().hypervisor_hostname
@abc.abstractmethod
def get_power_state(self):
# TODO(lpetrut): document the following methods
pass
@abc.abstractmethod
def get_id(self):
"""Return the node id provided by the bare metal service."""
pass
@abc.abstractmethod
def power_on(self):
pass
@abc.abstractmethod
def power_off(self):
pass
def set_power_state(self, state):
state = metal_constants.PowerState(state)
if state == metal_constants.PowerState.ON:
self.power_on()
elif state == metal_constants.PowerState.OFF:
self.power_off()
else:
raise exception.UnsupportedActionType(
"Cannot set power state: %s" % state)
class BaseMetalHelper(abc.ABC):
def __init__(self, osc):
self._osc = osc
@property
def nova_client(self):
if not getattr(self, "_nova_client", None):
self._nova_client = self._osc.nova()
return self._nova_client
@abc.abstractmethod
def list_compute_nodes(self):
pass
@abc.abstractmethod
def get_node(self, node_id):
pass

View File

@@ -0,0 +1,23 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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 enum
class PowerState(str, enum.Enum):
ON = "on"
OFF = "off"
UNKNOWN = "unknown"
ERROR = "error"

View File

@@ -0,0 +1,33 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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.
from oslo_config import cfg
from watcher.common import clients
from watcher.common.metal_helper import ironic
from watcher.common.metal_helper import maas
CONF = cfg.CONF
def get_helper(osc=None):
# TODO(lpetrut): consider caching this client.
if not osc:
osc = clients.OpenStackClients()
if CONF.maas_client.url:
return maas.MaasHelper(osc)
else:
return ironic.IronicHelper(osc)

View File

@@ -0,0 +1,94 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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.
from oslo_log import log
from watcher.common.metal_helper import base
from watcher.common.metal_helper import constants as metal_constants
LOG = log.getLogger(__name__)
POWER_STATES_MAP = {
'power on': metal_constants.PowerState.ON,
'power off': metal_constants.PowerState.OFF,
# For now, we only use ON/OFF states
'rebooting': metal_constants.PowerState.ON,
'soft power off': metal_constants.PowerState.OFF,
'soft reboot': metal_constants.PowerState.ON,
}
class IronicNode(base.BaseMetalNode):
hv_up_when_powered_off = True
def __init__(self, ironic_node, nova_node, ironic_client):
super().__init__(nova_node)
self._ironic_client = ironic_client
self._ironic_node = ironic_node
def get_power_state(self):
return POWER_STATES_MAP.get(self._ironic_node.power_state,
metal_constants.PowerState.UNKNOWN)
def get_id(self):
return self._ironic_node.uuid
def power_on(self):
self._ironic_client.node.set_power_state(self.get_id(), "on")
def power_off(self):
self._ironic_client.node.set_power_state(self.get_id(), "off")
class IronicHelper(base.BaseMetalHelper):
@property
def _client(self):
if not getattr(self, "_cached_client", None):
self._cached_client = self._osc.ironic()
return self._cached_client
def list_compute_nodes(self):
out_list = []
# TODO(lpetrut): consider using "detailed=True" instead of making
# an additional GET request per node
node_list = self._client.node.list()
for node in node_list:
node_info = self._client.node.get(node.uuid)
hypervisor_id = node_info.extra.get('compute_node_id', None)
if hypervisor_id is None:
LOG.warning('Cannot find compute_node_id in extra '
'of ironic node %s', node.uuid)
continue
hypervisor_node = self.nova_client.hypervisors.get(hypervisor_id)
if hypervisor_node is None:
LOG.warning('Cannot find hypervisor %s', hypervisor_id)
continue
out_node = IronicNode(node, hypervisor_node, self._client)
out_list.append(out_node)
return out_list
def get_node(self, node_id):
ironic_node = self._client.node.get(node_id)
compute_node_id = ironic_node.extra.get('compute_node_id')
if compute_node_id:
compute_node = self.nova_client.hypervisors.get(compute_node_id)
else:
compute_node = None
return IronicNode(ironic_node, compute_node, self._client)

View File

@@ -0,0 +1,125 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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.
from oslo_config import cfg
from oslo_log import log
from watcher.common import exception
from watcher.common.metal_helper import base
from watcher.common.metal_helper import constants as metal_constants
from watcher.common import utils
CONF = cfg.CONF
LOG = log.getLogger(__name__)
try:
from maas.client import enum as maas_enum
except ImportError:
maas_enum = None
class MaasNode(base.BaseMetalNode):
hv_up_when_powered_off = False
def __init__(self, maas_node, nova_node, maas_client):
super().__init__(nova_node)
self._maas_client = maas_client
self._maas_node = maas_node
def get_power_state(self):
maas_state = utils.async_compat_call(
self._maas_node.query_power_state,
timeout=CONF.maas_client.timeout)
# python-libmaas may not be available, so we'll avoid a global
# variable.
power_states_map = {
maas_enum.PowerState.ON: metal_constants.PowerState.ON,
maas_enum.PowerState.OFF: metal_constants.PowerState.OFF,
maas_enum.PowerState.ERROR: metal_constants.PowerState.ERROR,
maas_enum.PowerState.UNKNOWN: metal_constants.PowerState.UNKNOWN,
}
return power_states_map.get(maas_state,
metal_constants.PowerState.UNKNOWN)
def get_id(self):
return self._maas_node.system_id
def power_on(self):
LOG.info("Powering on MAAS node: %s %s",
self._maas_node.fqdn,
self._maas_node.system_id)
utils.async_compat_call(
self._maas_node.power_on,
timeout=CONF.maas_client.timeout)
def power_off(self):
LOG.info("Powering off MAAS node: %s %s",
self._maas_node.fqdn,
self._maas_node.system_id)
utils.async_compat_call(
self._maas_node.power_off,
timeout=CONF.maas_client.timeout)
class MaasHelper(base.BaseMetalHelper):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not maas_enum:
raise exception.UnsupportedError(
"MAAS client unavailable. Please install python-libmaas.")
@property
def _client(self):
if not getattr(self, "_cached_client", None):
self._cached_client = self._osc.maas()
return self._cached_client
def list_compute_nodes(self):
out_list = []
node_list = utils.async_compat_call(
self._client.machines.list,
timeout=CONF.maas_client.timeout)
compute_nodes = self.nova_client.hypervisors.list()
compute_node_map = dict()
for compute_node in compute_nodes:
compute_node_map[compute_node.hypervisor_hostname] = compute_node
for node in node_list:
hypervisor_node = compute_node_map.get(node.fqdn)
if not hypervisor_node:
LOG.info('Cannot find hypervisor %s', node.fqdn)
continue
out_node = MaasNode(node, hypervisor_node, self._client)
out_list.append(out_node)
return out_list
def _get_compute_node_by_hostname(self, hostname):
compute_nodes = self.nova_client.hypervisors.search(
hostname, detailed=True)
for compute_node in compute_nodes:
if compute_node.hypervisor_hostname == hostname:
return compute_node
def get_node(self, node_id):
maas_node = utils.async_compat_call(
self._client.machines.get, node_id,
timeout=CONF.maas_client.timeout)
compute_node = self._get_compute_node_by_hostname(maas_node.fqdn)
return MaasNode(maas_node, compute_node, self._client)

View File

@@ -121,7 +121,7 @@ class RequestContextSerializer(messaging.Serializer):
def get_client(target, version_cap=None, serializer=None): def get_client(target, version_cap=None, serializer=None):
assert TRANSPORT is not None assert TRANSPORT is not None
serializer = RequestContextSerializer(serializer) serializer = RequestContextSerializer(serializer)
return messaging.RPCClient( return messaging.get_rpc_client(
TRANSPORT, TRANSPORT,
target, target,
version_cap=version_cap, version_cap=version_cap,

View File

@@ -16,12 +16,14 @@
"""Utilities and helper functions.""" """Utilities and helper functions."""
import asyncio
import datetime import datetime
import random import inspect
import re import re
import string
from croniter import croniter from croniter import croniter
import eventlet
from eventlet import tpool
from jsonschema import validators from jsonschema import validators
from oslo_config import cfg from oslo_config import cfg
@@ -156,9 +158,39 @@ def extend_with_strict_schema(validator_class):
StrictDefaultValidatingDraft4Validator = extend_with_default( StrictDefaultValidatingDraft4Validator = extend_with_default(
extend_with_strict_schema(validators.Draft4Validator)) extend_with_strict_schema(validators.Draft4Validator))
Draft4Validator = validators.Draft4Validator Draft4Validator = validators.Draft4Validator
def random_string(n): # Some clients (e.g. MAAS) use asyncio, which isn't compatible with Eventlet.
return ''.join([random.choice( # As a workaround, we're delegating such calls to a native thread.
string.ascii_letters + string.digits) for i in range(n)]) def async_compat_call(f, *args, **kwargs):
timeout = kwargs.pop('timeout', None)
async def async_wrapper():
ret = f(*args, **kwargs)
if inspect.isawaitable(ret):
return await asyncio.wait_for(ret, timeout)
return ret
def tpool_wrapper():
# This will run in a separate native thread. Ideally, there should be
# a single thread permanently running an asyncio loop, but for
# convenience we'll use eventlet.tpool, which leverages a thread pool.
#
# That being considered, we're setting up a temporary asyncio loop to
# handle this call.
loop = asyncio.new_event_loop()
try:
asyncio.set_event_loop(loop)
return loop.run_until_complete(async_wrapper())
finally:
loop.close()
# We'll use eventlet timeouts as an extra precaution and asyncio timeouts
# to avoid lingering threads. For consistency, we'll convert eventlet
# timeout exceptions to asyncio timeout errors.
with eventlet.timeout.Timeout(
seconds=timeout,
exception=asyncio.TimeoutError("Timeout: %ss" % timeout)):
return tpool.execute(tpool_wrapper)

View File

@@ -35,6 +35,7 @@ from watcher.conf import grafana_client
from watcher.conf import grafana_translators from watcher.conf import grafana_translators
from watcher.conf import ironic_client from watcher.conf import ironic_client
from watcher.conf import keystone_client from watcher.conf import keystone_client
from watcher.conf import maas_client
from watcher.conf import monasca_client from watcher.conf import monasca_client
from watcher.conf import neutron_client from watcher.conf import neutron_client
from watcher.conf import nova_client from watcher.conf import nova_client
@@ -54,6 +55,7 @@ db.register_opts(CONF)
planner.register_opts(CONF) planner.register_opts(CONF)
applier.register_opts(CONF) applier.register_opts(CONF)
decision_engine.register_opts(CONF) decision_engine.register_opts(CONF)
maas_client.register_opts(CONF)
monasca_client.register_opts(CONF) monasca_client.register_opts(CONF)
nova_client.register_opts(CONF) nova_client.register_opts(CONF)
glance_client.register_opts(CONF) glance_client.register_opts(CONF)

View File

@@ -134,7 +134,13 @@ GRAFANA_CLIENT_OPTS = [
"InfluxDB this will be the retention period. " "InfluxDB this will be the retention period. "
"These queries will need to be constructed using tools " "These queries will need to be constructed using tools "
"such as Postman. Example: SELECT cpu FROM {4}." "such as Postman. Example: SELECT cpu FROM {4}."
"cpu_percent WHERE host == '{1}' AND time > now()-{2}s")] "cpu_percent WHERE host == '{1}' AND time > now()-{2}s"),
cfg.IntOpt('http_timeout',
min=0,
default=60,
mutable=True,
help='Timeout for Grafana request')
]
def register_opts(conf): def register_opts(conf):

View File

@@ -0,0 +1,38 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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.
from oslo_config import cfg
maas_client = cfg.OptGroup(name='maas_client',
title='Configuration Options for MaaS')
MAAS_CLIENT_OPTS = [
cfg.StrOpt('url',
help='MaaS URL, example: http://1.2.3.4:5240/MAAS'),
cfg.StrOpt('api_key',
help='MaaS API authentication key.'),
cfg.IntOpt('timeout',
default=60,
help='MaaS client operation timeout in seconds.')]
def register_opts(conf):
conf.register_group(maas_client)
conf.register_opts(MAAS_CLIENT_OPTS, group=maas_client)
def list_opts():
return [(maas_client, MAAS_CLIENT_OPTS)]

View File

@@ -13,8 +13,8 @@
from logging import config as log_config from logging import config as log_config
from alembic import context from alembic import context
from oslo_db.sqlalchemy import enginefacade
from watcher.db.sqlalchemy import api as sqla_api
from watcher.db.sqlalchemy import models from watcher.db.sqlalchemy import models
# this is the Alembic Config object, which provides # this is the Alembic Config object, which provides
@@ -43,7 +43,7 @@ def run_migrations_online():
and associate a connection with the context. and associate a connection with the context.
""" """
engine = sqla_api.get_engine() engine = enginefacade.writer.get_engine()
with engine.connect() as connection: with engine.connect() as connection:
context.configure(connection=connection, context.configure(connection=connection,
target_metadata=target_metadata) target_metadata=target_metadata)

View File

@@ -6,6 +6,7 @@ Create Date: 2017-03-24 11:21:29.036532
""" """
from alembic import op from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa import sqlalchemy as sa
from watcher.db.sqlalchemy import models from watcher.db.sqlalchemy import models
@@ -14,8 +15,17 @@ from watcher.db.sqlalchemy import models
revision = '0f6042416884' revision = '0f6042416884'
down_revision = '001' down_revision = '001'
def _table_exists(table_name):
bind = op.get_context().bind
insp = inspect(bind)
names = insp.get_table_names()
return any(t == table_name for t in names)
def upgrade(): def upgrade():
if _table_exists('apscheduler_jobs'):
return
op.create_table( op.create_table(
'apscheduler_jobs', 'apscheduler_jobs',
sa.Column('id', sa.Unicode(191, _warn_on_bytestring=False), sa.Column('id', sa.Unicode(191, _warn_on_bytestring=False),

View File

@@ -19,10 +19,12 @@
import collections import collections
import datetime import datetime
import operator import operator
import threading
from oslo_config import cfg from oslo_config import cfg
from oslo_db import api as oslo_db_api
from oslo_db import exception as db_exc from oslo_db import exception as db_exc
from oslo_db.sqlalchemy import session as db_session from oslo_db.sqlalchemy import enginefacade
from oslo_db.sqlalchemy import utils as db_utils from oslo_db.sqlalchemy import utils as db_utils
from oslo_utils import timeutils from oslo_utils import timeutils
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
@@ -38,24 +40,7 @@ from watcher import objects
CONF = cfg.CONF CONF = cfg.CONF
_FACADE = None _CONTEXT = threading.local()
def _create_facade_lazily():
global _FACADE
if _FACADE is None:
_FACADE = db_session.EngineFacade.from_config(CONF)
return _FACADE
def get_engine():
facade = _create_facade_lazily()
return facade.get_engine()
def get_session(**kwargs):
facade = _create_facade_lazily()
return facade.get_session(**kwargs)
def get_backend(): def get_backend():
@@ -63,14 +48,15 @@ def get_backend():
return Connection() return Connection()
def model_query(model, *args, **kwargs): def _session_for_read():
"""Query helper for simpler session usage. return enginefacade.reader.using(_CONTEXT)
:param session: if present, the session to use
""" # NOTE(tylerchristie) Please add @oslo_db_api.retry_on_deadlock decorator to
session = kwargs.get('session') or get_session() # any new methods using _session_for_write (as deadlocks happen on write), so
query = session.query(model, *args) # that oslo_db is able to retry in case of deadlocks.
return query def _session_for_write():
return enginefacade.writer.using(_CONTEXT)
def add_identity_filter(query, value): def add_identity_filter(query, value):
@@ -93,8 +79,6 @@ def add_identity_filter(query, value):
def _paginate_query(model, limit=None, marker=None, sort_key=None, def _paginate_query(model, limit=None, marker=None, sort_key=None,
sort_dir=None, query=None): sort_dir=None, query=None):
if not query:
query = model_query(model)
sort_keys = ['id'] sort_keys = ['id']
if sort_key and sort_key not in sort_keys: if sort_key and sort_key not in sort_keys:
sort_keys.insert(0, sort_key) sort_keys.insert(0, sort_key)
@@ -247,35 +231,39 @@ class Connection(api.BaseConnection):
query = query.options(joinedload(relationship.key)) query = query.options(joinedload(relationship.key))
return query return query
@oslo_db_api.retry_on_deadlock
def _create(self, model, values): def _create(self, model, values):
obj = model() with _session_for_write() as session:
cleaned_values = {k: v for k, v in values.items() obj = model()
if k not in self._get_relationships(model)} cleaned_values = {k: v for k, v in values.items()
obj.update(cleaned_values) if k not in self._get_relationships(model)}
obj.save() obj.update(cleaned_values)
return obj session.add(obj)
session.flush()
return obj
def _get(self, context, model, fieldname, value, eager): def _get(self, context, model, fieldname, value, eager):
query = model_query(model) with _session_for_read() as session:
if eager: query = session.query(model)
query = self._set_eager_options(model, query) if eager:
query = self._set_eager_options(model, query)
query = query.filter(getattr(model, fieldname) == value) query = query.filter(getattr(model, fieldname) == value)
if not context.show_deleted: if not context.show_deleted:
query = query.filter(model.deleted_at.is_(None)) query = query.filter(model.deleted_at.is_(None))
try: try:
obj = query.one() obj = query.one()
except exc.NoResultFound: except exc.NoResultFound:
raise exception.ResourceNotFound(name=model.__name__, id=value) raise exception.ResourceNotFound(name=model.__name__, id=value)
return obj return obj
@staticmethod @staticmethod
@oslo_db_api.retry_on_deadlock
def _update(model, id_, values): def _update(model, id_, values):
session = get_session() with _session_for_write() as session:
with session.begin(): query = session.query(model)
query = model_query(model, session=session)
query = add_identity_filter(query, id_) query = add_identity_filter(query, id_)
try: try:
ref = query.with_for_update().one() ref = query.with_for_update().one()
@@ -283,13 +271,14 @@ class Connection(api.BaseConnection):
raise exception.ResourceNotFound(name=model.__name__, id=id_) raise exception.ResourceNotFound(name=model.__name__, id=id_)
ref.update(values) ref.update(values)
return ref
return ref
@staticmethod @staticmethod
@oslo_db_api.retry_on_deadlock
def _soft_delete(model, id_): def _soft_delete(model, id_):
session = get_session() with _session_for_write() as session:
with session.begin(): query = session.query(model)
query = model_query(model, session=session)
query = add_identity_filter(query, id_) query = add_identity_filter(query, id_)
try: try:
row = query.one() row = query.one()
@@ -301,10 +290,10 @@ class Connection(api.BaseConnection):
return row return row
@staticmethod @staticmethod
@oslo_db_api.retry_on_deadlock
def _destroy(model, id_): def _destroy(model, id_):
session = get_session() with _session_for_write() as session:
with session.begin(): query = session.query(model)
query = model_query(model, session=session)
query = add_identity_filter(query, id_) query = add_identity_filter(query, id_)
try: try:
@@ -317,14 +306,15 @@ class Connection(api.BaseConnection):
def _get_model_list(self, model, add_filters_func, context, filters=None, def _get_model_list(self, model, add_filters_func, context, filters=None,
limit=None, marker=None, sort_key=None, sort_dir=None, limit=None, marker=None, sort_key=None, sort_dir=None,
eager=False): eager=False):
query = model_query(model) with _session_for_read() as session:
if eager: query = session.query(model)
query = self._set_eager_options(model, query) if eager:
query = add_filters_func(query, filters) query = self._set_eager_options(model, query)
if not context.show_deleted: query = add_filters_func(query, filters)
query = query.filter(model.deleted_at.is_(None)) if not context.show_deleted:
return _paginate_query(model, limit, marker, query = query.filter(model.deleted_at.is_(None))
sort_key, sort_dir, query) return _paginate_query(model, limit, marker,
sort_key, sort_dir, query)
# NOTE(erakli): _add_..._filters methods should be refactored to have same # NOTE(erakli): _add_..._filters methods should be refactored to have same
# content. join_fieldmap should be filled with JoinMap instead of dict # content. join_fieldmap should be filled with JoinMap instead of dict
@@ -419,11 +409,12 @@ class Connection(api.BaseConnection):
plain_fields=plain_fields, join_fieldmap=join_fieldmap) plain_fields=plain_fields, join_fieldmap=join_fieldmap)
if 'audit_uuid' in filters: if 'audit_uuid' in filters:
stmt = model_query(models.ActionPlan).join( with _session_for_read() as session:
models.Audit, stmt = session.query(models.ActionPlan).join(
models.Audit.id == models.ActionPlan.audit_id)\ models.Audit,
.filter_by(uuid=filters['audit_uuid']).subquery() models.Audit.id == models.ActionPlan.audit_id)\
query = query.filter_by(action_plan_id=stmt.c.id) .filter_by(uuid=filters['audit_uuid']).subquery()
query = query.filter_by(action_plan_id=stmt.c.id)
return query return query
@@ -601,20 +592,21 @@ class Connection(api.BaseConnection):
if not values.get('uuid'): if not values.get('uuid'):
values['uuid'] = utils.generate_uuid() values['uuid'] = utils.generate_uuid()
query = model_query(models.AuditTemplate) with _session_for_write() as session:
query = query.filter_by(name=values.get('name'), query = session.query(models.AuditTemplate)
deleted_at=None) query = query.filter_by(name=values.get('name'),
deleted_at=None)
if len(query.all()) > 0: if len(query.all()) > 0:
raise exception.AuditTemplateAlreadyExists( raise exception.AuditTemplateAlreadyExists(
audit_template=values['name']) audit_template=values['name'])
try: try:
audit_template = self._create(models.AuditTemplate, values) audit_template = self._create(models.AuditTemplate, values)
except db_exc.DBDuplicateEntry: except db_exc.DBDuplicateEntry:
raise exception.AuditTemplateAlreadyExists( raise exception.AuditTemplateAlreadyExists(
audit_template=values['name']) audit_template=values['name'])
return audit_template return audit_template
def _get_audit_template(self, context, fieldname, value, eager): def _get_audit_template(self, context, fieldname, value, eager):
try: try:
@@ -676,25 +668,26 @@ class Connection(api.BaseConnection):
if not values.get('uuid'): if not values.get('uuid'):
values['uuid'] = utils.generate_uuid() values['uuid'] = utils.generate_uuid()
query = model_query(models.Audit) with _session_for_write() as session:
query = query.filter_by(name=values.get('name'), query = session.query(models.Audit)
deleted_at=None) query = query.filter_by(name=values.get('name'),
deleted_at=None)
if len(query.all()) > 0: if len(query.all()) > 0:
raise exception.AuditAlreadyExists( raise exception.AuditAlreadyExists(
audit=values['name']) audit=values['name'])
if values.get('state') is None: if values.get('state') is None:
values['state'] = objects.audit.State.PENDING values['state'] = objects.audit.State.PENDING
if not values.get('auto_trigger'): if not values.get('auto_trigger'):
values['auto_trigger'] = False values['auto_trigger'] = False
try: try:
audit = self._create(models.Audit, values) audit = self._create(models.Audit, values)
except db_exc.DBDuplicateEntry: except db_exc.DBDuplicateEntry:
raise exception.AuditAlreadyExists(audit=values['uuid']) raise exception.AuditAlreadyExists(audit=values['uuid'])
return audit return audit
def _get_audit(self, context, fieldname, value, eager): def _get_audit(self, context, fieldname, value, eager):
try: try:
@@ -718,14 +711,13 @@ class Connection(api.BaseConnection):
def destroy_audit(self, audit_id): def destroy_audit(self, audit_id):
def is_audit_referenced(session, audit_id): def is_audit_referenced(session, audit_id):
"""Checks whether the audit is referenced by action_plan(s).""" """Checks whether the audit is referenced by action_plan(s)."""
query = model_query(models.ActionPlan, session=session) query = session.query(models.ActionPlan)
query = self._add_action_plans_filters( query = self._add_action_plans_filters(
query, {'audit_id': audit_id}) query, {'audit_id': audit_id})
return query.count() != 0 return query.count() != 0
session = get_session() with _session_for_write() as session:
with session.begin(): query = session.query(models.Audit)
query = model_query(models.Audit, session=session)
query = add_identity_filter(query, audit_id) query = add_identity_filter(query, audit_id)
try: try:
@@ -792,9 +784,8 @@ class Connection(api.BaseConnection):
context, fieldname="uuid", value=action_uuid, eager=eager) context, fieldname="uuid", value=action_uuid, eager=eager)
def destroy_action(self, action_id): def destroy_action(self, action_id):
session = get_session() with _session_for_write() as session:
with session.begin(): query = session.query(models.Action)
query = model_query(models.Action, session=session)
query = add_identity_filter(query, action_id) query = add_identity_filter(query, action_id)
count = query.delete() count = query.delete()
if count != 1: if count != 1:
@@ -810,9 +801,8 @@ class Connection(api.BaseConnection):
@staticmethod @staticmethod
def _do_update_action(action_id, values): def _do_update_action(action_id, values):
session = get_session() with _session_for_write() as session:
with session.begin(): query = session.query(models.Action)
query = model_query(models.Action, session=session)
query = add_identity_filter(query, action_id) query = add_identity_filter(query, action_id)
try: try:
ref = query.with_for_update().one() ref = query.with_for_update().one()
@@ -820,7 +810,7 @@ class Connection(api.BaseConnection):
raise exception.ActionNotFound(action=action_id) raise exception.ActionNotFound(action=action_id)
ref.update(values) ref.update(values)
return ref return ref
def soft_delete_action(self, action_id): def soft_delete_action(self, action_id):
try: try:
@@ -864,14 +854,13 @@ class Connection(api.BaseConnection):
def destroy_action_plan(self, action_plan_id): def destroy_action_plan(self, action_plan_id):
def is_action_plan_referenced(session, action_plan_id): def is_action_plan_referenced(session, action_plan_id):
"""Checks whether the action_plan is referenced by action(s).""" """Checks whether the action_plan is referenced by action(s)."""
query = model_query(models.Action, session=session) query = session.query(models.Action)
query = self._add_actions_filters( query = self._add_actions_filters(
query, {'action_plan_id': action_plan_id}) query, {'action_plan_id': action_plan_id})
return query.count() != 0 return query.count() != 0
session = get_session() with _session_for_write() as session:
with session.begin(): query = session.query(models.ActionPlan)
query = model_query(models.ActionPlan, session=session)
query = add_identity_filter(query, action_plan_id) query = add_identity_filter(query, action_plan_id)
try: try:
@@ -895,9 +884,8 @@ class Connection(api.BaseConnection):
@staticmethod @staticmethod
def _do_update_action_plan(action_plan_id, values): def _do_update_action_plan(action_plan_id, values):
session = get_session() with _session_for_write() as session:
with session.begin(): query = session.query(models.ActionPlan)
query = model_query(models.ActionPlan, session=session)
query = add_identity_filter(query, action_plan_id) query = add_identity_filter(query, action_plan_id)
try: try:
ref = query.with_for_update().one() ref = query.with_for_update().one()
@@ -905,7 +893,7 @@ class Connection(api.BaseConnection):
raise exception.ActionPlanNotFound(action_plan=action_plan_id) raise exception.ActionPlanNotFound(action_plan=action_plan_id)
ref.update(values) ref.update(values)
return ref return ref
def soft_delete_action_plan(self, action_plan_id): def soft_delete_action_plan(self, action_plan_id):
try: try:

View File

@@ -20,9 +20,9 @@ import alembic
from alembic import config as alembic_config from alembic import config as alembic_config
import alembic.migration as alembic_migration import alembic.migration as alembic_migration
from oslo_db import exception as db_exc from oslo_db import exception as db_exc
from oslo_db.sqlalchemy import enginefacade
from watcher._i18n import _ from watcher._i18n import _
from watcher.db.sqlalchemy import api as sqla_api
from watcher.db.sqlalchemy import models from watcher.db.sqlalchemy import models
@@ -39,7 +39,7 @@ def version(engine=None):
:rtype: string :rtype: string
""" """
if engine is None: if engine is None:
engine = sqla_api.get_engine() engine = enginefacade.reader.get_engine()
with engine.connect() as conn: with engine.connect() as conn:
context = alembic_migration.MigrationContext.configure(conn) context = alembic_migration.MigrationContext.configure(conn)
return context.get_current_revision() return context.get_current_revision()
@@ -63,7 +63,7 @@ def create_schema(config=None, engine=None):
Can be used for initial installation instead of upgrade('head'). Can be used for initial installation instead of upgrade('head').
""" """
if engine is None: if engine is None:
engine = sqla_api.get_engine() engine = enginefacade.writer.get_engine()
# NOTE(viktors): If we will use metadata.create_all() for non empty db # NOTE(viktors): If we will use metadata.create_all() for non empty db
# schema, it will only add the new tables, but leave # schema, it will only add the new tables, but leave

View File

@@ -93,14 +93,6 @@ class WatcherBase(models.SoftDeleteMixin,
d[c.name] = self[c.name] d[c.name] = self[c.name]
return d return d
def save(self, session=None):
import watcher.db.sqlalchemy.api as db_api
if session is None:
session = db_api.get_session()
super(WatcherBase, self).save(session)
Base = declarative_base(cls=WatcherBase) Base = declarative_base(cls=WatcherBase)

View File

@@ -52,7 +52,7 @@ class ContinuousAuditHandler(base.AuditHandler):
self._audit_scheduler = scheduling.BackgroundSchedulerService( self._audit_scheduler = scheduling.BackgroundSchedulerService(
jobstores={ jobstores={
'default': job_store.WatcherJobStore( 'default': job_store.WatcherJobStore(
engine=sq_api.get_engine()), engine=sq_api.enginefacade.writer.get_engine()),
} }
) )
return self._audit_scheduler return self._audit_scheduler

View File

@@ -63,7 +63,7 @@ class DataSourceBase(object):
raise exception.MetricNotAvailable(metric=meter_name) raise exception.MetricNotAvailable(metric=meter_name)
return meter return meter
def query_retry(self, f, *args, **kwargs): def query_retry(self, f, *args, ignored_exc=None, **kwargs):
"""Attempts to retrieve metrics from the external service """Attempts to retrieve metrics from the external service
Attempts to access data from the external service and handles Attempts to access data from the external service and handles
@@ -71,15 +71,23 @@ class DataSourceBase(object):
to the value of query_max_retries to the value of query_max_retries
:param f: The method that performs the actual querying for metrics :param f: The method that performs the actual querying for metrics
:param args: Array of arguments supplied to the method :param args: Array of arguments supplied to the method
:param ignored_exc: An exception or tuple of exceptions that shouldn't
be retried, for example "NotFound" exceptions.
:param kwargs: The amount of arguments supplied to the method :param kwargs: The amount of arguments supplied to the method
:return: The value as retrieved from the external service :return: The value as retrieved from the external service
""" """
num_retries = CONF.watcher_datasources.query_max_retries num_retries = CONF.watcher_datasources.query_max_retries
timeout = CONF.watcher_datasources.query_timeout timeout = CONF.watcher_datasources.query_timeout
ignored_exc = ignored_exc or tuple()
for i in range(num_retries): for i in range(num_retries):
try: try:
return f(*args, **kwargs) return f(*args, **kwargs)
except ignored_exc as e:
LOG.debug("Got an ignored exception (%s) while calling: %s ",
e, f)
return
except Exception as e: except Exception as e:
LOG.exception(e) LOG.exception(e)
self.query_retry_reset(e) self.query_retry_reset(e)

View File

@@ -19,6 +19,7 @@
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
from gnocchiclient import exceptions as gnc_exc
from oslo_config import cfg from oslo_config import cfg
from oslo_log import log from oslo_log import log
@@ -38,7 +39,7 @@ class GnocchiHelper(base.DataSourceBase):
host_inlet_temp='hardware.ipmi.node.temperature', host_inlet_temp='hardware.ipmi.node.temperature',
host_airflow='hardware.ipmi.node.airflow', host_airflow='hardware.ipmi.node.airflow',
host_power='hardware.ipmi.node.power', host_power='hardware.ipmi.node.power',
instance_cpu_usage='cpu_util', instance_cpu_usage='cpu',
instance_ram_usage='memory.resident', instance_ram_usage='memory.resident',
instance_ram_allocated='memory', instance_ram_allocated='memory',
instance_l3_cache_usage='cpu_l3_cache', instance_l3_cache_usage='cpu_l3_cache',
@@ -84,7 +85,9 @@ class GnocchiHelper(base.DataSourceBase):
kwargs = dict(query={"=": {"original_resource_id": resource_id}}, kwargs = dict(query={"=": {"original_resource_id": resource_id}},
limit=1) limit=1)
resources = self.query_retry( resources = self.query_retry(
f=self.gnocchi.resource.search, **kwargs) f=self.gnocchi.resource.search,
ignored_exc=gnc_exc.NotFound,
**kwargs)
if not resources: if not resources:
LOG.warning("The {0} resource {1} could not be " LOG.warning("The {0} resource {1} could not be "
@@ -93,6 +96,25 @@ class GnocchiHelper(base.DataSourceBase):
resource_id = resources[0]['id'] resource_id = resources[0]['id']
if meter_name == "instance_cpu_usage":
if resource_type != "instance":
LOG.warning("Unsupported resource type for metric "
"'instance_cpu_usage': ", resource_type)
return
# The "cpu_util" gauge (percentage) metric has been removed.
# We're going to obtain the same result by using the rate of change
# aggregate operation.
if aggregate not in ("mean", "rate:mean"):
LOG.warning("Unsupported aggregate for instance_cpu_usage "
"metric: %s. "
"Supported aggregates: mean, rate:mean ",
aggregate)
return
# TODO(lpetrut): consider supporting other aggregates.
aggregate = "rate:mean"
raw_kwargs = dict( raw_kwargs = dict(
metric=meter, metric=meter,
start=start_time, start=start_time,
@@ -105,7 +127,9 @@ class GnocchiHelper(base.DataSourceBase):
kwargs = {k: v for k, v in raw_kwargs.items() if k and v} kwargs = {k: v for k, v in raw_kwargs.items() if k and v}
statistics = self.query_retry( statistics = self.query_retry(
f=self.gnocchi.metric.get_measures, **kwargs) f=self.gnocchi.metric.get_measures,
ignored_exc=gnc_exc.NotFound,
**kwargs)
return_value = None return_value = None
if statistics: if statistics:
@@ -117,6 +141,17 @@ class GnocchiHelper(base.DataSourceBase):
# Airflow from hardware.ipmi.node.airflow is reported as # Airflow from hardware.ipmi.node.airflow is reported as
# 1/10 th of actual CFM # 1/10 th of actual CFM
return_value *= 10 return_value *= 10
if meter_name == "instance_cpu_usage":
# "rate:mean" can return negative values for migrated vms.
return_value = max(0, return_value)
# We're converting the cumulative cpu time (ns) to cpu usage
# percentage.
vcpus = resource.vcpus
if not vcpus:
LOG.warning("instance vcpu count not set, assuming 1")
vcpus = 1
return_value *= 100 / (granularity * 10e+8) / vcpus
return return_value return return_value
@@ -132,7 +167,9 @@ class GnocchiHelper(base.DataSourceBase):
kwargs = dict(query={"=": {"original_resource_id": resource_id}}, kwargs = dict(query={"=": {"original_resource_id": resource_id}},
limit=1) limit=1)
resources = self.query_retry( resources = self.query_retry(
f=self.gnocchi.resource.search, **kwargs) f=self.gnocchi.resource.search,
ignored_exc=gnc_exc.NotFound,
**kwargs)
if not resources: if not resources:
LOG.warning("The {0} resource {1} could not be " LOG.warning("The {0} resource {1} could not be "
@@ -152,7 +189,9 @@ class GnocchiHelper(base.DataSourceBase):
kwargs = {k: v for k, v in raw_kwargs.items() if k and v} kwargs = {k: v for k, v in raw_kwargs.items() if k and v}
statistics = self.query_retry( statistics = self.query_retry(
f=self.gnocchi.metric.get_measures, **kwargs) f=self.gnocchi.metric.get_measures,
ignored_exc=gnc_exc.NotFound,
**kwargs)
return_value = None return_value = None
if statistics: if statistics:

View File

@@ -138,7 +138,8 @@ class GrafanaHelper(base.DataSourceBase):
raise exception.DataSourceNotAvailable(self.NAME) raise exception.DataSourceNotAvailable(self.NAME)
resp = requests.get(self._base_url + str(project_id) + '/query', resp = requests.get(self._base_url + str(project_id) + '/query',
params=params, headers=self._headers) params=params, headers=self._headers,
timeout=CONF.grafana_client.http_timeout)
if resp.status_code == HTTPStatus.OK: if resp.status_code == HTTPStatus.OK:
return resp return resp
elif resp.status_code == HTTPStatus.BAD_REQUEST: elif resp.status_code == HTTPStatus.BAD_REQUEST:

View File

@@ -81,6 +81,7 @@ class BareMetalModelBuilder(base.BaseModelBuilder):
def __init__(self, osc): def __init__(self, osc):
self.osc = osc self.osc = osc
self.model = model_root.BaremetalModelRoot() self.model = model_root.BaremetalModelRoot()
# TODO(lpetrut): add MAAS support
self.ironic_helper = ironic_helper.IronicHelper(osc=self.osc) self.ironic_helper = ironic_helper.IronicHelper(osc=self.osc)
def add_ironic_node(self, node): def add_ironic_node(self, node):

View File

@@ -48,7 +48,7 @@ class NovaClusterDataModelCollector(base.BaseClusterDataModelCollector):
"type": "array", "type": "array",
"items": { "items": {
"anyOf": [ "anyOf": [
{"$ref": HOST_AGGREGATES + "id"}, {"$ref": HOST_AGGREGATES + "host_aggr_id"},
{"$ref": HOST_AGGREGATES + "name"}, {"$ref": HOST_AGGREGATES + "name"},
] ]
} }
@@ -98,7 +98,8 @@ class NovaClusterDataModelCollector(base.BaseClusterDataModelCollector):
"type": "array", "type": "array",
"items": { "items": {
"anyOf": [ "anyOf": [
{"$ref": HOST_AGGREGATES + "id"}, {"$ref":
HOST_AGGREGATES + "host_aggr_id"},
{"$ref": HOST_AGGREGATES + "name"}, {"$ref": HOST_AGGREGATES + "name"},
] ]
} }
@@ -129,7 +130,7 @@ class NovaClusterDataModelCollector(base.BaseClusterDataModelCollector):
"additionalProperties": False "additionalProperties": False
}, },
"host_aggregates": { "host_aggregates": {
"id": { "host_aggr_id": {
"properties": { "properties": {
"id": { "id": {
"oneOf": [ "oneOf": [

View File

@@ -157,7 +157,7 @@ class ModelRoot(nx.DiGraph, base.Model):
if node_list: if node_list:
return node_list[0] return node_list[0]
else: else:
raise exception.ComputeResourceNotFound raise exception.ComputeNodeNotFound(name=name)
except exception.ComputeResourceNotFound: except exception.ComputeResourceNotFound:
raise exception.ComputeNodeNotFound(name=name) raise exception.ComputeNodeNotFound(name=name)

View File

@@ -252,9 +252,6 @@ class BaseStrategy(loadable.Loadable, metaclass=abc.ABCMeta):
if not self.compute_model: if not self.compute_model:
raise exception.ClusterStateNotDefined() raise exception.ClusterStateNotDefined()
if self.compute_model.stale:
raise exception.ClusterStateStale()
LOG.debug(self.compute_model.to_string()) LOG.debug(self.compute_model.to_string())
def execute(self, audit=None): def execute(self, audit=None):

View File

@@ -23,6 +23,8 @@ from oslo_log import log
from watcher._i18n import _ from watcher._i18n import _
from watcher.common import exception from watcher.common import exception
from watcher.common.metal_helper import constants as metal_constants
from watcher.common.metal_helper import factory as metal_helper_factory
from watcher.decision_engine.strategy.strategies import base from watcher.decision_engine.strategy.strategies import base
LOG = log.getLogger(__name__) LOG = log.getLogger(__name__)
@@ -81,7 +83,7 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
def __init__(self, config, osc=None): def __init__(self, config, osc=None):
super(SavingEnergy, self).__init__(config, osc) super(SavingEnergy, self).__init__(config, osc)
self._ironic_client = None self._metal_helper = None
self._nova_client = None self._nova_client = None
self.with_vms_node_pool = [] self.with_vms_node_pool = []
@@ -91,10 +93,10 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
self.min_free_hosts_num = 1 self.min_free_hosts_num = 1
@property @property
def ironic_client(self): def metal_helper(self):
if not self._ironic_client: if not self._metal_helper:
self._ironic_client = self.osc.ironic() self._metal_helper = metal_helper_factory.get_helper(self.osc)
return self._ironic_client return self._metal_helper
@property @property
def nova_client(self): def nova_client(self):
@@ -149,10 +151,10 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
:return: None :return: None
""" """
params = {'state': state, params = {'state': state,
'resource_name': node.hostname} 'resource_name': node.get_hypervisor_hostname()}
self.solution.add_action( self.solution.add_action(
action_type='change_node_power_state', action_type='change_node_power_state',
resource_id=node.uuid, resource_id=node.get_id(),
input_parameters=params) input_parameters=params)
def get_hosts_pool(self): def get_hosts_pool(self):
@@ -162,36 +164,36 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
""" """
node_list = self.ironic_client.node.list() node_list = self.metal_helper.list_compute_nodes()
for node in node_list: for node in node_list:
node_info = self.ironic_client.node.get(node.uuid) hypervisor_node = node.get_hypervisor_node().to_dict()
hypervisor_id = node_info.extra.get('compute_node_id', None)
if hypervisor_id is None:
LOG.warning(('Cannot find compute_node_id in extra '
'of ironic node %s'), node.uuid)
continue
hypervisor_node = self.nova_client.hypervisors.get(hypervisor_id)
if hypervisor_node is None:
LOG.warning(('Cannot find hypervisor %s'), hypervisor_id)
continue
node.hostname = hypervisor_node.hypervisor_hostname
hypervisor_node = hypervisor_node.to_dict()
compute_service = hypervisor_node.get('service', None) compute_service = hypervisor_node.get('service', None)
host_name = compute_service.get('host') host_name = compute_service.get('host')
LOG.debug("Found hypervisor: %s", hypervisor_node)
try: try:
self.compute_model.get_node_by_name(host_name) self.compute_model.get_node_by_name(host_name)
except exception.ComputeNodeNotFound: except exception.ComputeNodeNotFound:
LOG.info("The compute model does not contain the host: %s",
host_name)
continue continue
if not (hypervisor_node.get('state') == 'up'): if (node.hv_up_when_powered_off and
"""filter nodes that are not in 'up' state""" hypervisor_node.get('state') != 'up'):
# filter nodes that are not in 'up' state
LOG.info("Ignoring node that isn't in 'up' state: %s",
host_name)
continue continue
else: else:
if (hypervisor_node['running_vms'] == 0): if (hypervisor_node['running_vms'] == 0):
if (node_info.power_state == 'power on'): power_state = node.get_power_state()
if power_state == metal_constants.PowerState.ON:
self.free_poweron_node_pool.append(node) self.free_poweron_node_pool.append(node)
elif (node_info.power_state == 'power off'): elif power_state == metal_constants.PowerState.OFF:
self.free_poweroff_node_pool.append(node) self.free_poweroff_node_pool.append(node)
else:
LOG.info("Ignoring node %s, unknown state: %s",
node, power_state)
else: else:
self.with_vms_node_pool.append(node) self.with_vms_node_pool.append(node)
@@ -202,17 +204,21 @@ class SavingEnergy(base.SavingEnergyBaseStrategy):
self.min_free_hosts_num))) self.min_free_hosts_num)))
len_poweron = len(self.free_poweron_node_pool) len_poweron = len(self.free_poweron_node_pool)
len_poweroff = len(self.free_poweroff_node_pool) len_poweroff = len(self.free_poweroff_node_pool)
LOG.debug("need_poweron: %s, len_poweron: %s, len_poweroff: %s",
need_poweron, len_poweron, len_poweroff)
if len_poweron > need_poweron: if len_poweron > need_poweron:
for node in random.sample(self.free_poweron_node_pool, for node in random.sample(self.free_poweron_node_pool,
(len_poweron - need_poweron)): (len_poweron - need_poweron)):
self.add_action_poweronoff_node(node, 'off') self.add_action_poweronoff_node(node,
LOG.debug("power off %s", node.uuid) metal_constants.PowerState.OFF)
LOG.info("power off %s", node.get_id())
elif len_poweron < need_poweron: elif len_poweron < need_poweron:
diff = need_poweron - len_poweron diff = need_poweron - len_poweron
for node in random.sample(self.free_poweroff_node_pool, for node in random.sample(self.free_poweroff_node_pool,
min(len_poweroff, diff)): min(len_poweroff, diff)):
self.add_action_poweronoff_node(node, 'on') self.add_action_poweronoff_node(node,
LOG.debug("power on %s", node.uuid) metal_constants.PowerState.ON)
LOG.info("power on %s", node.get_id())
def pre_execute(self): def pre_execute(self):
self._pre_execute() self._pre_execute()

View File

@@ -18,9 +18,13 @@
# limitations under the License. # limitations under the License.
# #
import collections
from oslo_log import log from oslo_log import log
import oslo_utils
from watcher._i18n import _ from watcher._i18n import _
from watcher.applier.actions import migration
from watcher.common import exception from watcher.common import exception
from watcher.decision_engine.model import element from watcher.decision_engine.model import element
from watcher.decision_engine.strategy.strategies import base from watcher.decision_engine.strategy.strategies import base
@@ -66,7 +70,8 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
AGGREGATE = 'mean' AGGREGATE = 'mean'
DATASOURCE_METRICS = ['instance_ram_allocated', 'instance_cpu_usage', DATASOURCE_METRICS = ['instance_ram_allocated', 'instance_cpu_usage',
'instance_ram_usage', 'instance_root_disk_size'] 'instance_ram_usage', 'instance_root_disk_size',
'host_cpu_usage', 'host_ram_usage']
MIGRATION = "migrate" MIGRATION = "migrate"
CHANGE_NOVA_SERVICE_STATE = "change_nova_service_state" CHANGE_NOVA_SERVICE_STATE = "change_nova_service_state"
@@ -76,6 +81,11 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
self.number_of_migrations = 0 self.number_of_migrations = 0
self.number_of_released_nodes = 0 self.number_of_released_nodes = 0
self.datasource_instance_data_cache = dict() self.datasource_instance_data_cache = dict()
self.datasource_node_data_cache = dict()
# Host metric adjustments that take into account planned
# migrations.
self.host_metric_delta = collections.defaultdict(
lambda: collections.defaultdict(int))
@classmethod @classmethod
def get_name(cls): def get_name(cls):
@@ -196,12 +206,12 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
:return: None :return: None
""" """
instance_state_str = self.get_instance_state_str(instance) instance_state_str = self.get_instance_state_str(instance)
if instance_state_str not in (element.InstanceState.ACTIVE.value, if instance_state_str in (element.InstanceState.ACTIVE.value,
element.InstanceState.PAUSED.value): element.InstanceState.PAUSED.value):
# Watcher currently only supports live VM migration and block live migration_type = migration.Migrate.LIVE_MIGRATION
# VM migration which both requires migrated VM to be active. elif instance_state_str == element.InstanceState.STOPPED.value:
# When supported, the cold migration may be used as a fallback migration_type = migration.Migrate.COLD_MIGRATION
# migration mechanism to move non active VMs. else:
LOG.error( LOG.error(
'Cannot live migrate: instance_uuid=%(instance_uuid)s, ' 'Cannot live migrate: instance_uuid=%(instance_uuid)s, '
'state=%(instance_state)s.', dict( 'state=%(instance_state)s.', dict(
@@ -209,8 +219,6 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
instance_state=instance_state_str)) instance_state=instance_state_str))
return return
migration_type = 'live'
# Here will makes repeated actions to enable the same compute node, # Here will makes repeated actions to enable the same compute node,
# when migrating VMs to the destination node which is disabled. # when migrating VMs to the destination node which is disabled.
# Whether should we remove the same actions in the solution??? # Whether should we remove the same actions in the solution???
@@ -228,6 +236,18 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
destination_node) destination_node)
self.number_of_migrations += 1 self.number_of_migrations += 1
instance_util = self.get_instance_utilization(instance)
self.host_metric_delta[source_node.hostname]['cpu'] -= (
instance_util['cpu'])
# We'll deduce the vm allocated memory.
self.host_metric_delta[source_node.hostname]['ram'] -= (
instance.memory)
self.host_metric_delta[destination_node.hostname]['cpu'] += (
instance_util['cpu'])
self.host_metric_delta[destination_node.hostname]['ram'] += (
instance.memory)
def disable_unused_nodes(self): def disable_unused_nodes(self):
"""Generate actions for disabling unused nodes. """Generate actions for disabling unused nodes.
@@ -290,6 +310,21 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
disk=instance_disk_util) disk=instance_disk_util)
return self.datasource_instance_data_cache.get(instance.uuid) return self.datasource_instance_data_cache.get(instance.uuid)
def _get_node_total_utilization(self, node):
if node.hostname in self.datasource_node_data_cache:
return self.datasource_node_data_cache[node.hostname]
cpu = self.datasource_backend.get_host_cpu_usage(
node, self.period, self.AGGREGATE,
self.granularity)
ram = self.datasource_backend.get_host_ram_usage(
node, self.period, self.AGGREGATE,
self.granularity)
self.datasource_node_data_cache[node.hostname] = dict(
cpu=cpu, ram=ram)
return self.datasource_node_data_cache[node.hostname]
def get_node_utilization(self, node): def get_node_utilization(self, node):
"""Collect cpu, ram and disk utilization statistics of a node. """Collect cpu, ram and disk utilization statistics of a node.
@@ -307,8 +342,36 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
node_cpu_util += instance_util['cpu'] node_cpu_util += instance_util['cpu']
node_ram_util += instance_util['ram'] node_ram_util += instance_util['ram']
node_disk_util += instance_util['disk'] node_disk_util += instance_util['disk']
LOG.debug("instance utilization: %s %s",
instance, instance_util)
return dict(cpu=node_cpu_util, ram=node_ram_util, total_node_util = self._get_node_total_utilization(node)
total_node_cpu_util = total_node_util['cpu'] or 0
if total_node_cpu_util:
total_node_cpu_util = total_node_cpu_util * node.vcpus / 100
# account for planned migrations
total_node_cpu_util += self.host_metric_delta[node.hostname]['cpu']
total_node_ram_util = total_node_util['ram'] or 0
if total_node_ram_util:
total_node_ram_util /= oslo_utils.units.Ki
total_node_ram_util += self.host_metric_delta[node.hostname]['ram']
LOG.debug(
"node utilization: %s. "
"total instance cpu: %s, "
"total instance ram: %s, "
"total instance disk: %s, "
"total host cpu: %s, "
"total host ram: %s, "
"node delta usage: %s.",
node,
node_cpu_util, node_ram_util, node_disk_util,
total_node_cpu_util, total_node_ram_util,
self.host_metric_delta[node.hostname])
return dict(cpu=max(node_cpu_util, total_node_cpu_util),
ram=max(node_ram_util, total_node_ram_util),
disk=node_disk_util) disk=node_disk_util)
def get_node_capacity(self, node): def get_node_capacity(self, node):
@@ -388,8 +451,15 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
instance_utilization = self.get_instance_utilization(instance) instance_utilization = self.get_instance_utilization(instance)
metrics = ['cpu', 'ram', 'disk'] metrics = ['cpu', 'ram', 'disk']
for m in metrics: for m in metrics:
if (instance_utilization[m] + node_utilization[m] > fits = (instance_utilization[m] + node_utilization[m] <=
node_capacity[m] * cc[m]): node_capacity[m] * cc[m])
LOG.debug(
"Instance fits: %s, metric: %s, instance: %s, "
"node: %s, instance utilization: %s, "
"node utilization: %s, node capacity: %s, cc: %s",
fits, m, instance, node, instance_utilization[m],
node_utilization[m], node_capacity[m], cc[m])
if not fits:
return False return False
return True return True
@@ -424,6 +494,9 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
for a in actions: for a in actions:
self.solution.actions.remove(a) self.solution.actions.remove(a)
self.number_of_migrations -= 1 self.number_of_migrations -= 1
LOG.info("Optimized migrations: %s. "
"Source: %s, destination: %s", actions,
src_name, dst_name)
src_node = self.compute_model.get_node_by_name(src_name) src_node = self.compute_model.get_node_by_name(src_name)
dst_node = self.compute_model.get_node_by_name(dst_name) dst_node = self.compute_model.get_node_by_name(dst_name)
instance = self.compute_model.get_instance_by_uuid( instance = self.compute_model.get_instance_by_uuid(
@@ -460,6 +533,8 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
key=lambda x: self.get_instance_utilization( key=lambda x: self.get_instance_utilization(
x)['cpu'] x)['cpu']
): ):
LOG.info("Node %s overloaded, attempting to reduce load.",
node)
# skip exclude instance when migrating # skip exclude instance when migrating
if instance.watcher_exclude: if instance.watcher_exclude:
LOG.debug("Instance is excluded by scope, " LOG.debug("Instance is excluded by scope, "
@@ -468,11 +543,19 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
for destination_node in reversed(sorted_nodes): for destination_node in reversed(sorted_nodes):
if self.instance_fits( if self.instance_fits(
instance, destination_node, cc): instance, destination_node, cc):
LOG.info("Offload: found fitting "
"destination (%s) for instance: %s. "
"Planning migration.",
destination_node, instance.uuid)
self.add_migration(instance, node, self.add_migration(instance, node,
destination_node) destination_node)
break break
if not self.is_overloaded(node, cc): if not self.is_overloaded(node, cc):
LOG.info("Node %s no longer overloaded.", node)
break break
else:
LOG.info("Node still overloaded (%s), "
"continuing offload phase.", node)
def consolidation_phase(self, cc): def consolidation_phase(self, cc):
"""Perform consolidation phase. """Perform consolidation phase.
@@ -508,6 +591,10 @@ class VMWorkloadConsolidation(base.ServerConsolidationBaseStrategy):
break break
if self.instance_fits( if self.instance_fits(
instance, destination_node, cc): instance, destination_node, cc):
LOG.info("Consolidation: found fitting "
"destination (%s) for instance: %s. "
"Planning migration.",
destination_node, instance.uuid)
self.add_migration(instance, node, self.add_migration(instance, node,
destination_node) destination_node)
break break

View File

@@ -295,7 +295,7 @@ class WorkloadBalance(base.WorkloadStabilizationBaseStrategy):
self.threshold) self.threshold)
return self.solution return self.solution
# choose the server with largest cpu_util # choose the server with largest cpu usage
source_nodes = sorted(source_nodes, source_nodes = sorted(source_nodes,
reverse=True, reverse=True,
key=lambda x: (x[self._meter])) key=lambda x: (x[self._meter]))

View File

@@ -57,8 +57,19 @@ class ZoneMigration(base.ZoneMigrationBaseStrategy):
self.planned_cold_count = 0 self.planned_cold_count = 0
self.volume_count = 0 self.volume_count = 0
self.planned_volume_count = 0 self.planned_volume_count = 0
self.volume_update_count = 0
self.planned_volume_update_count = 0 # TODO(sean-n-mooney) This is backward compatibility
# for calling the swap code paths. Swap is now an alias
# for migrate, we should clean this up in a future
# cycle.
@property
def volume_update_count(self):
return self.volume_count
# same as above clean up later.
@property
def planned_volume_update_count(self):
return self.planned_volume_count
@classmethod @classmethod
def get_name(cls): def get_name(cls):
@@ -312,8 +323,8 @@ class ZoneMigration(base.ZoneMigrationBaseStrategy):
planned_cold_migrate_instance_count=self.planned_cold_count, planned_cold_migrate_instance_count=self.planned_cold_count,
volume_migrate_count=self.volume_count, volume_migrate_count=self.volume_count,
planned_volume_migrate_count=self.planned_volume_count, planned_volume_migrate_count=self.planned_volume_count,
volume_update_count=self.volume_update_count, volume_update_count=self.volume_count,
planned_volume_update_count=self.planned_volume_update_count planned_volume_update_count=self.planned_volume_count
) )
def set_migration_count(self, targets): def set_migration_count(self, targets):
@@ -328,10 +339,7 @@ class ZoneMigration(base.ZoneMigrationBaseStrategy):
elif self.is_cold(instance): elif self.is_cold(instance):
self.cold_count += 1 self.cold_count += 1
for volume in targets.get('volume', []): for volume in targets.get('volume', []):
if self.is_available(volume): self.volume_count += 1
self.volume_count += 1
elif self.is_in_use(volume):
self.volume_update_count += 1
def is_live(self, instance): def is_live(self, instance):
status = getattr(instance, 'status') status = getattr(instance, 'status')
@@ -404,19 +412,16 @@ class ZoneMigration(base.ZoneMigrationBaseStrategy):
LOG.debug(src_type) LOG.debug(src_type)
LOG.debug("%s %s", dst_pool, dst_type) LOG.debug("%s %s", dst_pool, dst_type)
if self.is_available(volume): if src_type == dst_type:
if src_type == dst_type: self._volume_migrate(volume, dst_pool)
self._volume_migrate(volume, dst_pool) else:
else: self._volume_retype(volume, dst_type)
self._volume_retype(volume, dst_type)
elif self.is_in_use(volume):
self._volume_update(volume, dst_type)
# if with_attached_volume is True, migrate attaching instances # if with_attached_volume is True, migrate attaching instances
if self.with_attached_volume: if self.with_attached_volume:
instances = [self.nova.find_instance(dic.get('server_id')) instances = [self.nova.find_instance(dic.get('server_id'))
for dic in volume.attachments] for dic in volume.attachments]
self.instances_migration(instances, action_counter) self.instances_migration(instances, action_counter)
action_counter.add_pool(pool) action_counter.add_pool(pool)
@@ -464,16 +469,6 @@ class ZoneMigration(base.ZoneMigrationBaseStrategy):
input_parameters=parameters) input_parameters=parameters)
self.planned_cold_count += 1 self.planned_cold_count += 1
def _volume_update(self, volume, dst_type):
parameters = {"migration_type": "swap",
"destination_type": dst_type,
"resource_name": volume.name}
self.solution.add_action(
action_type="volume_migrate",
resource_id=volume.id,
input_parameters=parameters)
self.planned_volume_update_count += 1
def _volume_migrate(self, volume, dst_pool): def _volume_migrate(self, volume, dst_pool):
parameters = {"migration_type": "migrate", parameters = {"migration_type": "migrate",
"destination_node": dst_pool, "destination_node": dst_pool,

View File

@@ -1,15 +1,16 @@
# Andi Chandler <andi@gowling.com>, 2017. #zanata # Andi Chandler <andi@gowling.com>, 2017. #zanata
# Andi Chandler <andi@gowling.com>, 2018. #zanata # Andi Chandler <andi@gowling.com>, 2018. #zanata
# Andi Chandler <andi@gowling.com>, 2020. #zanata # Andi Chandler <andi@gowling.com>, 2020. #zanata
# Andi Chandler <andi@gowling.com>, 2022. #zanata
msgid "" msgid ""
msgstr "" msgstr ""
"Project-Id-Version: watcher VERSION\n" "Project-Id-Version: watcher VERSION\n"
"Report-Msgid-Bugs-To: https://bugs.launchpad.net/openstack-i18n/\n" "Report-Msgid-Bugs-To: https://bugs.launchpad.net/openstack-i18n/\n"
"POT-Creation-Date: 2020-10-27 04:14+0000\n" "POT-Creation-Date: 2022-08-29 03:03+0000\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"PO-Revision-Date: 2020-10-28 11:02+0000\n" "PO-Revision-Date: 2022-05-31 08:38+0000\n"
"Last-Translator: Andi Chandler <andi@gowling.com>\n" "Last-Translator: Andi Chandler <andi@gowling.com>\n"
"Language-Team: English (United Kingdom)\n" "Language-Team: English (United Kingdom)\n"
"Language: en_GB\n" "Language: en_GB\n"
@@ -507,6 +508,9 @@ msgstr ""
msgid "Plugins" msgid "Plugins"
msgstr "Plugins" msgstr "Plugins"
msgid "Policy File JSON to YAML Migration"
msgstr "Policy File JSON to YAML Migration"
#, python-format #, python-format
msgid "Policy doesn't allow %(action)s to be performed." msgid "Policy doesn't allow %(action)s to be performed."
msgstr "Policy doesn't allow %(action)s to be performed." msgstr "Policy doesn't allow %(action)s to be performed."

View File

@@ -19,134 +19,151 @@ import jsonschema
from watcher.applier.actions import base as baction from watcher.applier.actions import base as baction
from watcher.applier.actions import change_node_power_state from watcher.applier.actions import change_node_power_state
from watcher.common import clients from watcher.common.metal_helper import constants as m_constants
from watcher.common.metal_helper import factory as m_helper_factory
from watcher.tests import base from watcher.tests import base
from watcher.tests.decision_engine import fake_metal_helper
COMPUTE_NODE = "compute-1" COMPUTE_NODE = "compute-1"
@mock.patch.object(clients.OpenStackClients, 'nova')
@mock.patch.object(clients.OpenStackClients, 'ironic')
class TestChangeNodePowerState(base.TestCase): class TestChangeNodePowerState(base.TestCase):
def setUp(self): def setUp(self):
super(TestChangeNodePowerState, self).setUp() super(TestChangeNodePowerState, self).setUp()
p_m_factory = mock.patch.object(m_helper_factory, 'get_helper')
m_factory = p_m_factory.start()
self._metal_helper = m_factory.return_value
self.addCleanup(p_m_factory.stop)
# Let's avoid unnecessary sleep calls while running the test.
p_sleep = mock.patch('time.sleep')
p_sleep.start()
self.addCleanup(p_sleep.stop)
self.input_parameters = { self.input_parameters = {
baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, baction.BaseAction.RESOURCE_ID: COMPUTE_NODE,
"state": change_node_power_state.NodeState.POWERON.value, "state": m_constants.PowerState.ON.value,
} }
self.action = change_node_power_state.ChangeNodePowerState( self.action = change_node_power_state.ChangeNodePowerState(
mock.Mock()) mock.Mock())
self.action.input_parameters = self.input_parameters self.action.input_parameters = self.input_parameters
def test_parameters_down(self, mock_ironic, mock_nova): def test_parameters_down(self):
self.action.input_parameters = { self.action.input_parameters = {
baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, baction.BaseAction.RESOURCE_ID: COMPUTE_NODE,
self.action.STATE: self.action.STATE:
change_node_power_state.NodeState.POWEROFF.value} m_constants.PowerState.OFF.value}
self.assertTrue(self.action.validate_parameters()) self.assertTrue(self.action.validate_parameters())
def test_parameters_up(self, mock_ironic, mock_nova): def test_parameters_up(self):
self.action.input_parameters = { self.action.input_parameters = {
baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, baction.BaseAction.RESOURCE_ID: COMPUTE_NODE,
self.action.STATE: self.action.STATE:
change_node_power_state.NodeState.POWERON.value} m_constants.PowerState.ON.value}
self.assertTrue(self.action.validate_parameters()) self.assertTrue(self.action.validate_parameters())
def test_parameters_exception_wrong_state(self, mock_ironic, mock_nova): def test_parameters_exception_wrong_state(self):
self.action.input_parameters = { self.action.input_parameters = {
baction.BaseAction.RESOURCE_ID: COMPUTE_NODE, baction.BaseAction.RESOURCE_ID: COMPUTE_NODE,
self.action.STATE: 'error'} self.action.STATE: 'error'}
self.assertRaises(jsonschema.ValidationError, self.assertRaises(jsonschema.ValidationError,
self.action.validate_parameters) self.action.validate_parameters)
def test_parameters_resource_id_empty(self, mock_ironic, mock_nova): def test_parameters_resource_id_empty(self):
self.action.input_parameters = { self.action.input_parameters = {
self.action.STATE: self.action.STATE:
change_node_power_state.NodeState.POWERON.value, m_constants.PowerState.ON.value,
} }
self.assertRaises(jsonschema.ValidationError, self.assertRaises(jsonschema.ValidationError,
self.action.validate_parameters) self.action.validate_parameters)
def test_parameters_applies_add_extra(self, mock_ironic, mock_nova): def test_parameters_applies_add_extra(self):
self.action.input_parameters = {"extra": "failed"} self.action.input_parameters = {"extra": "failed"}
self.assertRaises(jsonschema.ValidationError, self.assertRaises(jsonschema.ValidationError,
self.action.validate_parameters) self.action.validate_parameters)
def test_change_service_state_pre_condition(self, mock_ironic, mock_nova): def test_change_service_state_pre_condition(self):
try: try:
self.action.pre_condition() self.action.pre_condition()
except Exception as exc: except Exception as exc:
self.fail(exc) self.fail(exc)
def test_change_node_state_post_condition(self, mock_ironic, mock_nova): def test_change_node_state_post_condition(self):
try: try:
self.action.post_condition() self.action.post_condition()
except Exception as exc: except Exception as exc:
self.fail(exc) self.fail(exc)
def test_execute_node_service_state_with_poweron_target( def test_execute_node_service_state_with_poweron_target(self):
self, mock_ironic, mock_nova):
mock_irclient = mock_ironic.return_value
self.action.input_parameters["state"] = ( self.action.input_parameters["state"] = (
change_node_power_state.NodeState.POWERON.value) m_constants.PowerState.ON.value)
mock_irclient.node.get.side_effect = [ mock_nodes = [
mock.MagicMock(power_state='power off'), fake_metal_helper.get_mock_metal_node(
mock.MagicMock(power_state='power on')] power_state=m_constants.PowerState.OFF),
fake_metal_helper.get_mock_metal_node(
power_state=m_constants.PowerState.ON)
]
self._metal_helper.get_node.side_effect = mock_nodes
result = self.action.execute() result = self.action.execute()
self.assertTrue(result) self.assertTrue(result)
mock_irclient.node.set_power_state.assert_called_once_with( mock_nodes[0].set_power_state.assert_called_once_with(
COMPUTE_NODE, change_node_power_state.NodeState.POWERON.value) m_constants.PowerState.ON.value)
def test_execute_change_node_state_with_poweroff_target( def test_execute_change_node_state_with_poweroff_target(self):
self, mock_ironic, mock_nova):
mock_irclient = mock_ironic.return_value
mock_nvclient = mock_nova.return_value
mock_get = mock.MagicMock()
mock_get.to_dict.return_value = {'running_vms': 0}
mock_nvclient.hypervisors.get.return_value = mock_get
self.action.input_parameters["state"] = ( self.action.input_parameters["state"] = (
change_node_power_state.NodeState.POWEROFF.value) m_constants.PowerState.OFF.value)
mock_irclient.node.get.side_effect = [
mock.MagicMock(power_state='power on'), mock_nodes = [
mock.MagicMock(power_state='power on'), fake_metal_helper.get_mock_metal_node(
mock.MagicMock(power_state='power off')] power_state=m_constants.PowerState.ON),
fake_metal_helper.get_mock_metal_node(
power_state=m_constants.PowerState.ON),
fake_metal_helper.get_mock_metal_node(
power_state=m_constants.PowerState.OFF)
]
self._metal_helper.get_node.side_effect = mock_nodes
result = self.action.execute() result = self.action.execute()
self.assertTrue(result) self.assertTrue(result)
mock_irclient.node.set_power_state.assert_called_once_with( mock_nodes[0].set_power_state.assert_called_once_with(
COMPUTE_NODE, change_node_power_state.NodeState.POWEROFF.value) m_constants.PowerState.OFF.value)
def test_revert_change_node_state_with_poweron_target( def test_revert_change_node_state_with_poweron_target(self):
self, mock_ironic, mock_nova):
mock_irclient = mock_ironic.return_value
mock_nvclient = mock_nova.return_value
mock_get = mock.MagicMock()
mock_get.to_dict.return_value = {'running_vms': 0}
mock_nvclient.hypervisors.get.return_value = mock_get
self.action.input_parameters["state"] = ( self.action.input_parameters["state"] = (
change_node_power_state.NodeState.POWERON.value) m_constants.PowerState.ON.value)
mock_irclient.node.get.side_effect = [
mock.MagicMock(power_state='power on'), mock_nodes = [
mock.MagicMock(power_state='power on'), fake_metal_helper.get_mock_metal_node(
mock.MagicMock(power_state='power off')] power_state=m_constants.PowerState.ON),
fake_metal_helper.get_mock_metal_node(
power_state=m_constants.PowerState.ON),
fake_metal_helper.get_mock_metal_node(
power_state=m_constants.PowerState.OFF)
]
self._metal_helper.get_node.side_effect = mock_nodes
self.action.revert() self.action.revert()
mock_irclient.node.set_power_state.assert_called_once_with( mock_nodes[0].set_power_state.assert_called_once_with(
COMPUTE_NODE, change_node_power_state.NodeState.POWEROFF.value) m_constants.PowerState.OFF.value)
def test_revert_change_node_state_with_poweroff_target( def test_revert_change_node_state_with_poweroff_target(self):
self, mock_ironic, mock_nova):
mock_irclient = mock_ironic.return_value
self.action.input_parameters["state"] = ( self.action.input_parameters["state"] = (
change_node_power_state.NodeState.POWEROFF.value) m_constants.PowerState.OFF.value)
mock_irclient.node.get.side_effect = [ mock_nodes = [
mock.MagicMock(power_state='power off'), fake_metal_helper.get_mock_metal_node(
mock.MagicMock(power_state='power on')] power_state=m_constants.PowerState.OFF),
fake_metal_helper.get_mock_metal_node(
power_state=m_constants.PowerState.ON)
]
self._metal_helper.get_node.side_effect = mock_nodes
self.action.revert() self.action.revert()
mock_irclient.node.set_power_state.assert_called_once_with( mock_nodes[0].set_power_state.assert_called_once_with(
COMPUTE_NODE, change_node_power_state.NodeState.POWERON.value) m_constants.PowerState.ON.value)

View File

@@ -22,7 +22,6 @@ from watcher.common import cinder_helper
from watcher.common import clients from watcher.common import clients
from watcher.common import keystone_helper from watcher.common import keystone_helper
from watcher.common import nova_helper from watcher.common import nova_helper
from watcher.common import utils as w_utils
from watcher.tests import base from watcher.tests import base
@@ -102,12 +101,15 @@ class TestMigration(base.TestCase):
@staticmethod @staticmethod
def fake_volume(**kwargs): def fake_volume(**kwargs):
# FIXME(sean-k-mooney): we should be using real objects in this
# test or at lease something more Representative of the real data
volume = mock.MagicMock() volume = mock.MagicMock()
volume.id = kwargs.get('id', TestMigration.VOLUME_UUID) volume.id = kwargs.get('id', TestMigration.VOLUME_UUID)
volume.size = kwargs.get('size', '1') volume.size = kwargs.get('size', '1')
volume.status = kwargs.get('status', 'available') volume.status = kwargs.get('status', 'available')
volume.snapshot_id = kwargs.get('snapshot_id', None) volume.snapshot_id = kwargs.get('snapshot_id', None)
volume.availability_zone = kwargs.get('availability_zone', 'nova') volume.availability_zone = kwargs.get('availability_zone', 'nova')
volume.attachments = kwargs.get('attachments', [])
return volume return volume
@staticmethod @staticmethod
@@ -175,42 +177,14 @@ class TestMigration(base.TestCase):
"storage1-typename", "storage1-typename",
) )
def test_swap_success(self):
volume = self.fake_volume(
status='in-use', attachments=[{'server_id': 'server_id'}])
self.m_n_helper.find_instance.return_value = self.fake_instance()
new_volume = self.fake_volume(id=w_utils.generate_uuid())
user = mock.Mock()
session = mock.MagicMock()
self.m_k_helper.create_user.return_value = user
self.m_k_helper.create_session.return_value = session
self.m_c_helper.get_volume.return_value = volume
self.m_c_helper.create_volume.return_value = new_volume
result = self.action_swap.execute()
self.assertTrue(result)
self.m_n_helper.swap_volume.assert_called_once_with(
volume,
new_volume
)
self.m_k_helper.delete_user.assert_called_once_with(user)
def test_swap_fail(self):
# _can_swap fail
instance = self.fake_instance(status='STOPPED')
self.m_n_helper.find_instance.return_value = instance
result = self.action_swap.execute()
self.assertFalse(result)
def test_can_swap_success(self): def test_can_swap_success(self):
volume = self.fake_volume( volume = self.fake_volume(
status='in-use', attachments=[{'server_id': 'server_id'}]) status='in-use', attachments=[
instance = self.fake_instance() {'server_id': TestMigration.INSTANCE_UUID}])
instance = self.fake_instance()
self.m_n_helper.find_instance.return_value = instance self.m_n_helper.find_instance.return_value = instance
result = self.action_swap._can_swap(volume) result = self.action_swap._can_swap(volume)
self.assertTrue(result) self.assertTrue(result)
@@ -219,16 +193,33 @@ class TestMigration(base.TestCase):
result = self.action_swap._can_swap(volume) result = self.action_swap._can_swap(volume)
self.assertTrue(result) self.assertTrue(result)
instance = self.fake_instance(status='RESIZED')
self.m_n_helper.find_instance.return_value = instance
result = self.action_swap._can_swap(volume)
self.assertTrue(result)
def test_can_swap_fail(self): def test_can_swap_fail(self):
volume = self.fake_volume( volume = self.fake_volume(
status='in-use', attachments=[{'server_id': 'server_id'}]) status='in-use', attachments=[
{'server_id': TestMigration.INSTANCE_UUID}])
instance = self.fake_instance(status='STOPPED') instance = self.fake_instance(status='STOPPED')
self.m_n_helper.find_instance.return_value = instance self.m_n_helper.find_instance.return_value = instance
result = self.action_swap._can_swap(volume) result = self.action_swap._can_swap(volume)
self.assertFalse(result) self.assertFalse(result)
instance = self.fake_instance(status='RESIZED')
self.m_n_helper.find_instance.return_value = instance
result = self.action_swap._can_swap(volume)
self.assertFalse(result)
def test_swap_success(self):
volume = self.fake_volume(
status='in-use', attachments=[
{'server_id': TestMigration.INSTANCE_UUID}])
self.m_c_helper.get_volume.return_value = volume
instance = self.fake_instance()
self.m_n_helper.find_instance.return_value = instance
result = self.action_swap.execute()
self.assertTrue(result)
self.m_c_helper.migrate.assert_called_once_with(
volume,
"storage1-poolname"
)

View File

@@ -0,0 +1,96 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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.
from unittest import mock
from watcher.common import exception
from watcher.common.metal_helper import base as m_helper_base
from watcher.common.metal_helper import constants as m_constants
from watcher.tests import base
# The base classes have abstract methods, we'll need to
# stub them.
class MockMetalNode(m_helper_base.BaseMetalNode):
def get_power_state(self):
raise NotImplementedError()
def get_id(self):
raise NotImplementedError()
def power_on(self):
raise NotImplementedError()
def power_off(self):
raise NotImplementedError()
class MockMetalHelper(m_helper_base.BaseMetalHelper):
def list_compute_nodes(self):
pass
def get_node(self, node_id):
pass
class TestBaseMetalNode(base.TestCase):
def setUp(self):
super().setUp()
self._nova_node = mock.Mock()
self._node = MockMetalNode(self._nova_node)
def test_get_hypervisor_node(self):
self.assertEqual(
self._nova_node,
self._node.get_hypervisor_node())
def test_get_hypervisor_node_missing(self):
node = MockMetalNode()
self.assertRaises(
exception.Invalid,
node.get_hypervisor_node)
def test_get_hypervisor_hostname(self):
self.assertEqual(
self._nova_node.hypervisor_hostname,
self._node.get_hypervisor_hostname())
@mock.patch.object(MockMetalNode, 'power_on')
@mock.patch.object(MockMetalNode, 'power_off')
def test_set_power_state(self,
mock_power_off, mock_power_on):
self._node.set_power_state(m_constants.PowerState.ON)
mock_power_on.assert_called_once_with()
self._node.set_power_state(m_constants.PowerState.OFF)
mock_power_off.assert_called_once_with()
self.assertRaises(
exception.UnsupportedActionType,
self._node.set_power_state,
m_constants.PowerState.UNKNOWN)
class TestBaseMetalHelper(base.TestCase):
def setUp(self):
super().setUp()
self._osc = mock.Mock()
self._helper = MockMetalHelper(self._osc)
def test_nova_client_attr(self):
self.assertEqual(self._osc.nova.return_value,
self._helper.nova_client)

View File

@@ -0,0 +1,38 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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.
from unittest import mock
from watcher.common import clients
from watcher.common.metal_helper import factory
from watcher.common.metal_helper import ironic
from watcher.common.metal_helper import maas
from watcher.tests import base
class TestMetalHelperFactory(base.TestCase):
@mock.patch.object(clients, 'OpenStackClients')
@mock.patch.object(maas, 'MaasHelper')
@mock.patch.object(ironic, 'IronicHelper')
def test_factory(self, mock_ironic, mock_maas, mock_osc):
self.assertEqual(
mock_ironic.return_value,
factory.get_helper())
self.config(url="fake_maas_url", group="maas_client")
self.assertEqual(
mock_maas.return_value,
factory.get_helper())

View File

@@ -0,0 +1,128 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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.
from unittest import mock
from watcher.common.metal_helper import constants as m_constants
from watcher.common.metal_helper import ironic
from watcher.tests import base
class TestIronicNode(base.TestCase):
def setUp(self):
super().setUp()
self._wrapped_node = mock.Mock()
self._nova_node = mock.Mock()
self._ironic_client = mock.Mock()
self._node = ironic.IronicNode(
self._wrapped_node, self._nova_node, self._ironic_client)
def test_get_power_state(self):
states = (
"power on",
"power off",
"rebooting",
"soft power off",
"soft reboot",
'SomeOtherState')
type(self._wrapped_node).power_state = mock.PropertyMock(
side_effect=states)
expected_states = (
m_constants.PowerState.ON,
m_constants.PowerState.OFF,
m_constants.PowerState.ON,
m_constants.PowerState.OFF,
m_constants.PowerState.ON,
m_constants.PowerState.UNKNOWN)
for expected_state in expected_states:
actual_state = self._node.get_power_state()
self.assertEqual(expected_state, actual_state)
def test_get_id(self):
self.assertEqual(
self._wrapped_node.uuid,
self._node.get_id())
def test_power_on(self):
self._node.power_on()
self._ironic_client.node.set_power_state.assert_called_once_with(
self._wrapped_node.uuid, "on")
def test_power_off(self):
self._node.power_off()
self._ironic_client.node.set_power_state.assert_called_once_with(
self._wrapped_node.uuid, "off")
class TestIronicHelper(base.TestCase):
def setUp(self):
super().setUp()
self._mock_osc = mock.Mock()
self._mock_nova_client = self._mock_osc.nova.return_value
self._mock_ironic_client = self._mock_osc.ironic.return_value
self._helper = ironic.IronicHelper(osc=self._mock_osc)
def test_list_compute_nodes(self):
mock_machines = [
mock.Mock(
extra=dict(compute_node_id=mock.sentinel.compute_node_id)),
mock.Mock(
extra=dict(compute_node_id=mock.sentinel.compute_node_id2)),
mock.Mock(
extra=dict())
]
mock_hypervisor = mock.Mock()
self._mock_ironic_client.node.list.return_value = mock_machines
self._mock_ironic_client.node.get.side_effect = mock_machines
self._mock_nova_client.hypervisors.get.side_effect = (
mock_hypervisor, None)
out_nodes = self._helper.list_compute_nodes()
self.assertEqual(1, len(out_nodes))
out_node = out_nodes[0]
self.assertIsInstance(out_node, ironic.IronicNode)
self.assertEqual(mock_hypervisor, out_node._nova_node)
self.assertEqual(mock_machines[0], out_node._ironic_node)
self.assertEqual(self._mock_ironic_client, out_node._ironic_client)
def test_get_node(self):
mock_machine = mock.Mock(
extra=dict(compute_node_id=mock.sentinel.compute_node_id))
self._mock_ironic_client.node.get.return_value = mock_machine
out_node = self._helper.get_node(mock.sentinel.id)
self.assertEqual(self._mock_nova_client.hypervisors.get.return_value,
out_node._nova_node)
self.assertEqual(self._mock_ironic_client, out_node._ironic_client)
self.assertEqual(mock_machine, out_node._ironic_node)
def test_get_node_not_a_hypervisor(self):
mock_machine = mock.Mock(extra=dict(compute_node_id=None))
self._mock_ironic_client.node.get.return_value = mock_machine
out_node = self._helper.get_node(mock.sentinel.id)
self._mock_nova_client.hypervisors.get.assert_not_called()
self.assertIsNone(out_node._nova_node)
self.assertEqual(self._mock_ironic_client, out_node._ironic_client)
self.assertEqual(mock_machine, out_node._ironic_node)

View File

@@ -0,0 +1,126 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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.
from unittest import mock
try:
from maas.client import enum as maas_enum
except ImportError:
maas_enum = None
from watcher.common.metal_helper import constants as m_constants
from watcher.common.metal_helper import maas
from watcher.tests import base
class TestMaasNode(base.TestCase):
def setUp(self):
super().setUp()
self._wrapped_node = mock.Mock()
self._nova_node = mock.Mock()
self._maas_client = mock.Mock()
self._node = maas.MaasNode(
self._wrapped_node, self._nova_node, self._maas_client)
def test_get_power_state(self):
if not maas_enum:
self.skipTest("python-libmaas not intalled.")
self._wrapped_node.query_power_state.side_effect = (
maas_enum.PowerState.ON,
maas_enum.PowerState.OFF,
maas_enum.PowerState.ERROR,
maas_enum.PowerState.UNKNOWN,
'SomeOtherState')
expected_states = (
m_constants.PowerState.ON,
m_constants.PowerState.OFF,
m_constants.PowerState.ERROR,
m_constants.PowerState.UNKNOWN,
m_constants.PowerState.UNKNOWN)
for expected_state in expected_states:
actual_state = self._node.get_power_state()
self.assertEqual(expected_state, actual_state)
def test_get_id(self):
self.assertEqual(
self._wrapped_node.system_id,
self._node.get_id())
def test_power_on(self):
self._node.power_on()
self._wrapped_node.power_on.assert_called_once_with()
def test_power_off(self):
self._node.power_off()
self._wrapped_node.power_off.assert_called_once_with()
class TestMaasHelper(base.TestCase):
def setUp(self):
super().setUp()
self._mock_osc = mock.Mock()
self._mock_nova_client = self._mock_osc.nova.return_value
self._mock_maas_client = self._mock_osc.maas.return_value
self._helper = maas.MaasHelper(osc=self._mock_osc)
def test_list_compute_nodes(self):
compute_fqdn = "compute-0"
# some other MAAS node, not a Nova node
ctrl_fqdn = "ctrl-1"
mock_machines = [
mock.Mock(fqdn=compute_fqdn,
system_id=mock.sentinel.compute_node_id),
mock.Mock(fqdn=ctrl_fqdn,
system_id=mock.sentinel.ctrl_node_id),
]
mock_hypervisors = [
mock.Mock(hypervisor_hostname=compute_fqdn),
]
self._mock_maas_client.machines.list.return_value = mock_machines
self._mock_nova_client.hypervisors.list.return_value = mock_hypervisors
out_nodes = self._helper.list_compute_nodes()
self.assertEqual(1, len(out_nodes))
out_node = out_nodes[0]
self.assertIsInstance(out_node, maas.MaasNode)
self.assertEqual(mock.sentinel.compute_node_id, out_node.get_id())
self.assertEqual(compute_fqdn, out_node.get_hypervisor_hostname())
def test_get_node(self):
mock_machine = mock.Mock(fqdn='compute-0')
self._mock_maas_client.machines.get.return_value = mock_machine
mock_compute_nodes = [
mock.Mock(hypervisor_hostname="compute-011"),
mock.Mock(hypervisor_hostname="compute-0"),
mock.Mock(hypervisor_hostname="compute-01"),
]
self._mock_nova_client.hypervisors.search.return_value = (
mock_compute_nodes)
out_node = self._helper.get_node(mock.sentinel.id)
self.assertEqual(mock_compute_nodes[1], out_node._nova_node)
self.assertEqual(self._mock_maas_client, out_node._maas_client)
self.assertEqual(mock_machine, out_node._maas_node)

View File

@@ -80,13 +80,13 @@ class TestService(base.TestCase):
super(TestService, self).setUp() super(TestService, self).setUp()
@mock.patch.object(om.rpc.server, "RPCServer") @mock.patch.object(om.rpc.server, "RPCServer")
def test_start(self, m_handler): def _test_start(self, m_handler):
dummy_service = service.Service(DummyManager) dummy_service = service.Service(DummyManager)
dummy_service.start() dummy_service.start()
self.assertEqual(1, m_handler.call_count) self.assertEqual(1, m_handler.call_count)
@mock.patch.object(om.rpc.server, "RPCServer") @mock.patch.object(om.rpc.server, "RPCServer")
def test_stop(self, m_handler): def _test_stop(self, m_handler):
dummy_service = service.Service(DummyManager) dummy_service = service.Service(DummyManager)
dummy_service.stop() dummy_service.stop()
self.assertEqual(1, m_handler.call_count) self.assertEqual(1, m_handler.call_count)

View File

@@ -0,0 +1,52 @@
# Copyright 2023 Cloudbase Solutions
# All Rights Reserved.
#
# 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 asyncio
import time
from unittest import mock
from watcher.common import utils
from watcher.tests import base
class TestCommonUtils(base.TestCase):
async def test_coro(self, sleep=0, raise_exc=None):
time.sleep(sleep)
if raise_exc:
raise raise_exc
return mock.sentinel.ret_val
def test_async_compat(self):
ret_val = utils.async_compat_call(self.test_coro)
self.assertEqual(mock.sentinel.ret_val, ret_val)
def test_async_compat_exc(self):
self.assertRaises(
IOError,
utils.async_compat_call,
self.test_coro,
raise_exc=IOError('fake error'))
def test_async_compat_timeout(self):
# Timeout not reached.
ret_val = utils.async_compat_call(self.test_coro, timeout=10)
self.assertEqual(mock.sentinel.ret_val, ret_val)
# Timeout reached.
self.assertRaises(
asyncio.TimeoutError,
utils.async_compat_call,
self.test_coro,
sleep=0.5, timeout=0.1)

View File

@@ -17,9 +17,10 @@
import fixtures import fixtures
from oslo_config import cfg from oslo_config import cfg
from oslo_db.sqlalchemy import enginefacade
from watcher.db import api as dbapi from watcher.db import api as dbapi
from watcher.db.sqlalchemy import api as sqla_api
from watcher.db.sqlalchemy import migration from watcher.db.sqlalchemy import migration
from watcher.db.sqlalchemy import models from watcher.db.sqlalchemy import models
from watcher.tests import base from watcher.tests import base
@@ -35,16 +36,16 @@ _DB_CACHE = None
class Database(fixtures.Fixture): class Database(fixtures.Fixture):
def __init__(self, db_api, db_migrate, sql_connection): def __init__(self, engine, db_migrate, sql_connection):
self.sql_connection = sql_connection self.sql_connection = sql_connection
self.engine = db_api.get_engine() self.engine = engine
self.engine.dispose() self.engine.dispose()
conn = self.engine.connect()
self.setup_sqlite(db_migrate)
self.post_migrations()
self._DB = "".join(line for line in conn.connection.iterdump()) with self.engine.connect() as conn:
self.setup_sqlite(db_migrate)
self.post_migrations()
self._DB = "".join(line for line in conn.connection.iterdump())
self.engine.dispose() self.engine.dispose()
def setup_sqlite(self, db_migrate): def setup_sqlite(self, db_migrate):
@@ -55,9 +56,8 @@ class Database(fixtures.Fixture):
def setUp(self): def setUp(self):
super(Database, self).setUp() super(Database, self).setUp()
with self.engine.connect() as conn:
conn = self.engine.connect() conn.connection.executescript(self._DB)
conn.connection.executescript(self._DB)
self.addCleanup(self.engine.dispose) self.addCleanup(self.engine.dispose)
def post_migrations(self): def post_migrations(self):
@@ -80,7 +80,9 @@ class DbTestCase(base.TestCase):
global _DB_CACHE global _DB_CACHE
if not _DB_CACHE: if not _DB_CACHE:
_DB_CACHE = Database(sqla_api, migration, engine = enginefacade.writer.get_engine()
_DB_CACHE = Database(engine, migration,
sql_connection=CONF.database.connection) sql_connection=CONF.database.connection)
engine.dispose()
self.useFixture(_DB_CACHE) self.useFixture(_DB_CACHE)
self._id_gen = utils.id_generator() self._id_gen = utils.id_generator()

View File

@@ -263,7 +263,7 @@ class TestContinuousAuditHandler(base.DbTestCase):
cfg.CONF.set_override("host", "hostname1") cfg.CONF.set_override("host", "hostname1")
@mock.patch.object(objects.service.Service, 'list') @mock.patch.object(objects.service.Service, 'list')
@mock.patch.object(sq_api, 'get_engine') @mock.patch.object(sq_api.enginefacade.writer, 'get_engine')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs')
@mock.patch.object(objects.audit.Audit, 'list') @mock.patch.object(objects.audit.Audit, 'list')
@@ -286,7 +286,7 @@ class TestContinuousAuditHandler(base.DbTestCase):
self.assertIsNone(self.audits[1].next_run_time) self.assertIsNone(self.audits[1].next_run_time)
@mock.patch.object(objects.service.Service, 'list') @mock.patch.object(objects.service.Service, 'list')
@mock.patch.object(sq_api, 'get_engine') @mock.patch.object(sq_api.enginefacade.writer, 'get_engine')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs')
@mock.patch.object(objects.audit.Audit, 'list') @mock.patch.object(objects.audit.Audit, 'list')
@@ -309,7 +309,7 @@ class TestContinuousAuditHandler(base.DbTestCase):
@mock.patch.object(continuous.ContinuousAuditHandler, '_next_cron_time') @mock.patch.object(continuous.ContinuousAuditHandler, '_next_cron_time')
@mock.patch.object(objects.service.Service, 'list') @mock.patch.object(objects.service.Service, 'list')
@mock.patch.object(sq_api, 'get_engine') @mock.patch.object(sq_api.enginefacade.writer, 'get_engine')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs')
@mock.patch.object(objects.audit.Audit, 'list') @mock.patch.object(objects.audit.Audit, 'list')
@@ -328,7 +328,7 @@ class TestContinuousAuditHandler(base.DbTestCase):
audit_handler.launch_audits_periodically) audit_handler.launch_audits_periodically)
@mock.patch.object(objects.service.Service, 'list') @mock.patch.object(objects.service.Service, 'list')
@mock.patch.object(sq_api, 'get_engine') @mock.patch.object(sq_api.enginefacade.writer, 'get_engine')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs')
@mock.patch.object(objects.audit.Audit, 'list') @mock.patch.object(objects.audit.Audit, 'list')
@@ -349,7 +349,7 @@ class TestContinuousAuditHandler(base.DbTestCase):
m_add_job.assert_has_calls(calls) m_add_job.assert_has_calls(calls)
@mock.patch.object(objects.service.Service, 'list') @mock.patch.object(objects.service.Service, 'list')
@mock.patch.object(sq_api, 'get_engine') @mock.patch.object(sq_api.enginefacade.writer, 'get_engine')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs')
@mock.patch.object(objects.audit.Audit, 'list') @mock.patch.object(objects.audit.Audit, 'list')
@@ -384,7 +384,7 @@ class TestContinuousAuditHandler(base.DbTestCase):
self.assertTrue(is_inactive) self.assertTrue(is_inactive)
@mock.patch.object(objects.service.Service, 'list') @mock.patch.object(objects.service.Service, 'list')
@mock.patch.object(sq_api, 'get_engine') @mock.patch.object(sq_api.enginefacade.writer, 'get_engine')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs')
@mock.patch.object(objects.audit.AuditStateTransitionManager, @mock.patch.object(objects.audit.AuditStateTransitionManager,
'is_inactive') 'is_inactive')
@@ -406,7 +406,7 @@ class TestContinuousAuditHandler(base.DbTestCase):
self.assertIsNotNone(self.audits[0].next_run_time) self.assertIsNotNone(self.audits[0].next_run_time)
@mock.patch.object(objects.service.Service, 'list') @mock.patch.object(objects.service.Service, 'list')
@mock.patch.object(sq_api, 'get_engine') @mock.patch.object(sq_api.enginefacade.writer, 'get_engine')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'remove_job') @mock.patch.object(scheduling.BackgroundSchedulerService, 'remove_job')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job') @mock.patch.object(scheduling.BackgroundSchedulerService, 'add_job')
@mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs') @mock.patch.object(scheduling.BackgroundSchedulerService, 'get_jobs')

View File

@@ -40,17 +40,25 @@ class TestGnocchiHelper(base.BaseTestCase):
self.addCleanup(stat_agg_patcher.stop) self.addCleanup(stat_agg_patcher.stop)
def test_gnocchi_statistic_aggregation(self, mock_gnocchi): def test_gnocchi_statistic_aggregation(self, mock_gnocchi):
vcpus = 2
mock_instance = mock.Mock(
id='16a86790-327a-45f9-bc82-45839f062fdc',
vcpus=vcpus)
gnocchi = mock.MagicMock() gnocchi = mock.MagicMock()
# cpu time rate of change (ns)
mock_rate_measure = 360 * 10e+8 * vcpus * 5.5 / 100
expected_result = 5.5 expected_result = 5.5
expected_measures = [["2017-02-02T09:00:00.000000", 360, 5.5]] expected_measures = [
["2017-02-02T09:00:00.000000", 360, mock_rate_measure]]
gnocchi.metric.get_measures.return_value = expected_measures gnocchi.metric.get_measures.return_value = expected_measures
mock_gnocchi.return_value = gnocchi mock_gnocchi.return_value = gnocchi
helper = gnocchi_helper.GnocchiHelper() helper = gnocchi_helper.GnocchiHelper()
result = helper.statistic_aggregation( result = helper.statistic_aggregation(
resource=mock.Mock(id='16a86790-327a-45f9-bc82-45839f062fdc'), resource=mock_instance,
resource_type='instance', resource_type='instance',
meter_name='instance_cpu_usage', meter_name='instance_cpu_usage',
period=300, period=300,
@@ -59,6 +67,14 @@ class TestGnocchiHelper(base.BaseTestCase):
) )
self.assertEqual(expected_result, result) self.assertEqual(expected_result, result)
gnocchi.metric.get_measures.assert_called_once_with(
metric="cpu",
start=mock.ANY,
stop=mock.ANY,
resource_id=mock_instance.uuid,
granularity=360,
aggregation="rate:mean")
def test_gnocchi_statistic_series(self, mock_gnocchi): def test_gnocchi_statistic_series(self, mock_gnocchi):
gnocchi = mock.MagicMock() gnocchi = mock.MagicMock()
expected_result = { expected_result = {

View File

@@ -0,0 +1,47 @@
# Copyright (c) 2023 Cloudbase Solutions
#
# 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.
from unittest import mock
import uuid
from watcher.common.metal_helper import constants as m_constants
def get_mock_metal_node(node_id=None,
power_state=m_constants.PowerState.ON,
running_vms=0,
hostname=None,
compute_state='up'):
node_id = node_id or str(uuid.uuid4())
# NOTE(lpetrut): the hostname is important for some of the tests,
# which expect it to match the fake cluster model.
hostname = hostname or "compute-" + str(uuid.uuid4()).split('-')[0]
hypervisor_node_dict = {
'hypervisor_hostname': hostname,
'running_vms': running_vms,
'service': {
'host': hostname,
},
'state': compute_state,
}
hypervisor_node = mock.Mock(**hypervisor_node_dict)
hypervisor_node.to_dict.return_value = hypervisor_node_dict
node = mock.Mock()
node.get_power_state.return_value = power_state
node.get_id.return_value = uuid
node.get_hypervisor_node.return_value = hypervisor_node
return node

View File

@@ -80,7 +80,7 @@ class FakerModelCollector(base.BaseClusterDataModelCollector):
return self.load_model('scenario_4_with_metrics.xml') return self.load_model('scenario_4_with_metrics.xml')
class FakeCeilometerMetrics(object): class FakeGnocchiMetrics(object):
def __init__(self, model): def __init__(self, model):
self.model = model self.model = model
@@ -90,6 +90,9 @@ class FakeCeilometerMetrics(object):
if meter_name == 'host_cpu_usage': if meter_name == 'host_cpu_usage':
return self.get_compute_node_cpu_util( return self.get_compute_node_cpu_util(
resource, period, aggregate, granularity) resource, period, aggregate, granularity)
elif meter_name == 'host_ram_usage':
return self.get_compute_node_ram_util(
resource, period, aggregate, granularity)
elif meter_name == 'instance_cpu_usage': elif meter_name == 'instance_cpu_usage':
return self.get_instance_cpu_util( return self.get_instance_cpu_util(
resource, period, aggregate, granularity) resource, period, aggregate, granularity)
@@ -110,109 +113,27 @@ class FakeCeilometerMetrics(object):
Returns relative node CPU utilization <0, 100>. Returns relative node CPU utilization <0, 100>.
:param r_id: resource id :param r_id: resource id
""" """
node_uuid = '%s_%s' % (resource.uuid, resource.hostname) node = self.model.get_node_by_uuid(resource.uuid)
node = self.model.get_node_by_uuid(node_uuid)
instances = self.model.get_node_instances(node) instances = self.model.get_node_instances(node)
util_sum = 0.0 util_sum = 0.0
for instance_uuid in instances: for instance in instances:
instance = self.model.get_instance_by_uuid(instance_uuid)
total_cpu_util = instance.vcpus * self.get_instance_cpu_util( total_cpu_util = instance.vcpus * self.get_instance_cpu_util(
instance.uuid) instance, period, aggregate, granularity)
util_sum += total_cpu_util / 100.0 util_sum += total_cpu_util / 100.0
util_sum /= node.vcpus util_sum /= node.vcpus
return util_sum * 100.0 return util_sum * 100.0
@staticmethod def get_compute_node_ram_util(self, resource, period, aggregate,
def get_instance_cpu_util(resource, period, aggregate,
granularity):
instance_cpu_util = dict()
instance_cpu_util['INSTANCE_0'] = 10
instance_cpu_util['INSTANCE_1'] = 30
instance_cpu_util['INSTANCE_2'] = 60
instance_cpu_util['INSTANCE_3'] = 20
instance_cpu_util['INSTANCE_4'] = 40
instance_cpu_util['INSTANCE_5'] = 50
instance_cpu_util['INSTANCE_6'] = 100
instance_cpu_util['INSTANCE_7'] = 100
instance_cpu_util['INSTANCE_8'] = 100
instance_cpu_util['INSTANCE_9'] = 100
return instance_cpu_util[str(resource.uuid)]
@staticmethod
def get_instance_ram_util(resource, period, aggregate,
granularity):
instance_ram_util = dict()
instance_ram_util['INSTANCE_0'] = 1
instance_ram_util['INSTANCE_1'] = 2
instance_ram_util['INSTANCE_2'] = 4
instance_ram_util['INSTANCE_3'] = 8
instance_ram_util['INSTANCE_4'] = 3
instance_ram_util['INSTANCE_5'] = 2
instance_ram_util['INSTANCE_6'] = 1
instance_ram_util['INSTANCE_7'] = 2
instance_ram_util['INSTANCE_8'] = 4
instance_ram_util['INSTANCE_9'] = 8
return instance_ram_util[str(resource.uuid)]
@staticmethod
def get_instance_disk_root_size(resource, period, aggregate,
granularity):
instance_disk_util = dict()
instance_disk_util['INSTANCE_0'] = 10
instance_disk_util['INSTANCE_1'] = 15
instance_disk_util['INSTANCE_2'] = 30
instance_disk_util['INSTANCE_3'] = 35
instance_disk_util['INSTANCE_4'] = 20
instance_disk_util['INSTANCE_5'] = 25
instance_disk_util['INSTANCE_6'] = 25
instance_disk_util['INSTANCE_7'] = 25
instance_disk_util['INSTANCE_8'] = 25
instance_disk_util['INSTANCE_9'] = 25
return instance_disk_util[str(resource.uuid)]
class FakeGnocchiMetrics(object):
def __init__(self, model):
self.model = model
def mock_get_statistics(self, resource=None, resource_type=None,
meter_name=None, period=300, aggregate='mean',
granularity=300):
if meter_name == 'host_cpu_usage':
return self.get_compute_node_cpu_util(
resource, period, aggregate, granularity)
elif meter_name == 'instance_cpu_usage':
return self.get_instance_cpu_util(
resource, period, aggregate, granularity)
elif meter_name == 'instance_ram_usage':
return self.get_instance_ram_util(
resource, period, aggregate, granularity)
elif meter_name == 'instance_root_disk_size':
return self.get_instance_disk_root_size(
resource, period, aggregate, granularity)
def get_compute_node_cpu_util(self, resource, period, aggregate,
granularity): granularity):
"""Calculates node utilization dynamicaly. # Returns mock host ram usage in KB based on the allocated
# instances.
node CPU utilization should consider node = self.model.get_node_by_uuid(resource.uuid)
and corelate with actual instance-node mappings
provided within a cluster model.
Returns relative node CPU utilization <0, 100>.
:param r_id: resource id
"""
node_uuid = "%s_%s" % (resource.uuid, resource.hostname)
node = self.model.get_node_by_uuid(node_uuid)
instances = self.model.get_node_instances(node) instances = self.model.get_node_instances(node)
util_sum = 0.0 util_sum = 0.0
for instance_uuid in instances: for instance in instances:
instance = self.model.get_instance_by_uuid(instance_uuid) util_sum += self.get_instance_ram_util(
total_cpu_util = instance.vcpus * self.get_instance_cpu_util( instance, period, aggregate, granularity)
instance.uuid) return util_sum / 1024
util_sum += total_cpu_util / 100.0
util_sum /= node.vcpus
return util_sum * 100.0
@staticmethod @staticmethod
def get_instance_cpu_util(resource, period, aggregate, def get_instance_cpu_util(resource, period, aggregate,
@@ -261,3 +182,9 @@ class FakeGnocchiMetrics(object):
instance_disk_util['INSTANCE_8'] = 25 instance_disk_util['INSTANCE_8'] = 25
instance_disk_util['INSTANCE_9'] = 25 instance_disk_util['INSTANCE_9'] = 25
return instance_disk_util[str(resource.uuid)] return instance_disk_util[str(resource.uuid)]
# TODO(lpetrut): consider dropping Ceilometer support, it was deprecated
# in Ocata.
class FakeCeilometerMetrics(FakeGnocchiMetrics):
pass

View File

@@ -18,8 +18,10 @@
from unittest import mock from unittest import mock
from watcher.common import clients from watcher.common import clients
from watcher.common.metal_helper import constants as m_constants
from watcher.common import utils from watcher.common import utils
from watcher.decision_engine.strategy import strategies from watcher.decision_engine.strategy import strategies
from watcher.tests.decision_engine import fake_metal_helper
from watcher.tests.decision_engine.strategy.strategies.test_base \ from watcher.tests.decision_engine.strategy.strategies.test_base \
import TestBaseStrategy import TestBaseStrategy
@@ -29,26 +31,15 @@ class TestSavingEnergy(TestBaseStrategy):
def setUp(self): def setUp(self):
super(TestSavingEnergy, self).setUp() super(TestSavingEnergy, self).setUp()
mock_node1_dict = { self.fake_nodes = [fake_metal_helper.get_mock_metal_node(),
'uuid': '922d4762-0bc5-4b30-9cb9-48ab644dd861'} fake_metal_helper.get_mock_metal_node()]
mock_node2_dict = { self._metal_helper = mock.Mock()
'uuid': '922d4762-0bc5-4b30-9cb9-48ab644dd862'} self._metal_helper.list_compute_nodes.return_value = self.fake_nodes
mock_node1 = mock.Mock(**mock_node1_dict)
mock_node2 = mock.Mock(**mock_node2_dict)
self.fake_nodes = [mock_node1, mock_node2]
p_ironic = mock.patch.object( p_nova = mock.patch.object(clients.OpenStackClients, 'nova')
clients.OpenStackClients, 'ironic')
self.m_ironic = p_ironic.start()
self.addCleanup(p_ironic.stop)
p_nova = mock.patch.object(
clients.OpenStackClients, 'nova')
self.m_nova = p_nova.start() self.m_nova = p_nova.start()
self.addCleanup(p_nova.stop) self.addCleanup(p_nova.stop)
self.m_ironic.node.list.return_value = self.fake_nodes
self.m_c_model.return_value = self.fake_c_cluster.generate_scenario_1() self.m_c_model.return_value = self.fake_c_cluster.generate_scenario_1()
self.strategy = strategies.SavingEnergy( self.strategy = strategies.SavingEnergy(
@@ -59,27 +50,20 @@ class TestSavingEnergy(TestBaseStrategy):
'min_free_hosts_num': 1}) 'min_free_hosts_num': 1})
self.strategy.free_used_percent = 10.0 self.strategy.free_used_percent = 10.0
self.strategy.min_free_hosts_num = 1 self.strategy.min_free_hosts_num = 1
self.strategy._ironic_client = self.m_ironic self.strategy._metal_helper = self._metal_helper
self.strategy._nova_client = self.m_nova self.strategy._nova_client = self.m_nova
def test_get_hosts_pool_with_vms_node_pool(self): def test_get_hosts_pool_with_vms_node_pool(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power on'} power_state=m_constants.PowerState.ON,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=2),
'power_state': 'power off'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.OFF,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_1',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=2),
]
mock_hyper1 = mock.Mock()
mock_hyper2 = mock.Mock()
mock_hyper1.to_dict.return_value = {
'running_vms': 2, 'service': {'host': 'hostname_0'}, 'state': 'up'}
mock_hyper2.to_dict.return_value = {
'running_vms': 2, 'service': {'host': 'hostname_1'}, 'state': 'up'}
self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2]
self.strategy.get_hosts_pool() self.strategy.get_hosts_pool()
@@ -88,23 +72,16 @@ class TestSavingEnergy(TestBaseStrategy):
self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0) self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0)
def test_get_hosts_pool_free_poweron_node_pool(self): def test_get_hosts_pool_free_poweron_node_pool(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power on'} power_state=m_constants.PowerState.ON,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=0),
'power_state': 'power on'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.ON,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_1',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=0),
]
mock_hyper1 = mock.Mock()
mock_hyper2 = mock.Mock()
mock_hyper1.to_dict.return_value = {
'running_vms': 0, 'service': {'host': 'hostname_0'}, 'state': 'up'}
mock_hyper2.to_dict.return_value = {
'running_vms': 0, 'service': {'host': 'hostname_1'}, 'state': 'up'}
self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2]
self.strategy.get_hosts_pool() self.strategy.get_hosts_pool()
@@ -113,23 +90,16 @@ class TestSavingEnergy(TestBaseStrategy):
self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0) self.assertEqual(len(self.strategy.free_poweroff_node_pool), 0)
def test_get_hosts_pool_free_poweroff_node_pool(self): def test_get_hosts_pool_free_poweroff_node_pool(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power off'} power_state=m_constants.PowerState.OFF,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=0),
'power_state': 'power off'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.OFF,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_1',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=0),
]
mock_hyper1 = mock.Mock()
mock_hyper2 = mock.Mock()
mock_hyper1.to_dict.return_value = {
'running_vms': 0, 'service': {'host': 'hostname_0'}, 'state': 'up'}
mock_hyper2.to_dict.return_value = {
'running_vms': 0, 'service': {'host': 'hostname_1'}, 'state': 'up'}
self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2]
self.strategy.get_hosts_pool() self.strategy.get_hosts_pool()
@@ -138,26 +108,16 @@ class TestSavingEnergy(TestBaseStrategy):
self.assertEqual(len(self.strategy.free_poweroff_node_pool), 2) self.assertEqual(len(self.strategy.free_poweroff_node_pool), 2)
def test_get_hosts_pool_with_node_out_model(self): def test_get_hosts_pool_with_node_out_model(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power off'} power_state=m_constants.PowerState.OFF,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=0),
'power_state': 'power off'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.OFF,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_10',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=0),
]
mock_hyper1 = mock.Mock()
mock_hyper2 = mock.Mock()
mock_hyper1.to_dict.return_value = {
'running_vms': 0, 'service': {'host': 'hostname_0'},
'state': 'up'}
mock_hyper2.to_dict.return_value = {
'running_vms': 0, 'service': {'host': 'hostname_10'},
'state': 'up'}
self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2]
self.strategy.get_hosts_pool() self.strategy.get_hosts_pool()
self.assertEqual(len(self.strategy.with_vms_node_pool), 0) self.assertEqual(len(self.strategy.with_vms_node_pool), 0)
@@ -166,9 +126,9 @@ class TestSavingEnergy(TestBaseStrategy):
def test_save_energy_poweron(self): def test_save_energy_poweron(self):
self.strategy.free_poweroff_node_pool = [ self.strategy.free_poweroff_node_pool = [
mock.Mock(uuid='922d4762-0bc5-4b30-9cb9-48ab644dd861'), fake_metal_helper.get_mock_metal_node(),
mock.Mock(uuid='922d4762-0bc5-4b30-9cb9-48ab644dd862') fake_metal_helper.get_mock_metal_node(),
] ]
self.strategy.save_energy() self.strategy.save_energy()
self.assertEqual(len(self.strategy.solution.actions), 1) self.assertEqual(len(self.strategy.solution.actions), 1)
action = self.strategy.solution.actions[0] action = self.strategy.solution.actions[0]
@@ -185,23 +145,16 @@ class TestSavingEnergy(TestBaseStrategy):
self.assertEqual(action.get('input_parameters').get('state'), 'off') self.assertEqual(action.get('input_parameters').get('state'), 'off')
def test_execute(self): def test_execute(self):
mock_node1_dict = { self._metal_helper.list_compute_nodes.return_value = [
'extra': {'compute_node_id': 1}, fake_metal_helper.get_mock_metal_node(
'power_state': 'power on'} power_state=m_constants.PowerState.ON,
mock_node2_dict = { hostname='hostname_0',
'extra': {'compute_node_id': 2}, running_vms=0),
'power_state': 'power on'} fake_metal_helper.get_mock_metal_node(
mock_node1 = mock.Mock(**mock_node1_dict) power_state=m_constants.PowerState.ON,
mock_node2 = mock.Mock(**mock_node2_dict) hostname='hostname_1',
self.m_ironic.node.get.side_effect = [mock_node1, mock_node2] running_vms=0),
]
mock_hyper1 = mock.Mock()
mock_hyper2 = mock.Mock()
mock_hyper1.to_dict.return_value = {
'running_vms': 0, 'service': {'host': 'hostname_0'}, 'state': 'up'}
mock_hyper2.to_dict.return_value = {
'running_vms': 0, 'service': {'host': 'hostname_1'}, 'state': 'up'}
self.m_nova.hypervisors.get.side_effect = [mock_hyper1, mock_hyper2]
model = self.fake_c_cluster.generate_scenario_1() model = self.fake_c_cluster.generate_scenario_1()
self.m_c_model.return_value = model self.m_c_model.return_value = model

View File

@@ -64,6 +64,10 @@ class TestVMWorkloadConsolidation(TestBaseStrategy):
self.fake_metrics.get_instance_ram_util), self.fake_metrics.get_instance_ram_util),
get_instance_root_disk_size=( get_instance_root_disk_size=(
self.fake_metrics.get_instance_disk_root_size), self.fake_metrics.get_instance_disk_root_size),
get_host_cpu_usage=(
self.fake_metrics.get_compute_node_cpu_util),
get_host_ram_usage=(
self.fake_metrics.get_compute_node_ram_util)
) )
self.strategy = strategies.VMWorkloadConsolidation( self.strategy = strategies.VMWorkloadConsolidation(
config=mock.Mock(datasources=self.datasource)) config=mock.Mock(datasources=self.datasource))
@@ -88,6 +92,71 @@ class TestVMWorkloadConsolidation(TestBaseStrategy):
node_util, node_util,
self.strategy.get_node_utilization(node_0)) self.strategy.get_node_utilization(node_0))
def test_get_node_utilization_using_host_metrics(self):
model = self.fake_c_cluster.generate_scenario_1()
self.m_c_model.return_value = model
self.fake_metrics.model = model
node_0 = model.get_node_by_uuid("Node_0")
# "get_node_utilization" is expected to return the maximum
# between the host metrics and the sum of the instance metrics.
data_src = self.m_datasource.return_value
cpu_usage = 30
data_src.get_host_cpu_usage = mock.Mock(return_value=cpu_usage)
data_src.get_host_ram_usage = mock.Mock(return_value=512 * 1024)
exp_cpu_usage = cpu_usage * node_0.vcpus / 100
exp_node_util = dict(cpu=exp_cpu_usage, ram=512, disk=10)
self.assertEqual(
exp_node_util,
self.strategy.get_node_utilization(node_0))
def test_get_node_utilization_after_migrations(self):
model = self.fake_c_cluster.generate_scenario_1()
self.m_c_model.return_value = model
self.fake_metrics.model = model
node_0 = model.get_node_by_uuid("Node_0")
node_1 = model.get_node_by_uuid("Node_1")
data_src = self.m_datasource.return_value
cpu_usage = 30
host_ram_usage_mb = 512
data_src.get_host_cpu_usage = mock.Mock(return_value=cpu_usage)
data_src.get_host_ram_usage = mock.Mock(
return_value=host_ram_usage_mb * 1024)
instance_uuid = 'INSTANCE_0'
instance = model.get_instance_by_uuid(instance_uuid)
self.strategy.add_migration(instance, node_0, node_1)
instance_util = self.strategy.get_instance_utilization(instance)
# Ensure that we take into account planned migrations when
# determining node utilization
exp_node_0_cpu_usage = (
cpu_usage * node_0.vcpus) / 100 - instance_util['cpu']
exp_node_1_cpu_usage = (
cpu_usage * node_1.vcpus) / 100 + instance_util['cpu']
exp_node_0_ram_usage = host_ram_usage_mb - instance.memory
exp_node_1_ram_usage = host_ram_usage_mb + instance.memory
exp_node_0_util = dict(
cpu=exp_node_0_cpu_usage,
ram=exp_node_0_ram_usage,
disk=0)
exp_node_1_util = dict(
cpu=exp_node_1_cpu_usage,
ram=exp_node_1_ram_usage,
disk=25)
self.assertEqual(
exp_node_0_util,
self.strategy.get_node_utilization(node_0))
self.assertEqual(
exp_node_1_util,
self.strategy.get_node_utilization(node_1))
def test_get_node_capacity(self): def test_get_node_capacity(self):
model = self.fake_c_cluster.generate_scenario_1() model = self.fake_c_cluster.generate_scenario_1()
self.m_c_model.return_value = model self.m_c_model.return_value = model
@@ -113,7 +182,8 @@ class TestVMWorkloadConsolidation(TestBaseStrategy):
expected_cru = {'cpu': 0.05, 'disk': 0.05, 'ram': 0.0234375} expected_cru = {'cpu': 0.05, 'disk': 0.05, 'ram': 0.0234375}
self.assertEqual(expected_cru, cru) self.assertEqual(expected_cru, cru)
def test_add_migration_with_active_state(self): def _test_add_migration(self, instance_state, expect_migration=True,
expected_migration_type="live"):
model = self.fake_c_cluster.generate_scenario_1() model = self.fake_c_cluster.generate_scenario_1()
self.m_c_model.return_value = model self.m_c_model.return_value = model
self.fake_metrics.model = model self.fake_metrics.model = model
@@ -121,38 +191,36 @@ class TestVMWorkloadConsolidation(TestBaseStrategy):
n2 = model.get_node_by_uuid('Node_1') n2 = model.get_node_by_uuid('Node_1')
instance_uuid = 'INSTANCE_0' instance_uuid = 'INSTANCE_0'
instance = model.get_instance_by_uuid(instance_uuid) instance = model.get_instance_by_uuid(instance_uuid)
instance.state = instance_state
self.strategy.add_migration(instance, n1, n2) self.strategy.add_migration(instance, n1, n2)
self.assertEqual(1, len(self.strategy.solution.actions))
expected = {'action_type': 'migrate', if expect_migration:
'input_parameters': {'destination_node': n2.hostname, self.assertEqual(1, len(self.strategy.solution.actions))
'source_node': n1.hostname,
'migration_type': 'live', expected = {'action_type': 'migrate',
'resource_id': instance.uuid, 'input_parameters': {
'resource_name': instance.name}} 'destination_node': n2.hostname,
self.assertEqual(expected, self.strategy.solution.actions[0]) 'source_node': n1.hostname,
'migration_type': expected_migration_type,
'resource_id': instance.uuid,
'resource_name': instance.name}}
self.assertEqual(expected, self.strategy.solution.actions[0])
else:
self.assertEqual(0, len(self.strategy.solution.actions))
def test_add_migration_with_active_state(self):
self._test_add_migration(element.InstanceState.ACTIVE.value)
def test_add_migration_with_paused_state(self): def test_add_migration_with_paused_state(self):
model = self.fake_c_cluster.generate_scenario_1() self._test_add_migration(element.InstanceState.PAUSED.value)
self.m_c_model.return_value = model
self.fake_metrics.model = model
n1 = model.get_node_by_uuid('Node_0')
n2 = model.get_node_by_uuid('Node_1')
instance_uuid = 'INSTANCE_0'
instance = model.get_instance_by_uuid(instance_uuid)
setattr(instance, 'state', element.InstanceState.ERROR.value)
self.strategy.add_migration(instance, n1, n2)
self.assertEqual(0, len(self.strategy.solution.actions))
setattr(instance, 'state', element.InstanceState.PAUSED.value) def test_add_migration_with_error_state(self):
self.strategy.add_migration(instance, n1, n2) self._test_add_migration(element.InstanceState.ERROR.value,
self.assertEqual(1, len(self.strategy.solution.actions)) expect_migration=False)
expected = {'action_type': 'migrate',
'input_parameters': {'destination_node': n2.hostname, def test_add_migration_with_stopped_state(self):
'source_node': n1.hostname, self._test_add_migration(element.InstanceState.STOPPED.value,
'migration_type': 'live', expected_migration_type="cold")
'resource_id': instance.uuid,
'resource_name': instance.name}}
self.assertEqual(expected, self.strategy.solution.actions[0])
def test_is_overloaded(self): def test_is_overloaded(self):
model = self.fake_c_cluster.generate_scenario_1() model = self.fake_c_cluster.generate_scenario_1()

View File

@@ -320,7 +320,10 @@ class TestZoneMigration(TestBaseStrategy):
migration_types = collections.Counter( migration_types = collections.Counter(
[action.get('input_parameters')['migration_type'] [action.get('input_parameters')['migration_type']
for action in solution.actions]) for action in solution.actions])
self.assertEqual(1, migration_types.get("swap", 0)) # watcher no longer implements swap. it is now an
# alias for migrate.
self.assertEqual(0, migration_types.get("swap", 0))
self.assertEqual(1, migration_types.get("migrate", 1))
global_efficacy_value = solution.global_efficacy[3].get('value', 0) global_efficacy_value = solution.global_efficacy[3].get('value', 0)
self.assertEqual(100, global_efficacy_value) self.assertEqual(100, global_efficacy_value)

View File

@@ -531,6 +531,7 @@ class TestRegistry(test_base.TestCase):
@mock.patch('watcher.objects.base.objects') @mock.patch('watcher.objects.base.objects')
def test_hook_chooses_newer_properly(self, mock_objects): def test_hook_chooses_newer_properly(self, mock_objects):
mock_objects.MyObj.VERSION = MyObj.VERSION
reg = base.WatcherObjectRegistry() reg = base.WatcherObjectRegistry()
reg.registration_hook(MyObj, 0) reg.registration_hook(MyObj, 0)
@@ -547,6 +548,7 @@ class TestRegistry(test_base.TestCase):
@mock.patch('watcher.objects.base.objects') @mock.patch('watcher.objects.base.objects')
def test_hook_keeps_newer_properly(self, mock_objects): def test_hook_keeps_newer_properly(self, mock_objects):
mock_objects.MyObj.VERSION = MyObj.VERSION
reg = base.WatcherObjectRegistry() reg = base.WatcherObjectRegistry()
reg.registration_hook(MyObj, 0) reg.registration_hook(MyObj, 0)