Merge "Add ops.scenario tests" into main

This commit is contained in:
Zuul 2023-09-26 14:38:03 +00:00 committed by Gerrit Code Review
commit f402fbac19
26 changed files with 1356 additions and 307 deletions

View File

@ -1,3 +1,3 @@
[DEFAULT]
test_path=./unit_tests
test_path=./tests/unit_tests
top_dir=./

View File

@ -15,4 +15,4 @@ charmcraft fetch-lib charms.traefik_k8s.v1.ingress
charmcraft fetch-lib charms.ceilometer_k8s.v0.ceilometer_service
charmcraft fetch-lib charms.cinder_ceph_k8s.v0.ceph_access
echo "Copying libs to to unit_test dir"
rsync --recursive --delete lib/ unit_tests/lib/
rsync --recursive --delete lib/ tests/lib/

View File

@ -2,3 +2,5 @@ coverage
mock
stestr
requests
pytest
ops-scenario>=4.0

View File

@ -1,4 +1,4 @@
# Copyright 2022 Canonical Ltd.
# Copyright 2023 Canonical Ltd.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Relation 'requires' side abstraction for database relation.
r"""[DEPRECATED] Relation 'requires' side abstraction for database relation.
This library is a uniform interface to a selection of common database
metadata, with added custom events that add convenience to database management,
@ -23,7 +23,10 @@ application charm code:
```python
from charms.data_platform_libs.v0.database_requires import DatabaseRequires
from charms.data_platform_libs.v0.database_requires import (
DatabaseCreatedEvent,
DatabaseRequires,
)
class ApplicationCharm(CharmBase):
# Application charm that connects to database charms.
@ -49,7 +52,7 @@ class ApplicationCharm(CharmBase):
self._start_application(config_file)
# Set active status
self.status.set(ActiveStatus("received database credentials"))
self.unit.status = ActiveStatus("received database credentials")
```
As shown above, the library provides some custom events to handle specific situations,
@ -84,7 +87,10 @@ The implementation would be something like the following code:
```python
from charms.data_platform_libs.v0.database_requires import DatabaseRequires
from charms.data_platform_libs.v0.database_requires import (
DatabaseCreatedEvent,
DatabaseRequires,
)
class ApplicationCharm(CharmBase):
# Application charm that connects to database charms.
@ -154,7 +160,7 @@ LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version.
LIBPATCH = 4
LIBPATCH = 6
logger = logging.getLogger(__name__)
@ -165,16 +171,25 @@ class DatabaseEvent(RelationEvent):
@property
def endpoints(self) -> Optional[str]:
"""Returns a comma separated list of read/write endpoints."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("endpoints")
@property
def password(self) -> Optional[str]:
"""Returns the password for the created user."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("password")
@property
def read_only_endpoints(self) -> Optional[str]:
"""Returns a comma separated list of read only endpoints."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("read-only-endpoints")
@property
@ -183,16 +198,25 @@ class DatabaseEvent(RelationEvent):
MongoDB only.
"""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("replset")
@property
def tls(self) -> Optional[str]:
"""Returns whether TLS is configured."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("tls")
@property
def tls_ca(self) -> Optional[str]:
"""Returns TLS CA."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("tls-ca")
@property
@ -201,11 +225,17 @@ class DatabaseEvent(RelationEvent):
MongoDB, Redis, OpenSearch and Kafka only.
"""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("uris")
@property
def username(self) -> Optional[str]:
"""Returns the created username."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("username")
@property
@ -214,6 +244,9 @@ class DatabaseEvent(RelationEvent):
Version as informed by the database daemon.
"""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("version")
@ -253,15 +286,15 @@ A tuple for storing the diff between two data mappings.
class DatabaseRequires(Object):
"""Requires-side of the database relation."""
on = DatabaseEvents()
on = DatabaseEvents() # pyright: ignore [reportGeneralTypeIssues]
def __init__(
self,
charm,
relation_name: str,
database_name: str,
extra_user_roles: str = None,
relations_aliases: List[str] = None,
extra_user_roles: Optional[str] = None,
relations_aliases: Optional[List[str]] = None,
):
"""Manager of database client relations."""
super().__init__(charm, relation_name)
@ -346,9 +379,11 @@ class DatabaseRequires(Object):
# Retrieve the old data from the data key in the local unit relation databag.
old_data = json.loads(event.relation.data[self.local_unit].get("data", "{}"))
# Retrieve the new data from the event relation databag.
new_data = {
key: value for key, value in event.relation.data[event.app].items() if key != "data"
}
new_data = (
{key: value for key, value in event.relation.data[event.app].items() if key != "data"}
if event.app
else {}
)
# These are the keys that were added to the databag and triggered this event.
added = new_data.keys() - old_data.keys()
@ -407,9 +442,11 @@ class DatabaseRequires(Object):
"""
data = {}
for relation in self.relations:
data[relation.id] = {
key: value for key, value in relation.data[relation.app].items() if key != "data"
}
data[relation.id] = (
{key: value for key, value in relation.data[relation.app].items() if key != "data"}
if relation.app
else {}
)
return data
def _update_relation_data(self, relation_id: int, data: dict) -> None:
@ -455,7 +492,9 @@ class DatabaseRequires(Object):
if "username" in diff.added and "password" in diff.added:
# Emit the default event (the one without an alias).
logger.info("database created at %s", datetime.now())
self.on.database_created.emit(event.relation, app=event.app, unit=event.unit)
getattr(self.on, "database_created").emit(
event.relation, app=event.app, unit=event.unit
)
# Emit the aliased event (if any).
self._emit_aliased_event(event, "database_created")
@ -469,7 +508,9 @@ class DatabaseRequires(Object):
if "endpoints" in diff.added or "endpoints" in diff.changed:
# Emit the default event (the one without an alias).
logger.info("endpoints changed on %s", datetime.now())
self.on.endpoints_changed.emit(event.relation, app=event.app, unit=event.unit)
getattr(self.on, "endpoints_changed").emit(
event.relation, app=event.app, unit=event.unit
)
# Emit the aliased event (if any).
self._emit_aliased_event(event, "endpoints_changed")
@ -483,7 +524,7 @@ class DatabaseRequires(Object):
if "read-only-endpoints" in diff.added or "read-only-endpoints" in diff.changed:
# Emit the default event (the one without an alias).
logger.info("read-only-endpoints changed on %s", datetime.now())
self.on.read_only_endpoints_changed.emit(
getattr(self.on, "read_only_endpoints_changed").emit(
event.relation, app=event.app, unit=event.unit
)

View File

@ -61,11 +61,34 @@ class IdentityResourceClientCharm(CharmBase):
# IdentityResource Relation has goneaway. No ops can be sent.
pass
```
A sample ops request can be of format
{
"id": <request id>
"tag": <string to identify request>
"ops": [
{
"name": <op name>,
"params": {
<param 1>: <value 1>,
<param 2>: <value 2>
}
}
]
}
For any sensitive data in the ops params, the charm can create secrets and pass
secret id instead of sensitive data as part of ops request. The charm should
ensure to grant secret access to provider charm i.e., keystone over relation.
The secret content should hold the sensitive data with same name as param name.
"""
import json
import logging
from ops.charm import (
RelationEvent,
)
from ops.framework import (
EventBase,
EventSource,
@ -88,7 +111,7 @@ LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 1
LIBPATCH = 2
REQUEST_NOT_SENT = 1
@ -96,19 +119,19 @@ REQUEST_SENT = 2
REQUEST_PROCESSED = 3
class IdentityOpsProviderReadyEvent(EventBase):
class IdentityOpsProviderReadyEvent(RelationEvent):
"""Has IdentityOpsProviderReady Event."""
pass
class IdentityOpsResponseEvent(EventBase):
class IdentityOpsResponseEvent(RelationEvent):
"""Has IdentityOpsResponse Event."""
pass
class IdentityOpsProviderGoneAwayEvent(EventBase):
class IdentityOpsProviderGoneAwayEvent(RelationEvent):
"""Has IdentityOpsProviderGoneAway Event."""
pass
@ -149,18 +172,18 @@ class IdentityResourceRequires(Object):
def _on_identity_resource_relation_joined(self, event):
"""Handle IdentityResource joined."""
self._stored.provider_ready = True
self.on.provider_ready.emit()
self.on.provider_ready.emit(event.relation)
def _on_identity_resource_relation_changed(self, event):
"""Handle IdentityResource changed."""
id_ = self.response.get("id")
self.save_request_in_store(id_, None, None, REQUEST_PROCESSED)
self.on.response_available.emit()
self.on.response_available.emit(event.relation)
def _on_identity_resource_relation_broken(self, event):
"""Handle IdentityResource broken."""
self._stored.provider_ready = False
self.on.provider_goneaway.emit()
self.on.provider_goneaway.emit(event.relation)
@property
def _identity_resource_rel(self) -> Relation:
@ -339,7 +362,9 @@ class IdentityResourceProvides(Object):
return
logger.debug("Update response from keystone")
_identity_resource_rel = self.charm.model.get_relation(relation_name, relation_id)
_identity_resource_rel = self.charm.model.get_relation(
relation_name, relation_id
)
if not _identity_resource_rel:
# Relation has disappeared so skip send of data
return

View File

@ -100,7 +100,7 @@ LIBAPI = 1
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 0
LIBPATCH = 1
logger = logging.getLogger(__name__)
@ -349,6 +349,11 @@ class IdentityServiceRequires(Object):
"""Return the public_auth_url."""
return self.get_remote_app_data('public-auth-url')
@property
def admin_role(self) -> str:
"""Return the admin_role."""
return self.get_remote_app_data('admin-role')
def register_services(self, service_endpoints: dict,
region: str) -> None:
"""Request access to the IdentityService server."""
@ -481,7 +486,8 @@ class IdentityServiceProvides(Object):
internal_auth_url: str,
admin_auth_url: str,
public_auth_url: str,
service_credentials: str):
service_credentials: str,
admin_role: str):
logging.debug("Setting identity_service connection information.")
_identity_service_rel = None
for relation in self.framework.model.relations[relation_name]:
@ -516,3 +522,4 @@ class IdentityServiceProvides(Object):
app_data["admin-auth-url"] = admin_auth_url
app_data["public-auth-url"] = public_auth_url
app_data["service-credentials"] = service_credentials
app_data["admin-role"] = admin_role

