diff --git a/src/charm.py b/src/charm.py index b41ea4d..3eb8a26 100755 --- a/src/charm.py +++ b/src/charm.py @@ -15,7 +15,7 @@ from constants import FLASK_CONTAINER_NAME from databases import Databases from exceptions import CharmConfigInvalidError, PebbleNotReadyError -from flask_app import FlaskApp +from flask_app import restart_flask from observability import Observability from secret_storage import SecretStorage from webserver import GunicornWebserver @@ -45,10 +45,9 @@ def __init__(self, *args: typing.Any) -> None: charm_state=self._charm_state, flask_container=self.unit.get_container(FLASK_CONTAINER_NAME), ) - self._flask_app = FlaskApp( + self._databases = Databases( charm=self, charm_state=self._charm_state, webserver=self._webserver ) - self._databases = Databases(charm=self, flask_app=self._flask_app) self._charm_state.database_uris = self._databases.get_uris() self._ingress = IngressPerAppRequirer( self, @@ -71,16 +70,6 @@ def __init__(self, *args: typing.Any) -> None: self.on.secret_storage_relation_changed, self._on_secret_storage_relation_changed ) - def _update_app_and_unit_status(self, status: ops.StatusBase) -> None: - """Update the application and unit status. - - Args: - status: the desired application and unit status. - """ - self.unit.status = status - if self.unit.is_leader(): - self.app.status = status - def container(self) -> ops.Container: """Get the flask application workload container controller. @@ -103,7 +92,7 @@ def _on_config_changed(self, _event: ops.EventBase) -> None: Args: _event: the config-changed event that triggers this callback function. """ - self._flask_app.restart_flask() + self._restart_flask() def _on_statsd_prometheus_exporter_pebble_ready(self, _event: ops.PebbleReadyEvent) -> None: """Handle the statsd-prometheus-exporter-pebble-ready event.""" @@ -145,7 +134,7 @@ def _on_rotate_secret_key_action(self, event: ops.ActionEvent) -> None: return self._secret_storage.reset_flask_secret_key() event.set_results({"status": "success"}) - self._flask_app.restart_flask() + self._restart_flask() def _on_secret_storage_relation_changed(self, _event: ops.RelationEvent) -> None: """Handle the secret-storage-relation-changed event. @@ -153,7 +142,25 @@ def _on_secret_storage_relation_changed(self, _event: ops.RelationEvent) -> None Args: _event: the action event that triggers this callback. """ - self._flask_app.restart_flask() + self._restart_flask() + + def _update_app_and_unit_status(self, status: ops.StatusBase) -> None: + """Update the application and unit status. + + Args: + status: the desired application and unit status. + """ + self.unit.status = status + if self.unit.is_leader(): + self.app.status = status + + def _restart_flask(self) -> None: + """Restart or start the flask service if not started with the latest configuration.""" + try: + restart_flask(charm=self, charm_state=self._charm_state, webserver=self._webserver) + self._update_app_and_unit_status(ops.ActiveStatus()) + except CharmConfigInvalidError as exc: + self._update_app_and_unit_status(ops.BlockedStatus(exc.msg)) if __name__ == "__main__": # pragma: nocover diff --git a/src/databases.py b/src/databases.py index 18af5a8..6a1f483 100644 --- a/src/databases.py +++ b/src/databases.py @@ -11,8 +11,11 @@ import yaml from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires, DatabaseRequiresEvent +from charm_state import CharmState from constants import FLASK_DATABASE_NAME, FLASK_SUPPORTED_DB_INTERFACES -from flask_app import FlaskApp +from exceptions import CharmConfigInvalidError +from flask_app import restart_flask +from webserver import GunicornWebserver logger = logging.getLogger(__name__) @@ -27,17 +30,21 @@ class Databases(ops.Object): # pylint: disable=too-few-public-methods _databases: A dict of DatabaseRequires to store relations """ - def __init__(self, charm: ops.CharmBase, flask_app: FlaskApp): + def __init__( + self, charm: ops.CharmBase, charm_state: CharmState, webserver: GunicornWebserver + ): """Initialize a new instance of the Databases class. Args: - charm: The main charm. Used for events callbacks - flask_app: The flask application manager + charm: The main charm. Used for events callbacks. + charm_state: The charm's state. + webserver: The webserver manager object. """ # The following is necessary to be able to subscribe to callbacks from ops.framework super().__init__(charm, "databases") self._charm = charm - self._flask_app = flask_app + self._charm_state = charm_state + self._webserver = webserver metadata = yaml.safe_load(pathlib.Path("metadata.yaml").read_text(encoding="utf-8")) self._db_interfaces = ( @@ -53,13 +60,33 @@ def __init__(self, charm: ops.CharmBase, flask_app: FlaskApp): for name in self._db_interfaces } + def _update_app_and_unit_status(self, status: ops.StatusBase) -> None: + """Update the application and unit status. + + Args: + status: the desired application and unit status. + """ + self._charm.unit.status = status + if self._charm.unit.is_leader(): + self._charm.app.status = status + + def _restart_flask(self) -> None: + """Restart or start the flask service if not started with the latest configuration.""" + try: + restart_flask( + charm=self._charm, charm_state=self._charm_state, webserver=self._webserver + ) + self._update_app_and_unit_status(ops.ActiveStatus()) + except CharmConfigInvalidError as exc: + self._update_app_and_unit_status(ops.BlockedStatus(exc.msg)) + def _on_database_requires_event(self, _event: DatabaseRequiresEvent) -> None: """Configure the flask pebble service layer in case of DatabaseRequiresEvent. Args: _event: the database-requires-changed event that trigger this callback function. """ - self._flask_app.restart_flask() + self._restart_flask() def _setup_database_requirer(self, relation_name: str, database_name: str) -> DatabaseRequires: """Set up a DatabaseRequires instance. diff --git a/src/flask_app.py b/src/flask_app.py index a52c907..7b0fe56 100644 --- a/src/flask_app.py +++ b/src/flask_app.py @@ -10,111 +10,96 @@ from charm_state import KNOWN_CHARM_CONFIG, CharmState from constants import FLASK_ENV_CONFIG_PREFIX, FLASK_SERVICE_NAME -from exceptions import CharmConfigInvalidError from webserver import GunicornWebserver logger = logging.getLogger(__name__) -class FlaskApp: # pylint: disable=too-few-public-methods - """A class representing the Flask application.""" - - def __init__( - self, charm: ops.CharmBase, charm_state: CharmState, webserver: GunicornWebserver - ): - """Initialize a new instance of the FlaskApp class. - - Args: - charm: The main charm object. - charm_state: The state of the charm that the FlaskApp instance belongs to. - webserver: The webserver manager object. - """ - self._charm = charm - self._charm_state = charm_state - self._webserver = webserver - - def _flask_environment(self) -> dict[str, str]: - """Generate a Flask environment dictionary from the charm Flask configurations. - - The Flask environment generation follows these rules: - 1. User-defined configuration cannot overwrite built-in Flask configurations, even if - the built-in Flask configuration value is None (undefined). - 2. Boolean and integer-typed configuration values will be JSON encoded before - being passed to Flask. - 3. String-typed configuration values will be passed to Flask as environment variables - directly. - - Returns: - A dictionary representing the Flask environment variables. - """ - builtin_flask_config = [ - c.removeprefix("flask_") for c in KNOWN_CHARM_CONFIG if c.startswith("flask_") - ] - flask_env = { - k: v for k, v in self._charm_state.app_config.items() if k not in builtin_flask_config - } - flask_env.update(self._charm_state.flask_config) - env = { - f"{FLASK_ENV_CONFIG_PREFIX}{k.upper()}": v if isinstance(v, str) else json.dumps(v) - for k, v in flask_env.items() - } - secret_key_env = f"{FLASK_ENV_CONFIG_PREFIX}SECRET_KEY" - if secret_key_env not in env: - env[secret_key_env] = self._charm_state.flask_secret_key - for proxy_variable in ("http_proxy", "https_proxy", "no_proxy"): - proxy_value = getattr(self._charm_state.proxy, proxy_variable) - if proxy_value: - env[proxy_variable] = str(proxy_value) - env[proxy_variable.upper()] = str(proxy_value) - env.update(self._charm_state.database_uris) - return env - - def _flask_layer(self) -> ops.pebble.LayerDict: - """Generate the pebble layer definition for flask application. - - Returns: - The pebble layer definition for flask application. - """ - environment = self._flask_environment() - return ops.pebble.LayerDict( - services={ - FLASK_SERVICE_NAME: { - "override": "replace", - "summary": "Flask application service", - "command": shlex.join(self._webserver.command), - "startup": "enabled", - "environment": environment, - } - }, - ) - - def _update_app_and_unit_status(self, status: ops.StatusBase) -> None: - """Update the application and unit status. - - Args: - status: the desired application and unit status. - """ - self._charm.unit.status = status - if self._charm.unit.is_leader(): - self._charm.app.status = status - - def restart_flask(self) -> None: - """Restart or start the flask service if not started with the latest configuration.""" - container = self._charm.unit.get_container("flask-app") - if not container.can_connect(): - logger.info("pebble client in the Flask container is not ready") - return - if not self._charm_state.is_secret_storage_ready: - logger.info("secret storage is not initialized") - return - container.add_layer("flask-app", self._flask_layer(), combine=True) - is_webserver_running = container.get_service(FLASK_SERVICE_NAME).is_running() - try: - self._webserver.update_config( - flask_environment=self._flask_environment(), - is_webserver_running=is_webserver_running, - ) - except CharmConfigInvalidError as exc: - self._update_app_and_unit_status(ops.BlockedStatus(exc.msg)) - container.replan() - self._update_app_and_unit_status(ops.ActiveStatus()) +def _flask_environment(charm_state: CharmState) -> dict[str, str]: + """Generate a Flask environment dictionary from the charm Flask configurations. + + Args: + charm_state: The state of the charm. + + The Flask environment generation follows these rules: + 1. User-defined configuration cannot overwrite built-in Flask configurations, even if + the built-in Flask configuration value is None (undefined). + 2. Boolean and integer-typed configuration values will be JSON encoded before + being passed to Flask. + 3. String-typed configuration values will be passed to Flask as environment variables + directly. + + Returns: + A dictionary representing the Flask environment variables. + """ + builtin_flask_config = [ + c.removeprefix("flask_") for c in KNOWN_CHARM_CONFIG if c.startswith("flask_") + ] + flask_env = {k: v for k, v in charm_state.app_config.items() if k not in builtin_flask_config} + flask_env.update(charm_state.flask_config) + env = { + f"{FLASK_ENV_CONFIG_PREFIX}{k.upper()}": v if isinstance(v, str) else json.dumps(v) + for k, v in flask_env.items() + } + secret_key_env = f"{FLASK_ENV_CONFIG_PREFIX}SECRET_KEY" + if secret_key_env not in env: + env[secret_key_env] = charm_state.flask_secret_key + for proxy_variable in ("http_proxy", "https_proxy", "no_proxy"): + proxy_value = getattr(charm_state.proxy, proxy_variable) + if proxy_value: + env[proxy_variable] = str(proxy_value) + env[proxy_variable.upper()] = str(proxy_value) + env.update(charm_state.database_uris) + return env + + +def _flask_layer(charm_state: CharmState, webserver: GunicornWebserver) -> ops.pebble.LayerDict: + """Generate the pebble layer definition for flask application. + + Args: + charm_state: The state of the charm + webserver: The webserver manager object + + Returns: + The pebble layer definition for flask application. + """ + environment = _flask_environment(charm_state=charm_state) + return ops.pebble.LayerDict( + services={ + FLASK_SERVICE_NAME: { + "override": "replace", + "summary": "Flask application service", + "command": shlex.join(webserver.command), + "startup": "enabled", + "environment": environment, + } + }, + ) + + +def restart_flask( + charm: ops.CharmBase, charm_state: CharmState, webserver: GunicornWebserver +) -> None: + """Restart or start the flask service if not started with the latest configuration. + + Raise CharmConfigInvalidError if the configuration is not valid. + + Args: + charm: The main charm object. + charm_state: The state of the charm. + webserver: The webserver manager object. + """ + container = charm.unit.get_container("flask-app") + if not container.can_connect(): + logger.info("pebble client in the Flask container is not ready") + return + if not charm_state.is_secret_storage_ready: + logger.info("secret storage is not initialized") + return + container.add_layer("flask-app", _flask_layer(charm_state, webserver), combine=True) + is_webserver_running = container.get_service(FLASK_SERVICE_NAME).is_running() + webserver.update_config( + flask_environment=_flask_environment(charm_state), + is_webserver_running=is_webserver_running, + ) + container.replan() diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 1aeba32..d8d0878 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -13,7 +13,7 @@ from charm_state import KNOWN_CHARM_CONFIG, CharmState from constants import FLASK_CONTAINER_NAME -from flask_app import FlaskApp +from flask_app import restart_flask from webserver import GunicornWebserver FLASK_BASE_DIR = "/srv/flask" @@ -34,8 +34,7 @@ def test_flask_pebble_layer(harness: Harness) -> None: charm_state=charm_state, flask_container=harness.charm.unit.get_container(FLASK_CONTAINER_NAME), ) - flask_app = FlaskApp(charm=harness.charm, charm_state=charm_state, webserver=webserver) - flask_app.restart_flask() + restart_flask(charm=harness.charm, charm_state=charm_state, webserver=webserver) flask_layer = harness.get_container_pebble_plan("flask-app").to_dict()["services"]["flask-app"] assert flask_layer == { "override": "replace", diff --git a/tests/unit/test_databases.py b/tests/unit/test_databases.py index 8666571..14a76ee 100644 --- a/tests/unit/test_databases.py +++ b/tests/unit/test_databases.py @@ -96,7 +96,9 @@ def test_database_uri_mocked( send_signal_mock = unittest.mock.MagicMock() monkeypatch.setattr(container, "send_signal", send_signal_mock) - databases = Databases(unittest.mock.MagicMock(), unittest.mock.MagicMock()) + databases = Databases( + unittest.mock.MagicMock(), unittest.mock.MagicMock(), unittest.mock.MagicMock() + ) assert not databases.get_uris() # Create the databases mock with the relation data diff --git a/tests/unit/test_flask_app.py b/tests/unit/test_flask_app.py index b594a47..612c212 100644 --- a/tests/unit/test_flask_app.py +++ b/tests/unit/test_flask_app.py @@ -10,12 +10,10 @@ import typing import pytest -from ops.testing import Harness from charm_state import CharmState -from constants import FLASK_CONTAINER_NAME, FLASK_ENV_CONFIG_PREFIX -from flask_app import FlaskApp -from webserver import GunicornWebserver +from constants import FLASK_ENV_CONFIG_PREFIX +from flask_app import _flask_environment @pytest.mark.parametrize( @@ -26,24 +24,18 @@ pytest.param({"debug": True}, id="debug"), ], ) -def test_flask_env(harness: Harness, flask_config: dict): +def test_flask_env(flask_config: dict): """ arrange: create the Flask app object with a controlled charm state. act: none. assert: flask_environment generated by the Flask app object should be acceptable by Flask app. """ - harness.begin_with_initial_hooks() charm_state = CharmState( flask_secret_key="foobar", is_secret_storage_ready=True, flask_config=flask_config, ) - webserver = GunicornWebserver( - charm_state=charm_state, - flask_container=harness.model.unit.get_container(FLASK_CONTAINER_NAME), - ) - flask_app = FlaskApp(charm=harness.charm, charm_state=charm_state, webserver=webserver) - env = flask_app._flask_environment() + env = _flask_environment(charm_state) assert env["FLASK_SECRET_KEY"] == "foobar" del env["FLASK_SECRET_KEY"] assert env == { @@ -80,15 +72,12 @@ def test_flask_env(harness: Harness, flask_config: dict): "set_env, expected", HTTP_PROXY_TEST_PARAMS, ) -def test_http_proxy( - harness: Harness, set_env: typing.Dict[str, str], expected: typing.Dict[str, str], monkeypatch -): +def test_http_proxy(set_env: typing.Dict[str, str], expected: typing.Dict[str, str], monkeypatch): """ arrange: set juju charm http proxy related environment variables. act: generate a flask environment. assert: flask_environment generated should contain proper proxy environment variables. """ - harness.begin() for set_env_name, set_env_value in set_env.items(): monkeypatch.setenv(set_env_name, set_env_value) charm_state = CharmState( @@ -96,12 +85,7 @@ def test_http_proxy( is_secret_storage_ready=True, flask_config={}, ) - webserver = GunicornWebserver( - charm_state=charm_state, - flask_container=harness.model.unit.get_container(FLASK_CONTAINER_NAME), - ) - flask_app = FlaskApp(charm=harness.charm, charm_state=charm_state, webserver=webserver) - env = flask_app._flask_environment() + env = _flask_environment(charm_state) expected_env: typing.Dict[str, typing.Optional[str]] = { "http_proxy": None, "https_proxy": None, diff --git a/tests/unit/test_webserver.py b/tests/unit/test_webserver.py index aa12980..7241bdb 100644 --- a/tests/unit/test_webserver.py +++ b/tests/unit/test_webserver.py @@ -15,7 +15,7 @@ from charm_state import CharmState from constants import FLASK_CONTAINER_NAME -from flask_app import FlaskApp +from flask_app import _flask_environment, restart_flask from webserver import GunicornWebserver FLASK_BASE_DIR = "/srv/flask" @@ -73,9 +73,9 @@ def test_gunicorn_config( **charm_state_params, ) webserver = GunicornWebserver(charm_state=charm_state, flask_container=container) - flask_app = FlaskApp(charm=harness.charm, charm_state=charm_state, webserver=webserver) + restart_flask(charm=harness.charm, charm_state=charm_state, webserver=webserver) webserver.update_config( - is_webserver_running=False, flask_environment=flask_app._flask_environment() + is_webserver_running=False, flask_environment=_flask_environment(charm_state) ) assert container.pull(f"{FLASK_BASE_DIR}/gunicorn.conf.py").read() == config_file @@ -94,10 +94,10 @@ def test_webserver_reload(monkeypatch, harness: Harness, is_running): container.push(f"{FLASK_BASE_DIR}/gunicorn.conf.py", "") charm_state = CharmState(flask_secret_key="", is_secret_storage_ready=True) webserver = GunicornWebserver(charm_state=charm_state, flask_container=container) - flask_app = FlaskApp(charm=harness.charm, charm_state=charm_state, webserver=webserver) + restart_flask(charm=harness.charm, charm_state=charm_state, webserver=webserver) send_signal_mock = unittest.mock.MagicMock() monkeypatch.setattr(container, "send_signal", send_signal_mock) webserver.update_config( - is_webserver_running=is_running, flask_environment=flask_app._flask_environment() + is_webserver_running=is_running, flask_environment=_flask_environment(charm_state) ) assert send_signal_mock.call_count == (1 if is_running else 0)