Add ops.scenario tests
Add ops.scenario tests. This allows each charm class to be easily tested with different permutations of missing/incomplete/complete relations. This is a starting point for using ops.scenario, additional tests should include: examining rendered files, peer relation, test secrets events etc Change-Id: I8ebdad250d7cb169c3c0d72858e0582000d98b6e
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
test_path=./unit_tests
|
test_path=./tests/unit_tests
|
||||||
top_dir=./
|
top_dir=./
|
||||||
|
@@ -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.ceilometer_k8s.v0.ceilometer_service
|
||||||
charmcraft fetch-lib charms.cinder_ceph_k8s.v0.ceph_access
|
charmcraft fetch-lib charms.cinder_ceph_k8s.v0.ceph_access
|
||||||
echo "Copying libs to to unit_test dir"
|
echo "Copying libs to to unit_test dir"
|
||||||
rsync --recursive --delete lib/ unit_tests/lib/
|
rsync --recursive --delete lib/ tests/lib/
|
||||||
|
@@ -2,3 +2,5 @@ coverage
|
|||||||
mock
|
mock
|
||||||
stestr
|
stestr
|
||||||
requests
|
requests
|
||||||
|
pytest
|
||||||
|
ops-scenario>=4.0
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
# Copyright 2022 Canonical Ltd.
|
# Copyright 2023 Canonical Ltd.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with 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
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# 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
|
This library is a uniform interface to a selection of common database
|
||||||
metadata, with added custom events that add convenience to database management,
|
metadata, with added custom events that add convenience to database management,
|
||||||
@@ -23,7 +23,10 @@ application charm code:
|
|||||||
|
|
||||||
```python
|
```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):
|
class ApplicationCharm(CharmBase):
|
||||||
# Application charm that connects to database charms.
|
# Application charm that connects to database charms.
|
||||||
@@ -49,7 +52,7 @@ class ApplicationCharm(CharmBase):
|
|||||||
self._start_application(config_file)
|
self._start_application(config_file)
|
||||||
|
|
||||||
# Set active status
|
# 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,
|
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
|
```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):
|
class ApplicationCharm(CharmBase):
|
||||||
# Application charm that connects to database charms.
|
# Application charm that connects to database charms.
|
||||||
@@ -154,7 +160,7 @@ LIBAPI = 0
|
|||||||
|
|
||||||
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
||||||
# to 0 if you are raising the major API version.
|
# to 0 if you are raising the major API version.
|
||||||
LIBPATCH = 4
|
LIBPATCH = 6
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -165,16 +171,25 @@ class DatabaseEvent(RelationEvent):
|
|||||||
@property
|
@property
|
||||||
def endpoints(self) -> Optional[str]:
|
def endpoints(self) -> Optional[str]:
|
||||||
"""Returns a comma separated list of read/write endpoints."""
|
"""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")
|
return self.relation.data[self.relation.app].get("endpoints")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def password(self) -> Optional[str]:
|
def password(self) -> Optional[str]:
|
||||||
"""Returns the password for the created user."""
|
"""Returns the password for the created user."""
|
||||||
|
if not self.relation.app:
|
||||||
|
return None
|
||||||
|
|
||||||
return self.relation.data[self.relation.app].get("password")
|
return self.relation.data[self.relation.app].get("password")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def read_only_endpoints(self) -> Optional[str]:
|
def read_only_endpoints(self) -> Optional[str]:
|
||||||
"""Returns a comma separated list of read only endpoints."""
|
"""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")
|
return self.relation.data[self.relation.app].get("read-only-endpoints")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -183,16 +198,25 @@ class DatabaseEvent(RelationEvent):
|
|||||||
|
|
||||||
MongoDB only.
|
MongoDB only.
|
||||||
"""
|
"""
|
||||||
|
if not self.relation.app:
|
||||||
|
return None
|
||||||
|
|
||||||
return self.relation.data[self.relation.app].get("replset")
|
return self.relation.data[self.relation.app].get("replset")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tls(self) -> Optional[str]:
|
def tls(self) -> Optional[str]:
|
||||||
"""Returns whether TLS is configured."""
|
"""Returns whether TLS is configured."""
|
||||||
|
if not self.relation.app:
|
||||||
|
return None
|
||||||
|
|
||||||
return self.relation.data[self.relation.app].get("tls")
|
return self.relation.data[self.relation.app].get("tls")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tls_ca(self) -> Optional[str]:
|
def tls_ca(self) -> Optional[str]:
|
||||||
"""Returns TLS CA."""
|
"""Returns TLS CA."""
|
||||||
|
if not self.relation.app:
|
||||||
|
return None
|
||||||
|
|
||||||
return self.relation.data[self.relation.app].get("tls-ca")
|
return self.relation.data[self.relation.app].get("tls-ca")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -201,11 +225,17 @@ class DatabaseEvent(RelationEvent):
|
|||||||
|
|
||||||
MongoDB, Redis, OpenSearch and Kafka only.
|
MongoDB, Redis, OpenSearch and Kafka only.
|
||||||
"""
|
"""
|
||||||
|
if not self.relation.app:
|
||||||
|
return None
|
||||||
|
|
||||||
return self.relation.data[self.relation.app].get("uris")
|
return self.relation.data[self.relation.app].get("uris")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def username(self) -> Optional[str]:
|
def username(self) -> Optional[str]:
|
||||||
"""Returns the created username."""
|
"""Returns the created username."""
|
||||||
|
if not self.relation.app:
|
||||||
|
return None
|
||||||
|
|
||||||
return self.relation.data[self.relation.app].get("username")
|
return self.relation.data[self.relation.app].get("username")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -214,6 +244,9 @@ class DatabaseEvent(RelationEvent):
|
|||||||
|
|
||||||
Version as informed by the database daemon.
|
Version as informed by the database daemon.
|
||||||
"""
|
"""
|
||||||
|
if not self.relation.app:
|
||||||
|
return None
|
||||||
|
|
||||||
return self.relation.data[self.relation.app].get("version")
|
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):
|
class DatabaseRequires(Object):
|
||||||
"""Requires-side of the database relation."""
|
"""Requires-side of the database relation."""
|
||||||
|
|
||||||
on = DatabaseEvents()
|
on = DatabaseEvents() # pyright: ignore [reportGeneralTypeIssues]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
charm,
|
charm,
|
||||||
relation_name: str,
|
relation_name: str,
|
||||||
database_name: str,
|
database_name: str,
|
||||||
extra_user_roles: str = None,
|
extra_user_roles: Optional[str] = None,
|
||||||
relations_aliases: List[str] = None,
|
relations_aliases: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
"""Manager of database client relations."""
|
"""Manager of database client relations."""
|
||||||
super().__init__(charm, relation_name)
|
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.
|
# 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", "{}"))
|
old_data = json.loads(event.relation.data[self.local_unit].get("data", "{}"))
|
||||||
# Retrieve the new data from the event relation databag.
|
# Retrieve the new data from the event relation databag.
|
||||||
new_data = {
|
new_data = (
|
||||||
key: value for key, value in event.relation.data[event.app].items() if key != "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.
|
# These are the keys that were added to the databag and triggered this event.
|
||||||
added = new_data.keys() - old_data.keys()
|
added = new_data.keys() - old_data.keys()
|
||||||
@@ -407,9 +442,11 @@ class DatabaseRequires(Object):
|
|||||||
"""
|
"""
|
||||||
data = {}
|
data = {}
|
||||||
for relation in self.relations:
|
for relation in self.relations:
|
||||||
data[relation.id] = {
|
data[relation.id] = (
|
||||||
key: value for key, value in relation.data[relation.app].items() if key != "data"
|
{key: value for key, value in relation.data[relation.app].items() if key != "data"}
|
||||||
}
|
if relation.app
|
||||||
|
else {}
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def _update_relation_data(self, relation_id: int, data: dict) -> None:
|
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:
|
if "username" in diff.added and "password" in diff.added:
|
||||||
# Emit the default event (the one without an alias).
|
# Emit the default event (the one without an alias).
|
||||||
logger.info("database created at %s", datetime.now())
|
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).
|
# Emit the aliased event (if any).
|
||||||
self._emit_aliased_event(event, "database_created")
|
self._emit_aliased_event(event, "database_created")
|
||||||
@@ -469,7 +508,9 @@ class DatabaseRequires(Object):
|
|||||||
if "endpoints" in diff.added or "endpoints" in diff.changed:
|
if "endpoints" in diff.added or "endpoints" in diff.changed:
|
||||||
# Emit the default event (the one without an alias).
|
# Emit the default event (the one without an alias).
|
||||||
logger.info("endpoints changed on %s", datetime.now())
|
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).
|
# Emit the aliased event (if any).
|
||||||
self._emit_aliased_event(event, "endpoints_changed")
|
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:
|
if "read-only-endpoints" in diff.added or "read-only-endpoints" in diff.changed:
|
||||||
# Emit the default event (the one without an alias).
|
# Emit the default event (the one without an alias).
|
||||||
logger.info("read-only-endpoints changed on %s", datetime.now())
|
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
|
event.relation, app=event.app, unit=event.unit
|
||||||
)
|
)
|
||||||
|
|
@@ -61,11 +61,34 @@ class IdentityResourceClientCharm(CharmBase):
|
|||||||
# IdentityResource Relation has goneaway. No ops can be sent.
|
# IdentityResource Relation has goneaway. No ops can be sent.
|
||||||
pass
|
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 json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from ops.charm import (
|
||||||
|
RelationEvent,
|
||||||
|
)
|
||||||
from ops.framework import (
|
from ops.framework import (
|
||||||
EventBase,
|
EventBase,
|
||||||
EventSource,
|
EventSource,
|
||||||
@@ -88,7 +111,7 @@ LIBAPI = 0
|
|||||||
|
|
||||||
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
||||||
# to 0 if you are raising the major API version
|
# to 0 if you are raising the major API version
|
||||||
LIBPATCH = 1
|
LIBPATCH = 2
|
||||||
|
|
||||||
|
|
||||||
REQUEST_NOT_SENT = 1
|
REQUEST_NOT_SENT = 1
|
||||||
@@ -96,19 +119,19 @@ REQUEST_SENT = 2
|
|||||||
REQUEST_PROCESSED = 3
|
REQUEST_PROCESSED = 3
|
||||||
|
|
||||||
|
|
||||||
class IdentityOpsProviderReadyEvent(EventBase):
|
class IdentityOpsProviderReadyEvent(RelationEvent):
|
||||||
"""Has IdentityOpsProviderReady Event."""
|
"""Has IdentityOpsProviderReady Event."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class IdentityOpsResponseEvent(EventBase):
|
class IdentityOpsResponseEvent(RelationEvent):
|
||||||
"""Has IdentityOpsResponse Event."""
|
"""Has IdentityOpsResponse Event."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class IdentityOpsProviderGoneAwayEvent(EventBase):
|
class IdentityOpsProviderGoneAwayEvent(RelationEvent):
|
||||||
"""Has IdentityOpsProviderGoneAway Event."""
|
"""Has IdentityOpsProviderGoneAway Event."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
@@ -149,18 +172,18 @@ class IdentityResourceRequires(Object):
|
|||||||
def _on_identity_resource_relation_joined(self, event):
|
def _on_identity_resource_relation_joined(self, event):
|
||||||
"""Handle IdentityResource joined."""
|
"""Handle IdentityResource joined."""
|
||||||
self._stored.provider_ready = True
|
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):
|
def _on_identity_resource_relation_changed(self, event):
|
||||||
"""Handle IdentityResource changed."""
|
"""Handle IdentityResource changed."""
|
||||||
id_ = self.response.get("id")
|
id_ = self.response.get("id")
|
||||||
self.save_request_in_store(id_, None, None, REQUEST_PROCESSED)
|
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):
|
def _on_identity_resource_relation_broken(self, event):
|
||||||
"""Handle IdentityResource broken."""
|
"""Handle IdentityResource broken."""
|
||||||
self._stored.provider_ready = False
|
self._stored.provider_ready = False
|
||||||
self.on.provider_goneaway.emit()
|
self.on.provider_goneaway.emit(event.relation)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _identity_resource_rel(self) -> Relation:
|
def _identity_resource_rel(self) -> Relation:
|
||||||
@@ -339,7 +362,9 @@ class IdentityResourceProvides(Object):
|
|||||||
return
|
return
|
||||||
|
|
||||||
logger.debug("Update response from keystone")
|
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:
|
if not _identity_resource_rel:
|
||||||
# Relation has disappeared so skip send of data
|
# Relation has disappeared so skip send of data
|
||||||
return
|
return
|
@@ -100,7 +100,7 @@ LIBAPI = 1
|
|||||||
|
|
||||||
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
||||||
# to 0 if you are raising the major API version
|
# to 0 if you are raising the major API version
|
||||||
LIBPATCH = 0
|
LIBPATCH = 1
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -349,6 +349,11 @@ class IdentityServiceRequires(Object):
|
|||||||
"""Return the public_auth_url."""
|
"""Return the public_auth_url."""
|
||||||
return self.get_remote_app_data('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,
|
def register_services(self, service_endpoints: dict,
|
||||||
region: str) -> None:
|
region: str) -> None:
|
||||||
"""Request access to the IdentityService server."""
|
"""Request access to the IdentityService server."""
|
||||||
@@ -481,7 +486,8 @@ class IdentityServiceProvides(Object):
|
|||||||
internal_auth_url: str,
|
internal_auth_url: str,
|
||||||
admin_auth_url: str,
|
admin_auth_url: str,
|
||||||
public_auth_url: str,
|
public_auth_url: str,
|
||||||
service_credentials: str):
|
service_credentials: str,
|
||||||
|
admin_role: str):
|
||||||
logging.debug("Setting identity_service connection information.")
|
logging.debug("Setting identity_service connection information.")
|
||||||
_identity_service_rel = None
|
_identity_service_rel = None
|
||||||
for relation in self.framework.model.relations[relation_name]:
|
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["admin-auth-url"] = admin_auth_url
|
||||||
app_data["public-auth-url"] = public_auth_url
|
app_data["public-auth-url"] = public_auth_url
|
||||||
app_data["service-credentials"] = service_credentials
|
app_data["service-credentials"] = service_credentials
|
||||||
|
app_data["admin-role"] = admin_role
|
@@ -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
|
||||||
|
)
|
@@ -69,7 +69,7 @@ LIBAPI = 1
|
|||||||
|
|
||||||
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
# Increment this PATCH version before using `charmcraft publish-lib` or reset
|
||||||
# to 0 if you are raising the major API version
|
# to 0 if you are raising the major API version
|
||||||
LIBPATCH = 5
|
LIBPATCH = 15
|
||||||
|
|
||||||
DEFAULT_RELATION_NAME = "ingress"
|
DEFAULT_RELATION_NAME = "ingress"
|
||||||
RELATION_INTERFACE = "ingress"
|
RELATION_INTERFACE = "ingress"
|
||||||
@@ -98,6 +98,7 @@ INGRESS_REQUIRES_APP_SCHEMA = {
|
|||||||
"host": {"type": "string"},
|
"host": {"type": "string"},
|
||||||
"port": {"type": "string"},
|
"port": {"type": "string"},
|
||||||
"strip-prefix": {"type": "string"},
|
"strip-prefix": {"type": "string"},
|
||||||
|
"redirect-https": {"type": "string"},
|
||||||
},
|
},
|
||||||
"required": ["model", "name", "host", "port"],
|
"required": ["model", "name", "host", "port"],
|
||||||
}
|
}
|
||||||
@@ -113,18 +114,25 @@ INGRESS_PROVIDES_APP_SCHEMA = {
|
|||||||
try:
|
try:
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
except ImportError:
|
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.
|
# Model of the data a unit implementing the requirer will need to provide.
|
||||||
RequirerData = TypedDict(
|
RequirerData = TypedDict(
|
||||||
"RequirerData",
|
"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,
|
total=False,
|
||||||
)
|
)
|
||||||
# Provider ingress data model.
|
# Provider ingress data model.
|
||||||
ProviderIngressData = TypedDict("ProviderIngressData", {"url": str})
|
ProviderIngressData = TypedDict("ProviderIngressData", {"url": str})
|
||||||
# Provider application databag model.
|
# Provider application databag model.
|
||||||
ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData})
|
ProviderApplicationData = TypedDict("ProviderApplicationData", {"ingress": ProviderIngressData}) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def _validate_data(data, schema):
|
def _validate_data(data, schema):
|
||||||
@@ -148,7 +156,7 @@ class _IngressPerAppBase(Object):
|
|||||||
"""Base class for IngressPerUnit interface classes."""
|
"""Base class for IngressPerUnit interface classes."""
|
||||||
|
|
||||||
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME):
|
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.charm: CharmBase = charm
|
||||||
self.relation_name = relation_name
|
self.relation_name = relation_name
|
||||||
@@ -161,8 +169,8 @@ class _IngressPerAppBase(Object):
|
|||||||
observe(rel_events.relation_joined, self._handle_relation)
|
observe(rel_events.relation_joined, self._handle_relation)
|
||||||
observe(rel_events.relation_changed, self._handle_relation)
|
observe(rel_events.relation_changed, self._handle_relation)
|
||||||
observe(rel_events.relation_broken, self._handle_relation_broken)
|
observe(rel_events.relation_broken, self._handle_relation_broken)
|
||||||
observe(charm.on.leader_elected, 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)
|
observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def relations(self):
|
def relations(self):
|
||||||
@@ -183,8 +191,8 @@ class _IngressPerAppBase(Object):
|
|||||||
|
|
||||||
|
|
||||||
class _IPAEvent(RelationEvent):
|
class _IPAEvent(RelationEvent):
|
||||||
__args__ = () # type: Tuple[str, ...]
|
__args__: Tuple[str, ...] = ()
|
||||||
__optional_kwargs__ = {} # type: Dict[str, Any]
|
__optional_kwargs__: Dict[str, Any] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __attrs__(cls):
|
def __attrs__(cls):
|
||||||
@@ -202,7 +210,7 @@ class _IPAEvent(RelationEvent):
|
|||||||
obj = kwargs.get(attr, default)
|
obj = kwargs.get(attr, default)
|
||||||
setattr(self, attr, obj)
|
setattr(self, attr, obj)
|
||||||
|
|
||||||
def snapshot(self) -> dict:
|
def snapshot(self):
|
||||||
dct = super().snapshot()
|
dct = super().snapshot()
|
||||||
for attr in self.__attrs__():
|
for attr in self.__attrs__():
|
||||||
obj = getattr(self, attr)
|
obj = getattr(self, attr)
|
||||||
@@ -217,7 +225,7 @@ class _IPAEvent(RelationEvent):
|
|||||||
|
|
||||||
return dct
|
return dct
|
||||||
|
|
||||||
def restore(self, snapshot: dict) -> None:
|
def restore(self, snapshot) -> None:
|
||||||
super().restore(snapshot)
|
super().restore(snapshot)
|
||||||
for attr, obj in snapshot.items():
|
for attr, obj in snapshot.items():
|
||||||
setattr(self, attr, obj)
|
setattr(self, attr, obj)
|
||||||
@@ -226,14 +234,15 @@ class _IPAEvent(RelationEvent):
|
|||||||
class IngressPerAppDataProvidedEvent(_IPAEvent):
|
class IngressPerAppDataProvidedEvent(_IPAEvent):
|
||||||
"""Event representing that ingress data has been provided for an app."""
|
"""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:
|
if typing.TYPE_CHECKING:
|
||||||
name = None # type: str
|
name: Optional[str] = None
|
||||||
model = None # type: str
|
model: Optional[str] = None
|
||||||
port = None # type: int
|
port: Optional[str] = None
|
||||||
host = None # type: str
|
host: Optional[str] = None
|
||||||
strip_prefix = False # type: bool
|
strip_prefix: bool = False
|
||||||
|
redirect_https: bool = False
|
||||||
|
|
||||||
|
|
||||||
class IngressPerAppDataRemovedEvent(RelationEvent):
|
class IngressPerAppDataRemovedEvent(RelationEvent):
|
||||||
@@ -250,7 +259,7 @@ class IngressPerAppProviderEvents(ObjectEvents):
|
|||||||
class IngressPerAppProvider(_IngressPerAppBase):
|
class IngressPerAppProvider(_IngressPerAppBase):
|
||||||
"""Implementation of the provider of ingress."""
|
"""Implementation of the provider of ingress."""
|
||||||
|
|
||||||
on = IngressPerAppProviderEvents()
|
on = IngressPerAppProviderEvents() # type: ignore
|
||||||
|
|
||||||
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME):
|
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME):
|
||||||
"""Constructor for IngressPerAppProvider.
|
"""Constructor for IngressPerAppProvider.
|
||||||
@@ -267,17 +276,18 @@ class IngressPerAppProvider(_IngressPerAppBase):
|
|||||||
# notify listeners.
|
# notify listeners.
|
||||||
if self.is_ready(event.relation):
|
if self.is_ready(event.relation):
|
||||||
data = self._get_requirer_data(event.relation)
|
data = self._get_requirer_data(event.relation)
|
||||||
self.on.data_provided.emit(
|
self.on.data_provided.emit( # type: ignore
|
||||||
event.relation,
|
event.relation,
|
||||||
data["name"],
|
data["name"],
|
||||||
data["model"],
|
data["model"],
|
||||||
data["port"],
|
data["port"],
|
||||||
data["host"],
|
data["host"],
|
||||||
data.get("strip-prefix", False),
|
data.get("strip-prefix", False),
|
||||||
|
data.get("redirect-https", False),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _handle_relation_broken(self, event):
|
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):
|
def wipe_ingress_data(self, relation: Relation):
|
||||||
"""Clear ingress data from relation."""
|
"""Clear ingress data from relation."""
|
||||||
@@ -293,33 +303,34 @@ class IngressPerAppProvider(_IngressPerAppBase):
|
|||||||
return
|
return
|
||||||
del relation.data[self.app]["ingress"]
|
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.
|
"""Fetch and validate the requirer's app databag.
|
||||||
|
|
||||||
For convenience, we convert 'port' to integer.
|
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.,
|
# Handle edge case where remote app name can be missing, e.g.,
|
||||||
# relation_broken events.
|
# relation_broken events.
|
||||||
# FIXME https://github.com/canonical/traefik-k8s-operator/issues/34
|
# FIXME https://github.com/canonical/traefik-k8s-operator/issues/34
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
databag = relation.data[relation.app]
|
databag = relation.data[relation.app]
|
||||||
remote_data = {} # type: Dict[str, Union[int, str]]
|
remote_data: Dict[str, Union[int, str]] = {}
|
||||||
for k in ("port", "host", "model", "name", "mode", "strip-prefix"):
|
for k in ("port", "host", "model", "name", "mode", "strip-prefix", "redirect-https"):
|
||||||
v = databag.get(k)
|
v = databag.get(k)
|
||||||
if v is not None:
|
if v is not None:
|
||||||
remote_data[k] = v
|
remote_data[k] = v
|
||||||
_validate_data(remote_data, INGRESS_REQUIRES_APP_SCHEMA)
|
_validate_data(remote_data, INGRESS_REQUIRES_APP_SCHEMA)
|
||||||
remote_data["port"] = int(remote_data["port"])
|
remote_data["port"] = int(remote_data["port"])
|
||||||
remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", False))
|
remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", "false") == "true")
|
||||||
return remote_data
|
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."""
|
"""Fetch the remote app's databag, i.e. the requirer data."""
|
||||||
return self._get_requirer_data(relation)
|
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."""
|
"""The Provider is ready if the requirer has sent valid data."""
|
||||||
if not relation:
|
if not relation:
|
||||||
return any(map(self.is_ready, self.relations))
|
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))
|
log.warning("Requirer not ready; validation error encountered: %s" % str(e))
|
||||||
return False
|
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."""
|
"""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.,
|
# Handle edge case where remote app name can be missing, e.g.,
|
||||||
# relation_broken events.
|
# relation_broken events.
|
||||||
# Also, only leader units can read own app databags.
|
# Also, only leader units can read own app databags.
|
||||||
# FIXME https://github.com/canonical/traefik-k8s-operator/issues/34
|
# FIXME https://github.com/canonical/traefik-k8s-operator/issues/34
|
||||||
return {} # noqa
|
return typing.cast(ProviderIngressData, {}) # noqa
|
||||||
|
|
||||||
# fetch the provider's app databag
|
# fetch the provider's app databag
|
||||||
raw_data = relation.data[self.app].get("ingress")
|
raw_data = relation.data[self.app].get("ingress")
|
||||||
@@ -374,6 +385,9 @@ class IngressPerAppProvider(_IngressPerAppBase):
|
|||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
for ingress_relation in self.relations:
|
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)
|
results[ingress_relation.app.name] = self._provided_url(ingress_relation)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
@@ -384,7 +398,7 @@ class IngressPerAppReadyEvent(_IPAEvent):
|
|||||||
|
|
||||||
__args__ = ("url",)
|
__args__ = ("url",)
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
url = None # type: str
|
url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class IngressPerAppRevokedEvent(RelationEvent):
|
class IngressPerAppRevokedEvent(RelationEvent):
|
||||||
@@ -401,8 +415,9 @@ class IngressPerAppRequirerEvents(ObjectEvents):
|
|||||||
class IngressPerAppRequirer(_IngressPerAppBase):
|
class IngressPerAppRequirer(_IngressPerAppBase):
|
||||||
"""Implementation of the requirer of the ingress relation."""
|
"""Implementation of the requirer of the ingress relation."""
|
||||||
|
|
||||||
on = IngressPerAppRequirerEvents()
|
on = IngressPerAppRequirerEvents() # type: ignore
|
||||||
# used to prevent spur1ious urls to be sent out if the event we're currently
|
|
||||||
|
# used to prevent spurious urls to be sent out if the event we're currently
|
||||||
# handling is a relation-broken one.
|
# handling is a relation-broken one.
|
||||||
_stored = StoredState()
|
_stored = StoredState()
|
||||||
|
|
||||||
@@ -411,9 +426,10 @@ class IngressPerAppRequirer(_IngressPerAppBase):
|
|||||||
charm: CharmBase,
|
charm: CharmBase,
|
||||||
relation_name: str = DEFAULT_RELATION_NAME,
|
relation_name: str = DEFAULT_RELATION_NAME,
|
||||||
*,
|
*,
|
||||||
host: str = None,
|
host: Optional[str] = None,
|
||||||
port: int = None,
|
port: Optional[int] = None,
|
||||||
strip_prefix: bool = False,
|
strip_prefix: bool = False,
|
||||||
|
redirect_https: bool = False,
|
||||||
):
|
):
|
||||||
"""Constructor for IngressRequirer.
|
"""Constructor for IngressRequirer.
|
||||||
|
|
||||||
@@ -429,6 +445,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
|
|||||||
host: Hostname to be used by the ingress provider to address the requiring
|
host: Hostname to be used by the ingress provider to address the requiring
|
||||||
application; if unspecified, the default Kubernetes service name will be used.
|
application; if unspecified, the default Kubernetes service name will be used.
|
||||||
strip_prefix: configure Traefik to strip the path prefix.
|
strip_prefix: configure Traefik to strip the path prefix.
|
||||||
|
redirect_https: redirect incoming requests to the HTTPS.
|
||||||
|
|
||||||
Request Args:
|
Request Args:
|
||||||
port: the port of the service
|
port: the port of the service
|
||||||
@@ -437,8 +454,9 @@ class IngressPerAppRequirer(_IngressPerAppBase):
|
|||||||
self.charm: CharmBase = charm
|
self.charm: CharmBase = charm
|
||||||
self.relation_name = relation_name
|
self.relation_name = relation_name
|
||||||
self._strip_prefix = strip_prefix
|
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
|
# if instantiated with a port, and we are related, then
|
||||||
# we immediately publish our ingress data to speed up the process.
|
# we immediately publish our ingress data to speed up the process.
|
||||||
@@ -458,13 +476,13 @@ class IngressPerAppRequirer(_IngressPerAppBase):
|
|||||||
if isinstance(event, RelationBrokenEvent)
|
if isinstance(event, RelationBrokenEvent)
|
||||||
else self._get_url_from_relation_data()
|
else self._get_url_from_relation_data()
|
||||||
)
|
)
|
||||||
if self._stored.current_url != new_url:
|
if self._stored.current_url != new_url: # type: ignore
|
||||||
self._stored.current_url = new_url
|
self._stored.current_url = new_url # type: ignore
|
||||||
self.on.ready.emit(event.relation, new_url)
|
self.on.ready.emit(event.relation, new_url) # type: ignore
|
||||||
|
|
||||||
def _handle_relation_broken(self, event):
|
def _handle_relation_broken(self, event):
|
||||||
self._stored.current_url = None
|
self._stored.current_url = None # type: ignore
|
||||||
self.on.revoked.emit(event.relation)
|
self.on.revoked.emit(event.relation) # type: ignore
|
||||||
|
|
||||||
def _handle_upgrade_or_leader(self, event):
|
def _handle_upgrade_or_leader(self, event):
|
||||||
"""On upgrade/leadership change: ensure we publish the data we have."""
|
"""On upgrade/leadership change: ensure we publish the data we have."""
|
||||||
@@ -484,7 +502,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
|
|||||||
host, port = self._auto_data
|
host, port = self._auto_data
|
||||||
self.provide_ingress_requirements(host=host, port=port)
|
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.
|
"""Publishes the data that Traefik needs to provide ingress.
|
||||||
|
|
||||||
NB only the leader unit is supposed to do this.
|
NB only the leader unit is supposed to do this.
|
||||||
@@ -513,6 +531,9 @@ class IngressPerAppRequirer(_IngressPerAppBase):
|
|||||||
if self._strip_prefix:
|
if self._strip_prefix:
|
||||||
data["strip-prefix"] = "true"
|
data["strip-prefix"] = "true"
|
||||||
|
|
||||||
|
if self._redirect_https:
|
||||||
|
data["redirect-https"] = "true"
|
||||||
|
|
||||||
_validate_data(data, INGRESS_REQUIRES_APP_SCHEMA)
|
_validate_data(data, INGRESS_REQUIRES_APP_SCHEMA)
|
||||||
self.relation.data[self.app].update(data)
|
self.relation.data[self.app].update(data)
|
||||||
|
|
||||||
@@ -527,7 +548,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
|
|||||||
Returns None if the URL isn't available yet.
|
Returns None if the URL isn't available yet.
|
||||||
"""
|
"""
|
||||||
relation = self.relation
|
relation = self.relation
|
||||||
if not relation:
|
if not relation or not relation.app:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# fetch the provider's app databag
|
# fetch the provider's app databag
|
||||||
@@ -553,6 +574,6 @@ class IngressPerAppRequirer(_IngressPerAppBase):
|
|||||||
|
|
||||||
Returns None if the URL isn't available yet.
|
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
|
assert isinstance(data, (str, type(None))) # for static checker
|
||||||
return data
|
return data
|
18
ops-sunbeam/tests/scenario_tests/__init__.py
Normal file
18
ops-sunbeam/tests/scenario_tests/__init__.py
Normal 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
|
142
ops-sunbeam/tests/scenario_tests/scenario_utils.py
Normal file
142
ops-sunbeam/tests/scenario_tests/scenario_utils.py
Normal 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
|
192
ops-sunbeam/tests/scenario_tests/test_fixtures.py
Normal file
192
ops-sunbeam/tests/scenario_tests/test_fixtures.py
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
383
ops-sunbeam/tests/scenario_tests/test_scenario.py
Normal file
383
ops-sunbeam/tests/scenario_tests/test_scenario.py
Normal 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("")
|
18
ops-sunbeam/tests/unit_tests/__init__.py
Normal file
18
ops-sunbeam/tests/unit_tests/__init__.py
Normal 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
|
@@ -30,7 +30,7 @@ from typing import (
|
|||||||
List,
|
List,
|
||||||
)
|
)
|
||||||
|
|
||||||
sys.path.append("unit_tests/lib") # noqa
|
sys.path.append("tests/unit_tests/lib") # noqa
|
||||||
sys.path.append("src") # noqa
|
sys.path.append("src") # noqa
|
||||||
|
|
||||||
import ops_sunbeam.charm as sunbeam_charm
|
import ops_sunbeam.charm as sunbeam_charm
|
@@ -19,7 +19,7 @@ import sys
|
|||||||
|
|
||||||
import mock
|
import mock
|
||||||
|
|
||||||
sys.path.append("lib") # noqa
|
sys.path.append("tests/lib") # noqa
|
||||||
sys.path.append("src") # noqa
|
sys.path.append("src") # noqa
|
||||||
|
|
||||||
import ops.model
|
import ops.model
|
@@ -10,8 +10,9 @@ requires = virtualenv < 20.0
|
|||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
src_path = {toxinidir}/ops_sunbeam
|
src_path = {toxinidir}/ops_sunbeam
|
||||||
tst_path = {toxinidir}/unit_tests/
|
tst_path = {toxinidir}/tests/unit_tests/
|
||||||
tst_lib_path = {toxinidir}/unit_tests/lib/
|
scenario_tst_path = {toxinidir}/tests/scenario_tests/
|
||||||
|
tst_lib_path = {toxinidir}/tests/lib/
|
||||||
pyproject_toml = {toxinidir}/pyproject.toml
|
pyproject_toml = {toxinidir}/pyproject.toml
|
||||||
cookie_cutter_path = {toxinidir}/shared_code/sunbeam_charm/\{\{cookiecutter.service_name\}\}
|
cookie_cutter_path = {toxinidir}/shared_code/sunbeam_charm/\{\{cookiecutter.service_name\}\}
|
||||||
all_path = {[vars]src_path} {[vars]tst_path}
|
all_path = {[vars]src_path} {[vars]tst_path}
|
||||||
@@ -20,7 +21,9 @@ all_path = {[vars]src_path} {[vars]tst_path}
|
|||||||
basepython = python3
|
basepython = python3
|
||||||
install_command =
|
install_command =
|
||||||
pip install {opts} {packages}
|
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 =
|
allowlist_externals =
|
||||||
git
|
git
|
||||||
charmcraft
|
charmcraft
|
||||||
@@ -103,6 +106,14 @@ commands =
|
|||||||
coverage xml -o cover/coverage.xml
|
coverage xml -o cover/coverage.xml
|
||||||
coverage report
|
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]
|
[coverage:run]
|
||||||
branch = True
|
branch = True
|
||||||
concurrency = multiprocessing
|
concurrency = multiprocessing
|
||||||
|
@@ -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()
|
|
Reference in New Issue
Block a user