View File

@ -0,0 +1,416 @@
# Copyright 2023 Canonical Ltd.
# Licensed under the Apache2.0. See LICENSE file in charm source for details.
"""Library for the ingress relation.
This library contains the Requires and Provides classes for handling
the ingress interface.
Import `IngressRequires` in your charm, with two required options:
- "self" (the charm itself)
- config_dict
`config_dict` accepts the following keys:
- additional-hostnames
- backend-protocol
- limit-rps
- limit-whitelist
- max-body-size
- owasp-modsecurity-crs
- owasp-modsecurity-custom-rules
- path-routes
- retry-errors
- rewrite-enabled
- rewrite-target
- service-hostname (required)
- service-name (required)
- service-namespace
- service-port (required)
- session-cookie-max-age
- tls-secret-name
See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions
of each, along with the required type.
As an example, add the following to `src/charm.py`:
```
from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
# In your charm's `__init__` method (assuming your app is listening on port 8080).
self.ingress = IngressRequires(self, {
"service-hostname": self.app.name,
"service-name": self.app.name,
"service-port": 8080,
}
)
```
And then add the following to `metadata.yaml`:
```
requires:
ingress:
interface: ingress
```
You _must_ register the IngressRequires class as part of the `__init__` method
rather than, for instance, a config-changed event handler, for the relation
changed event to be properly handled.
In the example above we're setting `service-hostname` (which translates to the
external hostname for the application when related to nginx-ingress-integrator)
to `self.app.name` here. This ensures by default the charm will be available on
the name of the deployed juju application, but can be overridden in a
production deployment by setting `service-hostname` on the
nginx-ingress-integrator charm. For example:
```bash
juju deploy nginx-ingress-integrator
juju deploy my-charm
juju relate nginx-ingress-integrator my-charm:ingress
# The service is now reachable on the ingress IP(s) of your k8s cluster at
# 'http://my-charm'.
juju config nginx-ingress-integrator service-hostname='my-charm.example.com'
# The service is now reachable on the ingress IP(s) of your k8s cluster at
# 'http://my-charm.example.com'.
"""
import copy
import logging
from typing import Dict
from ops.charm import CharmBase, CharmEvents, RelationBrokenEvent, RelationChangedEvent
from ops.framework import EventBase, EventSource, Object
from ops.model import BlockedStatus
INGRESS_RELATION_NAME = "ingress"
INGRESS_PROXY_RELATION_NAME = "ingress-proxy"
# The unique Charmhub library identifier, never change it
LIBID = "db0af4367506491c91663468fb5caa4c"
# Increment this major API version when introducing breaking changes
LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 17
LOGGER = logging.getLogger(__name__)
REQUIRED_INGRESS_RELATION_FIELDS = {"service-hostname", "service-name", "service-port"}
OPTIONAL_INGRESS_RELATION_FIELDS = {
"additional-hostnames",
"backend-protocol",
"limit-rps",
"limit-whitelist",
"max-body-size",
"owasp-modsecurity-crs",
"owasp-modsecurity-custom-rules",
"path-routes",
"retry-errors",
"rewrite-target",
"rewrite-enabled",
"service-namespace",
"session-cookie-max-age",
"tls-secret-name",
}
RELATION_INTERFACES_MAPPINGS = {
"service-hostname": "host",
"service-name": "name",
"service-namespace": "model",
"service-port": "port",
}
RELATION_INTERFACES_MAPPINGS_VALUES = set(RELATION_INTERFACES_MAPPINGS.values())
class IngressAvailableEvent(EventBase):
"""IngressAvailableEvent custom event.
This event indicates the Ingress provider is available.
"""
class IngressProxyAvailableEvent(EventBase):
"""IngressProxyAvailableEvent custom event.
This event indicates the IngressProxy provider is available.
"""
class IngressBrokenEvent(RelationBrokenEvent):
"""IngressBrokenEvent custom event.
This event indicates the Ingress provider is broken.
"""
class IngressCharmEvents(CharmEvents):
"""Custom charm events.
Attrs:
ingress_available: Event to indicate that Ingress is available.
ingress_proxy_available: Event to indicate that IngressProxy is available.
ingress_broken: Event to indicate that Ingress is broken.
"""
ingress_available = EventSource(IngressAvailableEvent)
ingress_proxy_available = EventSource(IngressProxyAvailableEvent)
ingress_broken = EventSource(IngressBrokenEvent)
class IngressRequires(Object):
"""This class defines the functionality for the 'requires' side of the 'ingress' relation.
Hook events observed:
- relation-changed
Attrs:
model: Juju model where the charm is deployed.
config_dict: Contains all the configuration options for Ingress.
"""
def __init__(self, charm: CharmBase, config_dict: Dict) -> None:
"""Init function for the IngressRequires class.
Args:
charm: The charm that requires the ingress relation.
config_dict: Contains all the configuration options for Ingress.
"""
super().__init__(charm, INGRESS_RELATION_NAME)
self.framework.observe(
charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed
)
# Set default values.
default_relation_fields = {
"service-namespace": self.model.name,
}
config_dict.update(
(key, value)
for key, value in default_relation_fields.items()
if key not in config_dict or not config_dict[key]
)
self.config_dict = self._convert_to_relation_interface(config_dict)
@staticmethod
def _convert_to_relation_interface(config_dict: Dict) -> Dict:
"""Create a new relation dict that conforms with charm-relation-interfaces.
Args:
config_dict: Ingress configuration that doesn't conform with charm-relation-interfaces.
Returns:
The Ingress configuration conforming with charm-relation-interfaces.
"""
config_dict = copy.copy(config_dict)
config_dict.update(
(key, config_dict[old_key])
for old_key, key in RELATION_INTERFACES_MAPPINGS.items()
if old_key in config_dict and config_dict[old_key]
)
return config_dict
def _config_dict_errors(self, config_dict: Dict, update_only: bool = False) -> bool:
"""Check our config dict for errors.
Args:
config_dict: Contains all the configuration options for Ingress.
update_only: If the charm needs to update only existing keys.
Returns:
If we need to update the config dict or not.
"""
blocked_message = "Error in ingress relation, check `juju debug-log`"
unknown = [
config_key
for config_key in config_dict
if config_key
not in REQUIRED_INGRESS_RELATION_FIELDS
| OPTIONAL_INGRESS_RELATION_FIELDS
| RELATION_INTERFACES_MAPPINGS_VALUES
]
if unknown:
LOGGER.error(
"Ingress relation error, unknown key(s) in config dictionary found: %s",
", ".join(unknown),
)
self.model.unit.status = BlockedStatus(blocked_message)
return True
if not update_only:
missing = tuple(
config_key
for config_key in REQUIRED_INGRESS_RELATION_FIELDS
if config_key not in self.config_dict
)
if missing:
LOGGER.error(
"Ingress relation error, missing required key(s) in config dictionary: %s",
", ".join(sorted(missing)),
)
self.model.unit.status = BlockedStatus(blocked_message)
return True
return False
def _on_relation_changed(self, event: RelationChangedEvent) -> None:
"""Handle the relation-changed event.
Args:
event: Event triggering the relation-changed hook for the relation.
"""
# `self.unit` isn't available here, so use `self.model.unit`.
if self.model.unit.is_leader():
if self._config_dict_errors(config_dict=self.config_dict):
return
event.relation.data[self.model.app].update(
(key, str(self.config_dict[key])) for key in self.config_dict
)
def update_config(self, config_dict: Dict) -> None:
"""Allow for updates to relation.
Args:
config_dict: Contains all the configuration options for Ingress.
Attrs:
config_dict: Contains all the configuration options for Ingress.
"""
if self.model.unit.is_leader():
self.config_dict = self._convert_to_relation_interface(config_dict)
if self._config_dict_errors(self.config_dict, update_only=True):
return
relation = self.model.get_relation(INGRESS_RELATION_NAME)
if relation:
for key in self.config_dict:
relation.data[self.model.app][key] = str(self.config_dict[key])
class IngressBaseProvides(Object):
"""Parent class for IngressProvides and IngressProxyProvides.
Attrs:
model: Juju model where the charm is deployed.
"""
def __init__(self, charm: CharmBase, relation_name: str) -> None:
"""Init function for the IngressProxyProvides class.
Args:
charm: The charm that provides the ingress-proxy relation.
relation_name: The name of the relation.
"""
super().__init__(charm, relation_name)
self.charm = charm
def _on_relation_changed(self, event: RelationChangedEvent) -> None:
"""Handle a change to the ingress/ingress-proxy relation.
Confirm we have the fields we expect to receive.
Args:
event: Event triggering the relation-changed hook for the relation.
"""
# `self.unit` isn't available here, so use `self.model.unit`.
if not self.model.unit.is_leader():
return
relation_name = event.relation.name
assert event.app is not None # nosec
if not event.relation.data[event.app]:
LOGGER.info(
"%s hasn't finished configuring, waiting until relation is changed again.",
relation_name,
)
return
ingress_data = {
field: event.relation.data[event.app].get(field)
for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
}
missing_fields = sorted(
field for field in REQUIRED_INGRESS_RELATION_FIELDS if ingress_data.get(field) is None
)
if missing_fields:
LOGGER.warning(
"Missing required data fields for %s relation: %s",
relation_name,
", ".join(missing_fields),
)
self.model.unit.status = BlockedStatus(
f"Missing fields for {relation_name}: {', '.join(missing_fields)}"
)
if relation_name == INGRESS_RELATION_NAME:
# Conform to charm-relation-interfaces.
if "name" in ingress_data and "port" in ingress_data:
name = ingress_data["name"]
port = ingress_data["port"]
else:
name = ingress_data["service-name"]
port = ingress_data["service-port"]
event.relation.data[self.model.app]["url"] = f"http://{name}:{port}/"
# Create an event that our charm can use to decide it's okay to
# configure the ingress.
self.charm.on.ingress_available.emit()
elif relation_name == INGRESS_PROXY_RELATION_NAME:
self.charm.on.ingress_proxy_available.emit()
class IngressProvides(IngressBaseProvides):
"""Class containing the functionality for the 'provides' side of the 'ingress' relation.
Hook events observed:
- relation-changed
"""
def __init__(self, charm: CharmBase) -> None:
"""Init function for the IngressProvides class.
Args:
charm: The charm that provides the ingress relation.
"""
super().__init__(charm, INGRESS_RELATION_NAME)
# Observe the relation-changed hook event and bind
# self.on_relation_changed() to handle the event.
self.framework.observe(
charm.on[INGRESS_RELATION_NAME].relation_changed, self._on_relation_changed
)
self.framework.observe(
charm.on[INGRESS_RELATION_NAME].relation_broken, self._on_relation_broken
)
def _on_relation_broken(self, event: RelationBrokenEvent) -> None:
"""Handle a relation-broken event in the ingress relation.
Args:
event: Event triggering the relation-broken hook for the relation.
"""
if not self.model.unit.is_leader():
return
# Create an event that our charm can use to remove the ingress resource.
self.charm.on.ingress_broken.emit(event.relation)
class IngressProxyProvides(IngressBaseProvides):
"""Class containing the functionality for the 'provides' side of the 'ingress-proxy' relation.
Hook events observed:
- relation-changed
"""
def __init__(self, charm: CharmBase) -> None:
"""Init function for the IngressProxyProvides class.
Args:
charm: The charm that provides the ingress-proxy relation.
"""
super().__init__(charm, INGRESS_PROXY_RELATION_NAME)
# Observe the relation-changed hook event and bind
# self.on_relation_changed() to handle the event.
self.framework.observe(
charm.on[INGRESS_PROXY_RELATION_NAME].relation_changed, self._on_relation_changed
)

