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