Add tempest-k8s charm

Add a charm for running tempest against a sunbeam openstack deployment.
It has support for running a "validate" action with parameters
passed to tempest.
Further development is planned, including periodic (cron based) tempest
checks and integration with COS.
Initial stubs are included to support this development.

Co-authored-by: Samuel Allan <samuel.allan@canonical.com>

Change-Id: Ie66177489ae38355a8f0025006192cc409e0e761
This commit is contained in:
Chi Wai Chan 2023-12-12 18:04:32 +08:00 committed by Samuel Allan
parent a4582d130a
commit ce20965836
22 changed files with 4400 additions and 1 deletions

View File

@ -0,0 +1,25 @@
# Contributing
To make contributions to this charm, you'll need a working [development setup](https://juju.is/docs/sdk/dev-setup).
## Testing and Development
This project uses `tox` for managing test environments. There are some pre-configured environments
that can be used for linting and formatting code when you're preparing contributions to the charm.
Please see the tox.ini file in the root of this repository.
For example:
```
tox -e fmt
tox -e pep8
tox -e cover
```
## Build the charm
Change to the root of this repository and run:
```
tox -e build -- tempest-k8s
```

202
charms/tempest-k8s/LICENSE Normal file
View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2023 Chi Wai CHAN
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.

View File