View File

@ -69,7 +69,7 @@ LIBAPI = 1
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 5
LIBPATCH = 15
DEFAULT_RELATION_NAME = "ingress"
RELATION_INTERFACE = "ingress"
@ -98,6 +98,7 @@ INGRESS_REQUIRES_APP_SCHEMA = {
"host": {"type": "string"},
"port": {"type": "string"},
"strip-prefix": {"type": "string"},
"redirect-https": {"type": "string"},
},
"required": ["model", "name", "host", "port"],
}
@ -113,18 +114,25 @@ INGRESS_PROVIDES_APP_SCHEMA = {
try:
from typing import TypedDict
except ImportError:
from typing_extensions import TypedDict # py35 compat
from typing_extensions import TypedDict # py35 compatibility
# Model of the data a unit implementing the requirer will need to provide.
RequirerData = TypedDict(
"RequirerData",
{"model": str, "name": str, "host": str, "port": int, "strip-prefix": bool},
{
"model": str,
"name": str,
"host": str,
"port": int,
"strip-prefix": bool,
"redirect-https": bool,
},
total=False,
)
# Provider ingress data model.
ProviderIngressData = TypedDict("ProviderIngressData", {"url": str})
# Provider application databag model.
ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData})
ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData}) # type: ignore
def _validate_data(data, schema):
@ -148,7 +156,7 @@ class _IngressPerAppBase(Object):
"""Base class for IngressPerUnit interface classes."""
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME):
super().__init__(charm, relation_name)
super().__init__(charm, relation_name + "_V1")
self.charm: CharmBase = charm
self.relation_name = relation_name
@ -161,8 +169,8 @@ class _IngressPerAppBase(Object):
observe(rel_events.relation_joined, self._handle_relation)
observe(rel_events.relation_changed, self._handle_relation)
observe(rel_events.relation_broken, self._handle_relation_broken)
observe(charm.on.leader_elected, self._handle_upgrade_or_leader)
observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader)
observe(charm.on.leader_elected, self._handle_upgrade_or_leader) # type: ignore
observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore
@property
def relations(self):
@ -183,8 +191,8 @@ class _IngressPerAppBase(Object):
class _IPAEvent(RelationEvent):
__args__ = () # type: Tuple[str, ...]
__optional_kwargs__ = {} # type: Dict[str, Any]
__args__: Tuple[str, ...] = ()
__optional_kwargs__: Dict[str, Any] = {}
@classmethod
def __attrs__(cls):
@ -202,7 +210,7 @@ class _IPAEvent(RelationEvent):
obj = kwargs.get(attr, default)
setattr(self, attr, obj)
def snapshot(self) -> dict:
def snapshot(self):
dct = super().snapshot()
for attr in self.__attrs__():
obj = getattr(self, attr)
@ -217,7 +225,7 @@ class _IPAEvent(RelationEvent):
return dct
def restore(self, snapshot: dict) -> None:
def restore(self, snapshot) -> None:
super().restore(snapshot)
for attr, obj in snapshot.items():
setattr(self, attr, obj)
@ -226,14 +234,15 @@ class _IPAEvent(RelationEvent):
class IngressPerAppDataProvidedEvent(_IPAEvent):
"""Event representing that ingress data has been provided for an app."""
__args__ = ("name", "model", "port", "host", "strip_prefix")
__args__ = ("name", "model", "port", "host", "strip_prefix", "redirect_https")
if typing.TYPE_CHECKING:
name = None # type: str
model = None # type: str
port = None # type: int
host = None # type: str
strip_prefix = False # type: bool
name: Optional[str] = None
model: Optional[str] = None
port: Optional[str] = None
host: Optional[str] = None
strip_prefix: bool = False
redirect_https: bool = False
class IngressPerAppDataRemovedEvent(RelationEvent):
@ -250,7 +259,7 @@ class IngressPerAppProviderEvents(ObjectEvents):
class IngressPerAppProvider(_IngressPerAppBase):
"""Implementation of the provider of ingress."""
on = IngressPerAppProviderEvents()
on = IngressPerAppProviderEvents() # type: ignore
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME):
"""Constructor for IngressPerAppProvider.
@ -267,17 +276,18 @@ class IngressPerAppProvider(_IngressPerAppBase):
# notify listeners.
if self.is_ready(event.relation):
data = self._get_requirer_data(event.relation)
self.on.data_provided.emit(
self.on.data_provided.emit( # type: ignore
event.relation,
data["name"],
data["model"],
data["port"],
data["host"],
data.get("strip-prefix", False),
data.get("redirect-https", False),
)
def _handle_relation_broken(self, event):
self.on.data_removed.emit(event.relation)
self.on.data_removed.emit(event.relation) # type: ignore
def wipe_ingress_data(self, relation: Relation):
"""Clear ingress data from relation."""
@ -293,33 +303,34 @@ class IngressPerAppProvider(_IngressPerAppBase):
return
del relation.data[self.app]["ingress"]
def _get_requirer_data(self, relation: Relation) -> RequirerData:
def _get_requirer_data(self, relation: Relation) -> RequirerData: # type: ignore
"""Fetch and validate the requirer's app databag.
For convenience, we convert 'port' to integer.
"""
if not all((relation.app, relation.app.name)):
if not relation.app or not relation.app.name: # type: ignore
# Handle edge case where remote app name can be missing, e.g.,
# relation_broken events.
# FIXME https://github.com/canonical/traefik-k8s-operator/issues/34
return {}
databag = relation.data[relation.app]
remote_data = {} # type: Dict[str, Union[int, str]]
for k in ("port", "host", "model", "name", "mode", "strip-prefix"):
remote_data: Dict[str, Union[int, str]] = {}
for k in ("port", "host", "model", "name", "mode", "strip-prefix", "redirect-https"):
v = databag.get(k)
if v is not None:
remote_data[k] = v
_validate_data(remote_data, INGRESS_REQUIRES_APP_SCHEMA)
remote_data["port"] = int(remote_data["port"])
remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", False))
return remote_data
remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", "false") == "true")
remote_data["redirect-https"] = bool(remote_data.get("redirect-https", "false") == "true")
return typing.cast(RequirerData, remote_data)
def get_data(self, relation: Relation) -> RequirerData:
def get_data(self, relation: Relation) -> RequirerData: # type: ignore
"""Fetch the remote app's databag, i.e. the requirer data."""
return self._get_requirer_data(relation)
def is_ready(self, relation: Relation = None):
def is_ready(self, relation: Optional[Relation] = None):
"""The Provider is ready if the requirer has sent valid data."""
if not relation:
return any(map(self.is_ready, self.relations))
@ -330,14 +341,14 @@ class IngressPerAppProvider(_IngressPerAppBase):
log.warning("Requirer not ready; validation error encountered: %s" % str(e))
return False
def _provided_url(self, relation: Relation) -> ProviderIngressData:
def _provided_url(self, relation: Relation) -> ProviderIngressData: # type: ignore
"""Fetch and validate this app databag; return the ingress url."""
if not all((relation.app, relation.app.name, self.unit.is_leader())):
if not relation.app or not relation.app.name or not self.unit.is_leader(): # type: ignore
# Handle edge case where remote app name can be missing, e.g.,
# relation_broken events.
# Also, only leader units can read own app databags.
# FIXME https://github.com/canonical/traefik-k8s-operator/issues/34
return {} # noqa
return typing.cast(ProviderIngressData, {}) # noqa
# fetch the provider's app databag
raw_data = relation.data[self.app].get("ingress")
@ -374,6 +385,9 @@ class IngressPerAppProvider(_IngressPerAppBase):
results = {}
for ingress_relation in self.relations:
assert (
ingress_relation.app
), "no app in relation (shouldn't happen)" # for type checker
results[ingress_relation.app.name] = self._provided_url(ingress_relation)
return results
@ -384,7 +398,7 @@ class IngressPerAppReadyEvent(_IPAEvent):
__args__ = ("url",)
if typing.TYPE_CHECKING:
url = None # type: str
url: Optional[str] = None
class IngressPerAppRevokedEvent(RelationEvent):
@ -401,8 +415,9 @@ class IngressPerAppRequirerEvents(ObjectEvents):
class IngressPerAppRequirer(_IngressPerAppBase):
"""Implementation of the requirer of the ingress relation."""
on = IngressPerAppRequirerEvents()
# used to prevent spur1ious urls to be sent out if the event we're currently
on = IngressPerAppRequirerEvents() # type: ignore
# used to prevent spurious urls to be sent out if the event we're currently
# handling is a relation-broken one.
_stored = StoredState()
@ -411,9 +426,10 @@ class IngressPerAppRequirer(_IngressPerAppBase):
charm: CharmBase,
relation_name: str = DEFAULT_RELATION_NAME,
*,
host: str = None,
port: int = None,
host: Optional[str] = None,
port: Optional[int] = None,
strip_prefix: bool = False,
redirect_https: bool = False,
):
"""Constructor for IngressRequirer.
@ -429,6 +445,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
host: Hostname to be used by the ingress provider to address the requiring
application; if unspecified, the default Kubernetes service name will be used.
strip_prefix: configure Traefik to strip the path prefix.
redirect_https: redirect incoming requests to the HTTPS.
Request Args:
port: the port of the service
@ -437,8 +454,9 @@ class IngressPerAppRequirer(_IngressPerAppBase):
self.charm: CharmBase = charm
self.relation_name = relation_name
self._strip_prefix = strip_prefix
self._redirect_https = redirect_https
self._stored.set_default(current_url=None)
self._stored.set_default(current_url=None) # type: ignore
# if instantiated with a port, and we are related, then
# we immediately publish our ingress data to speed up the process.
@ -458,13 +476,13 @@ class IngressPerAppRequirer(_IngressPerAppBase):
if isinstance(event, RelationBrokenEvent)
else self._get_url_from_relation_data()
)
if self._stored.current_url != new_url:
self._stored.current_url = new_url
self.on.ready.emit(event.relation, new_url)
if self._stored.current_url != new_url: # type: ignore
self._stored.current_url = new_url # type: ignore
self.on.ready.emit(event.relation, new_url) # type: ignore
def _handle_relation_broken(self, event):
self._stored.current_url = None
self.on.revoked.emit(event.relation)
self._stored.current_url = None # type: ignore
self.on.revoked.emit(event.relation) # type: ignore
def _handle_upgrade_or_leader(self, event):
"""On upgrade/leadership change: ensure we publish the data we have."""
@ -484,7 +502,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
host, port = self._auto_data
self.provide_ingress_requirements(host=host, port=port)
def provide_ingress_requirements(self, *, host: str = None, port: int):
def provide_ingress_requirements(self, *, host: Optional[str] = None, port: int):
"""Publishes the data that Traefik needs to provide ingress.
NB only the leader unit is supposed to do this.
@ -513,6 +531,9 @@ class IngressPerAppRequirer(_IngressPerAppBase):
if self._strip_prefix:
data["strip-prefix"] = "true"
if self._redirect_https:
data["redirect-https"] = "true"
_validate_data(data, INGRESS_REQUIRES_APP_SCHEMA)
self.relation.data[self.app].update(data)
@ -527,7 +548,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
Returns None if the URL isn't available yet.
"""
relation = self.relation
if not relation:
if not relation or not relation.app:
return None
# fetch the provider's app databag
@ -553,6 +574,6 @@ class IngressPerAppRequirer(_IngressPerAppBase):
Returns None if the URL isn't available yet.
"""
data = self._stored.current_url or None # type: ignore
data = self._stored.current_url or self._get_url_from_relation_data() # type: ignore
assert isinstance(data, (str, type(None))) # for static checker
return data

