From 14912510aefc7c52aab9e299e75d7eb1cb5e8a90 Mon Sep 17 00:00:00 2001 From: alesstimec Date: Tue, 6 Jun 2023 15:17:07 +0200 Subject: [PATCH 1/2] Fix for the StartJWKSRotator If Vault is not present, we cannot create a JWKS service and JIMM would panic starting the JWKS rotator. --- service.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/service.go b/service.go index 8938066fb..6d71c59e3 100644 --- a/service.go +++ b/service.go @@ -292,6 +292,8 @@ func NewService(ctx context.Context, p Params) (*Service, error) { if s.jimm.CredentialStore != nil { s.jimm.JWKService = jimmjwx.NewJWKSService(s.jimm.CredentialStore) s.jimm.JWTService = jimmjwx.NewJWTService(p.PublicDNSName, s.jimm.CredentialStore, false) + } else { + zapctx.Warn(ctx, "not starting JWKS service - vault not available") } mountHandler := func(path string, h jimmhttp.JIMMHttpHandler) { From 61a3558989db8566ec142c90619efc07114087c4 Mon Sep 17 00:00:00 2001 From: alesstimec Date: Tue, 6 Jun 2023 14:31:56 +0200 Subject: [PATCH 2/2] Adds nginx relation to the juju-jimm-k8s charm. K8s charm now has two separate relations: - traefik_ingress, which can be used to relate to traefik - ingress, which can be used to relate to nginx-ingress-integrator --- charms/jimm-k8s/config.yaml | 3 + .../nginx_ingress_integrator/v0/ingress.py | 227 -------- .../v0/nginx_route.py | 392 +++++++++++++ charms/jimm-k8s/metadata.yaml | 2 + charms/jimm-k8s/src/charm.py | 528 +++++++++--------- charms/jimm-k8s/src/state.py | 134 +++-- .../jimm-k8s/tests/integration/test_charm.py | 27 +- .../integration/test_charm_with_nginx.py | 129 +++++ charms/jimm-k8s/tests/unit/test_charm.py | 19 +- charms/jimm-k8s/tox.ini | 2 +- 10 files changed, 885 insertions(+), 578 deletions(-) delete mode 100644 charms/jimm-k8s/lib/charms/nginx_ingress_integrator/v0/ingress.py create mode 100644 charms/jimm-k8s/lib/charms/nginx_ingress_integrator/v0/nginx_route.py create mode 100644 charms/jimm-k8s/tests/integration/test_charm_with_nginx.py diff --git a/charms/jimm-k8s/config.yaml b/charms/jimm-k8s/config.yaml index 2f335db40..b718056a1 100644 --- a/charms/jimm-k8s/config.yaml +++ b/charms/jimm-k8s/config.yaml @@ -60,3 +60,6 @@ options: private-key: type: string description: The private part of JIMM's macaroon bakery keypair. + dns-name: + type: string + description: DNS hostname that JIMM is being served from. diff --git a/charms/jimm-k8s/lib/charms/nginx_ingress_integrator/v0/ingress.py b/charms/jimm-k8s/lib/charms/nginx_ingress_integrator/v0/ingress.py deleted file mode 100644 index c3fac4ca1..000000000 --- a/charms/jimm-k8s/lib/charms/nginx_ingress_integrator/v0/ingress.py +++ /dev/null @@ -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() diff --git a/charms/jimm-k8s/lib/charms/nginx_ingress_integrator/v0/nginx_route.py b/charms/jimm-k8s/lib/charms/nginx_ingress_integrator/v0/nginx_route.py new file mode 100644 index 000000000..fd2f54d62 --- /dev/null +++ b/charms/jimm-k8s/lib/charms/nginx_ingress_integrator/v0/nginx_route.py @@ -0,0 +1,392 @@ +# Copyright 2023 Canonical Ltd. +# Licensed under the Apache2.0, see LICENCE file in charm source for details. +"""Library for the nginx-route relation. + +This library contains the require and provide functions for handling +the nginx-route interface. + +Import `require_nginx_route` in your charm, with four required keyword arguments: +- charm: (the charm itself) +- service_hostname +- service_name +- service_port + +Other optional arguments include: +- 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 + +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`: +```python +from charms.nginx_ingress_integrator.v0.nginx_route import NginxRouteRequirer + +# In your charm's `__init__` method. +require_nginx_route( + charm=self, + service_hostname=self.config["external_hostname"], + service_name=self.app.name, + service_port=80 +) + +``` +And then add the following to `metadata.yaml`: +``` +requires: + nginx-route: + interface: nginx-route +``` +You _must_ require nginx route as part of the `__init__` method +rather than, for instance, a config-changed event handler, for the relation +changed event to be properly handled. +""" +import logging +import typing +import weakref + +import ops.charm +import ops.framework +import ops.model + +# The unique Charmhub library identifier, never change it +LIBID = "3c212b6ed3cf43dfbf9f2e322e634beb" + +# 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 = 2 + +__all__ = ["require_nginx_route", "provide_nginx_route"] + +logger = logging.getLogger(__name__) + + +class _NginxRouteAvailableEvent(ops.framework.EventBase): + """NginxRouteAvailableEvent custom event. + + This event indicates the nginx-route provider is available. + """ + + +class _NginxRouteBrokenEvent(ops.charm.RelationBrokenEvent): + """NginxRouteBrokenEvent custom event. + + This event indicates the nginx-route provider is broken. + """ + + +class _NginxRouteCharmEvents(ops.charm.CharmEvents): + """Custom charm events. + + Attrs: + nginx_route_available: Event to indicate that Nginx route relation is available. + nginx_route_broken: Event to indicate that Nginx route relation is broken. + """ + + nginx_route_available = ops.framework.EventSource(_NginxRouteAvailableEvent) + nginx_route_broken = ops.framework.EventSource(_NginxRouteBrokenEvent) + + +class _NginxRouteRequirer(ops.framework.Object): + """This class defines the functionality for the 'requires' side of the 'nginx-route' relation. + + Hook events observed: + - relation-changed + """ + + def __init__( + self, + charm: ops.charm.CharmBase, + config: typing.Dict[str, typing.Union[str, int, bool]], + nginx_route_relation_name: str = "nginx-route", + ): + """Init function for the NginxRouteRequires class. + + Args: + charm: The charm that requires the nginx-route relation. + config: Contains all the configuration options for nginx-route. + nginx_route_relation_name: Specifies the relation name of the relation handled by this + requirer class. The relation must have the nginx-route interface. + """ + super().__init__(charm, nginx_route_relation_name) + self._charm = charm + self._nginx_route_relation_name = nginx_route_relation_name + self._charm.framework.observe( + self._charm.on[self._nginx_route_relation_name].relation_changed, + self._config_reconciliation, + ) + # Set default values. + self._config: typing.Dict[str, typing.Union[str, int, bool]] = { + "service-namespace": self._charm.model.name, + **config, + } + self._config_reconciliation(None) + + def _config_reconciliation(self, _event: typing.Any = None) -> None: + """Update the nginx-route relation data to be exactly as defined by config.""" + if not self._charm.model.unit.is_leader(): + return + for relation in self._charm.model.relations[self._nginx_route_relation_name]: + relation_app_data = relation.data[self._charm.app] + delete_keys = { + relation_field + for relation_field in relation_app_data + if relation_field not in self._config + } + for delete_key in delete_keys: + del relation_app_data[delete_key] + relation_app_data.update({k: str(v) for k, v in self._config.items()}) + + +def require_nginx_route( # pylint: disable=too-many-locals,too-many-branches + *, + charm: ops.charm.CharmBase, + service_hostname: str, + service_name: str, + service_port: int, + additional_hostnames: typing.Optional[str] = None, + backend_protocol: typing.Optional[str] = None, + limit_rps: typing.Optional[int] = None, + limit_whitelist: typing.Optional[str] = None, + max_body_size: typing.Optional[int] = None, + owasp_modsecurity_crs: typing.Optional[str] = None, + owasp_modsecurity_custom_rules: typing.Optional[str] = None, + path_routes: typing.Optional[str] = None, + retry_errors: typing.Optional[str] = None, + rewrite_target: typing.Optional[str] = None, + rewrite_enabled: typing.Optional[bool] = None, + service_namespace: typing.Optional[str] = None, + session_cookie_max_age: typing.Optional[int] = None, + tls_secret_name: typing.Optional[str] = None, + nginx_route_relation_name: str = "nginx-route", +) -> None: + """Set up nginx-route relation handlers on the requirer side. + + This function must be invoked in the charm class constructor. + + Args: + charm: The charm that requires the nginx-route relation. + service_hostname: configure Nginx ingress integrator + service-hostname option via relation. + service_name: configure Nginx ingress integrator service-name + option via relation. + service_port: configure Nginx ingress integrator service-port + option via relation. + additional_hostnames: configure Nginx ingress integrator + additional-hostnames option via relation, optional. + backend_protocol: configure Nginx ingress integrator + backend-protocol option via relation, optional. + limit_rps: configure Nginx ingress integrator limit-rps + option via relation, optional. + limit_whitelist: configure Nginx ingress integrator + limit-whitelist option via relation, optional. + max_body_size: configure Nginx ingress integrator + max-body-size option via relation, optional. + owasp_modsecurity_crs: configure Nginx ingress integrator + owasp-modsecurity-crs option via relation, optional. + owasp_modsecurity_custom_rules: configure Nginx ingress + integrator owasp-modsecurity-custom-rules option via + relation, optional. + path_routes: configure Nginx ingress integrator path-routes + option via relation, optional. + retry_errors: configure Nginx ingress integrator retry-errors + option via relation, optional. + rewrite_target: configure Nginx ingress integrator + rewrite-target option via relation, optional. + rewrite_enabled: configure Nginx ingress integrator + rewrite-enabled option via relation, optional. + service_namespace: configure Nginx ingress integrator + service-namespace option via relation, optional. + session_cookie_max_age: configure Nginx ingress integrator + session-cookie-max-age option via relation, optional. + tls_secret_name: configure Nginx ingress integrator + tls-secret-name option via relation, optional. + nginx_route_relation_name: Specifies the relation name of + the relation handled by this requirer class. The relation + must have the nginx-route interface. + """ + config: typing.Dict[str, typing.Union[str, int, bool]] = {} + if service_hostname is not None: + config["service-hostname"] = service_hostname + if service_name is not None: + config["service-name"] = service_name + if service_port is not None: + config["service-port"] = service_port + if additional_hostnames is not None: + config["additional-hostnames"] = additional_hostnames + if backend_protocol is not None: + config["backend-protocol"] = backend_protocol + if limit_rps is not None: + config["limit-rps"] = limit_rps + if limit_whitelist is not None: + config["limit-whitelist"] = limit_whitelist + if max_body_size is not None: + config["max-body-size"] = max_body_size + if owasp_modsecurity_crs is not None: + config["owasp-modsecurity-crs"] = owasp_modsecurity_crs + if owasp_modsecurity_custom_rules is not None: + config["owasp-modsecurity-custom-rules"] = owasp_modsecurity_custom_rules + if path_routes is not None: + config["path-routes"] = path_routes + if retry_errors is not None: + config["retry-errors"] = retry_errors + if rewrite_target is not None: + config["rewrite-target"] = rewrite_target + if rewrite_enabled is not None: + config["rewrite-enabled"] = rewrite_enabled + if service_namespace is not None: + config["service-namespace"] = service_namespace + if session_cookie_max_age is not None: + config["session-cookie-max-age"] = session_cookie_max_age + if tls_secret_name is not None: + config["tls-secret-name"] = tls_secret_name + + _NginxRouteRequirer( + charm=charm, config=config, nginx_route_relation_name=nginx_route_relation_name + ) + + +class _NginxRouteProvider(ops.framework.Object): + """Class containing the functionality for the 'provides' side of the 'nginx-route' relation. + + Attrs: + on: nginx-route relation event describer. + + Hook events observed: + - relation-changed + """ + + on = _NginxRouteCharmEvents() + + def __init__( + self, + charm: ops.charm.CharmBase, + nginx_route_relation_name: str = "nginx-route", + ): + """Init function for the NginxRouterProvides class. + + Args: + charm: The charm that provides the nginx-route relation. + nginx_route_relation_name: Specifies the relation name of the relation handled by this + provider class. The relation must have the nginx-route interface. + """ + # Observe the relation-changed hook event and bind + # self.on_relation_changed() to handle the event. + super().__init__(charm, nginx_route_relation_name) + self._charm = charm + self._charm.framework.observe( + self._charm.on[nginx_route_relation_name].relation_changed, self._on_relation_changed + ) + self._charm.framework.observe( + self._charm.on[nginx_route_relation_name].relation_broken, self._on_relation_broken + ) + + def _on_relation_changed(self, event: ops.charm.RelationChangedEvent) -> None: + """Handle a change to the nginx-route 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._charm.model.unit.is_leader(): + return + + relation_name = event.relation.name + remote_app = event.app + if remote_app is None: + raise RuntimeError("_on_relation_changed was triggered by a broken relation.") + + if not event.relation.data[remote_app]: + logger.info( + "%s hasn't finished configuring, waiting until the relation data is populated.", + relation_name, + ) + return + + required_fields = {"service-hostname", "service-port", "service-name"} + missing_fields = sorted( + field + for field in required_fields + if event.relation.data[remote_app].get(field) is None + ) + if missing_fields: + logger.warning( + "Missing required data fields for %s relation: %s", + relation_name, + ", ".join(missing_fields), + ) + self._charm.model.unit.status = ops.model.BlockedStatus( + f"Missing fields for {relation_name}: {', '.join(missing_fields)}" + ) + return + + # Create an event that our charm can use to decide it's okay to + # configure the Kubernetes Nginx ingress resources. + self.on.nginx_route_available.emit() + + def _on_relation_broken(self, event: ops.charm.RelationBrokenEvent) -> None: + """Handle a relation-broken event in the nginx-route relation. + + Args: + event: Event triggering the relation-broken hook for the relation. + """ + if not self._charm.model.unit.is_leader(): + return + + # Create an event that our charm can use to remove the Kubernetes Nginx ingress resources. + self.on.nginx_route_broken.emit(event.relation) + + +# This is here only to maintain a reference to the instance of NginxRouteProvider created by +# the provide_nginx_route function. This is required for ops framework event handling to work. +# The provider instance will have the same lifetime as the charm that creates it. +__provider_references: weakref.WeakKeyDictionary = weakref.WeakKeyDictionary() + + +def provide_nginx_route( + charm: ops.charm.CharmBase, + on_nginx_route_available: typing.Callable, + on_nginx_route_broken: typing.Callable, + nginx_route_relation_name: str = "nginx-route", +) -> None: + """Set up nginx-route relation handlers on the provider side. + + This function must be invoked in the charm class constructor. + + Args: + charm: The charm that requires the nginx-route relation. + on_nginx_route_available: Callback function for the nginx-route-available event. + on_nginx_route_broken: Callback function for the nginx-route-broken event. + nginx_route_relation_name: Specifies the relation name of the relation handled by this + provider class. The relation must have the nginx-route interface. + """ + if __provider_references.get(charm, {}).get(nginx_route_relation_name) is not None: + raise RuntimeError( + "provide_nginx_route was invoked twice with the same nginx-route relation name" + ) + provider = _NginxRouteProvider( + charm=charm, nginx_route_relation_name=nginx_route_relation_name + ) + if charm in __provider_references: + __provider_references[charm][nginx_route_relation_name] = provider + else: + __provider_references[charm] = {nginx_route_relation_name: provider} + charm.framework.observe(provider.on.nginx_route_available, on_nginx_route_available) + charm.framework.observe(provider.on.nginx_route_broken, on_nginx_route_broken) diff --git a/charms/jimm-k8s/metadata.yaml b/charms/jimm-k8s/metadata.yaml index 1300e58d9..3290c4af3 100644 --- a/charms/jimm-k8s/metadata.yaml +++ b/charms/jimm-k8s/metadata.yaml @@ -24,6 +24,8 @@ requires: ingress: interface: ingress limit: 1 + nginx-route: + interface: nginx-route database: interface: postgresql_client limit: 1 diff --git a/charms/jimm-k8s/src/charm.py b/charms/jimm-k8s/src/charm.py index c6476ac61..5a32abb62 100755 --- a/charms/jimm-k8s/src/charm.py +++ b/charms/jimm-k8s/src/charm.py @@ -27,6 +27,7 @@ DatabaseEvent, DatabaseRequires, ) +from charms.nginx_ingress_integrator.v0.nginx_route import require_nginx_route from charms.openfga_k8s.v0.openfga import OpenFGARequires, OpenFGAStoreCreateEvent from charms.tls_certificates_interface.v1.tls_certificates import ( CertificateAvailableEvent, @@ -52,8 +53,7 @@ ModelError, WaitingStatus, ) - -from state import PeerRelationState, RelationNotReadyError +from state import State logger = logging.getLogger(__name__) @@ -61,33 +61,18 @@ REQUIRED_SETTINGS = [ "JIMM_UUID", - "JIMM_DNS_NAME", "JIMM_DSN", "CANDID_URL", ] -STATE_KEY_CA = "ca" -STATE_KEY_CERTIFICATE = "certificate" -STATE_KEY_CHAIN = "chain" -STATE_KEY_CSR = "csr" -STATE_KEY_DSN = "dsn" -STATE_KEY_DNS_NAME = "dns" -STATE_KEY_PRIVATE_KEY = "private-key" -STATE_KEY_VAULT_ADDRESS = "vault-address" - -OPENFGA_STORE_ID = "openfga-store-id" -OPENFGA_TOKEN = "openfga-token" -OPENFGA_ADDRESS = "openfga-address" -OPENFGA_PORT = "openfga-port" -OPENFGA_SCHEME = "openfga-scheme" -OPENFGA_AUTH_MODEL_ID = "openfga-auth-model" - - class JimmOperatorCharm(CharmBase): """JIMM Operator Charm.""" def __init__(self, *args): super().__init__(*args) + + self._state = State(self.app, lambda: self.model.get_relation("jimm")) + self.framework.observe(self.on.jimm_pebble_ready, self._on_jimm_pebble_ready) self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe(self.on.update_status, self._on_update_status) @@ -119,10 +104,25 @@ def __init__(self, *args): self._on_certificate_revoked, ) - # Ingress relation - self.ingress = IngressPerAppRequirer(self, port=8080) + # Traefik ingress relation + self.ingress = IngressPerAppRequirer( + self, + relation_name="ingress", + port=8080, + ) self.framework.observe(self.ingress.on.ready, self._on_ingress_ready) - self.framework.observe(self.ingress.on.revoked, self._on_ingress_revoked) + self.framework.observe( + self.ingress.on.revoked, + self._on_ingress_revoked, + ) + + # Nginx ingress relation + require_nginx_route( + charm=self, + service_hostname=self.config.get("dns-name", ""), + service_name=self.app.name, + service_port=8080 + ) # Database relation self.database = DatabaseRequires( @@ -170,8 +170,6 @@ def __init__(self, *args): self._dashboard_path = "/root/dashboard" self._dashboard_hash_path = "/root/dashboard/hash" - self.state = PeerRelationState(self.model, self.app, "jimm") - def _on_jimm_pebble_ready(self, event): self._update_workload(event) @@ -179,17 +177,14 @@ def _on_config_changed(self, event): self._update_workload(event) def _on_leader_elected(self, event): - if self.unit.is_leader(): - try: - # generate the private key if one is not present in the - # application data bucket of the peer relation - if not self.state.get(STATE_KEY_PRIVATE_KEY): - private_key: bytes = generate_private_key(key_size=4096) - self.state.set(STATE_KEY_PRIVATE_KEY, private_key.decode()) - - except RelationNotReadyError: - event.defer() - return + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + + if self.unit.is_leader() and not self._state.private_key: + private_key: bytes = generate_private_key(key_size=4096) + self._state.private_key = private_key.decode() self._update_workload(event) @@ -214,6 +209,12 @@ def _update_workload(self, event): """' Update workload with all available configuration data.""" + if not self._state.is_ready(): + event.defer() + print(self._state.is_ready()) + logger.warning("State is not ready") + return + container = self.unit.get_container(WORKLOAD_CONTAINER) if not container.can_connect(): logger.info( @@ -225,62 +226,39 @@ def _update_workload(self, event): self._ensure_bakery_agent_file(event) self._install_dashboard(event) - dnsname = "{}.{}-endpoints.{}.svc.cluster.local".format( - self.unit.name.replace("/", "-"), self.app.name, self.model.name - ) - dsn = "" - openfga_store_id = "" - openfga_auth_model_id = "" - openfga_token = "" - openfga_address = "" - openfga_port = "" - openfga_scheme = "" - vault_address = "" - try: - if self.state.get(STATE_KEY_DNS_NAME): - dnsname = self.state.get(STATE_KEY_DNS_NAME) - dsn = self.state.get(STATE_KEY_DSN) - - openfga_store_id = self.state.get(OPENFGA_STORE_ID) - openfga_auth_model_id = self.state.get(OPENFGA_AUTH_MODEL_ID) - openfga_token = self.state.get(OPENFGA_TOKEN) - openfga_address = self.state.get(OPENFGA_ADDRESS) - openfga_port = self.state.get(OPENFGA_PORT) - openfga_scheme = self.state.get(OPENFGA_SCHEME) - vault_address = self.state.get(STATE_KEY_VAULT_ADDRESS) - - except RelationNotReadyError: - event.defer() + dns_name = self._get_dns_name(event) + if not dns_name: + logger.warning("dns name not set") return config_values = { "CANDID_PUBLIC_KEY": self.config.get("candid-public-key", ""), "CANDID_URL": self.config.get("candid-url", ""), "JIMM_ADMINS": self.config.get("controller-admins", ""), - "JIMM_DNS_NAME": dnsname, + "JIMM_DNS_NAME": dns_name, "JIMM_LOG_LEVEL": self.config.get("log-level", ""), "JIMM_UUID": self.config.get("uuid", ""), "JIMM_DASHBOARD_LOCATION": self.config.get( "juju-dashboard-location", "https://jaas.ai/models" ), "JIMM_LISTEN_ADDR": ":8080", - "OPENFGA_STORE": openfga_store_id, - "OPENFGA_AUTH_MODEL": openfga_auth_model_id, - "OPENFGA_HOST": openfga_address, - "OPENFGA_SCHEME": openfga_scheme, - "OPENFGA_TOKEN": openfga_token, - "OPENFGA_PORT": openfga_port, + "OPENFGA_STORE": self._state.openfga_store_id, + "OPENFGA_AUTH_MODEL": self._state.openfga_auth_model_id, + "OPENFGA_HOST": self._state.openfga_address, + "OPENFGA_SCHEME": self._state.openfga_scheme, + "OPENFGA_TOKEN": self._state.openfga_token, + "OPENFGA_PORT": self._state.openfga_port, "PRIVATE_KEY": self.config.get("private-key", ""), "PUBLIC_KEY": self.config.get("public-key", ""), } - if dsn: - config_values["JIMM_DSN"] = dsn + if self._state.dsn: + config_values["JIMM_DSN"] = self._state.dsn if container.exists(self._agent_filename): config_values["BAKERY_AGENT_FILE"] = self._agent_filename if container.exists(self._vault_secret_filename): - config_values["VAULT_ADDR"] = vault_address + config_values["VAULT_ADDR"] = self._state.vault_address config_values["VAULT_PATH"] = self._vault_path config_values["VAULT_SECRET_FILE"] = self._vault_secret_filename config_values["VAULT_AUTH_PATH"] = "/auth/approle/login" @@ -293,7 +271,9 @@ def _update_workload(self, event): config_values["JIMM_DASHBOARD_LOCATION"] = self._dashboard_path # remove empty configuration values - config_values = {key: value for key, value in config_values.items() if value} + config_values = { + key: value for key, value in config_values.items() if value + } pebble_layer = { "summary": "jimm layer", @@ -330,7 +310,7 @@ def _update_workload(self, event): if dashboard_relation and self.unit.is_leader(): dashboard_relation.data[self.app].update( { - "controller-url": dnsname, + "controller-url": dns_name, "identity-provider-url": self.config.get("candid-url"), "is-juju": str(False), } @@ -352,19 +332,21 @@ def _on_update_status(self, _): self._ready() def _on_dashboard_relation_joined(self, event: RelationJoinedEvent): - dnsname = "{}.{}-endpoints.{}.svc.cluster.local".format( - self.unit.name.replace("/", "-"), self.app.name, self.model.name - ) - try: - if self.state.get(STATE_KEY_DNS_NAME): - dnsname = self.state.get(STATE_KEY_DNS_NAME) - except RelationNotReadyError: + if not self.unit.is_leader(): + return + + if not self._state.is_ready(): event.defer() + logger.warning("State is not ready") + return + + dns_name = self._get_dns_name(event) + if not dns_name: return event.relation.data[self.app].update( { - "controller-url": dnsname, + "controller-url": dns_name, "identity-provider-url": self.config["candid-url"], "is-juju": str(False), } @@ -373,32 +355,35 @@ def _on_dashboard_relation_joined(self, event: RelationJoinedEvent): def _on_database_event(self, event: DatabaseEvent) -> None: """Database event handler.""" + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + # get the first endpoint from a comma separate list ep = event.endpoints.split(",", 1)[0] # compose the db connection string uri = f"postgresql://{event.username}:{event.password}@{ep}/jimm" logger.info("received database uri: {}".format(uri)) + # record the connection string - try: - self.state.set(STATE_KEY_DSN, uri) - except RelationNotReadyError: - event.defer() - return + self._state.dsn = uri self._update_workload(event) def _on_database_relation_broken(self, event: DatabaseEvent) -> None: """Database relation broken handler.""" - + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + # when the database relation is broken, we unset the # connection string and schema-created from the application # bucket of the peer relation - try: - self.state.unset(STATE_KEY_DSN) - except RelationNotReadyError: - event.defer() - return + del self._state.dsn + self._update_workload(event) def _ready(self): @@ -520,16 +505,25 @@ def _get_network_address(self, event): ) def _on_vault_relation_joined(self, event): - event.relation.data[self.unit]["secret_backend"] = json.dumps(self._vault_path) - event.relation.data[self.unit]["hostname"] = json.dumps(socket.gethostname()) + event.relation.data[self.unit]["secret_backend"] = json.dumps( + self._vault_path + ) + event.relation.data[self.unit]["hostname"] = json.dumps( + socket.gethostname() + ) event.relation.data[self.unit]["access_address"] = json.dumps( self._get_network_address(event) ) event.relation.data[self.unit]["isolated"] = json.dumps(False) def _on_vault_relation_changed(self, event): - container = self.unit.get_container(WORKLOAD_CONTAINER) + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + container = self.unit.get_container(WORKLOAD_CONTAINER) + # if we can't connect to the container we should defer # this event. if not container.can_connect(): @@ -555,11 +549,7 @@ def _on_vault_relation_changed(self, event): secret_data = json.dumps(secret) self._push_to_workload(self._vault_secret_filename, secret_data, event) - try: - self.state.set(STATE_KEY_VAULT_ADDRESS, addr) - except RelationNotReadyError: - event.defer() - return + self._state.vault_address = addr def _path_exists_in_workload(self, path: str): """Returns true if the specified path exists in the @@ -594,212 +584,232 @@ def _hash(self, filename): return md5.hexdigest() def _on_openfga_store_created(self, event: OpenFGAStoreCreateEvent): + if not self.unit.is_leader(): + return + + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + if not event.store_id: return - if self.unit.is_leader(): - try: - self.state.set(OPENFGA_STORE_ID, event.store_id) - self.state.set(OPENFGA_TOKEN, event.token) - self.state.set(OPENFGA_ADDRESS, event.address) - self.state.set(OPENFGA_PORT, event.port) - self.state.set(OPENFGA_SCHEME, event.scheme) - except RelationNotReadyError: - event.defer() - return + self._state.openfga_store_id = event.store_id + self._state.openfga_token = event.token + self._state.openfga_address = event.address + self._state.openfga_port = event.port + self._state.openfga_scheme = event.scheme self._update_workload(event) - def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: - if not self.unit.is_leader(): + def _get_dns_name(self, event): + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") return - dnsname = "{}.{}-endpoints.{}.svc.cluster.local".format( + default_dns_name = "{}.{}-endpoints.{}.svc.cluster.local".format( self.unit.name.replace("/", "-"), self.app.name, self.model.name, ) - try: - if self.state.get(STATE_KEY_DNS_NAME): - dnsname = self.state.get(STATE_KEY_DNS_NAME) + dns_name = self.config.get("dns-name", default_dns_name) + if self._state.dns_name: + dns_name = self._state.dns_name + + return dns_name - private_key = self.state.get(STATE_KEY_PRIVATE_KEY) - csr = generate_csr( - private_key=private_key.encode(), - subject=dnsname, - ) - - self.state.set(STATE_KEY_CSR, csr.decode()) - - self.certificates.request_certificate_creation( - certificate_signing_request=csr - ) - except RelationNotReadyError: + def _on_certificates_relation_joined(self, event: RelationJoinedEvent) -> None: + if not self.unit.is_leader(): + return + + if not self._state.is_ready(): event.defer() + logger.warning("State is not ready") + return + + dns_name = self._get_dns_name(event) + if not dns_name: return + csr = generate_csr( + private_key=self._state.private_key.encode(), + subject=dns_name, + ) + + self._state.csr = csr.decode() + + self.certificates.request_certificate_creation( + certificate_signing_request=csr + ) + + def _on_certificate_available(self, event: CertificateAvailableEvent) -> None: - if self.unit.is_leader(): - try: - self.state.set(STATE_KEY_CERTIFICATE, event.certificate) - self.state.set(STATE_KEY_CA, event.ca) - self.state.set(STATE_KEY_CHAIN, event.chain) + if not self.unit.is_leader(): + return + + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return - except RelationNotReadyError: - event.defer() - return + self._state.certificate = event.certificate + self._state.ca = event.ca + self._state.chain = event.chain self._update_workload(event) def _on_certificate_expiring(self, event: CertificateExpiringEvent) -> None: - if self.unit.is_leader(): - old_csr = "" - private_key = "" - dnsname = "{}.{}-endpoints.{}.svc.cluster.local".format( - self.unit.name.replace("/", "-"), - self.app.name, - self.model.name, - ) - try: - old_csr = self.state.get(STATE_KEY_CSR) - private_key = self.state.get(STATE_KEY_PRIVATE_KEY) - if self.state.get(STATE_KEY_DNS_NAME): - dnsname = self.state.get(STATE_KEY_DNS_NAME) - - new_csr = generate_csr( - private_key=private_key.encode(), - subject=dnsname, - ) - self.certificates.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - self.state.set(STATE_KEY_CSR, new_csr.decode()) - except RelationNotReadyError: - event.defer() - return + if not self.unit.is_leader(): + return + + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + + old_csr = self._state.csr + private_key = self._state.private_key + dns_name = self._get_dns_name(event) + if not dns_name: + return + + new_csr = generate_csr( + private_key=private_key.encode(), + subject=self.dns_name, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + self._state.csr = new_csr.decode() self._update_workload() def _on_certificate_revoked(self, event: CertificateRevokedEvent) -> None: - if self.unit.is_leader(): - old_csr = "" - private_key = "" - dnsname = "{}.{}-endpoints.{}.svc.cluster.local".format( - self.unit.name.replace("/", "-"), - self.app.name, - self.model.name, - ) - try: - old_csr = self.state.get(STATE_KEY_CSR) - private_key = self.state.get(STATE_KEY_PRIVATE_KEY) - if self.state.get(STATE_KEY_DNS_NAME): - dnsname = self.state.get(STATE_KEY_DNS_NAME) - except RelationNotReadyError: - event.defer() - return - - new_csr = generate_csr( - private_key=private_key.encode(), - subject=dnsname, - ) - self.certificates.request_certificate_renewal( - old_certificate_signing_request=old_csr, - new_certificate_signing_request=new_csr, - ) - try: - self.state.set(STATE_KEY_CSR, new_csr.decode()) - self.state.unset(STATE_KEY_CERTIFICATE, STATE_KEY_CA, STATE_KEY_CHAIN) - except RelationNotReadyError: - event.defer() - return + if not self.unit.is_leader(): + return + + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + + old_csr = self._state.csr + private_key = self._state.private_key + dns_name = self._get_dns_name(event) + if not dns_name: + return + + new_csr = generate_csr( + private_key=private_key.encode(), + subject=dns_name, + ) + self.certificates.request_certificate_renewal( + old_certificate_signing_request=old_csr, + new_certificate_signing_request=new_csr, + ) + self._state.csr = new_csr.decode() + del self._state.certificate + del self._state.ca + del self._state.chain + self.unit.status = WaitingStatus("Waiting for new certificate") self._update_workload() def _on_ingress_ready(self, event: IngressPerAppReadyEvent): - if self.unit.is_leader(): - try: - self.state.set(STATE_KEY_DNS_NAME, event.url) - except RelationNotReadyError: - event.defer() - return + if not self.unit.is_leader(): + return + + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + + self._state.dns_name = event.url self._update_workload(event) def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): - if self.unit.is_leader(): - try: - self.state.unset(STATE_KEY_DNS_NAME) - except RelationNotReadyError: - event.defer() - return + if not self.unit.is_leader(): + return + + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + + del self._state.dns_name self._update_workload(event) def _on_create_authorization_model_action(self, event: ActionEvent): + if not self._state.is_ready(): + event.defer() + logger.warning("State is not ready") + return + model = event.params["model"] if not model: event.fail("authorization model not specified") return modelJSON = json.loads(model) - try: - openfga_store_id = self.state.get(OPENFGA_STORE_ID) - openfga_token = self.state.get(OPENFGA_TOKEN) - openfga_address = self.state.get(OPENFGA_ADDRESS) - openfga_port = self.state.get(OPENFGA_PORT) - openfga_scheme = self.state.get(OPENFGA_SCHEME) - - if ( - not openfga_address - or not openfga_port - or not openfga_scheme - or not openfga_token - or not openfga_store_id - ): - event.fail("missing openfga relation") - return - - url = "{}://{}:{}/stores/{}/authorization-models".format( - openfga_scheme, - openfga_address, - openfga_port, - openfga_store_id, - ) - headers = {"Content-Type": "application/json"} - if openfga_token: - headers["Authorization"] = "Bearer {}".format(openfga_token) - - # do the post request - logger.info("posting to {}, with headers {}".format(url, headers)) - response = requests.post( - url, - json=modelJSON, - headers=headers, - verify=False, + openfga_store_id = self._state.openfga_store_id + openfga_token = self._state.openfga_token + openfga_address = self._state.openfga_address + openfga_port = self._state.openfga_port + openfga_scheme = self._state.openfga_scheme + + if ( + not openfga_address + or not openfga_port + or not openfga_scheme + or not openfga_token + or not openfga_store_id + ): + event.fail("missing openfga relation") + return + + url = "{}://{}:{}/stores/{}/authorization-models".format( + openfga_scheme, + openfga_address, + openfga_port, + openfga_store_id, + ) + headers = {"Content-Type": "application/json"} + if openfga_token: + headers["Authorization"] = "Bearer {}".format(openfga_token) + + # do the post request + logger.info("posting to {}, with headers {}".format(url, headers)) + response = requests.post( + url, + json=modelJSON, + headers=headers, + verify=False, + ) + if not response.ok: + event.fail( + "failed to create the authorization model: {}".format( + response.text + ), ) - if not response.ok: - event.fail( - "failed to create the authorization model: {}".format( - response.text - ), - ) - return - data = response.json() - authorization_model_id = data.get("authorization_model_id", "") - if not authorization_model_id: - event.fail( - "response does not contain authorization model id: {}".format( - response.text - ) + return + data = response.json() + authorization_model_id = data.get("authorization_model_id", "") + if not authorization_model_id: + event.fail( + "response does not contain authorization model id: {}".format( + response.text ) - return - self.state.set(OPENFGA_AUTH_MODEL_ID, authorization_model_id) - self._update_workload(event) - except RelationNotReadyError: - event.defer() + ) return + self._state.openfga_auth_model_id = authorization_model_id + self._update_workload(event) + def _json_data(event, key): diff --git a/charms/jimm-k8s/src/state.py b/charms/jimm-k8s/src/state.py index c64161548..71a165423 100644 --- a/charms/jimm-k8s/src/state.py +++ b/charms/jimm-k8s/src/state.py @@ -1,68 +1,66 @@ -#!/usr/bin/env python3 -# Copyright 2022 Canonical Ltd. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 3, as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranties of -# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR -# PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from ops.model import Application, Model, Relation - - -class RelationNotReadyError(Exception): - pass - - -class PeerRelationState: - """RelationState uses the peer relation to store the state of the charm.""" - - def __init__( - self, - model: Model, - app: Application, - relation_name: str, - defaults: dict = None, - ): - self._model = model - self._app = app - self._relation_name = relation_name - - if defaults: - relation = self._model.get_relation(relation_name) - if not relation: - raise RelationNotReadyError - else: - relation.data[self._app].update(defaults) - - def _get_relation(self) -> Relation: - relation = self._model.get_relation(self._relation_name) - return relation - - def set(self, key: str, value: str) -> None: - relation = self._get_relation() - if not relation: - raise RelationNotReadyError - else: - relation.data[self._app].update({key: value}) - - def unset(self, *keys) -> None: - relation = self._get_relation() - if not relation: - raise RelationNotReadyError - else: - for key in keys: - relation.data[self._app].pop(key, None) - - def get(self, key: str) -> str: - relation = self._get_relation() - if not relation: - return "" - else: - return relation.data[self._app].get(key, "") +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Manager for handling charm state.""" + +import json + + +class State: + """A magic state that uses a relation as the data store. + + The get_relation callable is used to retrieve the relation. + As relation data values must be strings, all values are JSON encoded. + """ + + def __init__(self, app, get_relation): + """Construct. + + Args: + app: workload application + get_relation: get peer relation method + """ + # Use __dict__ to avoid calling __setattr__ and subsequent infinite recursion. + self.__dict__["_app"] = app + self.__dict__["_get_relation"] = get_relation + + def __setattr__(self, name, value): + """Set a value in the store with the given name. + + Args: + name: name of value to set in store. + value: value to set in store. + """ + v = json.dumps(value) + self._get_relation().data[self._app].update({name: v}) + + def __getattr__(self, name): + """Get from the store the value with the given name, or None. + + Args: + name: name of value to get from store. + + Returns: + value from store with given name. + """ + v = self._get_relation().data[self._app].get(name, "null") + return json.loads(v) + + def __delattr__(self, name): + """Delete the value with the given name from the store, if it exists. + + Args: + name: name of value to delete from store. + + Returns: + deleted value from store. + """ + return self._get_relation().data[self._app].pop(name, None) + + def is_ready(self): + """Report whether the relation is ready to be used. + + Returns: + A boolean representing whether the relation is ready to be used or not. + """ + return bool(self._get_relation()) diff --git a/charms/jimm-k8s/tests/integration/test_charm.py b/charms/jimm-k8s/tests/integration/test_charm.py index 23c40ea5f..d07bbc30a 100644 --- a/charms/jimm-k8s/tests/integration/test_charm.py +++ b/charms/jimm-k8s/tests/integration/test_charm.py @@ -36,10 +36,19 @@ async def test_build_and_deploy(ops_test: OpsTest): resources=resources, application_name=APP_NAME, series="focal", + config={ + "uuid": "f4dec11e-e2b6-40bb-871a-cc38e958af49", + "candid-url": "https://api.jujucharms.com/identity", + "public-key": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=", + "private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", + }, ) traefik_app = await ops_test.model.deploy( "traefik-k8s", application_name="traefik", + config= { + "external_hostname": "traefik.test.canonical.com", + }, ) await asyncio.gather( ops_test.model.deploy( @@ -54,22 +63,6 @@ async def test_build_and_deploy(ops_test: OpsTest): ), ) - await traefik_app.set_config( - { - "external_hostname": "traefik.test.canonical.com", - } - ) - - logger.info("setting jimm config") - await jimm_app.set_config( - { - "uuid": "f4dec11e-e2b6-40bb-871a-cc38e958af49", - "candid-url": "https://api.jujucharms.com/identity", - "public-key": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=", - "private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", - } - ) - logger.info("waiting for postgresql") await ops_test.model.wait_for_idle( apps=["postgresql", "traefik"], @@ -79,7 +72,7 @@ async def test_build_and_deploy(ops_test: OpsTest): ) logger.info("adding traefik relation") - await ops_test.model.relate(APP_NAME, "traefik") + await ops_test.model.relate("{}:ingress".format(APP_NAME), "traefik") logger.info("adding openfga postgresql relation") await ops_test.model.relate("openfga:database", "postgresql:database") diff --git a/charms/jimm-k8s/tests/integration/test_charm_with_nginx.py b/charms/jimm-k8s/tests/integration/test_charm_with_nginx.py new file mode 100644 index 000000000..6d62f1e9b --- /dev/null +++ b/charms/jimm-k8s/tests/integration/test_charm_with_nginx.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd +# See LICENSE file for licensing details. + +import asyncio +import logging +import time +from pathlib import Path + +import pytest +import utils +import yaml +from juju.action import Action +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) +APP_NAME = "juju-jimm-k8s" + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy_with_ngingx(ops_test: OpsTest): + """Build the charm-under-test and deploy it together with related charms. + + Assert on the unit status before any relations/configurations take place. + """ + # Build and deploy charm from local source folder + charm = await ops_test.build_charm(".") + resources = {"jimm-image": "localhost:32000/jimm:latest"} + + # Deploy the charm and wait for active/idle status + logger.debug("deploying charms") + jimm_app = await ops_test.model.deploy( + charm, + resources=resources, + application_name=APP_NAME, + series="focal", + config={ + "uuid": "f4dec11e-e2b6-40bb-871a-cc38e958af49", + "dns-name": "test.jimm.local", + "candid-url": "https://api.jujucharms.com/identity", + "public-key": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=", + "private-key": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", + }, + ) + nginx_app = await ops_test.model.deploy( + "nginx-ingress-integrator", + application_name="nginx", + ) + await asyncio.gather( + ops_test.model.deploy( + "postgresql-k8s", + application_name="postgresql", + channel="edge", + ), + ops_test.model.deploy( + "openfga-k8s", + application_name="openfga", + channel="edge", + ), + ) + + logger.info("waiting for postgresql") + await ops_test.model.wait_for_idle( + apps=["postgresql", "nginx"], + status="active", + raise_on_blocked=True, + timeout=40000, + ) + + logger.info("adding ingress relation") + await ops_test.model.relate("{}:nginx-route".format(APP_NAME), "nginx") + + logger.info("adding openfga postgresql relation") + await ops_test.model.relate("openfga:database", "postgresql:database") + + logger.info("waiting for openfga") + await ops_test.model.wait_for_idle( + apps=["openfga"], + status="blocked", + timeout=40000, + ) + + openfga_unit = await utils.get_unit_by_name("openfga", "0", ops_test.model.units) + for i in range(10): + action: Action = await openfga_unit.run_action("schema-upgrade") + result = await action.wait() + logger.info( + "attempt {} -> action result {} {}".format(i, result.status, result.results) + ) + if result.results == {"result": "done", "return-code": 0}: + break + time.sleep(2) + + logger.info("adding openfga relation") + await ops_test.model.relate(APP_NAME, "openfga") + + logger.info("adding postgresql relation") + await ops_test.model.relate(APP_NAME, "postgresql:database") + + logger.info("waiting for jimm") + await ops_test.model.wait_for_idle( + apps=[APP_NAME], + status="active", + # raise_on_blocked=True, + timeout=40000, + ) + + logger.info("running the create authorization model action") + jimm_unit = await utils.get_unit_by_name(APP_NAME, "0", ops_test.model.units) + with open("../../local/openfga/authorisation_model.json", "r") as model_file: + model_data = model_file.read() + for i in range(10): + action: Action = await jimm_unit.run_action( + "create-authorization-model", + model=model_data, + ) + result = await action.wait() + logger.info( + "attempt {} -> action result {} {}".format( + i, result.status, result.results + ) + ) + if result.results == {"return-code": 0}: + break + time.sleep(2) + + assert ops_test.model.applications[APP_NAME].status == "active" diff --git a/charms/jimm-k8s/tests/unit/test_charm.py b/charms/jimm-k8s/tests/unit/test_charm.py index ff126fc4d..fa2c5a4d2 100644 --- a/charms/jimm-k8s/tests/unit/test_charm.py +++ b/charms/jimm-k8s/tests/unit/test_charm.py @@ -12,9 +12,8 @@ import unittest from unittest.mock import MagicMock, patch -from ops.testing import Harness - from charm import JimmOperatorCharm +from ops.testing import Harness MINIMAL_CONFIG = { "uuid": "1234567890", @@ -36,21 +35,23 @@ def setUp(self): self.addCleanup(self.harness.cleanup) self.harness.disable_hooks() self.harness.add_oci_resource("jimm-image") + self.harness.set_can_connect("jimm", True) + self.harness.set_leader(True) self.harness.begin() - self.harness.charm.db = MagicMock() - + self.tempdir = tempfile.TemporaryDirectory() self.addCleanup(self.tempdir.cleanup) self.harness.charm.framework.charm_dir = pathlib.Path(self.tempdir.name) + self.harness.add_relation("jimm", "jimm") self.harness.container_pebble_ready("jimm") rel_id = self.harness.add_relation("ingress", "nginx-ingress") self.harness.add_relation_unit(rel_id, "nginx-ingress/0") - + + #import ipdb; ipdb.set_trace() def test_on_pebble_ready(self): self.harness.update_config(MINIMAL_CONFIG) - self.harness.set_leader(True) container = self.harness.model.unit.get_container("jimm") # Emit the pebble-ready event for jimm @@ -71,6 +72,7 @@ def test_on_pebble_ready(self): "CANDID_URL": "test-candid-url", "JIMM_DASHBOARD_LOCATION": "https://jaas.ai/models", "JIMM_DNS_NAME": "juju-jimm-k8s-0.juju-jimm-k8s-endpoints.None.svc.cluster.local", + "JIMM_ENABLE_JWKS_ROTATOR": "1", "JIMM_LISTEN_ADDR": ":8080", "JIMM_LOG_LEVEL": "info", "JIMM_UUID": "1234567890", @@ -108,6 +110,7 @@ def test_on_config_changed(self): "CANDID_URL": "test-candid-url", "JIMM_DASHBOARD_LOCATION": "https://jaas.ai/models", "JIMM_DNS_NAME": "juju-jimm-k8s-0.juju-jimm-k8s-endpoints.None.svc.cluster.local", + "JIMM_ENABLE_JWKS_ROTATOR": "1", "JIMM_LISTEN_ADDR": ":8080", "JIMM_LOG_LEVEL": "info", "JIMM_UUID": "1234567890", @@ -155,9 +158,11 @@ def test_bakery_configuration(self): "CANDID_URL": "test-candid-url", "JIMM_DASHBOARD_LOCATION": "https://jaas.ai/models", "JIMM_DNS_NAME": "juju-jimm-k8s-0.juju-jimm-k8s-endpoints.None.svc.cluster.local", + 'JIMM_ENABLE_JWKS_ROTATOR': '1', "JIMM_LISTEN_ADDR": ":8080", "JIMM_LOG_LEVEL": "info", "JIMM_UUID": "1234567890", + 'JIMM_WATCH_CONTROLLERS': '1', "PRIVATE_KEY": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", "PUBLIC_KEY": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=", }, @@ -204,8 +209,10 @@ def test_install_dashboard(self, exec): "JIMM_LISTEN_ADDR": ":8080", "JIMM_DASHBOARD_LOCATION": "/root/dashboard", "JIMM_DNS_NAME": "juju-jimm-k8s-0.juju-jimm-k8s-endpoints.None.svc.cluster.local", + 'JIMM_ENABLE_JWKS_ROTATOR': '1', "JIMM_LOG_LEVEL": "info", "JIMM_UUID": "1234567890", + 'JIMM_WATCH_CONTROLLERS': '1', "PRIVATE_KEY": "ly/dzsI9Nt/4JxUILQeAX79qZ4mygDiuYGqc2ZEiDEc=", "PUBLIC_KEY": "izcYsQy3TePp6bLjqOo3IRPFvkQd2IKtyODGqC6SdFk=", }, diff --git a/charms/jimm-k8s/tox.ini b/charms/jimm-k8s/tox.ini index 33c187dc6..917dd8bd7 100644 --- a/charms/jimm-k8s/tox.ini +++ b/charms/jimm-k8s/tox.ini @@ -1,4 +1,4 @@ -# Copyright 2022 Ales Stimec +# Copyright 2022 Canonical Ltd # See LICENSE file for licensing details. [tox]