@ -0,0 +1,20 @@
<!--
Avoid using this README file for information that is maintained or published elsewhere, e.g.:
* metadata.yaml > published on Charmhub
* documentation > published on (or linked to from) Charmhub
* detailed contribution guide > documentation or CONTRIBUTING.md
Use links instead.
-->
# tempest-k8s
Tempest provides a set of integration tests to be run, in ad-hoc
or periodic fasion, against a live OpenStack cluster for OpenStack API
validation, scenarios, and other specific tests useful in validating an
OpenStack deployment.
## Other resources
- [tempest-k8s](https://charmhub.io/tempest-k8s) on Charmhub

View File

@ -0,0 +1,126 @@
type: "charm"
bases:
- build-on:
- name: "ubuntu"
channel: "22.04"
run-on:
- name: "ubuntu"
channel: "22.04"
parts:
update-certificates:
plugin: nil
override-build: |
apt update
apt install -y ca-certificates
update-ca-certificates
charm:
after: [update-certificates]
build-packages:
- git
- libffi-dev
- libssl-dev
- rustc
- cargo
- pkg-config
charm-binary-python-packages:
- cryptography
- jsonschema
- pydantic<2.0
- jinja2
name: tempest-k8s
summary: OpenStack integration test suite (tempest)
description: |
Tempest provides a set of integration tests to be run, in ad-hoc
or periodic fasion, against a live OpenStack cluster for OpenStack API
validation, scenarios, and other specific tests useful in validating an
OpenStack deployment.
assumes:
- k8s-api
- juju >= 3.1
links:
source: https://opendev.org/openstack/sunbeam-charms
issues: https://bugs.launchpad.net/sunbeam-charms
containers:
tempest:
resource: tempest-image
resources:
tempest-image:
type: oci-image
description: OCI image for tempest
# ghcr.io/canonical/tempest:2023.2
upstream-source: ghcr.io/canonical/tempest:2023.2
requires:
identity-ops:
interface: keystone-resources
logging:
interface: loki_push_api
provides:
grafana-dashboard:
interface: grafana_dashboard
peers:
peers:
interface: tempest-peer
config:
options:
schedule:
type: string
default: "off"
description: |
The cron-like schedule to define when to run tempest. When the value is
"off" (case-insensitive), then period checks will be disabled. The
default is to turn off period checks.
actions:
validate:
description: |
Run a set of tempest tests.
Tests can be filtered using parameters: regex, exclude-regex, and test-list.
These parameters are optional; if none are given, all tests will run.
Provided parameters narrow down the tests that will run.
For example, `regex="one two" exclude-regex=three test-list=list1`,
will run tests that are:
- found in test list "list1"
- AND match regex "one" or "two"
- AND don't match regex "three"
params:
regex:
type: string
default: ""
description: |
A list of regexes, whitespace separated, used to select tests from the list.
Tests matching any of the regexes will be selected.
If no value provided (the default), all tests will be selected.
To run the equivalent of tempest smoke tests (`tempest run --smoke`),
use `regex=smoke`.
exclude-regex:
type: string
default: ""
description: |
A single regex to exclude tests.
Any test that matches this regex will be excluded from the final list.
serial:
type: boolean
default: false
description: Run tests serially. By default, tests run in parallel.
test-list:
type: string
default: ""
description: |
Use a predefined test list. See `get-lists` for available test lists.
get-lists:
description: List existing test lists, to be used with validate action.

View File

@ -0,0 +1,3 @@
# This file is used to trigger a build.
# Change uuid to trigger a new build.
c3b9c7c9-2bd4-4df1-a1df-89c729b34eb6

View File

@ -0,0 +1,9 @@
ops
jinja2
lightkube
lightkube-models
# COS requirement
cosl
# From ops_sunbeam
tenacity

231
charms/tempest-k8s/src/charm.py Executable file
View File

@ -0,0 +1,231 @@
#!/usr/bin/env python3
#
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tempest Operator Charm.
This charm provide Tempest as part of an OpenStack deployment
"""
import logging
from typing import (
Dict,
List,
)
import ops
import ops.charm
import ops.pebble
import ops_sunbeam.charm as sunbeam_charm
import ops_sunbeam.container_handlers as sunbeam_chandlers
import ops_sunbeam.core as sunbeam_core
import ops_sunbeam.relation_handlers as sunbeam_rhandlers
from handlers import (
GrafanaDashboardRelationHandler,
LoggingRelationHandler,
TempestPebbleHandler,
TempestUserIdentityRelationHandler,
)
from ops.main import (
main,
)
from ops.model import (
ActiveStatus,
BlockedStatus,
MaintenanceStatus,
)
from utils.constants import (
CONTAINER,
TEMPEST_CONCURRENCY,
TEMPEST_CONF,
TEMPEST_HOME,
TEMPEST_LIST_DIR,
TEMPEST_OUTPUT,
TEMPEST_TEST_ACCOUNTS,
TEMPEST_WORKSPACE,
TEMPEST_WORKSPACE_PATH,
)
logger = logging.getLogger(__name__)
class TempestOperatorCharm(sunbeam_charm.OSBaseOperatorCharmK8S):
"""Charm the service."""
_state = ops.framework.StoredState()
service_name = "tempest"
mandatory_relations = {"identity-ops"}
def __init__(self, framework: ops.framework.Framework) -> None:
"""Run the constructor."""
# config for openstack, used by tempest
super().__init__(framework)
self.framework.observe(
self.on.validate_action, self._on_validate_action
)
self.framework.observe(
self.on.get_lists_action, self._on_get_lists_action
)
@property
def container_configs(self) -> List[sunbeam_core.ContainerConfigFile]:
"""Container configuration files for the operator."""
return [
# crontab is owned and run by root
sunbeam_core.ContainerConfigFile("/etc/crontab", "root", "root"),
# Only give exec access to root and tempest user
# for these wrappers, simply for principle of least privilege.
sunbeam_core.ContainerConfigFile(
"/usr/local/sbin/tempest-run-wrapper",
"root",
"tempest",
0o750,
),
sunbeam_core.ContainerConfigFile(
"/usr/local/sbin/tempest-init",
"root",
"tempest",
0o750,
),
]
def get_pebble_handlers(self) -> List[sunbeam_chandlers.PebbleHandler]:
"""Pebble handlers for operator."""
return [
TempestPebbleHandler(
self,
CONTAINER,
self.service_name,
self.container_configs,
self.template_dir,
self.configure_charm,
)
]
def get_relation_handlers(self) -> List[sunbeam_rhandlers.RelationHandler]:
"""Relation handlers for the service."""
handlers = super().get_relation_handlers()
self.user_id_ops = TempestUserIdentityRelationHandler(
self,
"identity-ops",
self.configure_charm,
mandatory="identity-ops" in self.mandatory_relations,
)
handlers.append(self.user_id_ops)
self.loki = LoggingRelationHandler(
self,
"logging",
self.configure_charm,
mandatory="logging" in self.mandatory_relations,
)
handlers.append(self.loki)
self.grafana = GrafanaDashboardRelationHandler(
self,
"grafana-dashboard",
self.configure_charm,
mandatory="grafana-dashboard" in self.mandatory_relations,
)
handlers.append(self.grafana)
return handlers
def _get_environment_for_tempest(self) -> Dict[str, str]:
"""Return a dictionary of environment variables.
To be used with pebble commands that run tempest discover, etc.
"""
logger.debug("Retrieving OpenStack credentials")
credential = self.user_id_ops.get_user_credential()
return {
"OS_REGION_NAME": "RegionOne",
"OS_IDENTITY_API_VERSION": "3",
"OS_AUTH_VERSION": "3",
"OS_AUTH_URL": credential.get("auth-url"),
"OS_USERNAME": credential.get("username"),
"OS_PASSWORD": credential.get("password"),
"OS_USER_DOMAIN_NAME": credential.get("domain-name"),
"OS_PROJECT_NAME": credential.get("project-name"),
"OS_PROJECT_DOMAIN_NAME": credential.get("domain-name"),
"OS_DOMAIN_NAME": credential.get("domain-name"),
"TEMPEST_CONCURRENCY": TEMPEST_CONCURRENCY,
"TEMPEST_CONF": TEMPEST_CONF,
"TEMPEST_HOME": TEMPEST_HOME,
"TEMPEST_LIST_DIR": TEMPEST_LIST_DIR,
"TEMPEST_OUTPUT": TEMPEST_OUTPUT,
"TEMPEST_TEST_ACCOUNTS": TEMPEST_TEST_ACCOUNTS,
"TEMPEST_WORKSPACE": TEMPEST_WORKSPACE,
"TEMPEST_WORKSPACE_PATH": TEMPEST_WORKSPACE_PATH,
}
def post_config_setup(self) -> None:
"""Configuration steps after services have been setup.
NOTE: this will be improved in future to avoid running unnecessarily.
"""
logger.debug("Running post config setup")
self.status.set(MaintenanceStatus("tempest init in progress"))
pebble = self.pebble_handler()
logger.debug("Ready to init tempest environment")
env = self._get_environment_for_tempest()
try:
pebble.init_tempest(env)
except RuntimeError:
self.status.set(
BlockedStatus("tempest init failed, see logs for more info")
)
return
self.status.set(ActiveStatus(""))
logger.debug("Finish post config setup")
def pebble_handler(self) -> TempestPebbleHandler:
"""Get the pebble handler."""
return self.get_named_pebble_handler(CONTAINER)
def _on_validate_action(self, event: ops.charm.ActionEvent) -> None:
"""Run tempest action."""
serial: bool = event.params["serial"]
regexes: List[str] = event.params["regex"].strip().split()
exclude_regex: str = event.params["exclude-regex"].strip()
test_list: str = event.params["test-list"].strip()
env = self._get_environment_for_tempest()
try:
output = self.pebble_handler().run_tempest_tests(
regexes, exclude_regex, test_list, serial, env
)
except RuntimeError as e:
event.fail(str(e))
# still print the message,
# because it could be a lot of output from tempest,
# and we want it neatly formatted
print(e)
return
print(output)
def _on_get_lists_action(self, event: ops.charm.ActionEvent) -> None:
"""List tempest test lists action."""
try:
lists = self.pebble_handler().get_test_lists()
except RuntimeError as e:
event.fail(str(e))
return
# display neatly to the user. This will also end up in the action output results.stdout
print("\n".join(lists))
if __name__ == "__main__":
main(TempestOperatorCharm)

View File

@ -0,0 +1,519 @@
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Handers for the tempest charm."""
import hashlib
import json
import logging
import re
import secrets
import string
from functools import (
wraps,
)
from typing import (
Callable,
Dict,
FrozenSet,
List,
Optional,
)
import charms.grafana_k8s.v0.grafana_dashboard as grafana_dashboard
import charms.loki_k8s.v1.loki_push_api as loki_push_api
import ops
import ops.model
import ops.pebble
import ops_sunbeam.container_handlers as sunbeam_chandlers
import ops_sunbeam.relation_handlers as sunbeam_rhandlers
from utils.constants import (
OPENSTACK_DOMAIN,
OPENSTACK_PROJECT,
OPENSTACK_ROLE,
OPENSTACK_USER,
TEMPEST_HOME,
TEMPEST_LIST_DIR,
TEMPEST_OUTPUT,
)
logger = logging.getLogger(__name__)
def assert_ready(f):
"""Decorator for gating pebble handler methods for readiness.
Raise a runtime error if the pebble handler is not ready.
"""
@wraps(f)
def wrapper(self, *args, **kwargs):
if not self.pebble_ready:
raise RuntimeError("pebble is not ready")
return f(self, *args, **kwargs)
return wrapper
class TempestPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
"""Pebble handler for the container."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.container = self.charm.unit.get_container(self.container_name)
def get_layer(self) -> dict:
"""Pebble configuration layer for the container."""
return {
"summary": "Periodic cloud validation service",
"description": "Pebble config layer for periodic cloud validation job",
"services": {
# Note: cron service is started when the charm is ready,
# but the cronjobs will only be configured to run
# when the right conditions are met
# (eg. observability connected, configuration set to run).
self.service_name: {
"override": "replace",
"summary": "Running tempest periodically",
# Must run cron in foreground to be managed by pebble
"command": "cron -f",
"user": "root",
"group": "root",
"startup": "enabled",
},
},
}
@assert_ready
def get_test_lists(self) -> List[str]:
"""Get the filenames of available test lists."""
files = self.container.list_files(TEMPEST_LIST_DIR)
return [x.name for x in files]
@assert_ready
def init_tempest(self, env: Dict[str, str]):
"""Init the openstack environment for tempest.
Raise a RuntimeError if something goes wrong.
"""
# Pebble runs cron, which runs tempest periodically
# when periodic checks are enabled.
# This ensures that tempest gets the env, inherited from cron.
layer = self.get_layer()
layer["services"][self.service_name]["environment"] = env
self.container.add_layer(self.service_name, layer, combine=True)
try:
self.execute(
["tempest-init"],
user="tempest",
group="tempest",
working_dir=TEMPEST_HOME,
exception_on_error=True,
environment=env,
)
except ops.pebble.ExecError as e:
if e.stdout:
for line in e.stdout.splitlines():
logger.error(" %s", line)
raise RuntimeError("tempest init failed")
@assert_ready
def run_tempest_tests(
self,
regexes: List[str],
exclude_regex: str,
test_list: str,
serial: bool,
env: Dict[str, str],
) -> str:
"""Wrapper for running a set of tempest tests.
Return the output as a string.
Raises a RuntimeError if something goes wrong.
"""
# validation before running anything
for r in [*regexes, exclude_regex]:
try:
re.compile(r)
except re.error as e:
raise RuntimeError(f"{r!r} is an invalid regex: {e}")
if test_list and test_list not in self.get_test_lists():
raise RuntimeError(
f"'{test_list}' is not a known test list. "
"Please run list-tests action to view available lists."
)
# now build the command line for tempest
serial_args = ["--serial" if serial else "--parallel"]
regex_args = ["--regex", " ".join(regexes)] if regexes else []
exclude_regex_args = (
["--exclude-regex", exclude_regex] if exclude_regex else []
)
list_args = (
["--load-list", TEMPEST_LIST_DIR + "/" + test_list]
if test_list
else []
)
args = [
"tempest-run-wrapper",
*serial_args,
*regex_args,
*exclude_regex_args,
*list_args,
]
try:
output = self.execute(
args,
user="tempest",
group="tempest",
working_dir=TEMPEST_HOME,
exception_on_error=True,
environment=env,
)
except ops.pebble.ExecError as e:
if e.stdout:
output = f"{e.stdout}\n\n{e.stderr}"
else:
output = e.stderr
raise RuntimeError(output)
return output
class TempestUserIdentityRelationHandler(sunbeam_rhandlers.RelationHandler):
"""Relation handler for identity ops."""
CREDENTIALS_SECRET_PREFIX = "tempest-user-identity-resource-"
CONFIGURE_SECRET_PREFIX = "configure-credential-"
resource_identifiers: FrozenSet[str] = frozenset(
{
"name",
"domain",
"project",
}
)
def __init__(
self,
charm: ops.CharmBase,
relation_name: str,
callback_f: Callable,
mandatory: bool,
):
super().__init__(charm, relation_name, callback_f, mandatory)
self.charm = charm
@property
def ready(self) -> bool:
"""Whether the relation is ready."""
content = self.get_user_credential()
if content and content.get("auth-url") is not None:
return True
return False
@property
def label(self) -> str:
"""Secret label to share over keystone resource relation."""
return self.CREDENTIALS_SECRET_PREFIX + OPENSTACK_USER
def setup_event_handler(self) -> ops.Object:
"""Configure event handlers for the relation."""
import charms.keystone_k8s.v0.identity_resource as id_ops
logger.debug("Setting up Identity Resource event handler")
ops_svc = id_ops.IdentityResourceRequires(
self.charm,
self.relation_name,
)
self.framework.observe(
ops_svc.on.provider_ready,
self._on_provider_ready,
)
self.framework.observe(
ops_svc.on.provider_goneaway,
self._on_provider_goneaway,
)
self.framework.observe(
ops_svc.on.response_available,
self._on_response_available,
)
return ops_svc
def get_user_credential(self) -> Optional[dict]:
"""Retrieve the user credential."""
credentials_id = self.charm.leader_get(self.label)
if not credentials_id:
logger.warning("Failed to get openstack credential for tempest.")
return None
secret = self.model.get_secret(id=credentials_id)
return secret.get_content()
def _hash_ops(self, ops: list) -> str:
"""Hash ops request."""
return hashlib.sha256(json.dumps(ops).encode()).hexdigest()
def _ensure_credential(self) -> str:
"""Ensure the credential exists and return the secret id."""
credentials_id = self.charm.leader_get(self.label)
# If it exists and the credentials have already been set,
# simply return the id
if credentials_id:
secret = self.model.get_secret(id=credentials_id)
content = secret.get_content()
if "password" in content:
return credentials_id
# Otherwise, generate and save the credentials.
return self._set_secret(
{
"username": OPENSTACK_USER,
"password": self._generate_password(18),
"project-name": OPENSTACK_PROJECT,
"domain-name": OPENSTACK_DOMAIN,
},
)
def _set_secret(self, entries: Dict[str, str]) -> str:
"""Create or update a secret."""
credential_id = self.charm.leader_get(self.label)
# update secret if credential_id exists
if credential_id:
secret = self.model.get_secret(id=credential_id)
content = secret.get_content()
content.update(entries)
if content != secret.get_content():
secret.set_content(content)
return credential_id
# create new secret if credential_id does not exist
credential_secret = self.model.app.add_secret(
entries,
label=self.label,
)
self.charm.leader_set({self.label: credential_secret.id})
return credential_secret.id
def _generate_password(self, length: int) -> str:
"""Utility function to generate secure random string for password."""
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for i in range(length))
def _grant_ops_secret(self, relation: ops.Relation) -> None:
"""Grant ops secret."""
secret = self.model.get_secret(id=self._ensure_credential())
secret.grant(relation)
def _setup_tempest_resource_ops(self) -> List[dict]:
"""Set up openstack resource ops."""
credential_id = self._ensure_credential()
credential_secret = self.model.get_secret(id=credential_id)
content = credential_secret.get_content()
username = content.get("username")
password = content.get("password")
setup_ops = [
{
"name": "create_role",
"params": {
"name": OPENSTACK_ROLE,
},
},
{
"name": "create_domain",
"params": {
"name": OPENSTACK_DOMAIN,
"enable": True,
},
},
{
"name": "create_project",
"params": {
"name": OPENSTACK_PROJECT,
"domain": "{{ create_domain[0].id }}",
},
},
{
"name": "create_user",
"params": {
"name": username,
"password": password,
"domain": "{{ create_domain[0].id }}",
},
},
{
"name": "grant_role",
"params": {
"role": "{{ create_role[0].id }}",
"domain": "{{ create_domain[0].id }}",
"user": "{{ create_user[0].id }}",
"user_domain": "{{ create_domain[0].id }}",
},
},
{
"name": "grant_role",
"params": {
"role": "{{ create_role[0].id }}",
"user": "{{ create_user[0].id }}",
"user_domain": "{{ create_domain[0].id }}",
"project": "{{ create_project[0].id }}",
"project_domain": "{{ create_domain[0].id }}",
},
},
]
return setup_ops
def _list_endpoint_ops(self) -> List[dict]:
"""List endpoint ops."""
list_endpoint_ops = [
{
"name": "list_endpoint",
"params": {"name": "keystone", "interface": "admin"},
},
]
return list_endpoint_ops
def _teardown_tempest_resource_ops(self) -> List[dict]:
"""Tear down openstack resource ops."""
teardown_ops = [
{
"name": "show_domain",
"params": {
"name": OPENSTACK_DOMAIN,
},
},
{
"name": "update_domain",
"params": {
"domain": "{{ show_domain[0].id }}",
"enable": False,
},
},
{
"name": "delete_domain",
"params": {
"name": "{{ show_domain[0].id }}",
},
},
]
return teardown_ops
def _setup_tempest_resource_request(self) -> dict:
"""Set up openstack resource for tempest."""
ops = []
ops.extend(self._teardown_tempest_resource_ops())
ops.extend(self._setup_tempest_resource_ops())
ops.extend(self._list_endpoint_ops())
request = {
"id": self._hash_ops(ops),
"tag": "setup_tempest_resource",
"ops": ops,
}
return request
def _teardown_tempest_resource_request(self) -> dict:
"""Tear down openstack resources for tempest."""
ops = []
ops.extend(self._teardown_tempest_resource_ops())
request = {
"id": self._hash_ops(ops),
"tag": "teardown_tempest_resource",
"ops": ops,
}
return request
def _process_list_endpoint_response(self, response: dict) -> None:
"""Process extra ops request: `_list_endpoint_ops`."""
for op in response.get("ops", []):
if op.get("name") != "list_endpoint":
continue
if op.get("return-code") != 0:
logger.warning("List endpoint ops failed.")
return
for endpoint in op.get("value", {}):
auth_url = endpoint.get("url")
if auth_url is not None:
self._set_secret({"auth-url": auth_url})
return
def _on_provider_ready(self, event) -> None:
"""Handles response available events."""
if not self.model.unit.is_leader():
return
logger.info("Identity ops provider ready: setup tempest resources")
self.interface.request_ops(self._setup_tempest_resource_request())
self._grant_ops_secret(event.relation)
self.callback_f(event)
def _on_response_available(self, event) -> None:
"""Handles response available events."""
if not self.model.unit.is_leader():
return
logger.info("Handle response from identity ops")
response = self.interface.response
logger.info("%s", json.dumps(response, indent=4))
self._process_list_endpoint_response(response)
self.callback_f(event)
def _on_provider_goneaway(self, event) -> None:
"""Handle gone_away event."""
if not self.model.unit.is_leader():
return
logger.info(
"Identity ops provider gone away: teardown tempest resources"
)
self.callback_f(event)
class GrafanaDashboardRelationHandler(sunbeam_rhandlers.RelationHandler):
"""Relation handler for grafana-dashboard relation."""
def setup_event_handler(self) -> ops.framework.Object:
"""Configure event handlers for the relation."""
logger.debug("Setting up Grafana Dashboards Provider event handler")
interface = grafana_dashboard.GrafanaDashboardProvider(
self.charm,
relation_name=self.relation_name,
dashboards_path="src/grafana_dashboards",
)
return interface
@property
def ready(self) -> bool:
"""Determine with the relation is ready for use."""
return True
class LoggingRelationHandler(sunbeam_rhandlers.RelationHandler):
"""Relation handler for logging relation."""
def setup_event_handler(self) -> ops.framework.Object:
"""Configure event handlers for the relation."""
logger.debug("Setting up Logging Provider event handler")
interface = loki_push_api.LogProxyConsumer(
self.charm,
recursive=True,
relation_name=self.relation_name,
alert_rules_path="src/loki_alert_rules",
logs_scheme={"tempest": {"log-files": [TEMPEST_OUTPUT]}},
)
return interface
@property
def ready(self) -> bool:
"""Determine with the relation is ready for use."""
return True

View File

@ -0,0 +1,17 @@
# Do not change this file, this file is managed by juju. This is a dedicated
# system-wide crontab for running tempest periodically.
SHELL=/bin/sh
# Example of job definition:
# .---------------- minute (0 - 59)
# | .------------- hour (0 - 23)
# | | .---------- day of month (1 - 31)
# | | | .------- month (1 - 12) OR jan,feb,mar,apr ...
# | | | | .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# | | | | |
# * * * * * user-name command to be executed
{% if options.schedule.casefold() != "off" %}
# Note that the process lock is shared between ad hoc check and the periodic check.
{{ options.schedule }} tempest tempest-run-wrapper --load-list /tempest_test_lists/readonly-quick
{% endif %}

View File

@ -0,0 +1,21 @@
#!/bin/bash
# Do not change this file, this file is managed by juju.
# set -e is important, to ensure the script bails out
# if there are issues, such as lock not acquired,
# or failure in one of the tempest steps.
set -ex
(flock -n 9 || (echo "lock could not be acquired"; exit 1)
# clean up before initialising everything
rm -rf "$TEMPEST_WORKSPACE_PATH"
rm -rf "${TEMPEST_HOME}/.tempest"
tempest init --name "$TEMPEST_WORKSPACE" "$TEMPEST_WORKSPACE_PATH"
discover-tempest-config --out "$TEMPEST_CONF"
tempest account-generator -r "$TEMPEST_CONCURRENCY" -c "$TEMPEST_CONF" "$TEMPEST_TEST_ACCOUNTS"
) 9>/var/lock/tempest

View File

@ -0,0 +1,17 @@
#!/bin/bash
# Do not change this file, this file is managed by juju.
# set -e is important, to ensure the script bails out
# if there are issues, such as lock not acquired,
# or failure in one of the tempest steps.
set -ex
(flock -n 9 || (echo "lock could not be acquired"; exit 1)
discover-tempest-config --test-accounts "$TEMPEST_TEST_ACCOUNTS" --out "$TEMPEST_CONF"
TMP_FILE="$(mktemp)"
tempest run --workspace "$TEMPEST_WORKSPACE" "$@" 2>&1 | tee "$TMP_FILE"
mv "$TMP_FILE" "$TEMPEST_OUTPUT"
) 9>/var/lock/tempest

View File

@ -0,0 +1,14 @@
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Utility modules for tempest-k8s charm."""

View File

@ -0,0 +1,32 @@
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Constants for the tempest charm."""
CONTAINER = "tempest"
TEMPEST_CONCURRENCY = "4"
TEMPEST_HOME = "/var/lib/tempest"
TEMPEST_WORKSPACE_PATH = f"{TEMPEST_HOME}/workspace"
TEMPEST_CONF = f"{TEMPEST_WORKSPACE_PATH}/etc/tempest.conf"
TEMPEST_TEST_ACCOUNTS = f"{TEMPEST_WORKSPACE_PATH}/test_accounts.yaml"
TEMPEST_LIST_DIR = "/tempest_test_lists"
# this file will contain the output from tempest's latest test run
TEMPEST_OUTPUT = f"{TEMPEST_WORKSPACE_PATH}/tempest-output.log"
# This is the workspace name registered with tempest.
# It will be saved in a file in $HOME/.tempest/
TEMPEST_WORKSPACE = "tempest"
OPENSTACK_USER = "tempest"
OPENSTACK_DOMAIN = "tempest"
OPENSTACK_PROJECT = "tempest-CloudValidation"
OPENSTACK_ROLE = "admin"

View File

@ -0,0 +1,15 @@
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for tempest operator."""

View File

@ -0,0 +1,389 @@
#!/usr/bin/env python3
# Copyright 2024 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for Tempest operator."""
import json
import pathlib
import charm
import mock
import ops_sunbeam.test_utils as test_utils
import yaml
from utils.constants import (
CONTAINER,
TEMPEST_HOME,
)
TEST_TEMPEST_ENV = {
"OS_REGION_NAME": "RegionOne",
"OS_IDENTITY_API_VERSION": "3",
"OS_AUTH_VERSION": "3",
"OS_AUTH_URL": "http://10.6.0.23/openstack-keystone/v3",
"OS_USERNAME": "tempest",
"OS_PASSWORD": "password",
"OS_USER_DOMAIN_NAME": "tempest",
"OS_PROJECT_NAME": "tempest-CloudValidation",
"OS_PROJECT_DOMAIN_NAME": "tempest",
"OS_DOMAIN_NAME": "tempest",
"TEMPEST_CONCURRENCY": "4",
"TEMPEST_CONF": "/var/lib/tempest/workspace/etc/tempest.conf",
"TEMPEST_HOME": "/var/lib/tempest",
"TEMPEST_LIST_DIR": "/tempest_test_lists",
"TEMPEST_OUTPUT": "/var/lib/tempest/workspace/tempest-output.log",
"TEMPEST_TEST_ACCOUNTS": "/var/lib/tempest/workspace/test_accounts.yaml",
"TEMPEST_WORKSPACE": "tempest",
"TEMPEST_WORKSPACE_PATH": "/var/lib/tempest/workspace",
}
charmcraft = (
pathlib.Path(__file__).parents[2] / "charmcraft.yaml"
).read_text()
config = yaml.dump(yaml.safe_load(charmcraft)["config"])
actions = yaml.dump(yaml.safe_load(charmcraft)["actions"])
class _TempestTestOperatorCharm(charm.TempestOperatorCharm):
"""Test Operator Charm for Tempest operator."""
def __init__(self, framework):
self.seen_events = []
super().__init__(framework)
def _log_event(self, event):
self.seen_events.append(type(event).__name__)
def configure_charm(self, event):
super().configure_charm(event)
self._log_event(event)
class TestTempestOperatorCharm(test_utils.CharmTestCase):
"""Classes for testing tempest charms."""
PATCHES = []
def setUp(self):
"""Setup Placement tests."""
super().setUp(charm, self.PATCHES)
self.harness = test_utils.get_harness(
_TempestTestOperatorCharm,
container_calls=self.container_calls,
charm_metadata=charmcraft,
charm_config=config,
charm_actions=actions,
)
self.addCleanup(self.harness.cleanup)
self.harness.begin()
self.harness.set_leader()
def add_identity_ops_relation(self, harness):
"""Add identity resource relation."""
rel_id = harness.add_relation("identity-ops", "keystone")
harness.add_relation_unit(rel_id, "keystone/0")
harness.charm.user_id_ops.callback_f = mock.Mock()
harness.charm.user_id_ops.get_user_credential = mock.Mock(
return_value={
"username": "tempest",
"password": "password",
"domain-name": "tempest",
"project-name": "tempest-CloudValidation",
"auth-url": "http://10.6.0.23/openstack-keystone/v3",
},
)
# Only show the list_endpoint ops for simplicity
harness.update_relation_data(
rel_id,
"keystone",
{
"response": json.dumps(
{
"id": "c8e02ce67f57057d1a0d6660c6571361eea1a03d749d021d33e13ea4b0a7982a",
"tag": "setup_tempest_resource",
"ops": [
{
"name": "some_other_ops",
"return-code": 0,
"value": "",
},
{
"name": "list_endpoint",
"return-code": 0,
"value": [
{
"id": "68c4eba8b01f41829d30cf2519998883",
"service_id": "b2a08eea7699460e838f7cce97529e55",
"interface": "admin",
"region": "RegionOne",
"url": "http://10.152.183.48:5000/v3",
"enabled": True,
}
],
},
],
}
)
},
)
return rel_id
def add_logging_relation(self, harness):
"""Add logging relation."""
rel_id = harness.add_relation("logging", "loki")
harness.add_relation_unit(rel_id, "loki/0")
harness.charm.loki.interface = mock.Mock()
return rel_id
def add_grafana_dashboard_relation(self, harness):
"""Add grafana dashboard relation."""
rel_id = harness.add_relation("grafana_dashboard", "grafana")
harness.add_relation_unit(rel_id, "grafana/0")
harness.charm.grafana.interface = mock.Mock()
return rel_id
def test_pebble_ready_handler(self):
"""Test Pebble ready event is captured."""
self.assertEqual(self.harness.charm.seen_events, [])
test_utils.set_all_pebbles_ready(self.harness)
self.assertEqual(self.harness.charm.seen_events, ["PebbleReadyEvent"])
def test_all_relations(self):
"""Test all integrations ready and okay for operator."""
test_utils.set_all_pebbles_ready(self.harness)
logging_rel_id = self.add_logging_relation(self.harness)
identity_ops_rel_id = self.add_identity_ops_relation(self.harness)
grafana_dashboard_rel_id = self.add_grafana_dashboard_relation(
self.harness
)
self.harness.update_config({"schedule": "0 0 */7 * *"})
config_files = [
"/etc/crontab",
"/usr/local/sbin/tempest-run-wrapper",
"/usr/local/sbin/tempest-init",
]
for f in config_files:
self.check_file(charm.CONTAINER, f)
self.assertEqual(self.harness.charm.status.message(), "")
self.assertEqual(self.harness.charm.status.status.name, "active")
self.harness.remove_relation(logging_rel_id)
self.harness.remove_relation(identity_ops_rel_id)
self.harness.remove_relation(grafana_dashboard_rel_id)
def test_validate_action_invalid_regex(self):
"""Test validate action with invalid regex provided."""
test_utils.set_all_pebbles_ready(self.harness)
logging_rel_id = self.add_logging_relation(self.harness)
identity_ops_rel_id = self.add_identity_ops_relation(self.harness)
grafana_dashboard_rel_id = self.add_grafana_dashboard_relation(
self.harness
)
action_event = mock.Mock()
action_event.params = {
"serial": False,
"regex": "test(",
"exclude-regex": "",
"test-list": "",
}
self.harness.charm._on_validate_action(action_event)
action_event.fail.assert_called_with(
"'test(' is an invalid regex: missing ), unterminated subpattern at position 4"
)
self.harness.remove_relation(logging_rel_id)
self.harness.remove_relation(identity_ops_rel_id)
self.harness.remove_relation(grafana_dashboard_rel_id)
def test_validate_action_invalid_list(self):
"""Test validate action with invalid list provided."""
test_utils.set_all_pebbles_ready(self.harness)
logging_rel_id = self.add_logging_relation(self.harness)
identity_ops_rel_id = self.add_identity_ops_relation(self.harness)
grafana_dashboard_rel_id = self.add_grafana_dashboard_relation(
self.harness
)
file1 = mock.Mock()
file1.name = "file_1"
file2 = mock.Mock()
file2.name = "file_2"
self.harness.charm.pebble_handler().container.list_files = mock.Mock(
return_value=[file1, file2]
)
action_event = mock.Mock()
action_event.params = {
"serial": False,
"regex": "",
"exclude-regex": "",
"test-list": "nonexistent",
}
self.harness.charm._on_validate_action(action_event)
action_event.fail.assert_called_with(
"'nonexistent' is not a known test list. Please run list-tests action to view available lists."
)
self.harness.remove_relation(logging_rel_id)
self.harness.remove_relation(identity_ops_rel_id)
self.harness.remove_relation(grafana_dashboard_rel_id)
def test_validate_action_success(self):
"""Test validate action with default params."""
test_utils.set_all_pebbles_ready(self.harness)
logging_rel_id = self.add_logging_relation(self.harness)
identity_ops_rel_id = self.add_identity_ops_relation(self.harness)
grafana_dashboard_rel_id = self.add_grafana_dashboard_relation(
self.harness
)
file1 = mock.Mock()
file1.name = "file_1"
file2 = mock.Mock()
file2.name = "file_2"
self.harness.charm.pebble_handler().container.list_files = mock.Mock(
return_value=[file1, file2]
)
exec_mock = mock.Mock()
self.harness.charm.pebble_handler().execute = exec_mock
action_event = mock.Mock()
action_event.params = {
"serial": False,
"regex": "",
"exclude-regex": "",
"test-list": "",
}
self.harness.charm._on_validate_action(action_event)
action_event.fail.assert_not_called()
exec_mock.assert_called_with(
["tempest-run-wrapper", "--parallel"],
user="tempest",
group="tempest",
working_dir=TEMPEST_HOME,
exception_on_error=True,
environment=TEST_TEMPEST_ENV,
)
self.harness.remove_relation(logging_rel_id)
self.harness.remove_relation(identity_ops_rel_id)
self.harness.remove_relation(grafana_dashboard_rel_id)
def test_validate_action_params(self):
"""Test validate action with more params."""
test_utils.set_all_pebbles_ready(self.harness)
logging_rel_id = self.add_logging_relation(self.harness)
identity_ops_rel_id = self.add_identity_ops_relation(self.harness)
grafana_dashboard_rel_id = self.add_grafana_dashboard_relation(
self.harness
)
file1 = mock.Mock()
file1.name = "file_1"
file2 = mock.Mock()
file2.name = "file_2"
self.harness.charm.pebble_handler().container.list_files = mock.Mock(
return_value=[file1, file2]
)
exec_mock = mock.Mock()
self.harness.charm.pebble_handler().execute = exec_mock
action_event = mock.Mock()
action_event.params = {
"serial": True,
"regex": "re1 re2",
"exclude-regex": "excludethis",
"test-list": "file_1",
}
self.harness.charm._on_validate_action(action_event)
action_event.fail.assert_not_called()
exec_mock.assert_called_with(
[
"tempest-run-wrapper",
"--serial",
"--regex",
"re1 re2",
"--exclude-regex",
"excludethis",
"--load-list",
"/tempest_test_lists/file_1",
],
user="tempest",
group="tempest",
working_dir=TEMPEST_HOME,
exception_on_error=True,
environment=TEST_TEMPEST_ENV,
)
self.harness.remove_relation(logging_rel_id)
self.harness.remove_relation(identity_ops_rel_id)
self.harness.remove_relation(grafana_dashboard_rel_id)
def test_get_list_action(self):
"""Test get-list action."""
test_utils.set_all_pebbles_ready(self.harness)
logging_rel_id = self.add_logging_relation(self.harness)
identity_ops_rel_id = self.add_identity_ops_relation(self.harness)
grafana_dashboard_rel_id = self.add_grafana_dashboard_relation(
self.harness
)
file1 = mock.Mock()
file1.name = "file_1"
file2 = mock.Mock()
file2.name = "file_2"
self.harness.charm.pebble_handler().container.list_files = mock.Mock(
return_value=[file1, file2]
)
action_event = mock.Mock()
self.harness.charm._on_get_lists_action(action_event)
action_event.fail.assert_not_called()
self.harness.remove_relation(logging_rel_id)
self.harness.remove_relation(identity_ops_rel_id)
self.harness.remove_relation(grafana_dashboard_rel_id)
def test_get_list_action_not_ready(self):
"""Test get-list action when pebble is not ready."""
test_utils.set_all_pebbles_ready(self.harness)
logging_rel_id = self.add_logging_relation(self.harness)
identity_ops_rel_id = self.add_identity_ops_relation(self.harness)
grafana_dashboard_rel_id = self.add_grafana_dashboard_relation(
self.harness
)
file1 = mock.Mock()
file1.name = "file_1"
file2 = mock.Mock()
file2.name = "file_2"
self.harness.charm.unit.get_container(CONTAINER).can_connect = (
mock.Mock(return_value=False)
)
action_event = mock.Mock()
self.harness.charm._on_get_lists_action(action_event)
action_event.fail.assert_called_with("pebble is not ready")
self.harness.remove_relation(logging_rel_id)
self.harness.remove_relation(identity_ops_rel_id)
self.harness.remove_relation(grafana_dashboard_rel_id)

View File

@ -142,6 +142,12 @@ EXTERNAL_OVN_RELAY_LIBS=(
"observability_libs"
)
EXTERNAL_TEMPEST_LIBS=(
"observability_libs"
"grafana_k8s"
"loki_k8s"
)
# Config template parts for each component.
CONFIG_TEMPLATES_AODH=(
"section-database"
@ -282,6 +288,7 @@ declare -A INTERNAL_LIBS=(
[ovn-central-k8s]=${INTERNAL_OVN_CENTRAL_LIBS[@]}
[ovn-relay-k8s]=${INTERNAL_OVN_CENTRAL_LIBS[@]}
[placement-k8s]=${INTERNAL_KEYSTONE_LIBS[@]}
[tempest-k8s]=${INTERNAL_KEYSTONE_LIBS[@]}
)
declare -A EXTERNAL_LIBS=(
@ -309,6 +316,7 @@ declare -A EXTERNAL_LIBS=(
[ovn-central-k8s]=${EXTERNAL_OVN_CENTRAL_LIBS[@]}
[ovn-relay-k8s]=${EXTERNAL_OVN_RELAY_LIBS[@]}
[placement-k8s]=${EXTERNAL_AODH_LIBS[@]}
[tempest-k8s]=${EXTERNAL_TEMPEST_LIBS[@]}
)
declare -A CONFIG_TEMPLATES=(
@ -336,6 +344,7 @@ declare -A CONFIG_TEMPLATES=(
[ovn-central-k8s]=${NULL_ARRAY[@]}
[ovn-relay-k8s]=${NULL_ARRAY[@]}
[placement-k8s]=${CONFIG_TEMPLATES_PLACEMENT[@]}
[tempest-k8s]=${NULL_ARRAY[@]}
)

View File

@ -5,6 +5,8 @@ pushd libs/external
echo "INFO: Fetching libs from charmhub."
charmcraft fetch-lib charms.data_platform_libs.v0.data_interfaces
charmcraft fetch-lib charms.grafana_k8s.v0.grafana_auth
charmcraft fetch-lib charms.loki_k8s.v1.loki_push_api
charmcraft fetch-lib charms.observability_libs.v0.juju_topology
charmcraft fetch-lib charms.observability_libs.v1.kubernetes_service_patch
charmcraft fetch-lib charms.operator_libs_linux.v2.snap
charmcraft fetch-lib charms.prometheus_k8s.v0.prometheus_scrape

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,301 @@
# Copyright 2022 Canonical Ltd.
# See LICENSE file for licensing details.
"""## Overview.
This document explains how to use the `JujuTopology` class to
create and consume topology information from Juju in a consistent manner.
The goal of the Juju topology is to uniquely identify a piece
of software running across any of your Juju-managed deployments.
This is achieved by combining the following four elements:
- Model name
- Model UUID
- Application name
- Unit identifier
For a more in-depth description of the concept, as well as a
walk-through of it's use-case in observability, see
[this blog post](https://juju.is/blog/model-driven-observability-part-2-juju-topology-metrics)
on the Juju blog.
## Library Usage
This library may be used to create and consume `JujuTopology` objects.
The `JujuTopology` class provides three ways to create instances:
### Using the `from_charm` method
Enables instantiation by supplying the charm as an argument. When
creating topology objects for the current charm, this is the recommended
approach.
```python
topology = JujuTopology.from_charm(self)
```
### Using the `from_dict` method
Allows for instantion using a dictionary of relation data, like the
`scrape_metadata` from Prometheus or the labels of an alert rule. When
creating topology objects for remote charms, this is the recommended
approach.
```python
scrape_metadata = json.loads(relation.data[relation.app].get("scrape_metadata", "{}"))
topology = JujuTopology.from_dict(scrape_metadata)
```
### Using the class constructor
Enables instantiation using whatever values you want. While this
is useful in some very specific cases, this is almost certainly not
what you are looking for as setting these values manually may
result in observability metrics which do not uniquely identify a
charm in order to provide accurate usage reporting, alerting,
horizontal scaling, or other use cases.
```python
topology = JujuTopology(
model="some-juju-model",
model_uuid="00000000-0000-0000-0000-000000000001",
application="fancy-juju-application",
unit="fancy-juju-application/0",
charm_name="fancy-juju-application-k8s",
)
```
"""
from collections import OrderedDict
from typing import Dict, List, Optional
from uuid import UUID
# The unique Charmhub library identifier, never change it
LIBID = "bced1658f20f49d28b88f61f83c2d232"
LIBAPI = 0
LIBPATCH = 6
class InvalidUUIDError(Exception):
"""Invalid UUID was provided."""
def __init__(self, uuid: str):
self.message = "'{}' is not a valid UUID.".format(uuid)
super().__init__(self.message)
class JujuTopology:
"""JujuTopology is used for storing, generating and formatting juju topology information.
DEPRECATED: This class is deprecated. Use `pip install cosl` and
`from cosl.juju_topology import JujuTopology` instead.
"""
def __init__(
self,
model: str,
model_uuid: str,
application: str,
unit: Optional[str] = None,
charm_name: Optional[str] = None,
):
"""Build a JujuTopology object.
A `JujuTopology` object is used for storing and transforming
Juju topology information. This information is used to
annotate Prometheus scrape jobs and alert rules. Such
annotation when applied to scrape jobs helps in identifying
the source of the scrapped metrics. On the other hand when
applied to alert rules topology information ensures that
evaluation of alert expressions is restricted to the source
(charm) from which the alert rules were obtained.
Args:
model: a string name of the Juju model
model_uuid: a globally unique string identifier for the Juju model
application: an application name as a string
unit: a unit name as a string
charm_name: name of charm as a string
"""
if not self.is_valid_uuid(model_uuid):
raise InvalidUUIDError(model_uuid)
self._model = model
self._model_uuid = model_uuid
self._application = application
self._charm_name = charm_name
self._unit = unit
def is_valid_uuid(self, uuid):
"""Validate the supplied UUID against the Juju Model UUID pattern.
Args:
uuid: string that needs to be checked if it is valid v4 UUID.
Returns:
True if parameter is a valid v4 UUID, False otherwise.
"""
try:
return str(UUID(uuid, version=4)) == uuid
except (ValueError, TypeError):
return False
@classmethod
def from_charm(cls, charm):
"""Creates a JujuTopology instance by using the model data available on a charm object.
Args:
charm: a `CharmBase` object for which the `JujuTopology` will be constructed
Returns:
a `JujuTopology` object.
"""
return cls(
model=charm.model.name,
model_uuid=charm.model.uuid,
application=charm.model.app.name,
unit=charm.model.unit.name,
charm_name=charm.meta.name,
)
@classmethod
def from_dict(cls, data: dict):
"""Factory method for creating `JujuTopology` children from a dictionary.
Args:
data: a dictionary with five keys providing topology information. The keys are
- "model"
- "model_uuid"
- "application"
- "unit"
- "charm_name"
`unit` and `charm_name` may be empty, but will result in more limited
labels. However, this allows us to support charms without workloads.
Returns:
a `JujuTopology` object.
"""
return cls(
model=data["model"],
model_uuid=data["model_uuid"],
application=data["application"],
unit=data.get("unit", ""),
charm_name=data.get("charm_name", ""),
)
def as_dict(
self,
*,
remapped_keys: Optional[Dict[str, str]] = None,
excluded_keys: Optional[List[str]] = None,
) -> OrderedDict:
"""Format the topology information into an ordered dict.
Keeping the dictionary ordered is important to be able to
compare dicts without having to resort to deep comparisons.
Args:
remapped_keys: A dictionary mapping old key names to new key names,
which will be substituted when invoked.
excluded_keys: A list of key names to exclude from the returned dict.
uuid_length: The length to crop the UUID to.
"""
ret = OrderedDict(
[
("model", self.model),
("model_uuid", self.model_uuid),
("application", self.application),
("unit", self.unit),
("charm_name", self.charm_name),
]
)
if excluded_keys:
ret = OrderedDict({k: v for k, v in ret.items() if k not in excluded_keys})
if remapped_keys:
ret = OrderedDict(
(remapped_keys.get(k), v) if remapped_keys.get(k) else (k, v) for k, v in ret.items() # type: ignore
)
return ret
@property
def identifier(self) -> str:
"""Format the topology information into a terse string.
This crops the model UUID, making it unsuitable for comparisons against
anything but other identifiers. Mainly to be used as a display name or file
name where long strings might become an issue.
>>> JujuTopology( \
model = "a-model", \
model_uuid = "00000000-0000-4000-8000-000000000000", \
application = "some-app", \
unit = "some-app/1" \
).identifier
'a-model_00000000_some-app'
"""
parts = self.as_dict(
excluded_keys=["unit", "charm_name"],
)
parts["model_uuid"] = self.model_uuid_short
values = parts.values()
return "_".join([str(val) for val in values]).replace("/", "_")
@property
def label_matcher_dict(self) -> Dict[str, str]:
"""Format the topology information into a dict with keys having 'juju_' as prefix.
Relabelled topology never includes the unit as it would then only match
the leader unit (ie. the unit that produced the dict).
"""
items = self.as_dict(
remapped_keys={"charm_name": "charm"},
excluded_keys=["unit"],
).items()
return {"juju_{}".format(key): value for key, value in items if value}
@property
def label_matchers(self) -> str:
"""Format the topology information into a promql/logql label matcher string.
Topology label matchers should never include the unit as it
would then only match the leader unit (ie. the unit that
produced the matchers).
"""
items = self.label_matcher_dict.items()
return ", ".join(['{}="{}"'.format(key, value) for key, value in items if value])
@property
def model(self) -> str:
"""Getter for the juju model value."""
return self._model
@property
def model_uuid(self) -> str:
"""Getter for the juju model uuid value."""
return self._model_uuid
@property
def model_uuid_short(self) -> str:
"""Getter for the juju model value, truncated to the first eight letters."""
return self._model_uuid[:8]
@property
def application(self) -> str:
"""Getter for the juju application value."""
return self._application
@property
def charm_name(self) -> Optional[str]:
"""Getter for the juju charm name value."""
return self._charm_name
@property
def unit(self) -> Optional[str]:
"""Getter for the juju unit value."""
return self._unit

View File

@ -652,6 +652,7 @@ def get_harness(
charm_metadata: str = None,
container_calls: dict = None,
charm_config: str = None,
charm_actions: str = None,
initial_charm_config: dict = None,
) -> Harness:
"""Return a testing harness."""
@ -759,7 +760,7 @@ def get_harness(
with open(metadata_file) as f:
charm_metadata = f.read()
harness = Harness(charm_class, meta=charm_metadata, config=charm_config)
harness = Harness(charm_class, meta=charm_metadata, config=charm_config, actions=charm_actions)
harness._backend = _OSTestingModelBackend(
harness._unit_name, harness._meta, harness._get_config(charm_config)
)

View File

@ -10,6 +10,18 @@
- rebuild
vars:
charm: keystone-k8s
- job:
name: charm-build-tempest-k8s
description: Build sunbeam tempest-k8s charm
run: playbooks/charm/build.yaml
timeout: 3600
match-on-config-updates: false
files:
- ops-sunbeam/ops_sunbeam/*
- charms/tempest-k8s/*
- rebuild
vars:
charm: tempest-k8s
- job:
name: charm-build-glance-k8s
description: Build sunbeam glance-k8s charm

View File

@ -84,6 +84,8 @@
nodeset: ubuntu-jammy
- charm-build-sunbeam-clusterd:
nodeset: ubuntu-jammy
- charm-build-tempest-k8s:
nodeset: ubuntu-jammy
gate:
fail-fast: true
jobs:
@ -135,6 +137,8 @@
nodeset: ubuntu-jammy
- charm-build-sunbeam-clusterd:
nodeset: ubuntu-jammy
- charm-build-tempest-k8s:
nodeset: ubuntu-jammy
- project-template:
name: charm-publish-jobs