View File

@ -0,0 +1,18 @@
# Copyright 2021 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 aso."""
import ops.testing
ops.testing.SIMULATE_CAN_CONNECT = True

View File

@ -0,0 +1,142 @@
#!/usr/bin/env python3
# Copyright 2023 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.
"""Utilities for writing sunbeam scenario tests."""
import functools
import itertools
from scenario import (
Relation,
Secret,
)
# Data used to create Relation objects. If an incomplete relation is being
# created only the 'endpoint', 'interface' and 'remote_app_name' key are
# used.
default_relations = {
"amqp": {
"endpoint": "amqp",
"interface": "rabbitmq",
"remote_app_name": "rabbitmq",
"remote_app_data": {"password": "foo"},
"remote_units_data": {0: {"ingress-address": "host1"}},
},
"identity-credentials": {
"endpoint": "identity-credentials",
"interface": "keystone-credentials",
"remote_app_name": "keystone",
"remote_app_data": {
"api-version": "3",
"auth-host": "keystone.local",
"auth-port": "12345",
"auth-protocol": "http",
"internal-host": "keystone.internal",
"internal-port": "5000",
"internal-protocol": "http",
"credentials": "foo",
"project-name": "user-project",
"project-id": "uproj-id",
"user-domain-name": "udomain-name",
"user-domain-id": "udomain-id",
"project-domain-name": "pdomain_-ame",
"project-domain-id": "pdomain-id",
"region": "region12",
"public-endpoint": "http://10.20.21.11:80/openstack-keystone",
"internal-endpoint": "http://10.153.2.45:80/openstack-keystone",
},
},
}
def relation_combinations(
metadata, one_missing=False, incomplete_relation=False
):
"""Based on a charms metadata generate tuples of relations.
:param metadata: Dict of charm metadata
:param one_missing: Bool if set then each unique relations tuple will be
missing one relation.
:param one_missing: Bool if set then each unique relations tuple will
include one relation that has missing relation
data
"""
_incomplete_relations = []
_complete_relations = []
_relation_pairs = []
for rel_name in metadata.get("requires", {}):
rel = default_relations[rel_name]
complete_relation = Relation(
endpoint=rel["endpoint"],
interface=rel["interface"],
remote_app_name=rel["remote_app_name"],
local_unit_data=rel.get("local_unit_data", {}),
remote_app_data=rel.get("remote_app_data", {}),
remote_units_data=rel.get("remote_units_data", {}),
)
relation_missing_data = Relation(
endpoint=rel["endpoint"],
interface=rel["interface"],
remote_app_name=rel["remote_app_name"],
)
_incomplete_relations.append(relation_missing_data)
_complete_relations.append(complete_relation)
_relation_pairs.append([relation_missing_data, complete_relation])
if not (one_missing or incomplete_relation):
return [tuple(_complete_relations)]
if incomplete_relation:
relations = list(itertools.product(*_relation_pairs))
relations.remove(tuple(_complete_relations))
return relations
if one_missing:
event_count = range(len(_incomplete_relations))
else:
event_count = range(len(_incomplete_relations) + 1)
combinations = []
for i in event_count:
combinations.extend(
list(itertools.combinations(_incomplete_relations, i))
)
return combinations
missing_relation = functools.partial(
relation_combinations, one_missing=True, incomplete_relation=False
)
incomplete_relation = functools.partial(
relation_combinations, one_missing=False, incomplete_relation=True
)
complete_relation = functools.partial(
relation_combinations, one_missing=False, incomplete_relation=False
)
def get_keystone_secret_definition(relations):
"""Create the keystone identity secret."""
ident_rel_id = None
secret = None
for relation in relations:
if relation.remote_app_name == "keystone":
ident_rel_id = relation.relation_id
if ident_rel_id:
secret = Secret(
id="foo",
contents={0: {"username": "svcuser1", "password": "svcpass1"}},
owner="keystone", # or 'app'
remote_grants={ident_rel_id: {"my-service/0"}},
)
return secret

View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
# Copyright 2023 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.
"""Charm definitions for scenatio tests."""
import ops_sunbeam.charm as sunbeam_charm
import ops_sunbeam.container_handlers as sunbeam_chandlers
import ops_sunbeam.core as sunbeam_core
class MyCharm(sunbeam_charm.OSBaseOperatorCharm):
"""Test charm for testing OSBaseOperatorCharm."""
service_name = "my-service"
MyCharm_Metadata = {
"name": "my-service",
"version": "3",
"bases": {"name": "ubuntu", "channel": "20.04/stable"},
"tags": ["openstack", "identity", "misc"],
"subordinate": False,
}
class MyCharmMulti(sunbeam_charm.OSBaseOperatorCharm):
"""Test charm for testing OSBaseOperatorCharm."""
# mandatory_relations = {"amqp", "database", "identity-credentials"}
mandatory_relations = {"amqp", "identity-credentials"}
service_name = "my-service"
MyCharmMulti_Metadata = {
"name": "my-service",
"version": "3",
"bases": {"name": "ubuntu", "channel": "20.04/stable"},
"tags": ["openstack", "identity", "misc"],
"subordinate": False,
"requires": {
# "database": {"interface": "mysql_client", "limit": 1},
"amqp": {"interface": "rabbitmq"},
"identity-credentials": {
"interface": "keystone-credentials",
"limit": 1,
},
},
}
class NovaSchedulerPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
"""Pebble handler for Nova scheduler."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.enable_service_check = True
def get_layer(self) -> dict:
"""Nova Scheduler service layer.
:returns: pebble layer configuration for scheduler service
:rtype: dict
"""
return {
"summary": "nova scheduler layer",
"description": "pebble configuration for nova services",
"services": {
"nova-scheduler": {
"override": "replace",
"summary": "Nova Scheduler",
"command": "nova-scheduler",
"startup": "enabled",
"user": "nova",
"group": "nova",
}
},
}
class NovaConductorPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
"""Pebble handler for Nova Conductor container."""
def get_layer(self):
"""Nova Conductor service.
:returns: pebble service layer configuration for conductor service
:rtype: dict
"""
return {
"summary": "nova conductor layer",
"description": "pebble configuration for nova services",
"services": {
"nova-conductor": {
"override": "replace",
"summary": "Nova Conductor",
"command": "nova-conductor",
"startup": "enabled",
"user": "nova",
"group": "nova",
}
},
}
class MyCharmK8S(sunbeam_charm.OSBaseOperatorCharmK8S):
"""Test charm for testing OSBaseOperatorCharm."""
# mandatory_relations = {"amqp", "database", "identity-credentials"}
mandatory_relations = {"amqp", "identity-credentials"}
service_name = "my-service"
def get_pebble_handlers(self):
"""Pebble handlers for the operator."""
return [
NovaSchedulerPebbleHandler(
self,
"container1",
"container1-svc",
self.container_configs,
"/tmp",
self.configure_charm,
),
NovaConductorPebbleHandler(
self,
"container2",
"container2-svc",
self.container_configs,
"/tmp",
self.configure_charm,
),
]
MyCharmK8S_Metadata = {
"name": "my-service",
"version": "3",
"bases": {"name": "ubuntu", "channel": "20.04/stable"},
"tags": ["openstack", "identity", "misc"],
"subordinate": False,
"containers": {
"container1": {"resource": "container1-image"},
"container2": {"resource": "container2-image"},
},
"requires": {
# "database": {"interface": "mysql_client", "limit": 1},
"amqp": {"interface": "rabbitmq"},
"identity-credentials": {
"interface": "keystone-credentials",
"limit": 1,
},
},
}
class MyCharmK8SAPI(sunbeam_charm.OSBaseOperatorCharmK8S):
"""Test charm for testing OSBaseOperatorCharm."""
# mandatory_relations = {"amqp", "database", "identity-credentials"}
mandatory_relations = {"amqp", "identity-credentials"}
service_name = "my-service"
MyCharmK8SAPI_Metadata = {
"name": "my-service",
"version": "3",
"bases": {"name": "ubuntu", "channel": "20.04/stable"},
"tags": ["openstack", "identity", "misc"],
"subordinate": False,
"containers": {
"my-service": {"resource": "container1-image"},
},
"requires": {
# "database": {"interface": "mysql_client", "limit": 1},
"amqp": {"interface": "rabbitmq"},
"identity-credentials": {
"interface": "keystone-credentials",
},
},
}

View File

@ -0,0 +1,383 @@
#!/usr/bin/env python3
# Copyright 2023 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.
"""Test charms for unit tests."""
from . import test_fixtures
from . import scenario_utils as utils
import re
import sys
sys.path.append("tests/lib") # noqa
sys.path.append("src") # noqa
import pytest
from scenario import (
State,
Context,
Container,
Mount,
)
from ops.model import (
ActiveStatus,
MaintenanceStatus,
)
class TestOSBaseOperatorCharmScenarios:
@pytest.mark.parametrize("leader", (True, False))
def test_no_relations(self, leader):
"""Check charm with no relations becomes active."""
state = State(leader=leader, config={}, containers=[])
ctxt = Context(
charm_type=test_fixtures.MyCharm,
meta=test_fixtures.MyCharm_Metadata,
)
out = ctxt.run("install", state)
assert out.unit_status == MaintenanceStatus(
"(bootstrap) Service not bootstrapped"
)
out = ctxt.run("config-changed", state)
assert out.unit_status == ActiveStatus("")
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations",
utils.missing_relation(test_fixtures.MyCharmMulti_Metadata),
)
def test_relation_missing(self, relations, leader):
"""Check charm with a missing relation is blocked."""
ctxt = Context(
charm_type=test_fixtures.MyCharmMulti,
meta=test_fixtures.MyCharmMulti_Metadata,
)
state = State(
leader=True,
config={},
containers=[],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status.name == "blocked"
assert re.match(r".*integration missing", out.unit_status.message)
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations",
utils.incomplete_relation(test_fixtures.MyCharmMulti_Metadata),
)
def test_relation_incomplete(self, relations, leader):
"""Check charm with an incomplete relation is waiting."""
ctxt = Context(
charm_type=test_fixtures.MyCharmMulti,
meta=test_fixtures.MyCharmMulti_Metadata,
)
state = State(
leader=True,
config={},
containers=[],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status.name == "waiting"
assert re.match(
r".*Not all relations are ready", out.unit_status.message
)
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations",
utils.complete_relation(test_fixtures.MyCharmMulti_Metadata),
)
def test_relations_complete(self, relations, leader):
"""Check charm with complete relations is active."""
ctxt = Context(
charm_type=test_fixtures.MyCharmMulti,
meta=test_fixtures.MyCharmMulti_Metadata,
)
state = State(
leader=True,
config={},
containers=[],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status == ActiveStatus("")
class TestOSBaseOperatorCharmK8SScenarios:
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations", utils.missing_relation(test_fixtures.MyCharmK8S_Metadata)
)
def test_relation_missing(self, tmp_path, relations, leader):
"""Check k8s charm with a missing relation is blocked."""
ctxt = Context(
charm_type=test_fixtures.MyCharmK8S,
meta=test_fixtures.MyCharmK8S_Metadata,
)
p1 = tmp_path / "c1"
p2 = tmp_path / "c2"
state = State(
leader=True,
config={},
containers=[
Container(
name="container1",
can_connect=True,
mounts={"local": Mount("/etc", p1)},
),
Container(
name="container2",
can_connect=True,
mounts={"local": Mount("/etc", p2)},
),
],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert re.match(r".*integration missing", out.unit_status.message)
assert out.unit_status.name == "blocked"
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations",
utils.incomplete_relation(test_fixtures.MyCharmK8S_Metadata),
)
def test_relation_incomplete(self, tmp_path, relations, leader):
"""Check k8s charm with an incomplete relation is waiting."""
ctxt = Context(
charm_type=test_fixtures.MyCharmK8S,
meta=test_fixtures.MyCharmK8S_Metadata,
)
p1 = tmp_path / "c1"
p2 = tmp_path / "c2"
state = State(
leader=True,
config={},
containers=[
Container(
name="container1",
can_connect=True,
mounts={"local": Mount("/etc", p1)},
),
Container(
name="container2",
can_connect=True,
mounts={"local": Mount("/etc", p2)},
),
],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status.name == "waiting"
assert re.match(
r".*Not all relations are ready", out.unit_status.message
)
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations", utils.complete_relation(test_fixtures.MyCharmK8S_Metadata)
)
def test_relation_container_not_ready(self, tmp_path, relations, leader):
"""Check k8s charm with container is cannot connect to it waiting ."""
ctxt = Context(
charm_type=test_fixtures.MyCharmK8S,
meta=test_fixtures.MyCharmK8S_Metadata,
)
p1 = tmp_path / "c1"
p2 = tmp_path / "c2"
state = State(
leader=True,
config={},
containers=[
Container(
name="container1",
can_connect=False,
mounts={"local": Mount("/etc", p1)},
),
Container(
name="container2",
can_connect=True,
mounts={"local": Mount("/etc", p2)},
),
],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status.name == "waiting"
assert re.match(
r".*Payload container not ready", out.unit_status.message
)
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations", utils.complete_relation(test_fixtures.MyCharmK8S_Metadata)
)
def test_relation_all_complete(self, tmp_path, relations, leader):
"""Check k8s charm with complete rels & ready containers is active."""
ctxt = Context(
charm_type=test_fixtures.MyCharmK8S,
meta=test_fixtures.MyCharmK8S_Metadata,
)
p1 = tmp_path / "c1"
p2 = tmp_path / "c2"
state = State(
leader=True,
config={},
containers=[
Container(
name="container1",
can_connect=True,
mounts={"local": Mount("/etc", p1)},
),
Container(
name="container2",
can_connect=True,
mounts={"local": Mount("/etc", p2)},
),
],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status == ActiveStatus("")
class TestOSBaseOperatorCharmK8SAPIScenarios:
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations",
utils.missing_relation(test_fixtures.MyCharmK8SAPI_Metadata),
)
def test_relation_missing(self, tmp_path, relations, leader):
"""Check k8s API charm with a missing relation is blocked."""
ctxt = Context(
charm_type=test_fixtures.MyCharmK8SAPI,
meta=test_fixtures.MyCharmK8SAPI_Metadata,
)
p1 = tmp_path / "c1"
state = State(
leader=True,
config={},
containers=[
Container(
name="my-service",
can_connect=True,
mounts={"local": Mount("/etc", p1)},
)
],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert re.match(r".*integration missing", out.unit_status.message)
assert out.unit_status.name == "blocked"
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations",
utils.incomplete_relation(test_fixtures.MyCharmK8SAPI_Metadata),
)
def test_relation_incomplete(self, tmp_path, relations, leader):
"""Check k8s API charm with an incomplete relation is waiting."""
ctxt = Context(
charm_type=test_fixtures.MyCharmK8SAPI,
meta=test_fixtures.MyCharmK8SAPI_Metadata,
)
p1 = tmp_path / "c1"
state = State(
leader=True,
config={},
containers=[
Container(
name="my-service",
can_connect=True,
mounts={"local": Mount("/etc", p1)},
)
],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status.name == "waiting"
assert re.match(
r".*Not all relations are ready", out.unit_status.message
)
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations",
utils.complete_relation(test_fixtures.MyCharmK8SAPI_Metadata),
)
def test_relation_container_not_ready(self, tmp_path, relations, leader):
"""Check k8s API charm with stopped container is waiting."""
ctxt = Context(
charm_type=test_fixtures.MyCharmK8SAPI,
meta=test_fixtures.MyCharmK8SAPI_Metadata,
)
p1 = tmp_path / "c1"
state = State(
leader=True,
config={},
containers=[
Container(
name="my-service",
can_connect=False,
mounts={"local": Mount("/etc", p1)},
)
],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status.name == "waiting"
assert re.match(
r".*Payload container not ready", out.unit_status.message
)
@pytest.mark.parametrize("leader", (True, False))
@pytest.mark.parametrize(
"relations",
utils.complete_relation(test_fixtures.MyCharmK8SAPI_Metadata),
)
def test_relation_all_complete(self, tmp_path, relations, leader):
"""Check k8s API charm all rels and containers are ready."""
ctxt = Context(
charm_type=test_fixtures.MyCharmK8SAPI,
meta=test_fixtures.MyCharmK8SAPI_Metadata,
)
p1 = tmp_path / "c1"
state = State(
leader=True,
config={},
containers=[
Container(
name="my-service",
can_connect=True,
mounts={"local": Mount("/etc", p1)},
)
],
relations=list(relations),
secrets=[utils.get_keystone_secret_definition(relations)],
)
out = ctxt.run("config-changed", state)
assert out.unit_status == ActiveStatus("")

View File

@ -0,0 +1,18 @@
# Copyright 2021 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 aso."""
import ops.testing
ops.testing.SIMULATE_CAN_CONNECT = True

View File

@ -30,7 +30,7 @@ from typing import (
List,
)
sys.path.append("unit_tests/lib") # noqa
sys.path.append("tests/unit_tests/lib") # noqa
sys.path.append("src") # noqa
import ops_sunbeam.charm as sunbeam_charm

View File

@ -19,7 +19,7 @@ import sys
import mock
sys.path.append("lib") # noqa
sys.path.append("tests/lib") # noqa
sys.path.append("src") # noqa
import ops.model

View File

@ -10,8 +10,9 @@ requires = virtualenv < 20.0
[vars]
src_path = {toxinidir}/ops_sunbeam
tst_path = {toxinidir}/unit_tests/
tst_lib_path = {toxinidir}/unit_tests/lib/
tst_path = {toxinidir}/tests/unit_tests/
scenario_tst_path = {toxinidir}/tests/scenario_tests/
tst_lib_path = {toxinidir}/tests/lib/
pyproject_toml = {toxinidir}/pyproject.toml
cookie_cutter_path = {toxinidir}/shared_code/sunbeam_charm/\{\{cookiecutter.service_name\}\}
all_path = {[vars]src_path} {[vars]tst_path}
@ -20,7 +21,9 @@ all_path = {[vars]src_path} {[vars]tst_path}
basepython = python3
install_command =
pip install {opts} {packages}
commands = stestr run --slowest {posargs}
commands =
stestr run --slowest {posargs}
pytest -v --tb native {[vars]scenario_tst_path} --log-cli-level=INFO
allowlist_externals =
git
charmcraft
@ -103,6 +106,14 @@ commands =
coverage xml -o cover/coverage.xml
coverage report
[testenv:scenario]
description = Scenario tests
deps =
-r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
commands =
pytest -v --tb native {[vars]scenario_tst_path} --log-cli-level=INFO
[coverage:run]
branch = True
concurrency = multiprocessing

View File

@ -1,227 +0,0 @@
"""Library for the ingress relation.
This library contains the Requires and Provides classes for handling
the ingress interface.
Import `IngressRequires` in your charm, with two required options:
- "self" (the charm itself)
- config_dict
`config_dict` accepts the following keys:
- service-hostname (required)
- service-name (required)
- service-port (required)
- additional-hostnames
- limit-rps
- limit-whitelist
- max-body-size
- owasp-modsecurity-crs
- path-routes
- retry-errors
- rewrite-enabled
- rewrite-target
- service-namespace
- session-cookie-max-age
- tls-secret-name
See [the config section](https://charmhub.io/nginx-ingress-integrator/configure) for descriptions
of each, along with the required type.
As an example, add the following to `src/charm.py`:
```
from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
# In your charm's `__init__` method.
self.ingress = IngressRequires(self, {"service-hostname": self.config["external_hostname"],
"service-name": self.app.name,
"service-port": 80})
# In your charm's `config-changed` handler.
self.ingress.update_config({"service-hostname": self.config["external_hostname"]})
```
And then add the following to `metadata.yaml`:
```
requires:
ingress:
interface: ingress
```
You _must_ register the IngressRequires class as part of the `__init__` method
rather than, for instance, a config-changed event handler. This is because
doing so won't get the current relation changed event, because it wasn't
registered to handle the event (because it wasn't created in `__init__` when
the event was fired).
"""
import logging
from ops.charm import CharmEvents
from ops.framework import EventBase, EventSource, Object
from ops.model import BlockedStatus
# The unique Charmhub library identifier, never change it
LIBID = "db0af4367506491c91663468fb5caa4c"
# Increment this major API version when introducing breaking changes
LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 10
logger = logging.getLogger(__name__)
REQUIRED_INGRESS_RELATION_FIELDS = {
"service-hostname",
"service-name",
"service-port",
}
OPTIONAL_INGRESS_RELATION_FIELDS = {
"additional-hostnames",
"limit-rps",
"limit-whitelist",
"max-body-size",
"owasp-modsecurity-crs",
"path-routes",
"retry-errors",
"rewrite-target",
"rewrite-enabled",
"service-namespace",
"session-cookie-max-age",
"tls-secret-name",
}
class IngressAvailableEvent(EventBase):
pass
class IngressBrokenEvent(EventBase):
pass
class IngressCharmEvents(CharmEvents):
"""Custom charm events."""
ingress_available = EventSource(IngressAvailableEvent)
ingress_broken = EventSource(IngressBrokenEvent)
class IngressRequires(Object):
"""This class defines the functionality for the 'requires' side of the 'ingress' relation.
Hook events observed:
- relation-changed
"""
def __init__(self, charm, config_dict):
super().__init__(charm, "ingress")
self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
self.config_dict = config_dict
def _config_dict_errors(self, update_only=False):
"""Check our config dict for errors."""
blocked_message = "Error in ingress relation, check `juju debug-log`"
unknown = [
x
for x in self.config_dict
if x not in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
]
if unknown:
logger.error(
"Ingress relation error, unknown key(s) in config dictionary found: %s",
", ".join(unknown),
)
self.model.unit.status = BlockedStatus(blocked_message)
return True
if not update_only:
missing = [x for x in REQUIRED_INGRESS_RELATION_FIELDS if x not in self.config_dict]
if missing:
logger.error(
"Ingress relation error, missing required key(s) in config dictionary: %s",
", ".join(sorted(missing)),
)
self.model.unit.status = BlockedStatus(blocked_message)
return True
return False
def _on_relation_changed(self, event):
"""Handle the relation-changed event."""
# `self.unit` isn't available here, so use `self.model.unit`.
if self.model.unit.is_leader():
if self._config_dict_errors():
return
for key in self.config_dict:
event.relation.data[self.model.app][key] = str(self.config_dict[key])
def update_config(self, config_dict):
"""Allow for updates to relation."""
if self.model.unit.is_leader():
self.config_dict = config_dict
if self._config_dict_errors(update_only=True):
return
relation = self.model.get_relation("ingress")
if relation:
for key in self.config_dict:
relation.data[self.model.app][key] = str(self.config_dict[key])
class IngressProvides(Object):
"""This class defines the functionality for the 'provides' side of the 'ingress' relation.
Hook events observed:
- relation-changed
"""
def __init__(self, charm):
super().__init__(charm, "ingress")
# Observe the relation-changed hook event and bind
# self.on_relation_changed() to handle the event.
self.framework.observe(charm.on["ingress"].relation_changed, self._on_relation_changed)
self.framework.observe(charm.on["ingress"].relation_broken, self._on_relation_broken)
self.charm = charm
def _on_relation_changed(self, event):
"""Handle a change to the ingress relation.
Confirm we have the fields we expect to receive."""
# `self.unit` isn't available here, so use `self.model.unit`.
if not self.model.unit.is_leader():
return
ingress_data = {
field: event.relation.data[event.app].get(field)
for field in REQUIRED_INGRESS_RELATION_FIELDS | OPTIONAL_INGRESS_RELATION_FIELDS
}
missing_fields = sorted(
[
field
for field in REQUIRED_INGRESS_RELATION_FIELDS
if ingress_data.get(field) is None
]
)
if missing_fields:
logger.error(
"Missing required data fields for ingress relation: {}".format(
", ".join(missing_fields)
)
)
self.model.unit.status = BlockedStatus(
"Missing fields for ingress: {}".format(", ".join(missing_fields))
)
# Create an event that our charm can use to decide it's okay to
# configure the ingress.
self.charm.on.ingress_available.emit()
def _on_relation_broken(self, _):
"""Handle a relation-broken event in the ingress relation."""
if not self.model.unit.is_leader():
return
# Create an event that our charm can use to remove the ingress resource.
self.charm.on.ingress_broken.emit()