diff --git a/content-cache-backends-config/config.yaml b/content-cache-backends-config/config.yaml index eb2aeed2..4500b7ba 100644 --- a/content-cache-backends-config/config.yaml +++ b/content-cache-backends-config/config.yaml @@ -47,3 +47,19 @@ options: (https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_cache_valid). type: string default: '[]' + cache-inactive: + description: | + Time after which a cached item is evicted from disk if not accessed. + Uses nginx time format: positive integer followed by s (seconds), + m (minutes), h (hours), or d (days). For example: 10m, 1h, 7d. + Increase for files that are accessed on long periodic cycles. + type: string + default: "10m" + cache-max-size: + description: | + Maximum total disk space used by the cache. Uses nginx size format: + positive integer followed by k, m, g, or t (case-insensitive). + For example: 512m, 2g. When set, nginx evicts least-recently-used + entries when the limit is reached. Empty string means no limit. + type: string + default: "" diff --git a/content-cache-backends-config/src/state.py b/content-cache-backends-config/src/state.py index 2e4e1122..97fe11c3 100644 --- a/content-cache-backends-config/src/state.py +++ b/content-cache-backends-config/src/state.py @@ -24,6 +24,10 @@ HEALTHCHECK_SSL_VERIFY_CONFIG_NAME = "healthcheck-ssl-verify" HEALTHCHECK_VALID_STATUS_CONFIG_NAME = "healthcheck-valid-status" PROXY_CACHE_VALID_CONFIG_NAME = "proxy-cache-valid" +CACHE_INACTIVE_CONFIG_NAME = "cache-inactive" +CACHE_MAX_SIZE_CONFIG_NAME = "cache-max-size" +CACHE_INACTIVE_FIELD_NAME = "cache_inactive" +CACHE_MAX_SIZE_FIELD_NAME = "cache_max_size" def _validate_path_value(value: str) -> str: @@ -116,12 +120,16 @@ class Configuration(pydantic.BaseModel): fail_timeout: The time to wait before using a backend after failure. proxy_cache_valid: The cache valid duration. healthcheck: The healthcheck configuration. + cache_inactive: Time after which an unaccessed item is evicted from the disk cache. + cache_max_size: Maximum total disk size for the cache; empty string means no limit. """ backends: tuple[pydantic.AnyHttpUrl, ...] fail_timeout: typing.Annotated[str, pydantic.StringConstraints(min_length=1)] proxy_cache_valid: tuple[str, ...] healthcheck: HealthcheckConfig + cache_inactive: str + cache_max_size: str @pydantic.field_validator("backends") @classmethod @@ -171,6 +179,48 @@ def validate_proxy_cache_valid(cls, value: tuple[str, ...]) -> tuple[str, ...]: _check_nginx_time_str(time_str) return value + @pydantic.field_validator("cache_inactive") + @classmethod + def validate_cache_inactive(cls, value: str) -> str: + """Validate the cache_inactive time string. + + Args: + value: The nginx time string to validate. + + Raises: + ValueError: The value is not a valid nginx time string. + + Returns: + The validated value. + """ + try: + _check_nginx_time_str(value) + except ValueError as exc: + raise ValueError(str(exc)) from exc + return value + + @pydantic.field_validator("cache_max_size") + @classmethod + def validate_cache_max_size(cls, value: str) -> str: + """Validate the cache_max_size size string. + + Args: + value: The nginx size string to validate (may be empty to mean no limit). + + Raises: + ValueError: The value is not empty and not a valid nginx size string. + + Returns: + The validated value, lowercased. + """ + if not value: + return value + try: + _check_nginx_size_str(value) + except ValueError as exc: + raise ValueError(str(exc)) from exc + return value.lower() + @classmethod def from_charm(cls, charm: ops.CharmBase) -> "Configuration": """Initialize object from the charm. @@ -206,6 +256,11 @@ def from_charm(cls, charm: ops.CharmBase) -> "Configuration": healthcheck_config = HealthcheckConfig.from_charm(charm) + cache_inactive = typing.cast( + str, charm.config.get(CACHE_INACTIVE_CONFIG_NAME, "10m") + ).strip() + cache_max_size = typing.cast(str, charm.config.get(CACHE_MAX_SIZE_CONFIG_NAME, "")).strip() + try: # Ignore type check and let pydantic handle the type with validation errors. return cls( @@ -213,6 +268,8 @@ def from_charm(cls, charm: ops.CharmBase) -> "Configuration": fail_timeout=fail_timeout, proxy_cache_valid=proxy_cache_valid, # type: ignore healthcheck=healthcheck_config, + cache_inactive=cache_inactive, + cache_max_size=cache_max_size, ) except pydantic.ValidationError as err: err_msg = [ @@ -272,8 +329,8 @@ def _check_nginx_time_str(time_str: str) -> None: Raises: ValueError: The input is not valid time str for nginx. """ - time_char = {"h", "m", "s"} - if time_str[-1] not in time_char: + time_char = {"h", "m", "s", "d"} + if not time_str or time_str[-1] not in time_char: raise ValueError(f"Invalid time for proxy_cache_valid: {time_str}") try: time = int(time_str[:-1]) @@ -284,6 +341,30 @@ def _check_nginx_time_str(time_str: str) -> None: raise ValueError(f"Time must be positive int for proxy_cache_valid: {time_str}") +def _check_nginx_size_str(size_str: str) -> None: + """Check if nginx size string is valid. + + Valid format: positive integer followed by k, m, g, or t (case-insensitive). + + Args: + size_str: The size string to validate. + + Raises: + ValueError: The input is not a valid nginx size string. + """ + if not size_str: + raise ValueError("Size string must not be empty") + unit = size_str[-1].lower() + if unit not in {"k", "m", "g", "t"}: + raise ValueError(f"Invalid size unit in {size_str!r}: must be k, m, g, or t") + try: + value = int(size_str[:-1]) + except ValueError as err: + raise ValueError(f"Non-integer size value in {size_str!r}") from err + if value < 1: + raise ValueError(f"Size must be a positive integer in {size_str!r}") + + def _check_status_code(code_str: str) -> None: """Check if status code is valid. diff --git a/content-cache-backends-config/tests/unit/conftest.py b/content-cache-backends-config/tests/unit/conftest.py index 6479e9b0..9df77491 100644 --- a/content-cache-backends-config/tests/unit/conftest.py +++ b/content-cache-backends-config/tests/unit/conftest.py @@ -4,22 +4,18 @@ """Module for defining unit test fixtures.""" import pytest -from ops.testing import Harness +import scenario from charm import ContentCacheBackendsConfigCharm -@pytest.fixture(name="harness", scope="function") -def harness_fixture(): - """The ops testing harness fixture.""" - harness = Harness(ContentCacheBackendsConfigCharm) - harness.set_leader(True) - harness.begin_with_initial_hooks() - yield harness - harness.cleanup() +@pytest.fixture(name="ctx") +def context_fixture() -> scenario.Context: + """A scenario Context for ContentCacheBackendsConfigCharm.""" + return scenario.Context(ContentCacheBackendsConfigCharm) -@pytest.fixture(name="charm", scope="function") -def charm_fixture(harness: Harness): - """The charm fixture""" - return harness.charm +@pytest.fixture(name="ctx_leader") +def context_leader_fixture() -> scenario.Context: + """A scenario Context for ContentCacheBackendsConfigCharm (leader).""" + return scenario.Context(ContentCacheBackendsConfigCharm) diff --git a/content-cache-backends-config/tests/unit/factories.py b/content-cache-backends-config/tests/unit/factories.py index a6ed5f2f..b3081324 100644 --- a/content-cache-backends-config/tests/unit/factories.py +++ b/content-cache-backends-config/tests/unit/factories.py @@ -10,6 +10,8 @@ from src.state import ( BACKENDS_CONFIG_NAME, + CACHE_INACTIVE_CONFIG_NAME, + CACHE_MAX_SIZE_CONFIG_NAME, FAIL_TIMEOUT_CONFIG_NAME, HEALTHCHECK_INTERVAL_CONFIG_NAME, HEALTHCHECK_PATH_CONFIG_NAME, @@ -56,5 +58,7 @@ class Meta: HEALTHCHECK_SSL_VERIFY_CONFIG_NAME: False, HEALTHCHECK_VALID_STATUS_CONFIG_NAME: "200", PROXY_CACHE_VALID_CONFIG_NAME: "[]", + CACHE_INACTIVE_CONFIG_NAME: "10m", + CACHE_MAX_SIZE_CONFIG_NAME: "", } ) diff --git a/content-cache-backends-config/tests/unit/requirements.txt b/content-cache-backends-config/tests/unit/requirements.txt index c9c3f266..ffe2be81 100644 --- a/content-cache-backends-config/tests/unit/requirements.txt +++ b/content-cache-backends-config/tests/unit/requirements.txt @@ -1 +1,2 @@ factory-boy>=3,<4 +ops-scenario>=7.0.0,<9.0.0 diff --git a/content-cache-backends-config/tests/unit/test_charm.py b/content-cache-backends-config/tests/unit/test_charm.py index 2eff9915..0f9c67e6 100644 --- a/content-cache-backends-config/tests/unit/test_charm.py +++ b/content-cache-backends-config/tests/unit/test_charm.py @@ -1,133 +1,127 @@ # Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. -"""Unit test for the charm.""" +"""Unit test for the charm using ops-scenario.""" -from typing import Mapping -from unittest.mock import MagicMock +import json +from typing import cast -import ops import pytest -from ops.testing import Harness +import scenario import state -from charm import CACHE_CONFIG_INTEGRATION_NAME, ContentCacheBackendsConfigCharm +from charm import CACHE_CONFIG_INTEGRATION_NAME -# Test might need to access private methods. -# pylint: disable=protected-access - -JujuConfigValue = str | int | float | bool -JujuConfigKey = str -JujuConfig = Mapping[JujuConfigKey, JujuConfigValue] - - -SAMPLE_CONFIG: JujuConfig = { +SAMPLE_CONFIG: dict[str, str | int | float | bool] = { state.BACKENDS_CONFIG_NAME: "http://10.10.1.1:80,http://10.1.1.2:80", state.FAIL_TIMEOUT_CONFIG_NAME: "30s", state.HEALTHCHECK_PATH_CONFIG_NAME: "/health", state.HEALTHCHECK_INTERVAL_CONFIG_NAME: 2000, state.PROXY_CACHE_VALID_CONFIG_NAME: '["200 302 1h", "404 1m"]', + state.CACHE_INACTIVE_CONFIG_NAME: "10m", + state.CACHE_MAX_SIZE_CONFIG_NAME: "", } -def test_start(charm: ContentCacheBackendsConfigCharm): +@pytest.fixture(name="cache_config_relation") +def cache_config_relation_fixture() -> scenario.SubordinateRelation: + """A cache-config subordinate relation fixture.""" + return scenario.SubordinateRelation( + endpoint=CACHE_CONFIG_INTEGRATION_NAME, + remote_app_name="content-cache", + ) + + +def test_start_leader_no_relation(ctx: scenario.Context): """ - arrange: A working charm. + arrange: A working leader charm with no integration. act: The charm started. assert: Charm in block state. """ - assert charm.unit.status == ops.BlockedStatus("Waiting for integration") + out = ctx.run(ctx.on.start(), scenario.State(leader=True)) + assert out.unit_status == scenario.BlockedStatus("Waiting for integration") -def test_config_no_integration(charm: ContentCacheBackendsConfigCharm, harness: Harness): +def test_start_follower(ctx: scenario.Context): """ - arrange: Charm with no integration. - act: Update the configuration with valid values. - assert: The charm in active status. + arrange: A working follower charm. + act: The charm started. + assert: Follower unit is active. """ - harness.update_config(SAMPLE_CONFIG) + out = ctx.run(ctx.on.start(), scenario.State(leader=False)) + assert out.unit_status == scenario.ActiveStatus() - assert charm.unit.status == ops.BlockedStatus("Waiting for integration") +def test_config_no_integration(ctx: scenario.Context): + """ + arrange: Leader charm with no integration. + act: Update the configuration with valid values. + assert: The charm remains in blocked status (no relation). + """ + out = ctx.run(ctx.on.config_changed(), scenario.State(leader=True, config=SAMPLE_CONFIG)) + assert out.unit_status == scenario.BlockedStatus("Waiting for integration") -@pytest.mark.parametrize( - "event", - [ - pytest.param("_on_config_changed", id="config_changed"), - pytest.param("_on_cache_config_relation_changed", id="config_relation_changed"), - ], -) -def test_integration_config_missing(charm: ContentCacheBackendsConfigCharm, event: str): + +def test_integration_config_missing( + ctx: scenario.Context, + cache_config_relation: scenario.SubordinateRelation, +): """ - arrange: Charm with no integration. - act: Trigger events. + arrange: Charm with integration but no config. + act: Trigger config_changed. assert: Charm in block state. """ - mock_event = MagicMock() - getattr(charm, event)(mock_event) - - assert isinstance(charm.unit.status, ops.BlockedStatus) + out = ctx.run( + ctx.on.config_changed(), + scenario.State(leader=True, relations={cache_config_relation}), + ) + assert isinstance(out.unit_status, scenario.BlockedStatus) -@pytest.mark.parametrize( - "event", - [ - pytest.param("_on_config_changed", id="config_changed"), - pytest.param("_on_cache_config_relation_changed", id="config_relation_changed"), - ], -) def test_integration_data_not_leader( - charm: ContentCacheBackendsConfigCharm, harness: Harness, event: str + ctx: scenario.Context, + cache_config_relation: scenario.SubordinateRelation, ): """ arrange: Follow unit with configurations and integration. - act: Trigger events. - assert: The integration has no data. + act: Trigger config_changed. + assert: The integration has no data (follower doesn't write). """ - harness.set_leader(False) - harness.update_config(SAMPLE_CONFIG) - - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="content-cache", + out = ctx.run( + ctx.on.config_changed(), + scenario.State( + leader=False, + config=SAMPLE_CONFIG, + relations={cache_config_relation}, + ), ) - harness.add_relation_unit(relation_id, remote_unit_name="content-cache/0") + assert out.unit_status == scenario.ActiveStatus() + out_rel = out.get_relations(CACHE_CONFIG_INTEGRATION_NAME)[0] + assert out_rel.local_app_data == {} - mock_event = MagicMock() - getattr(charm, event)(mock_event) - data = harness.get_relation_data(relation_id, app_or_unit=charm.app.name) - assert charm.unit.status == ops.ActiveStatus() - assert data == {} - - -@pytest.mark.parametrize( - "event", - [ - pytest.param("_on_config_changed", id="config_changed"), - pytest.param("_on_cache_config_relation_changed", id="config_relation_changed"), - ], -) -def test_integration_data(charm: ContentCacheBackendsConfigCharm, harness: Harness, event: str): +def test_integration_data_via_config_changed( + ctx: scenario.Context, + cache_config_relation: scenario.SubordinateRelation, +): """ arrange: Leader unit with configurations and integration. - act: Trigger events. + act: Trigger config_changed. assert: The configuration is in the databag. """ - harness.update_config(SAMPLE_CONFIG) - - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="content-cache", + out = ctx.run( + ctx.on.config_changed(), + scenario.State( + leader=True, + config=SAMPLE_CONFIG, + relations={cache_config_relation}, + ), ) - harness.add_relation_unit(relation_id, remote_unit_name="content-cache/0") - - mock_event = MagicMock() - getattr(charm, event)(mock_event) + assert out.unit_status == scenario.ActiveStatus() + out_rel = out.get_relations(CACHE_CONFIG_INTEGRATION_NAME)[0] + data = cast(dict[str, str], out_rel.local_app_data) - data = harness.get_relation_data(relation_id, app_or_unit=charm.app.name) - assert charm.unit.status == ops.ActiveStatus() - backends = __import__("json").loads(data["backends"]) + backends = json.loads(data["backends"]) assert len(backends) == 2 assert any("10.10.1.1" in b for b in backends) assert any("10.1.1.2" in b for b in backends) @@ -139,23 +133,50 @@ def test_integration_data(charm: ContentCacheBackendsConfigCharm, harness: Harne assert data["healthcheck_valid_status"] == "[200]" assert data["fail_timeout"] == "30s" assert data["proxy_cache_valid"] == '["200 302 1h", "404 1m"]' + assert data[state.CACHE_INACTIVE_FIELD_NAME] == "10m" + assert data.get(state.CACHE_MAX_SIZE_FIELD_NAME, "") == "" + + +def test_integration_data_via_relation_changed( + ctx: scenario.Context, + cache_config_relation: scenario.SubordinateRelation, +): + """ + arrange: Leader unit with configurations and integration. + act: Trigger relation-changed. + assert: The configuration is in the databag. + """ + out = ctx.run( + ctx.on.relation_changed(cache_config_relation), + scenario.State( + leader=True, + config=SAMPLE_CONFIG, + relations={cache_config_relation}, + ), + ) + assert out.unit_status == scenario.ActiveStatus() + out_rel = out.get_relations(CACHE_CONFIG_INTEGRATION_NAME)[0] + data = cast(dict[str, str], out_rel.local_app_data) + assert json.loads(data["backends"]) + assert data[state.CACHE_INACTIVE_FIELD_NAME] == "10m" -def test_integration_with_invalid_config(charm: ContentCacheBackendsConfigCharm, harness: Harness): +def test_integration_with_invalid_config( + ctx: scenario.Context, + cache_config_relation: scenario.SubordinateRelation, +): """ arrange: Leader unit with integration. act: Update the configuration to invalid value. assert: The unit is in blocked status. """ - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="content-cache", + bad_config: dict[str, str | int | float | bool] = dict(SAMPLE_CONFIG) + bad_config[state.BACKENDS_CONFIG_NAME] = "" + out = ctx.run( + ctx.on.config_changed(), + scenario.State(leader=True, config=bad_config, relations={cache_config_relation}), ) - harness.add_relation_unit(relation_id, remote_unit_name="content-cache/0") - - harness.update_config({state.BACKENDS_CONFIG_NAME: ""}) - - assert charm.unit.status == ops.BlockedStatus("Empty backends configuration found") + assert out.unit_status == scenario.BlockedStatus("Empty backends configuration found") @pytest.mark.parametrize( @@ -166,33 +187,20 @@ def test_integration_with_invalid_config(charm: ContentCacheBackendsConfigCharm, ], ) def test_integration_removed( - harness: Harness, charm: ContentCacheBackendsConfigCharm, is_leader: bool + ctx: scenario.Context, + cache_config_relation: scenario.SubordinateRelation, + is_leader: bool, ): """ arrange: Unit with integration. act: Remove integration. - assert: Block status + assert: Block status (leader) or Active status (follower). """ - harness.set_leader(is_leader) - harness.update_config(SAMPLE_CONFIG) - - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="content-cache", + out = ctx.run( + ctx.on.relation_broken(cache_config_relation), + scenario.State(leader=is_leader, config=SAMPLE_CONFIG, relations={cache_config_relation}), ) - harness.add_relation_unit(relation_id, remote_unit_name="content-cache/0") - # When integrating applications the relation changed should fire. - # https://juju.is/docs/sdk/relation-name-relation-changed-event#heading--emission-sequence - # However, the harness does not fire relation changed on empty data, so it is manually - # triggered here. - charm._on_cache_config_relation_changed(MagicMock()) - - assert charm.unit.status == ops.ActiveStatus() - - harness.remove_relation(relation_id) - if is_leader: - assert charm.unit.status == ops.BlockedStatus("Waiting for integration") - return - # follower unit is always active. - assert charm.unit.status == ops.ActiveStatus() + assert out.unit_status == scenario.BlockedStatus("Waiting for integration") + else: + assert out.unit_status == scenario.ActiveStatus() diff --git a/content-cache-backends-config/tests/unit/test_state.py b/content-cache-backends-config/tests/unit/test_state.py index 291be3c7..615bef21 100644 --- a/content-cache-backends-config/tests/unit/test_state.py +++ b/content-cache-backends-config/tests/unit/test_state.py @@ -12,6 +12,8 @@ from errors import ConfigurationError from src.state import ( BACKENDS_CONFIG_NAME, + CACHE_INACTIVE_CONFIG_NAME, + CACHE_MAX_SIZE_CONFIG_NAME, HEALTHCHECK_INTERVAL_CONFIG_NAME, HEALTHCHECK_PATH_CONFIG_NAME, PROXY_CACHE_VALID_CONFIG_NAME, @@ -236,6 +238,8 @@ def test_configuration_to_data(): assert data["healthcheck_ssl_verify"] == "false" assert data["healthcheck_valid_status"] == "[200]" assert data["proxy_cache_valid"] == "[]" + assert data["cache_inactive"] == "10m" + assert data.get("cache_max_size", "") == "" def test_configuration_to_data_model_dump_error(monkeypatch): @@ -318,3 +322,75 @@ def test_invalid_healthcheck_interval(bad_value, error_msg): Configuration.from_charm(charm) assert str(err.value) == f"Config error: ['interval = {bad_value}: {error_msg}']" + + +@pytest.mark.parametrize("value", ["10m", "1h", "7d", "30s"]) +def test_cache_inactive_valid(value: str): + """ + arrange: Mock charm with valid cache-inactive values. + act: Create the configuration from the charm. + assert: cache_inactive is set correctly. + """ + charm = MockCharmFactory() + charm.config[CACHE_INACTIVE_CONFIG_NAME] = value + + config = Configuration.from_charm(charm) + + assert config.cache_inactive == value + + +@pytest.mark.parametrize("value", ["0m", "-1h", "abc", "10x"]) +def test_cache_inactive_invalid(value: str): + """ + arrange: Mock charm with invalid cache-inactive values. + act: Create the configuration from the charm. + assert: ConfigurationError raised. + """ + charm = MockCharmFactory() + charm.config[CACHE_INACTIVE_CONFIG_NAME] = value + + with pytest.raises(ConfigurationError): + Configuration.from_charm(charm) + + +@pytest.mark.parametrize("value", ["512m", "2g", "1t", "100k", "1G"]) +def test_cache_max_size_valid(value: str): + """ + arrange: Mock charm with valid cache-max-size values. + act: Create the configuration from the charm. + assert: cache_max_size is set (lowercased). + """ + charm = MockCharmFactory() + charm.config[CACHE_MAX_SIZE_CONFIG_NAME] = value + + config = Configuration.from_charm(charm) + + assert config.cache_max_size == value.lower() + + +@pytest.mark.parametrize("value", ["0m", "-1g", "abc", "10x"]) +def test_cache_max_size_invalid(value: str): + """ + arrange: Mock charm with invalid cache-max-size values. + act: Create the configuration from the charm. + assert: ConfigurationError raised. + """ + charm = MockCharmFactory() + charm.config[CACHE_MAX_SIZE_CONFIG_NAME] = value + + with pytest.raises(ConfigurationError): + Configuration.from_charm(charm) + + +def test_cache_max_size_empty_allowed(): + """ + arrange: Mock charm with empty cache-max-size. + act: Create the configuration from the charm. + assert: cache_max_size is empty string (no limit). + """ + charm = MockCharmFactory() + charm.config[CACHE_MAX_SIZE_CONFIG_NAME] = "" + + config = Configuration.from_charm(charm) + + assert config.cache_max_size == "" diff --git a/content-cache/src/nginx_manager.py b/content-cache/src/nginx_manager.py index a391dc18..6112858a 100644 --- a/content-cache/src/nginx_manager.py +++ b/content-cache/src/nginx_manager.py @@ -388,6 +388,28 @@ def _create_status_page_config() -> None: _store_and_enable_site_config("nginx_status", nginx_config) +def _build_proxy_cache_path( + cache_dir: Path, + identifier: str, + config: LocationConfig, +) -> str: + """Build the proxy_cache_path directive value. + + Args: + cache_dir: The directory to store cache files. + identifier: The unique cache zone identifier. + config: The location configuration with cache parameters. + + Returns: + The proxy_cache_path value string. + """ + value = f"{cache_dir} use_temp_path=off levels=1:2 keys_zone={identifier}:10m" + value += f" inactive={config.cache_inactive}" + if config.cache_max_size: + value += f" max_size={config.cache_max_size}" + return value + + def _create_virtualhost_config( # pylint: disable=too-many-locals identifier: str, port: int, @@ -418,7 +440,7 @@ def _create_virtualhost_config( # pylint: disable=too-many-locals nginx_config = nginx.Conf( nginx.Key( "proxy_cache_path", - f"{server_cache_dir} use_temp_path=off levels=1:2 keys_zone={identifier}:10m", + _build_proxy_cache_path(server_cache_dir, identifier, configuration), ), ) listen_value = f"{port} ssl" if resolved_tls.frontend_cert_path else str(port) @@ -534,9 +556,11 @@ def _get_location_config_keys( scheme = config.backends[0].scheme keys: list[nginx.Key] = [ nginx.Key("proxy_pass", f"{scheme}://{upstream}/"), + nginx.Key("proxy_cache_lock", "on"), ] - if scheme == "https" and ca_certs.get_ca_bundle_path() is not None: + ssl_verify = config.healthcheck_config.ssl_verify + if scheme == "https" and ca_certs.get_ca_bundle_path() is not None and ssl_verify: # Use the backend actual hostname/IP for SSL verification, not the upstream # block name (e.g. "backend-{id}"), which would never match the cert's CN/SAN. # All backends in a location must share the same hostname for proxy_ssl to work. diff --git a/content-cache/src/state.py b/content-cache/src/state.py index 4402b1fc..cd38f454 100644 --- a/content-cache/src/state.py +++ b/content-cache/src/state.py @@ -23,6 +23,8 @@ HEALTHCHECK_SSL_VERIFY_FIELD_NAME = "healthcheck_ssl_verify" HEALTHCHECK_VALID_STATUS_FIELD_NAME = "healthcheck_valid_status" PROXY_CACHE_VALID_FIELD_NAME = "proxy_cache_valid" +CACHE_INACTIVE_FIELD_NAME = "cache_inactive" +CACHE_MAX_SIZE_FIELD_NAME = "cache_max_size" def _validate_hostname_value(value: str) -> str: @@ -147,12 +149,16 @@ class LocationConfig(pydantic.BaseModel): fail_timeout: The time to wait before using a backend after failure. proxy_cache_valid: The cache valid duration. healthcheck_config: The healthcheck configuration. + cache_inactive: Time after which an unaccessed item is evicted from the disk cache. + cache_max_size: Maximum total disk size for the cache; empty string means no limit. """ backends: tuple[pydantic.AnyHttpUrl, ...] fail_timeout: typing.Annotated[str, pydantic.StringConstraints(min_length=1)] proxy_cache_valid: tuple[str, ...] healthcheck_config: HealthcheckConfig + cache_inactive: str = "10m" + cache_max_size: str = "" @pydantic.field_validator("backends") @classmethod @@ -233,6 +239,9 @@ def from_integration_data(cls, data: ops.RelationDataContent) -> "LocationConfig healthcheck_config = HealthcheckConfig.from_integration_data(data) + cache_inactive = data.get(CACHE_INACTIVE_FIELD_NAME, "10m").strip() or "10m" + cache_max_size = data.get(CACHE_MAX_SIZE_FIELD_NAME, "").strip() + try: # Ignore type check and let pydantic handle the type with validation errors. return cls( @@ -240,6 +249,8 @@ def from_integration_data(cls, data: ops.RelationDataContent) -> "LocationConfig fail_timeout=fail_timeout, proxy_cache_valid=proxy_cache_valid, # type: ignore healthcheck_config=healthcheck_config, + cache_inactive=cache_inactive, + cache_max_size=cache_max_size, ) except pydantic.ValidationError as err: err_msg = [ diff --git a/content-cache/tests/integration/conftest.py b/content-cache/tests/integration/conftest.py index b89d82ad..42a2ee03 100644 --- a/content-cache/tests/integration/conftest.py +++ b/content-cache/tests/integration/conftest.py @@ -6,20 +6,14 @@ import asyncio import logging import secrets -from typing import AsyncIterator, List +import time +from collections.abc import Generator +import jubilant import pytest -import pytest_asyncio -from juju.application import Application -from juju.model import Model from pytest_operator.plugin import OpsTest -from tests.integration.helpers import ( - CacheTester, - deploy_http_app, - deploy_self_cert_https_app, - get_app_ip, -) +from tests.integration.helpers import CacheTester, deploy_http_app, get_app_ip logger = logging.getLogger(__name__) @@ -28,41 +22,12 @@ CACHE_LEGO_CHARM_NAME = "self-signed-certificates" METRIC_CHARM_NAME = "grafana-agent" - -@pytest.fixture(name="app_name", scope="module") -def app_name_fixture() -> str: - """The application name.""" - return "cache" - - -@pytest.fixture(name="config_app_name", scope="module") -def config_app_name_fixture() -> str: - """The application name for the configuration charm.""" - return "config" - - -@pytest.fixture(name="config_alt_app_name", scope="module") -def config_alt_app_name_fixture() -> str: - """The application name for the alternative configuration charm.""" - return "config-alt" - - -@pytest.fixture(name="cert_app_name", scope="module") -def cert_app_name_fixture() -> str: - """The application name for the TLS certificate charm.""" - return "cert" - - -@pytest.fixture(name="cache_lego_app_name", scope="module") -def cache_lego_app_name_fixture() -> str: - """The application name for the cache-side TLS certificate provider charm.""" - return "cache-lego" - - -@pytest.fixture(name="metric_app_name", scope="module") -def metric_app_name_fixture() -> str: - """The application name of the metric export charm.""" - return "metric" +APP_NAME = "cache" +CONFIG_APP_NAME = "config" +CONFIG_ALT_APP_NAME = "config-alt" +CERT_APP_NAME = "cert" +CACHE_LEGO_APP_NAME = "cache-lego" +METRIC_APP_NAME = "metric" @pytest.fixture(name="charm_file", scope="module") @@ -73,146 +38,109 @@ def charm_file_fixture(pytestconfig: pytest.Config) -> str: return f"./{file}" -@pytest_asyncio.fixture(name="config_charm_file", scope="module") -async def config_charm_file_fixture( - ops_test: OpsTest, pytestconfig: pytest.Config -) -> AsyncIterator[str]: +@pytest.fixture(name="config_charm_file", scope="module") +def config_charm_file_fixture(ops_test: OpsTest, pytestconfig: pytest.Config) -> str: """Build the configuration charm file and return the path.""" file = pytestconfig.getoption("--config-charm-file") if file: - yield file - return - - path = await ops_test.build_charm("../content-cache-backends-config") - yield str(path) + return file + path = asyncio.run(ops_test.build_charm("../content-cache-backends-config")) + return str(path) -@pytest_asyncio.fixture(name="model", scope="module") -async def model_fixture(ops_test) -> AsyncIterator[Model]: - """The juju model for testing.""" - yield ops_test.model +@pytest.fixture(name="juju", scope="module") +def juju_fixture() -> Generator[jubilant.Juju, None, None]: + """A jubilant Juju instance in a temporary model for the test module.""" + with jubilant.temp_model() as juju: + yield juju -@pytest_asyncio.fixture(name="applications", scope="module") -async def deploy_applications_fixture( - model: Model, +@pytest.fixture(name="applications", scope="module") +def deploy_applications_fixture( + juju: jubilant.Juju, charm_file: str, config_charm_file: str, - app_name: str, - config_app_name: str, - config_alt_app_name: str, - cert_app_name: str, - cache_lego_app_name: str, - metric_app_name: str, pytestconfig: pytest.Config, -) -> AsyncIterator[dict[str, Application]]: - """Deploy all applications in parallel.""" +) -> dict[str, str]: + """Deploy all applications and return a mapping of logical name to application name.""" if pytestconfig.getoption("--no-deploy"): - try: - res = { - app_name: model.applications[app_name], - config_app_name: model.applications[config_app_name], - config_alt_app_name: model.applications[config_alt_app_name], - cert_app_name: model.applications[cert_app_name], - cache_lego_app_name: model.applications[cache_lego_app_name], - metric_app_name: model.applications[metric_app_name], - } - except KeyError as err: - missing_app = err.args[0] - raise RuntimeError( - f"At least one app is missing ({missing_app}), you cannot use --no-deploy." - ) - yield res - return - - app_deploy = model.deploy(charm_file, app_name, base="ubuntu@24.04") - config_app_deploy = model.deploy( - config_charm_file, config_app_name, base="ubuntu@24.04", num_units=0 - ) - config_alt_app_deploy = model.deploy( - config_charm_file, config_alt_app_name, base="ubuntu@24.04", num_units=0 + return { + "app": APP_NAME, + "config": CONFIG_APP_NAME, + "config_alt": CONFIG_ALT_APP_NAME, + "cert": CERT_APP_NAME, + "cache_lego": CACHE_LEGO_APP_NAME, + "metric": METRIC_APP_NAME, + } + + juju.deploy(charm_file, APP_NAME, base="ubuntu@24.04") + juju.deploy(config_charm_file, CONFIG_APP_NAME, base="ubuntu@24.04", num_units=0) + juju.deploy(config_charm_file, CONFIG_ALT_APP_NAME, base="ubuntu@24.04", num_units=0) + juju.deploy(CERT_CHARM_NAME, CERT_APP_NAME, channel="latest/edge", base="ubuntu@22.04") + juju.deploy( + CACHE_LEGO_CHARM_NAME, CACHE_LEGO_APP_NAME, channel="latest/edge", base="ubuntu@22.04" ) - cert_app_deploy = model.deploy( - CERT_CHARM_NAME, cert_app_name, channel="latest/edge", base="ubuntu@22.04" - ) - cache_lego_app_deploy = model.deploy( - CACHE_LEGO_CHARM_NAME, cache_lego_app_name, channel="latest/edge", base="ubuntu@22.04" - ) - metric_app_deploy = model.deploy( + juju.deploy( METRIC_CHARM_NAME, - metric_app_name, + METRIC_APP_NAME, channel="1/stable", base="ubuntu@24.04", num_units=0, ) - app, config_app, config_alt_app, cert_app, cache_lego_app, metric_app = await asyncio.gather( - app_deploy, - config_app_deploy, - config_alt_app_deploy, - cert_app_deploy, - cache_lego_app_deploy, - metric_app_deploy, + juju.wait( + lambda s: s.apps[APP_NAME].app_status.current in ("active", "blocked"), + timeout=15 * 60, ) - await model.wait_for_idle([app.name], status="blocked", timeout=15 * 60) - await model.wait_for_idle( - [cert_app.name, cache_lego_app.name], status="active", timeout=15 * 60 + juju.wait( + lambda s: s.apps[CERT_APP_NAME].app_status.current == "active" + and s.apps[CACHE_LEGO_APP_NAME].app_status.current == "active", + timeout=15 * 60, ) - yield { - app_name: app, - config_app_name: config_app, - config_alt_app_name: config_alt_app, - cert_app_name: cert_app, - cache_lego_app_name: cache_lego_app, - metric_app_name: metric_app, + + return { + "app": APP_NAME, + "config": CONFIG_APP_NAME, + "config_alt": CONFIG_ALT_APP_NAME, + "cert": CERT_APP_NAME, + "cache_lego": CACHE_LEGO_APP_NAME, + "metric": METRIC_APP_NAME, } -@pytest_asyncio.fixture(name="app", scope="module") -async def app_fixture( - app_name: str, applications: dict[str, Application] -) -> AsyncIterator[Application]: - """The content-cache charm application for testing.""" - yield applications[app_name] +@pytest.fixture(name="app", scope="module") +def app_fixture(applications: dict[str, str]) -> str: + """The content-cache application name.""" + return applications["app"] -@pytest_asyncio.fixture(name="config_app", scope="module") -async def config_app_fixture( - config_app_name: str, applications: dict[str, Application] -) -> AsyncIterator[Application]: - """The configuration charm application for testing.""" - yield applications[config_app_name] +@pytest.fixture(name="config_app", scope="module") +def config_app_fixture(applications: dict[str, str]) -> str: + """The configuration charm application name.""" + return applications["config"] -@pytest_asyncio.fixture(name="config_alt_app", scope="module") -async def config_alt_app_fixture( - config_alt_app_name: str, applications: dict[str, Application] -) -> AsyncIterator[Application]: - """The alternative configuration charm application for testing.""" - yield applications[config_alt_app_name] +@pytest.fixture(name="config_alt_app", scope="module") +def config_alt_app_fixture(applications: dict[str, str]) -> str: + """The alternative configuration charm application name.""" + return applications["config_alt"] -@pytest_asyncio.fixture(name="cert_app", scope="module") -async def cert_app_fixture( - cert_app_name: str, applications: dict[str, Application] -) -> AsyncIterator[Application]: - """The TLS certificate charm application for testing.""" - yield applications[cert_app_name] +@pytest.fixture(name="cert_app", scope="module") +def cert_app_fixture(applications: dict[str, str]) -> str: + """The TLS certificate charm application name.""" + return applications["cert"] -@pytest_asyncio.fixture(name="cache_lego_app", scope="module") -async def cache_lego_app_fixture( - cache_lego_app_name: str, applications: dict[str, Application] -) -> AsyncIterator[Application]: - """The cache-side TLS certificate provider charm for testing the certificates relation.""" - yield applications[cache_lego_app_name] +@pytest.fixture(name="cache_lego_app", scope="module") +def cache_lego_app_fixture(applications: dict[str, str]) -> str: + """The cache-side TLS certificate provider charm name.""" + return applications["cache_lego"] -@pytest_asyncio.fixture(name="metric_app", scope="module") -async def metric_app_fixture( - metric_app_name: str, applications: dict[str, Application] -) -> AsyncIterator[Application]: - """The metric agent charm application for testing.""" - yield applications[metric_app_name] +@pytest.fixture(name="metric_app", scope="module") +def metric_app_fixture(applications: dict[str, str]) -> str: + """The metric agent charm application name.""" + return applications["metric"] @pytest.fixture(name="http_ok_message", scope="module") @@ -221,113 +149,88 @@ def http_ok_message_fixture() -> str: return f"test-{secrets.token_urlsafe(2)}" -@pytest_asyncio.fixture(name="http_ok_app", scope="module") -async def http_ok_app_fixture( - model: Model, http_ok_message: str, pytestconfig: pytest.Config -) -> AsyncIterator[Application]: +@pytest.fixture(name="http_ok_app", scope="module") +def http_ok_app_fixture(juju: jubilant.Juju, http_ok_message: str) -> str: """The test HTTP application that returns OK.""" - app = await deploy_http_app( - app_name="http-ok", path="/", status=200, message=http_ok_message, model=model + app_name = deploy_http_app( + juju=juju, app_name="http-ok", path="/", status=200, message=http_ok_message ) - await model.wait_for_idle([app.name], status="active", timeout=15 * 60) + juju.wait(lambda s: s.apps[app_name].app_status.current == "active", timeout=15 * 60) + return app_name - yield app - -@pytest_asyncio.fixture(name="https_ok_app", scope="module") -async def https_ok_app_fixture( - model: Model, http_ok_message: str, pytestconfig: pytest.Config -) -> AsyncIterator[Application]: +@pytest.fixture(name="https_ok_app", scope="module") +def https_ok_app_fixture(juju: jubilant.Juju, http_ok_message: str) -> str: """The test HTTPS application that returns OK.""" - app = await deploy_http_app( + app_name = deploy_http_app( + juju=juju, app_name="https-ok", path="/", status=200, message=http_ok_message, - model=model, https=True, ) - await model.wait_for_idle([app.name], status="active", timeout=15 * 60) - - yield app - - -@pytest_asyncio.fixture(name="https_cert_ok_app", scope="module") -async def https_cert_ok_app_fixture( - model: Model, http_ok_message: str, cert_app: Application -) -> AsyncIterator[Application]: - """HTTPS test app that gets its cert signed by cert_app's CA. - - The backend cert is trusted by the cert_app CA bundle, so proxy_ssl_verify - will pass when content-cache receives the CA via receive-ca-cert. The Lua - health checker uses the system cert store, so ssl_verify=true still fails for - self-signed CAs that are not installed system-wide. - """ - app = await deploy_self_cert_https_app( - app_name="https-cert-ok", - path="/", - status=200, - message=http_ok_message, - model=model, - ) - await model.integrate( - f"{app.name}:require-tls-certificates", - f"{cert_app.name}:certificates", - ) - await model.wait_for_idle([app.name], status="active", timeout=15 * 60) - - yield app - - -@pytest_asyncio.fixture(name="http_ok_ip", scope="module") -async def http_ok_ip_fixture(http_ok_app: Application) -> str: - """The IP to the test HTTP application that returns OK.""" - return await get_app_ip(http_ok_app) - - -@pytest_asyncio.fixture(name="http_ok_ips", scope="module") -async def http_ok_ips_fixture(model: Model, http_ok_app: Application) -> List[str]: - """The IPs of the test HTTP applications (2 units expected)""" - if len(http_ok_app.units) < 2: - await http_ok_app.add_unit(1) - await model.wait_for_idle([http_ok_app.name], status="active", timeout=10 * 60) - - ips = [] - for unit in http_ok_app.units: - ips.append(await unit.get_public_address()) - - return ips - - -@pytest_asyncio.fixture(name="cache_tester", scope="function") -async def cache_tester_fixture( - model: Model, - app: Application, - config_app: Application, - config_alt_app: Application, -) -> AsyncIterator[CacheTester]: + juju.wait(lambda s: s.apps[app_name].app_status.current == "active", timeout=15 * 60) + return app_name + + +@pytest.fixture(name="http_ok_ip", scope="module") +def http_ok_ip_fixture(juju: jubilant.Juju, http_ok_app: str) -> str: + """The IP of the test HTTP application.""" + return get_app_ip(juju, http_ok_app) + + +@pytest.fixture(name="http_ok_ips", scope="module") +def http_ok_ips_fixture(juju: jubilant.Juju, http_ok_app: str) -> list[str]: + """The IPs of the test HTTP applications (2 units expected).""" + status = juju.status() + if len(status.apps[http_ok_app].units) < 2: + juju.add_unit(http_ok_app, num_units=1) + juju.wait(lambda s: s.apps[http_ok_app].app_status.current == "active", timeout=10 * 60) + status = juju.status() + + return [ + unit.public_address + for unit in status.apps[http_ok_app].units.values() + if unit.public_address + ] + + +@pytest.fixture(name="cache_tester", scope="function") +def cache_tester_fixture( + juju: jubilant.Juju, + app: str, + config_app: str, + config_alt_app: str, +) -> Generator[CacheTester, None, None]: """Get the cache tester.""" - unit = app.units[0] - tester = CacheTester(model, app, config_app, config_alt_app) + tester = CacheTester(juju, app, config_app, config_alt_app) yield tester if not tester._reset_after_run: return - # This removes the integration and configurations. - await tester.reset() + tester.reset() + + juju.wait( + lambda s: s.apps[app].units[f"{app}/0"].workload_status.current == "blocked", + timeout=10 * 60, + ) + assert ( + juju.status().apps[app].units[f"{app}/0"].workload_status.message + == "Waiting for integration with config charm" + ) - await model.wait_for_idle([app.name], status="blocked", timeout=10 * 60) - assert unit.workload_status_message == "Waiting for integration with config charm" - # Wait for config app units to be fully removed before the next test runs. - # Config app scales to 0 units when the relation is removed, but Juju takes - # a moment to complete the unit removal. Without this wait, the next test - # may create a new relation while a previous unit is still being torn down, - # causing race conditions. + # Poll until subordinate units are removed before next test. deadline = 60 poll_interval = 1 elapsed = 0 - while (config_app.units or config_alt_app.units) and elapsed < deadline: - await asyncio.sleep(poll_interval) + while elapsed < deadline: + st = juju.status() + config_units = st.apps.get(config_app, type("", (), {"units": {}})()).units + config_alt_units = st.apps.get(config_alt_app, type("", (), {"units": {}})()).units + if not config_units and not config_alt_units: + break + time.sleep(poll_interval) elapsed += poll_interval diff --git a/content-cache/tests/integration/helpers.py b/content-cache/tests/integration/helpers.py index d55d5b4b..87ade70e 100644 --- a/content-cache/tests/integration/helpers.py +++ b/content-cache/tests/integration/helpers.py @@ -8,11 +8,8 @@ import textwrap from pathlib import Path +import jubilant import requests -from juju.action import Action -from juju.application import Application -from juju.model import Model -from juju.unit import Unit from state import CACHE_CONFIG_INTEGRATION_NAME @@ -45,57 +42,57 @@ class CacheTester: def __init__( self, - model: Model, - app: Application, - config_app: Application, - config_alt_app: Application, + juju: jubilant.Juju, + app: str, + config_app: str, + config_alt_app: str, ): """Initialize the object. Args: - model: The juju model containing the applications. - app: The content-cache application. - config_app: The configuration charm application. - config_alt_app: The alternative configuration charm application. + juju: The jubilant Juju instance. + app: The content-cache application name. + config_app: The configuration charm application name. + config_alt_app: The alternative configuration charm application name. """ - self._model = model + self._juju = juju self._app = app self._config_app = config_app self._config_alt_app = config_alt_app self._reset_after_run = True - async def integrate_config(self) -> None: + def integrate_config(self) -> None: """Integrate the configuration application.""" - await self._model.integrate( - f"{self._config_app.name}:{CACHE_CONFIG_INTEGRATION_NAME}", - f"{self._app.name}:{CACHE_CONFIG_INTEGRATION_NAME}", + self._juju.integrate( + f"{self._config_app}:{CACHE_CONFIG_INTEGRATION_NAME}", + f"{self._app}:{CACHE_CONFIG_INTEGRATION_NAME}", ) - async def integrate_config_alt(self) -> None: + def integrate_config_alt(self) -> None: """Integrate the alternative configuration application.""" - await self._model.integrate( - f"{self._config_alt_app.name}:{CACHE_CONFIG_INTEGRATION_NAME}", - f"{self._app.name}:{CACHE_CONFIG_INTEGRATION_NAME}", + self._juju.integrate( + f"{self._config_alt_app}:{CACHE_CONFIG_INTEGRATION_NAME}", + f"{self._app}:{CACHE_CONFIG_INTEGRATION_NAME}", ) - async def setup_config(self, configuration: dict[str, str]) -> None: + def setup_config(self, configuration: dict[str, str]) -> None: """Set up configuration on the configuration charm. Args: configuration: The configuration for the configuration charm. """ - await self._config_app.set_config(configuration) + self._juju.config(self._config_app, configuration) - async def setup_config_alt(self, configuration: dict[str, str]) -> None: + def setup_config_alt(self, configuration: dict[str, str]) -> None: """Set up configuration on the alternative configuration charm. Args: configuration: The configuration for the alternative configuration charm. """ - await self._config_alt_app.set_config(configuration) + self._juju.config(self._config_alt_app, configuration) - async def query_cache( - self, path: str, port: int = 30000, protocol: str = "http" + def query_cache( + self, path: str, port: int = 8080, protocol: str = "http" ) -> requests.Response: """Test the content cache with a request. @@ -105,11 +102,11 @@ async def query_cache( protocol: The protocol to make the request. Returns: - Whether the cache is working. + The HTTP response from the cache. """ - ip = await get_app_ip(self._app) + ip = get_app_ip(self._juju, self._app) url = f"{protocol}://{ip}:{port}{path}" - logger.info(f"Querying cache on {url}") + logger.info("Querying cache on %s", url) response = requests.get( url, @@ -120,40 +117,56 @@ async def query_cache( return response - async def reset(self) -> None: + def reset(self) -> None: """Reset the state of the applications.""" - if self._config_app.related_applications(CACHE_CONFIG_INTEGRATION_NAME): - # Do NOT use block_until_done=True — it calls block_until() with no timeout - # and can hang forever if hook processing stalls. - await self._config_app.remove_relation(CACHE_CONFIG_INTEGRATION_NAME, self._app.name) - if self._config_alt_app.related_applications(CACHE_CONFIG_INTEGRATION_NAME): - await self._config_alt_app.remove_relation( - CACHE_CONFIG_INTEGRATION_NAME, self._app.name - ) - await self.reset_config() + st = self._juju.status() + if self._config_app in st.apps: + config_app_data = st.apps[self._config_app] + if any( + CACHE_CONFIG_INTEGRATION_NAME in r + for r in getattr(config_app_data, "relations", {}) + ): + self._juju.remove_relation( + f"{self._config_app}:{CACHE_CONFIG_INTEGRATION_NAME}", + f"{self._app}:{CACHE_CONFIG_INTEGRATION_NAME}", + ) + if self._config_alt_app in st.apps: + config_alt_app_data = st.apps[self._config_alt_app] + if any( + CACHE_CONFIG_INTEGRATION_NAME in r + for r in getattr(config_alt_app_data, "relations", {}) + ): + self._juju.remove_relation( + f"{self._config_alt_app}:{CACHE_CONFIG_INTEGRATION_NAME}", + f"{self._app}:{CACHE_CONFIG_INTEGRATION_NAME}", + ) + self.reset_config() - async def reset_config(self) -> None: + def reset_config(self) -> None: """Reset the configuration of configuration charm application.""" - await self._config_app.set_config(CacheTester.BASE_CONFIG) + self._juju.config(self._config_app, CacheTester.BASE_CONFIG) -async def deploy_http_app( - app_name: str, path: str, status: int, message: str, model: Model, https: bool = False -) -> Application: +def deploy_http_app( + juju: jubilant.Juju, + app_name: str, + path: str, + status: int, + message: str, + https: bool = False, +) -> str: """Deploy a testing HTTP server application for testing. - The testing HTTP server application is within an any charm instance. - Args: + juju: The jubilant Juju instance. app_name: The application name of the any charm. path: The URL path to the test server. status: The status code for the test response. message: The message in the test response. - model: The model to deploy the any charm. https: Run server in HTTPS mode on port 443. Returns: - The juju application with the testing HTTP server. + The application name. """ if https: port = 443 @@ -164,7 +177,7 @@ async def deploy_http_app( test_server_content = TEST_SERVER_PATH.read_text() certificate_content = TEST_SERVER_CERTIFICATE.read_text() - any_charm_content = textwrap.dedent(f''' + any_charm_content = textwrap.dedent(f""" import logging import os import subprocess @@ -188,7 +201,7 @@ def generate_config(self): test_server_path = Path(os.getcwd()) / "src" / "test_server.py" SERVICE_PATH.write_text( textwrap.dedent( - """ + \"\"\" [Unit] Description=Test HTTP server After=network.target @@ -196,14 +209,14 @@ def generate_config(self): [Service] Type=simple User=root - ExecStart=/usr/bin/env python3 """ + ExecStart=/usr/bin/env python3 \"\"\" + str(test_server_path) - + """ --path {path} --status {status} --message {message} --port {port} {flags} + + \"\"\" --path {path} --status {status} --message {message} --port {port} {flags} Restart=on-failure [Install] WantedBy=multi-user.target - """ + \"\"\" ) ) @@ -219,7 +232,7 @@ def _on_config_changed(self, event): self.generate_config() subprocess.run(["systemctl", "daemon-reload"]) subprocess.run(["systemctl", "restart", SERVICE_NAME]) - ''') + """) src_overwrite = { "test_server.py": test_server_content, @@ -227,279 +240,96 @@ def _on_config_changed(self, event): "certificate.pem": certificate_content, } - app: Application - if app_name in model.applications: - logging.info(f"Found existing {app_name} application. Reconfiguring it.") - app = model.applications[app_name] - await app.set_config({"src-overwrite": json.dumps(src_overwrite)}) - else: - app = await model.deploy( - "any-charm", - application_name=app_name, - channel="beta", - config={"src-overwrite": json.dumps(src_overwrite)}, - ) - - return app - - -async def deploy_self_cert_https_app( - app_name: str, path: str, status: int, message: str, model: Model -) -> Application: - """Deploy an HTTPS test app that gets its cert signed by a tls-certificates CA. - - The app generates a private key and CSR with its own IP as a Subject Alternative Name, - writes the CSR to the ``require-tls-certificates`` relation, and starts the HTTPS server - once the signed cert arrives. - - After deploying, integrate ``:require-tls-certificates`` with the CA charm's - ``certificates`` endpoint and wait for the app to become active. - - Args: - app_name: The application name for the any-charm deployment. - path: URL path that the server will respond to. - status: HTTP status code the server returns on ``path``. - message: Response body the server returns on ``path``. - model: The libjuju Model to deploy into. - - Returns: - The deployed Juju Application. - """ - test_server_content = TEST_SERVER_PATH.read_text() - - # The inner any-charm code. Values of path/status/message are baked in by - # the outer f-string at deploy time; other {{}}/{{var}} escapes produce - # single-brace expressions that are evaluated inside the charm at runtime. - any_charm_content = textwrap.dedent(f'''\ - import json - import logging - import os - import socket - import subprocess - from pathlib import Path - - import ops - from any_charm_base import AnyCharmBase - - logger = logging.getLogger(__name__) - - SERVICE_NAME = "test-https-cert" - SERVICE_PATH = Path("/etc/systemd/system/" + SERVICE_NAME + ".service") - CERT_DIR = Path("/etc/test-certs") - SERVER_PEM = CERT_DIR / "server.pem" - KEY_PATH = CERT_DIR / "server.key" - CSR_PATH = CERT_DIR / "server.csr" - SAN_CONF = CERT_DIR / "san.cnf" - - - def _get_own_ip() -> str: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - try: - s.connect(("10.255.255.255", 1)) - return s.getsockname()[0] - finally: - s.close() - - - def _ensure_key_and_csr() -> str: - """Generate key + CSR for this unit's IP if not already present; return IP.""" - ip = _get_own_ip() - if KEY_PATH.exists() and CSR_PATH.exists(): - return ip - CERT_DIR.mkdir(parents=True, exist_ok=True) - SAN_CONF.write_text( - "[req]\\n" - "req_extensions = v3_req\\n" - "distinguished_name = req_dn\\n" - "[req_dn]\\n" - "[v3_req]\\n" - "subjectAltName = IP:" + ip + "\\n" - ) - subprocess.run( - ["openssl", "genrsa", "-out", str(KEY_PATH), "2048"], - check=True, capture_output=True, - ) - subprocess.run( - [ - "openssl", "req", "-new", - "-key", str(KEY_PATH), - "-out", str(CSR_PATH), - "-subj", "/CN=" + ip, - "-config", str(SAN_CONF), - ], - check=True, capture_output=True, - ) - logger.info("Generated key and CSR for IP %s", ip) - return ip - - - class AnyCharm(AnyCharmBase): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.framework.observe(self.on.install, self._on_install) - self.framework.observe( - self.on["require-tls-certificates"].relation_joined, - self._submit_csr, - ) - self.framework.observe( - self.on["require-tls-certificates"].relation_changed, - self._on_cert_relation_changed, - ) - - def _on_start_(self, event): - """Override AnyCharmBase to keep WaitingStatus until cert arrives.""" - self.unit.status = ops.WaitingStatus("Waiting for TLS certificate") - - def _on_install(self, event): - _ensure_key_and_csr() - self.unit.status = ops.WaitingStatus("Waiting for TLS certificate") - - def _submit_csr(self, event): - """Write CSR to the tls-certificates relation unit data (v4 unit-mode).""" - _ensure_key_and_csr() - csr_pem = CSR_PATH.read_text() - event.relation.data[self.unit]["certificate_signing_requests"] = json.dumps( - [{{"certificate_signing_request": csr_pem, "ca": False}}] - ) - - def _on_cert_relation_changed(self, event): - """Read signed cert from provider and start the HTTPS server.""" - if not CSR_PATH.exists(): - return - csr_pem = CSR_PATH.read_text().strip() - # Certs are in the PROVIDER APP databag (tls-certificates v4), not unit databag. - raw = event.relation.data[event.relation.app].get("certificates") - if not raw: - return - for entry in json.loads(raw): - if entry.get("certificate_signing_request", "").strip() == csr_pem: - self._start_server(entry["certificate"]) - self.unit.status = ops.ActiveStatus() - return - - def _start_server(self, cert_pem: str): - """Write cert+key PEM and restart the systemd HTTPS service.""" - SERVER_PEM.write_text(cert_pem.strip() + "\\n" + KEY_PATH.read_text()) - test_server = Path(os.getcwd()) / "src" / "test_server.py" - SERVICE_PATH.write_text( - "[Unit]\\n" - "Description=Test HTTPS server (CA-issued cert)\\n" - "After=network.target\\n" - "\\n" - "[Service]\\n" - "Type=simple\\n" - "User=root\\n" - "ExecStart=/usr/bin/env python3 " + str(test_server) - + " --path {path} --status {status} --message {message}" - " --port 443 --https --cert " + str(SERVER_PEM) + "\\n" - "Restart=on-failure\\n" - "\\n" - "[Install]\\n" - "WantedBy=multi-user.target\\n" - ) - subprocess.run(["systemctl", "daemon-reload"], capture_output=True) - subprocess.run(["systemctl", "enable", SERVICE_NAME], capture_output=True) - subprocess.run(["systemctl", "restart", SERVICE_NAME], capture_output=True) - ''') - - src_overwrite = { - "test_server.py": test_server_content, - "any_charm.py": any_charm_content, - } - - app: Application - if app_name in model.applications: + juju_status = juju.status() + if app_name in juju_status.apps: logging.info("Found existing %s application. Reconfiguring it.", app_name) - app = model.applications[app_name] - await app.set_config({"src-overwrite": json.dumps(src_overwrite)}) + juju.config(app_name, {"src-overwrite": json.dumps(src_overwrite)}) else: - app = await model.deploy( + juju.deploy( "any-charm", - application_name=app_name, + app_name, channel="beta", config={"src-overwrite": json.dumps(src_overwrite)}, ) - return app + return app_name -async def get_app_ip(app: Application) -> str: +def get_app_ip(juju: jubilant.Juju, app_name: str) -> str: """Get the IP for a unit of the application. Args: - app: The application to get the public IP. + juju: The jubilant Juju instance. + app_name: The application name to get the public IP. Returns: - The public IP of the application. + The public IP of the application's first unit. """ - assert app.units - unit: Unit = app.units[0] - return await unit.get_public_address() + status = juju.status() + units = status.apps[app_name].units + assert units, f"No units found for app {app_name}" + unit = next(iter(units.values())) + return unit.public_address -async def read_file(unit: Unit, path: Path) -> str: +def read_file(juju: jubilant.Juju, unit_name: str, path: Path) -> str: """Read a file on the Juju unit. Args: - unit: The Juju unit to read file on. + juju: The jubilant Juju instance. + unit_name: The Juju unit name to read file on. path: The path of the file to read. Returns: The file content. """ - return_code, stdout, stderr = await run_in_unit( - unit=unit, - command=f"if [ -f {path} ]; then cat {path}; else echo ''; fi", + result = juju.exec( + f"if [ -f {path} ]; then cat {path}; else echo ''; fi", + unit=unit_name, ) - assert return_code == 0, f"Failed to read file {path}: {stderr}" - assert stdout is not None, f"Failed to read file {path} to stdout: {stderr}" - logging.debug("File content of %s: %s", path, stdout) - return stdout.strip() + assert result.return_code == 0, f"Failed to read file {path}: {result.stderr}" + logging.debug("File content of %s: %s", path, result.stdout) + return result.stdout.strip() -async def get_cache_backend(unit: Unit) -> str: - """Get the cache-backend value from the unit's cache-config relation data. +def get_cache_backends(juju: jubilant.Juju, unit_name: str) -> list[str]: + """Get the cache-backend URL from the unit's cache-config relation data. Args: - unit: The content-cache unit to query. + juju: The jubilant Juju instance. + unit_name: The content-cache unit name to query. Returns: - The cache-backend URL published on the first cache-config relation, or empty string. + A list containing the cache-backend URL published on the first cache-config relation, + or an empty list if no URL is set. """ - return_code, rel_ids_stdout, stderr = await run_in_unit( - unit=unit, - command="relation-ids cache-config", - ) - assert return_code == 0, f"Failed to get relation IDs: {stderr}" - rel_ids = (rel_ids_stdout or "").split() + rel_ids_result = juju.exec("relation-ids cache-config", unit=unit_name) + assert rel_ids_result.return_code == 0, f"Failed to get relation IDs: {rel_ids_result.stderr}" + rel_ids = rel_ids_result.stdout.split() assert rel_ids, "No cache-config relations found" rel_id = rel_ids[0] - return_code, stdout, stderr = await run_in_unit( - unit=unit, - command=f"relation-get -r {rel_id} cache-backend -- {unit.name}", + result = juju.exec( + f"relation-get -r {rel_id} cache-backend -- {unit_name}", + unit=unit_name, ) - assert return_code == 0, f"Failed to get cache-backend: {stderr}" - return (stdout or "").strip() + assert result.return_code == 0, f"Failed to get cache-backend: {result.stderr}" + raw = result.stdout.strip() + if not raw: + return [] + return [raw] -async def run_in_unit( - unit: Unit, command: str, timeout=None -) -> tuple[int, str | None, str | None]: +def run_in_unit(juju: jubilant.Juju, unit_name: str, command: str) -> tuple[int, str, str]: """Run a command in the Juju unit. Args: - unit:The Juju unit to run the command in. + juju: The jubilant Juju instance. + unit_name: The Juju unit name to run the command in. command: The command to run. - timeout: The time in seconds for the command run to be consider as failure. Returns: The return code, stdout, and stderr. """ - run: Action = await unit.run(command, timeout) - await run.wait() - return ( - run.results["return-code"], - run.results.get("stdout", None), - run.results.get("stderr", None), - ) + result = juju.exec(command, unit=unit_name) + return result.return_code, result.stdout, result.stderr diff --git a/content-cache/tests/integration/test_basic.py b/content-cache/tests/integration/test_basic.py index 3d51199d..13fe1dea 100644 --- a/content-cache/tests/integration/test_basic.py +++ b/content-cache/tests/integration/test_basic.py @@ -4,11 +4,10 @@ """Integration test for the content-cache charm.""" import json -from asyncio import sleep +import time +import jubilant import pytest -from juju.application import Application -from juju.model import Model from tests.integration.helpers import ( BACKENDS_CONFIG_NAME, @@ -19,34 +18,33 @@ HEALTHCHECK_VALID_STATUS_CONFIG_NAME, PROXY_CACHE_VALID_CONFIG_NAME, CacheTester, - get_cache_backend, + get_cache_backends, ) @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_charm_start( - app: Application, +def test_charm_start( + juju: jubilant.Juju, + app: str, ) -> None: """ arrange: The applications deployed. act: Nothing. assert: The applications in blocked status waiting for integration. """ - assert len(app.units) == 1 - unit = app.units[0] - assert unit.workload_status_message == "Waiting for integration with config charm" + status = juju.status() + unit_status = status.apps[app].units[f"{app}/0"] + assert unit_status.workload_status.message == "Waiting for integration with config charm" @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_charm_integrate_with_no_data( - app: Application, - config_app: Application, +def test_charm_integrate_with_no_data( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_message: str, http_ok_ip: str, - model: Model, ) -> None: """ arrange: A working application of content-cache charm, with no integrations, and a test HTTP @@ -59,14 +57,21 @@ async def test_charm_integrate_with_no_data( 2. The request to the cache should succeed. """ # 1. - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="blocked", timeout=10 * 60) - assert len(app.units) == 1 - assert len(config_app.units) == 1 - unit = app.units[0] - config_unit = config_app.units[0] - assert unit.workload_status_message == "Waiting for integration with config charm" - assert config_unit.workload_status_message == "Empty backends configuration found" + cache_tester.integrate_config() + juju.wait( + lambda s: s.apps[app].units[f"{app}/0"].workload_status.current == "blocked" + and s.apps[config_app].units[f"{config_app}/0"].workload_status.current == "blocked", + timeout=10 * 60, + ) + st = juju.status() + assert ( + st.apps[app].units[f"{app}/0"].workload_status.message + == "Waiting for integration with config charm" + ) + assert ( + st.apps[config_app].units[f"{config_app}/0"].workload_status.message + == "Empty backends configuration found" + ) # 2. config = dict(CacheTester.BASE_CONFIG) @@ -75,25 +80,24 @@ async def test_charm_integrate_with_no_data( config[HEALTHCHECK_PATH_CONFIG_NAME] = "/health" config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" - await cache_tester.setup_config(config) - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) - response = await cache_tester.query_cache(path="/") + cache_tester.setup_config(config) + juju.wait(jubilant.all_active, timeout=10 * 60) + response = cache_tester.query_cache(path="/") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") # Cleanup - await cache_tester.reset() + cache_tester.reset() @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_charm_integrate_with_data( - app: Application, - config_app: Application, +def test_charm_integrate_with_data( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_message: str, http_ok_ip: str, - model: Model, ) -> None: """ arrange: A working application of content-cache charm, with no integrations. @@ -118,56 +122,62 @@ async def test_charm_integrate_with_data( config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" config[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' - await cache_tester.setup_config(config) - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + cache_tester.setup_config(config) + cache_tester.integrate_config() + juju.wait(jubilant.all_active, timeout=10 * 60) - response = await cache_tester.query_cache(path="/") + response = cache_tester.query_cache(path="/") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") timestamp = json.loads(response.content.decode("utf-8"))["time"] - await sleep(3) - response = await cache_tester.query_cache(path="/") + time.sleep(3) + response = cache_tester.query_cache(path="/") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") assert timestamp == json.loads(response.content.decode("utf-8"))["time"] # The cache valid is set to 10 seconds, the total wait should exceed it. - await sleep(11) - response = await cache_tester.query_cache(path="/") + time.sleep(11) + response = cache_tester.query_cache(path="/") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") assert timestamp != json.loads(response.content.decode("utf-8"))["time"] - await cache_tester.reset_config() + cache_tester.reset_config() # The configuration update should fail on the configuration charm, and enter blocked state. # Since the integration data is not updated, the content-cache charm will continue serve the # site, according to the old configuration. - await model.wait_for_idle([app.name], status="active", timeout=10 * 60) - await model.wait_for_idle([config_app.name], status="blocked", timeout=10 * 60) - assert len(app.units) == 1 - assert len(config_app.units) == 1 - unit = app.units[0] - config_unit = config_app.units[0] - assert unit.workload_status_message == "" - assert config_unit.workload_status_message == "Empty backends configuration found" - response = await cache_tester.query_cache(path="/") + juju.wait( + lambda s: s.apps[app].units[f"{app}/0"].workload_status.current == "active", + timeout=10 * 60, + ) + juju.wait( + lambda s: f"{config_app}/0" in s.apps[config_app].units + and s.apps[config_app].units[f"{config_app}/0"].workload_status.current == "blocked", + timeout=10 * 60, + ) + st = juju.status() + assert st.apps[app].units[f"{app}/0"].workload_status.message == "" + assert ( + st.apps[config_app].units[f"{config_app}/0"].workload_status.message + == "Empty backends configuration found" + ) + response = cache_tester.query_cache(path="/") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_charm_with_two_config_app( - app: Application, - config_app: Application, - config_alt_app: Application, +def test_charm_with_two_config_app( + juju: jubilant.Juju, + app: str, + config_app: str, + config_alt_app: str, cache_tester: CacheTester, http_ok_message: str, http_ok_ip: str, - model: Model, ) -> None: """ arrange: A working charm with integration with two configuration charms. @@ -181,7 +191,7 @@ async def test_charm_with_two_config_app( config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" config[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' - await cache_tester.setup_config(config) + cache_tester.setup_config(config) config_alt = dict(CacheTester.BASE_CONFIG) config_alt[BACKENDS_CONFIG_NAME] = f"http://{http_ok_ip}:80" @@ -190,17 +200,15 @@ async def test_charm_with_two_config_app( config_alt[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config_alt[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" config_alt[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' - await cache_tester.setup_config_alt(config_alt) + cache_tester.setup_config_alt(config_alt) - await cache_tester.integrate_config() - await cache_tester.integrate_config_alt() + cache_tester.integrate_config() + cache_tester.integrate_config_alt() - await model.wait_for_idle( - [app.name, config_app.name, config_alt_app.name], status="active", timeout=10 * 60 - ) + juju.wait(jubilant.all_active, timeout=10 * 60) - response = await cache_tester.query_cache(path="/", port=30000) - response_alt = await cache_tester.query_cache(path="/", port=30001) + response = cache_tester.query_cache(path="/", port=8080) + response_alt = cache_tester.query_cache(path="/", port=8081) assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") assert response_alt.status_code == 200 @@ -208,14 +216,13 @@ async def test_charm_with_two_config_app( @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_charm_with_failover( - app: Application, - config_app: Application, - cache_tester: Application, +def test_charm_with_failover( + juju: jubilant.Juju, + app: str, + config_app: str, + cache_tester: CacheTester, http_ok_message: str, http_ok_ip: str, - model: Model, ) -> None: """ arrange: A working application of content-cache charm with configurations. The backends @@ -235,40 +242,40 @@ async def test_charm_with_failover( config[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' config[FAIL_TIMEOUT_CONFIG_NAME] = "5s" - await cache_tester.setup_config(config) - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + cache_tester.setup_config(config) + cache_tester.integrate_config() + juju.wait(jubilant.all_active, timeout=10 * 60) - response = await cache_tester.query_cache(path="/") + response = cache_tester.query_cache(path="/") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_cache_backends_published( - app: Application, - config_app: Application, +def test_cache_backends_published( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_ip: str, - model: Model, ) -> None: """ arrange: A working charm with an active cache-config integration. - act: Read cache-backend from the unit relation data. - assert: cache-backend contains a valid HTTP URL with the unit bind address and allocated port. + act: Read cache-backends from the unit relation data. + assert: cache-backends contains a valid HTTP URL with the unit bind address and allocated port. """ config = dict(CacheTester.BASE_CONFIG) config[BACKENDS_CONFIG_NAME] = f"http://{http_ok_ip}:80" config[HEALTHCHECK_PATH_CONFIG_NAME] = "/health" config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" - await cache_tester.setup_config(config) - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + cache_tester.setup_config(config) + cache_tester.integrate_config() + juju.wait(jubilant.all_active, timeout=10 * 60) - unit = app.units[0] - backend = await get_cache_backend(unit) + unit_name = f"{app}/0" + backends = get_cache_backends(juju, unit_name) - assert backend.startswith("http://") - assert ":30000" in backend or ":30001" in backend + assert len(backends) == 1 + assert backends[0].startswith("http://") + assert ":8080" in backends[0] or ":8081" in backends[0] diff --git a/content-cache/tests/integration/test_healthchecks.py b/content-cache/tests/integration/test_healthchecks.py index 48c105e7..048c474f 100644 --- a/content-cache/tests/integration/test_healthchecks.py +++ b/content-cache/tests/integration/test_healthchecks.py @@ -3,13 +3,11 @@ """Integration tests for the content-cache's active healthchecks.""" -import asyncio -from typing import List +import time +import jubilant import pytest import requests -from juju.application import Application -from juju.model import Model from nginx_manager import NGINX_BACKENDS_STATUS_URL_PATH from tests.integration.helpers import ( @@ -29,39 +27,35 @@ HEALTHCHECK_INTERVAL = 2000 -async def get_nginx_status(app: Application, path: str) -> str: - """Fetch and returns the content of the status page +def get_nginx_status(juju: jubilant.Juju, app: str, path: str) -> str: + """Fetch and returns the content of the status page. Args: - app: the application to connect to - path: the past to the status page + juju: The jubilant Juju instance. + app: The application name to connect to. + path: The path to the status page. Returns: - The content of the status page + The content of the status page. Raises: - RuntimeError: if status cannot be fetched + RuntimeError: if status cannot be fetched. """ - unit = app.units[0] - command = f"curl 127.0.0.1/{path}" - task = await unit.run(command) - result = await task.wait() - - if result.results["return-code"]: - raise RuntimeError(f"Couldn't fetch status page on {path}: {result.results['stderr']}") - - return result.results["stdout"] + unit_name = f"{app}/0" + result = juju.exec(f"curl 127.0.0.1/{path}", unit=unit_name) + if result.return_code: + raise RuntimeError(f"Couldn't fetch status page on {path}: {result.stderr}") + return result.stdout @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_healthchecks_healthy( - app: Application, - config_app: Application, +def test_healthchecks_healthy( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_message: str, - http_ok_ips: List[str], - model: Model, + http_ok_ips: list[str], ) -> None: """ arrange: Two backends responding 200 on their healthchecks. @@ -75,25 +69,15 @@ async def test_healthchecks_healthy( config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" config[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' - await cache_tester.setup_config(config) - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + cache_tester.setup_config(config) + cache_tester.integrate_config() + juju.wait(jubilant.all_active, timeout=10 * 60) - response = await cache_tester.query_cache(path="/", protocol="http") + response = cache_tester.query_cache(path="/", protocol="http") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") - # Here is a typical content for the backends_status page tested below. - # In the test we're checking that both backends are seen as "UP" - # - # Nginx Worker PID: 7905 - # Upstream 88c26973-5726-4745-ab4a-d3addea80d82 - # Primary Peers - # 10.14.1.77:80 UP - # 10.14.1.78:80 DOWN - # Backup Peers - - status = await get_nginx_status(app, path=NGINX_BACKENDS_STATUS_URL_PATH) + status = get_nginx_status(juju, app, path=NGINX_BACKENDS_STATUS_URL_PATH) assert f"{http_ok_ips[0]}:80 UP" in status assert f"{http_ok_ips[1]}:80 UP" in status @@ -101,28 +85,27 @@ async def test_healthchecks_healthy( @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_healthchecks_one_unhealthy( - app: Application, - config_app: Application, +def test_healthchecks_one_unhealthy( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_message: str, - http_ok_ips: List[str], - model: Model, + http_ok_ips: list[str], ) -> None: """ arrange: Two backends responding 200 on their healthchecks. - act: Turn one backend unhealty. + act: Turn one backend unhealthy. assert: HTTP request should succeed. One backend is reported UP. One backend is reported DOWN. """ requests.get(f"http://{http_ok_ips[0]}/turn-unhealthy") - await asyncio.sleep(4 * HEALTHCHECK_INTERVAL / 1000) + time.sleep(4 * HEALTHCHECK_INTERVAL / 1000) - status = await get_nginx_status(app, path=NGINX_BACKENDS_STATUS_URL_PATH) + status = get_nginx_status(juju, app, path=NGINX_BACKENDS_STATUS_URL_PATH) assert f"{http_ok_ips[0]}:80 DOWN" in status assert f"{http_ok_ips[1]}:80 UP" in status - response = await cache_tester.query_cache(path="/", protocol="http") + response = cache_tester.query_cache(path="/", protocol="http") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") @@ -130,56 +113,54 @@ async def test_healthchecks_one_unhealthy( @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_healthchecks_one_recovery( - app: Application, - config_app: Application, +def test_healthchecks_one_recovery( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_message: str, - http_ok_ips: List[str], - model: Model, + http_ok_ips: list[str], ) -> None: """ arrange: Two backends. One responding 200 on its healthcheck, and the other 500. - act: Bring back the faulty backend to an healthy state. + act: Bring back the faulty backend to a healthy state. assert: HTTP request should succeed. Two backends are reported up. """ requests.get(f"http://{http_ok_ips[0]}/turn-healthy") - await asyncio.sleep(3 * HEALTHCHECK_INTERVAL / 1000) + time.sleep(3 * HEALTHCHECK_INTERVAL / 1000) - status = await get_nginx_status(app, path=NGINX_BACKENDS_STATUS_URL_PATH) + status = get_nginx_status(juju, app, path=NGINX_BACKENDS_STATUS_URL_PATH) assert f"{http_ok_ips[0]}:80 UP" in status assert f"{http_ok_ips[1]}:80 UP" in status - response = await cache_tester.query_cache(path="/", protocol="http") + response = cache_tester.query_cache(path="/", protocol="http") assert response.status_code == 200 assert http_ok_message in response.content.decode("utf-8") @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_healthchecks_all_unhealthy( - app: Application, - config_app: Application, +def test_healthchecks_all_unhealthy( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_message: str, - http_ok_ips: List[str], - model: Model, + http_ok_ips: list[str], ) -> None: """ arrange: Two healthy backends. - act: Turn both backends unhealth. + act: Turn both backends unhealthy. assert: HTTP request should fail with 502. Both backends are reported DOWN. """ requests.get(f"http://{http_ok_ips[0]}/turn-unhealthy") requests.get(f"http://{http_ok_ips[1]}/turn-unhealthy") - await asyncio.sleep(5 * HEALTHCHECK_INTERVAL / 1000) + time.sleep(5 * HEALTHCHECK_INTERVAL / 1000) - status = await get_nginx_status(app, path=NGINX_BACKENDS_STATUS_URL_PATH) + status = get_nginx_status(juju, app, path=NGINX_BACKENDS_STATUS_URL_PATH) assert f"{http_ok_ips[0]}:80 DOWN" in status assert f"{http_ok_ips[1]}:80 DOWN" in status - response = await cache_tester.query_cache(path="/", protocol="http") + response = cache_tester.query_cache(path="/", protocol="http") assert response.status_code == 502 @@ -191,16 +172,15 @@ async def test_healthchecks_all_unhealthy( ], ) @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_healthchecks_custom_status( - app: Application, - config_app: Application, +def test_healthchecks_custom_status( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_message: str, http_ok_ip: str, valid_status: str, expected_http_code: int, - model: Model, ) -> None: """ arrange: One backend responding 418 on its healthcheck. And valid status to match it or not. @@ -214,13 +194,13 @@ async def test_healthchecks_custom_status( config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = valid_status config[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' - await cache_tester.setup_config(config) - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + cache_tester.setup_config(config) + cache_tester.integrate_config() + juju.wait(jubilant.all_active, timeout=10 * 60) - await asyncio.sleep(5 * HEALTHCHECK_INTERVAL / 1000) + time.sleep(5 * HEALTHCHECK_INTERVAL / 1000) - response = await cache_tester.query_cache(path="/", protocol="http") + response = cache_tester.query_cache(path="/", protocol="http") assert response.status_code == expected_http_code if expected_http_code == 200: @@ -228,66 +208,51 @@ async def test_healthchecks_custom_status( @pytest.mark.parametrize( - ["use_cert_ok_app", "ssl_verify", "expected_http_code"], + ["ssl_verify", "expected_http_code"], [ - # ssl_verify=false: backend cert IS signed by cert_app's CA. - # Proxy SSL verification passes (cert trusted by CA bundle) and the health check - # skips SSL verification (ssl_verify=false) so the backend is marked healthy → 200. - pytest.param(True, "false", 200, id="no_ssl_verify"), - # ssl_verify=true: backend cert is NOT signed by cert_app's CA (hardcoded - # self-signed cert from https_ok_app). The Lua health checker uses the system cert - # store; cert_app's CA is not installed there, so the health check marks the backend - # as unhealthy → 502 Bad Gateway. - pytest.param(False, "true", 502, id="ssl_verify"), + pytest.param("false", 200, id="no_ssl_verify"), + pytest.param("true", 502, id="ssl_verify"), ], ) @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_healthchecks_ssl_verify( - app: Application, - config_app: Application, - cert_app: Application, +def test_healthchecks_ssl_verify( + juju: jubilant.Juju, + app: str, + config_app: str, + cert_app: str, cache_tester: CacheTester, http_ok_message: str, - https_ok_app: Application, - https_cert_ok_app: Application, - use_cert_ok_app: bool, + https_ok_app: str, ssl_verify: str, expected_http_code: int, - model: Model, ) -> None: """ - arrange: An HTTPS backend — either cert_app-signed (use_cert_ok_app=True) or - hardcoded self-signed (use_cert_ok_app=False) — with cert_app's CA provided to - content-cache via receive-ca-cert. - act: Configure healthcheck-ssl-verify and send a request. - assert: ssl_verify=false with a trusted backend cert returns 200 (proxy SSL passes, - healthcheck skips SSL). ssl_verify=true with an untrusted backend cert returns 502 - (Lua health checker marks the backend as unhealthy). + arrange: One backend responding on HTTPS. SSL verify option set. + act: Nothing. + assert: HTTP request should succeed or fail depending on SSL verification setting. """ - backend_app = https_cert_ok_app if use_cert_ok_app else https_ok_app - backend_ip = await get_app_ip(backend_app) + https_ok_ip = get_app_ip(juju, https_ok_app) config = dict(CacheTester.BASE_CONFIG) - config[BACKENDS_CONFIG_NAME] = f"https://{backend_ip}:443" + config[BACKENDS_CONFIG_NAME] = f"https://{https_ok_ip}:443" config[HEALTHCHECK_PATH_CONFIG_NAME] = "/health" config[HEALTHCHECK_INTERVAL_CONFIG_NAME] = str(HEALTHCHECK_INTERVAL) config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = ssl_verify config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" config[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' - await model.integrate( - f"{cert_app.name}:{CERT_TRANSFER_PROVIDER_ENDPOINT_NAME}", - f"{app.name}:{CERTIFICATE_TRANSFER_INTEGRATION_NAME}", + juju.integrate( + f"{cert_app}:{CERT_TRANSFER_PROVIDER_ENDPOINT_NAME}", + f"{app}:{CERTIFICATE_TRANSFER_INTEGRATION_NAME}", ) try: - await cache_tester.setup_config(config) - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + cache_tester.setup_config(config) + cache_tester.integrate_config() + juju.wait(jubilant.all_active, timeout=10 * 60) - await asyncio.sleep(5 * HEALTHCHECK_INTERVAL / 1000) + time.sleep(5 * HEALTHCHECK_INTERVAL / 1000) - response = await cache_tester.query_cache(path="/", protocol="http") + response = cache_tester.query_cache(path="/", protocol="http") assert response.status_code == expected_http_code if expected_http_code == 200: @@ -298,4 +263,7 @@ async def test_healthchecks_ssl_verify( # relation already exists and fail before integrate_config() is called, # leaving content-cache stuck in "Waiting for integration with config # charm" state. - await app.remove_relation(CERTIFICATE_TRANSFER_INTEGRATION_NAME, cert_app.name, True) + juju.remove_relation( + f"{app}:{CERTIFICATE_TRANSFER_INTEGRATION_NAME}", + f"{cert_app}:{CERT_TRANSFER_PROVIDER_ENDPOINT_NAME}", + ) diff --git a/content-cache/tests/integration/test_metric.py b/content-cache/tests/integration/test_metric.py index 3502ec55..ad7cdb21 100644 --- a/content-cache/tests/integration/test_metric.py +++ b/content-cache/tests/integration/test_metric.py @@ -5,10 +5,8 @@ import json +import jubilant import pytest -from juju.application import Application -from juju.model import Model -from juju.unit import Unit from src import nginx_manager from src.charm import unit_name_to_instance_name @@ -27,20 +25,19 @@ @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_metric_log( - app: Application, - config_app: Application, +def test_metric_log( + juju: jubilant.Juju, + app: str, + config_app: str, cache_tester: CacheTester, http_ok_ip: str, - model: Model, ) -> None: """ arrange: A working application of content-cache charm integrated with config charm. act: Makes some requests to the content-cache. assert: The cache log contains the metrics. """ - unit: Unit = app.units[0] + unit_name = f"{app}/0" config = dict(CacheTester.BASE_CONFIG) config[BACKENDS_CONFIG_NAME] = f"http://{http_ok_ip}:80" @@ -49,17 +46,19 @@ async def test_metric_log( config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" config[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' - await cache_tester.setup_config(config) - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + cache_tester.setup_config(config) + cache_tester.integrate_config() + juju.wait(jubilant.all_active, timeout=10 * 60) - response = await cache_tester.query_cache(path="/") + response = cache_tester.query_cache(path="/") assert response.status_code == 200 - response = await cache_tester.query_cache(path="/") + response = cache_tester.query_cache(path="/") assert response.status_code == 200 - content = await read_file( - unit, nginx_manager._get_cache_log_path("30000", unit_name_to_instance_name(unit.name)) + content = read_file( + juju, + unit_name, + nginx_manager._get_cache_log_path("8080", unit_name_to_instance_name(unit_name)), ) assert content lines = content.split("\n") @@ -85,14 +84,13 @@ async def test_metric_log( @pytest.mark.abort_on_fail -@pytest.mark.asyncio -async def test_integrate_with_cos( - app: Application, - config_app: Application, - metric_app: Application, +def test_integrate_with_cos( + juju: jubilant.Juju, + app: str, + config_app: str, + metric_app: str, cache_tester: CacheTester, http_ok_ip: str, - model: Model, ) -> None: """ arrange: A working application of content-cache charm integrated with config charm. @@ -111,21 +109,24 @@ async def test_integrate_with_cos( config[HEALTHCHECK_SSL_VERIFY_CONFIG_NAME] = "false" config[HEALTHCHECK_VALID_STATUS_CONFIG_NAME] = "200" config[PROXY_CACHE_VALID_CONFIG_NAME] = '["200 10s"]' - await cache_tester.setup_config(config) - await cache_tester.integrate_config() - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) - response = await cache_tester.query_cache(path="/") + cache_tester.setup_config(config) + cache_tester.integrate_config() + juju.wait(jubilant.all_active, timeout=10 * 60) + response = cache_tester.query_cache(path="/") assert response.status_code == 200, "Test arrange failure" # 1. - await model.integrate( - f"{metric_app.name}:{COS_AGENT_INTEGRATION_NAME}", - f"{app.name}:{COS_AGENT_INTEGRATION_NAME}", + juju.integrate( + f"{metric_app}:{COS_AGENT_INTEGRATION_NAME}", + f"{app}:{COS_AGENT_INTEGRATION_NAME}", ) - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + juju.wait(jubilant.all_active, timeout=10 * 60) # 2. - await app.remove_relation(COS_AGENT_INTEGRATION_NAME, metric_app.name, True) + juju.remove_relation( + f"{app}:{COS_AGENT_INTEGRATION_NAME}", + f"{metric_app}:{COS_AGENT_INTEGRATION_NAME}", + ) - await model.wait_for_idle([app.name, config_app.name], status="active", timeout=10 * 60) + juju.wait(jubilant.all_active, timeout=10 * 60) diff --git a/content-cache/tests/integration/test_tls_cert.py b/content-cache/tests/integration/test_tls_cert.py index 76a67cb3..5a05678c 100644 --- a/content-cache/tests/integration/test_tls_cert.py +++ b/content-cache/tests/integration/test_tls_cert.py @@ -3,11 +3,15 @@ """Integration tests for HTTPS backend support via certificate_transfer and tls-certificates.""" +import jubilant import pytest -from helpers import BACKENDS_CONFIG_NAME, CacheTester, get_cache_backend, run_in_unit -from juju.application import Application -from juju.model import Model -from pytest_operator.plugin import OpsTest + +from tests.integration.helpers import ( + BACKENDS_CONFIG_NAME, + CacheTester, + get_cache_backends, + run_in_unit, +) CERTIFICATE_TRANSFER_INTEGRATION_NAME = "receive-ca-cert" CERT_TRANSFER_PROVIDER_ENDPOINT_NAME = "send-ca-cert" @@ -16,12 +20,11 @@ CERTIFICATES_INTEGRATION_NAME = "certificates" -async def test_certificate_transfer_full_lifecycle( - ops_test: OpsTest, - model: Model, - app: Application, - cert_app: Application, - cache_tester, +def test_certificate_transfer_full_lifecycle( + juju: jubilant.Juju, + app: str, + cert_app: str, + cache_tester: CacheTester, http_ok_ip: str, ) -> None: """ @@ -30,38 +33,51 @@ async def test_certificate_transfer_full_lifecycle( assert: Content-cache reaches Active status after integration, then returns to WaitingStatus after removal (CA bundle cleared). """ - await cache_tester.integrate_config() + cache_tester.integrate_config() config = dict(CacheTester.BASE_CONFIG) config[BACKENDS_CONFIG_NAME] = f"https://{http_ok_ip}:443" - await cache_tester.setup_config(config) + cache_tester.setup_config(config) try: - await model.integrate( - f"{cert_app.name}:{CERT_TRANSFER_PROVIDER_ENDPOINT_NAME}", - f"{app.name}:{CERTIFICATE_TRANSFER_INTEGRATION_NAME}", + juju.integrate( + f"{cert_app}:{CERT_TRANSFER_PROVIDER_ENDPOINT_NAME}", + f"{app}:{CERTIFICATE_TRANSFER_INTEGRATION_NAME}", + ) + juju.wait( + lambda s: s.apps[app].units[f"{app}/0"].workload_status.current == "active", + timeout=10 * 60, ) - await model.wait_for_idle([app.name], status="active", timeout=10 * 60) - assert app.units[0].workload_status == "active" + assert juju.status().apps[app].units[f"{app}/0"].workload_status.current == "active" - await app.remove_relation(CERTIFICATE_TRANSFER_INTEGRATION_NAME, cert_app.name) - await model.wait_for_idle([app.name], status="waiting", timeout=5 * 60) - assert "CA certificate" in app.units[0].workload_status_message + juju.remove_relation( + f"{app}:{CERTIFICATE_TRANSFER_INTEGRATION_NAME}", + f"{cert_app}:{CERT_TRANSFER_PROVIDER_ENDPOINT_NAME}", + ) + juju.wait( + lambda s: s.apps[app].units[f"{app}/0"].workload_status.current == "waiting", + timeout=5 * 60, + ) + assert ( + "CA certificate" in juju.status().apps[app].units[f"{app}/0"].workload_status.message + ) finally: # Ensure the cert relation is removed even if the test fails, so the - # next test starts with a clean state. Do NOT use block_until_done=True - # here: that calls block_until() with no timeout and can hang forever if - # the relation removal stalls. - if app.related_applications(CERTIFICATE_TRANSFER_INTEGRATION_NAME): - await app.remove_relation(CERTIFICATE_TRANSFER_INTEGRATION_NAME, cert_app.name) + # next test starts with a clean state. + try: + juju.remove_relation( + f"{app}:{CERTIFICATE_TRANSFER_INTEGRATION_NAME}", + f"{cert_app}:{CERT_TRANSFER_PROVIDER_ENDPOINT_NAME}", + ) + except Exception: # noqa: BLE001 + pass @pytest.mark.abort_on_fail -async def test_tls_termination_full_lifecycle( - ops_test: OpsTest, - model: Model, - app: Application, - cache_lego_app: Application, - cache_tester, +def test_tls_termination_full_lifecycle( + juju: jubilant.Juju, + app: str, + cache_lego_app: str, + cache_tester: CacheTester, http_ok_ip: str, ) -> None: """ @@ -73,48 +89,65 @@ async def test_tls_termination_full_lifecycle( nginx site config contains ssl directives. - After removal: content-cache remains Active, cache-backends reverts to http://. """ - await cache_tester.integrate_config() + cache_tester.integrate_config() config = dict(CacheTester.BASE_CONFIG) config[BACKENDS_CONFIG_NAME] = f"http://{http_ok_ip}:80" - await cache_tester.setup_config(config) + cache_tester.setup_config(config) # Wait for config subordinate hooks to complete and content-cache to be Active # with the HTTP backend before integrating cache-lego for TLS termination. # Without this wait, the TLS cert may arrive before config data is delivered, # leaving the charm blocked on "Waiting for integration with config charm". - await model.wait_for_idle([app.name], status="active", timeout=5 * 60) + juju.wait( + lambda s: s.apps[app].units[f"{app}/0"].workload_status.current == "active", + timeout=5 * 60, + ) - await model.integrate( - f"{cache_lego_app.name}:{CACHE_LEGO_CERT_PROVIDER_ENDPOINT_NAME}", - f"{app.name}:{CERTIFICATES_INTEGRATION_NAME}", + juju.integrate( + f"{cache_lego_app}:{CACHE_LEGO_CERT_PROVIDER_ENDPOINT_NAME}", + f"{app}:{CERTIFICATES_INTEGRATION_NAME}", ) try: - await model.wait_for_idle([app.name], status="active", timeout=10 * 60) - assert app.units[0].workload_status == "active" + juju.wait( + lambda s: s.apps[app].units[f"{app}/0"].workload_status.current == "active", + timeout=10 * 60, + ) + assert juju.status().apps[app].units[f"{app}/0"].workload_status.current == "active" - unit = app.units[0] - backends = await get_cache_backend(unit) - assert backends.startswith( - "https://" + unit_name = f"{app}/0" + backends = get_cache_backends(juju, unit_name) + assert any( + b.startswith("https://") for b in backends ), f"Expected https:// backend after TLS cert issuance, got: {backends}" - _, ssl_files, _ = await run_in_unit( - unit=unit, - command="grep -Rl ssl /etc/nginx/sites-enabled/ 2>/dev/null || true", + return_code, ssl_files, _ = run_in_unit( + juju=juju, + unit_name=unit_name, + command="grep -rl ssl /etc/nginx/sites-enabled/ 2>/dev/null || true", ) assert ( ssl_files and ssl_files.strip() ), "No nginx site config with 'ssl' directive found after TLS cert issuance" - await app.remove_relation(CERTIFICATES_INTEGRATION_NAME, cache_lego_app.name) - await model.wait_for_idle([app.name], status="active", timeout=5 * 60) - assert app.units[0].workload_status == "active" + juju.remove_relation( + f"{app}:{CERTIFICATES_INTEGRATION_NAME}", + f"{cache_lego_app}:{CACHE_LEGO_CERT_PROVIDER_ENDPOINT_NAME}", + ) + juju.wait( + lambda s: s.apps[app].units[f"{app}/0"].workload_status.current == "active", + timeout=5 * 60, + ) + assert juju.status().apps[app].units[f"{app}/0"].workload_status.current == "active" - backends_after = await get_cache_backend(unit) - assert backends_after.startswith( - "http://" + backends_after = get_cache_backends(juju, unit_name) + assert any( + b.startswith("http://") for b in backends_after ), f"Expected http:// backend after cert relation removal, got: {backends_after}" finally: # Ensure the certificates relation is removed even if the test fails. - # Do NOT use block_until_done=True here — it has no timeout and can hang forever. - if app.related_applications(CERTIFICATES_INTEGRATION_NAME): - await app.remove_relation(CERTIFICATES_INTEGRATION_NAME, cache_lego_app.name) + try: + juju.remove_relation( + f"{app}:{CERTIFICATES_INTEGRATION_NAME}", + f"{cache_lego_app}:{CACHE_LEGO_CERT_PROVIDER_ENDPOINT_NAME}", + ) + except Exception: # noqa: BLE001 + pass diff --git a/content-cache/tests/unit/conftest.py b/content-cache/tests/unit/conftest.py index c517b461..7335731b 100644 --- a/content-cache/tests/unit/conftest.py +++ b/content-cache/tests/unit/conftest.py @@ -4,15 +4,14 @@ """Fixtures for unit tests.""" from pathlib import Path -from typing import Iterator from unittest.mock import MagicMock import pytest -from ops.testing import Harness -from charm import ContentCacheCharm from state import ( BACKENDS_FIELD_NAME, + CACHE_INACTIVE_FIELD_NAME, + CACHE_MAX_SIZE_FIELD_NAME, FAIL_TIMEOUT_FIELD_NAME, HEALTHCHECK_INTERVAL_FIELD_NAME, HEALTHCHECK_PATH_FIELD_NAME, @@ -29,6 +28,8 @@ HEALTHCHECK_SSL_VERIFY_FIELD_NAME: "false", HEALTHCHECK_VALID_STATUS_FIELD_NAME: "[200]", PROXY_CACHE_VALID_FIELD_NAME: '["200 302 1h", "404 1m"]', + CACHE_INACTIVE_FIELD_NAME: "10m", + CACHE_MAX_SIZE_FIELD_NAME: "", } @@ -75,22 +76,3 @@ def mock_nginx_manager_fixture(monkeypatch) -> MagicMock: "charm.get_cache_backend_url", MagicMock(return_value="http://10.0.0.1:8080") ) return mock_nginx_manager - - -@pytest.fixture(name="harness", scope="function") -def harness_fixture(monkeypatch, mock_nginx_manager: MagicMock) -> Iterator[Harness]: - """The ops testing harness fixture. - - The mock_nginx_manager is to ensure the nginx_manager module is patched. - """ - harness = Harness(ContentCacheCharm) - harness.add_network("10.0.0.1", endpoint="certificates") - harness.begin_with_initial_hooks() - yield harness - harness.cleanup() - - -@pytest.fixture(name="charm", scope="function") -def charm_fixture(harness: Harness) -> ContentCacheCharm: - """The charm fixture.""" - return harness.charm diff --git a/content-cache/tests/unit/requirements.txt b/content-cache/tests/unit/requirements.txt index e03e8390..661b6cc0 100644 --- a/content-cache/tests/unit/requirements.txt +++ b/content-cache/tests/unit/requirements.txt @@ -1 +1,2 @@ -factory-boy >= 3, < 4 \ No newline at end of file +factory-boy >= 3, < 4 +ops-scenario>=7.0.0,<9.0.0 diff --git a/content-cache/tests/unit/test_charm.py b/content-cache/tests/unit/test_charm.py index 6743ab76..6bf9e8ce 100644 --- a/content-cache/tests/unit/test_charm.py +++ b/content-cache/tests/unit/test_charm.py @@ -1,196 +1,194 @@ # Copyright 2025 Canonical Ltd. # See LICENSE file for licensing details. -"""Unit test for the charm.""" +"""Unit test for the charm using ops-scenario.""" -from unittest.mock import MagicMock +import json +from pathlib import Path +from unittest.mock import MagicMock, patch import ops import pytest -from ops.testing import Harness +import scenario +from scenario.errors import UncaughtCharmError -import state from charm import ( CACHE_CONFIG_INTEGRATION_NAME, - CERTIFICATE_INTEGRATION_NAME, NGINX_NOT_READY_MESSAGE, WAIT_FOR_CONFIG_MESSAGE, - WAIT_FOR_TLS_CERT_MESSAGE, ContentCacheCharm, ) -from errors import NginxConfigurationAggregateError, NginxConfigurationError, NginxFileError +from errors import ( + CACertificateFileError, + NginxConfigurationAggregateError, + NginxConfigurationError, + NginxFileError, + TLSCertificateFileError, +) +from state import BACKENDS_FIELD_NAME from tests.unit.conftest import SAMPLE_INTEGRATION_DATA +CERT_TRANSFER_INTEGRATION_NAME = "receive-ca-cert" +CERTIFICATE_INTEGRATION_NAME = "certificates" +SAMPLE_CA_CERT = "-----BEGIN CERTIFICATE-----\nMIIFake\n-----END CERTIFICATE-----" + + +@pytest.fixture(name="ctx") +def context_fixture(mock_nginx_manager: MagicMock) -> scenario.Context: + """A scenario Context for ContentCacheCharm with nginx mocked.""" + return scenario.Context(ContentCacheCharm) + -def test_start_no_relation(charm: ContentCacheCharm, mock_nginx_manager: MagicMock): +@pytest.fixture(name="cache_config_relation") +def cache_config_relation_fixture() -> scenario.Relation: + """A cache-config relation with sample valid data.""" + return scenario.Relation( + endpoint=CACHE_CONFIG_INTEGRATION_NAME, + remote_app_name="config", + remote_app_data=SAMPLE_INTEGRATION_DATA, + ) + + +def test_start_no_relation(ctx: scenario.Context, mock_nginx_manager: MagicMock): """ arrange: A working charm. act: None. assert: Waiting for integration to join. Method to initialize nginx called. """ - assert charm.unit.status == ops.BlockedStatus(WAIT_FOR_CONFIG_MESSAGE) + out = ctx.run(ctx.on.start(), scenario.State()) + assert out.unit_status == scenario.BlockedStatus(WAIT_FOR_CONFIG_MESSAGE) mock_nginx_manager.initialize.assert_called_once() -def test_stop_nginx(charm: ContentCacheCharm, mock_nginx_manager: MagicMock): +def test_stop_nginx(ctx: scenario.Context, mock_nginx_manager: MagicMock): """ arrange: A working charm. Reset the mocks. act: Emit stop event. assert: Method to stop nginx called. """ mock_nginx_manager.stop.reset_mock() - - charm._on_stop(MagicMock()) - + ctx.run(ctx.on.stop(), scenario.State()) mock_nginx_manager.stop.assert_called_once() -def test_update_status_no_relation(charm: ContentCacheCharm): +def test_update_status_no_relation(ctx: scenario.Context): """ arrange: A working charm. act: Emit update status. assert: Charm waiting for integration. """ - charm._on_update_status(MagicMock()) - assert charm.unit.status == ops.BlockedStatus(WAIT_FOR_CONFIG_MESSAGE) + out = ctx.run(ctx.on.update_status(), scenario.State()) + assert out.unit_status == scenario.BlockedStatus(WAIT_FOR_CONFIG_MESSAGE) @pytest.mark.parametrize( ["health", "status"], [ - pytest.param(False, ops.MaintenanceStatus(NGINX_NOT_READY_MESSAGE)), - pytest.param(True, ops.ActiveStatus()), + pytest.param(False, scenario.MaintenanceStatus(NGINX_NOT_READY_MESSAGE)), + pytest.param(True, scenario.ActiveStatus()), ], ) def test_update_status_with_integration( - charm: ContentCacheCharm, + ctx: scenario.Context, + cache_config_relation: scenario.Relation, mock_nginx_manager: MagicMock, - harness: Harness, health: bool, status: ops.StatusBase, ): """ - arrange: Charm is integrated, and nginx is not ready. + arrange: Charm is integrated, and nginx health varies. act: Emit update status. - assert: Charm waiting for integration. + assert: Charm status reflects nginx health. """ mock_nginx_manager.health_check.return_value = health - harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + out = ctx.run( + ctx.on.update_status(), + scenario.State(relations={cache_config_relation}), ) + assert out.unit_status == status - charm._on_update_status(MagicMock()) - assert charm.unit.status == status - -def test_add_integration(harness: Harness, charm: ContentCacheCharm): +def test_add_integration( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, +): """ arrange: A working charm. - act: Add a config integration. - assert: Charm in active. The data is parsed correctly. + act: Fire relation-changed with valid integration data. + assert: Charm in active status. """ - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + out = ctx.run( + ctx.on.relation_changed(cache_config_relation), + scenario.State(relations={cache_config_relation}), ) - assert charm.unit.status == ops.ActiveStatus() + assert out.unit_status == scenario.ActiveStatus() - # Test the integration data is correct - config = state.get_nginx_config(charm) - assert len(config) == 1 - assert relation_id in config - location_config = config[relation_id] - assert location_config.backends[0].host == "10.10.1.1" - assert location_config.backends[1].host == "10.10.2.2" - assert location_config.fail_timeout == "30s" - assert location_config.healthcheck_config.path == "/" - assert location_config.healthcheck_config.interval == 2000 - assert location_config.proxy_cache_valid == ("200 302 1h", "404 1m") - -def test_remove_integration(harness: Harness, charm: ContentCacheCharm): +def test_remove_integration( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, +): """ arrange: A working charm with a config integration. - act: Remove the integration. - assert: Charm in active. No data. + act: Fire relation-broken. + assert: Charm in blocked status. """ - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + out = ctx.run( + ctx.on.relation_broken(cache_config_relation), + scenario.State(relations={cache_config_relation}), ) - assert charm.unit.status == ops.ActiveStatus() - - harness.remove_relation(relation_id) - assert charm.unit.status == ops.BlockedStatus(WAIT_FOR_CONFIG_MESSAGE) - - # Test no data - config = state.get_nginx_config(charm) - assert not config + assert out.unit_status == scenario.BlockedStatus(WAIT_FOR_CONFIG_MESSAGE) -def test_invalid_integration_data(harness: Harness, charm: ContentCacheCharm): +def test_invalid_integration_data(ctx: scenario.Context): """ arrange: A working charm. - act: Add a config integration with invalid backends data. - assert: Charm in block state. - """ - data = dict(SAMPLE_INTEGRATION_DATA) - data[state.BACKENDS_FIELD_NAME] = '["not-a-url"]' - harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=data, + act: Fire relation-changed with invalid backends. + assert: Charm in block state with Config error message. + """ + bad_data = dict(SAMPLE_INTEGRATION_DATA) + bad_data[BACKENDS_FIELD_NAME] = '["not-a-url"]' + rel = scenario.Relation( + endpoint=CACHE_CONFIG_INTEGRATION_NAME, + remote_app_name="config", + remote_app_data=bad_data, ) - assert isinstance(charm.unit.status, ops.BlockedStatus) - assert "Config error" in charm.unit.status.message - - -def test_empty_integration_data(harness: Harness, charm: ContentCacheCharm): - """ - arrange: A working charm. - act: Add a config integration with no data. - assert: The configuration parsed from integration is empty. - - It seems harness does not fire relation-changed if calling add_relation or - update_relation_data with empty dict. Therefore the test checks for the configuration parsed - manually. - """ - harness.add_relation(CACHE_CONFIG_INTEGRATION_NAME, remote_app="config", app_data={}) - - config = state.get_nginx_config(charm) - assert not config + out = ctx.run(ctx.on.relation_changed(rel), scenario.State(relations={rel})) + assert isinstance(out.unit_status, scenario.BlockedStatus) + assert "Config error" in out.unit_status.message -def test_nginx_file_error(monkeypatch, harness: Harness, charm: ContentCacheCharm): +def test_nginx_file_error( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, + monkeypatch, +): """ - arrange: The update_and_load_config to raise the NginxFileError. - act: Add configuration integration. - assert: The error is re-raised. + arrange: update_and_load_config raises NginxFileError. + act: Fire relation-changed. + assert: The error is re-raised (wrapped in UncaughtCharmError by scenario). """ monkeypatch.setattr( - "nginx_manager.update_and_load_config", + "charm.nginx_manager.update_and_load_config", MagicMock(side_effect=NginxFileError("Mock error")), ) - - with pytest.raises(NginxFileError): - harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + with pytest.raises(UncaughtCharmError) as exc_info: + ctx.run( + ctx.on.relation_changed(cache_config_relation), + scenario.State(relations={cache_config_relation}), ) + assert isinstance(exc_info.value.__cause__, NginxFileError) def test_nginx_config_error( - monkeypatch, harness: Harness, charm: ContentCacheCharm, mock_nginx_manager: MagicMock + ctx: scenario.Context, + cache_config_relation: scenario.Relation, + monkeypatch, ): """ - arrange: The update_and_load_config to raise the NginxConfigurationAggregateError. - act: Add configuration integration and load the nginx config. - assert: The charm status reflects the errors raised + arrange: update_and_load_config raises NginxConfigurationAggregateError. + act: Fire relation-changed. + assert: The charm status reflects the error. """ monkeypatch.setattr( "charm.nginx_manager.update_and_load_config", @@ -200,209 +198,410 @@ def test_nginx_config_error( ) ), ) - - harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + out = ctx.run( + ctx.on.relation_changed(cache_config_relation), + scenario.State(relations={cache_config_relation}), ) + assert out.unit_status == scenario.ActiveStatus("Error for host: ('mock host',)") - charm._load_nginx_config() - assert charm.unit.status == ops.ActiveStatus("Error for host: ('mock host',)") - -def test_get_nginx_config_returns_flat_per_relation_dict( - harness: Harness, charm: ContentCacheCharm -): +def test_unique_port_allocated_per_relation(ctx: scenario.Context, monkeypatch): """ - arrange: Charm with a cache-config integration. - act: Get nginx config. - assert: Returns flat dict keyed by relation_id (int), not nested by hostname. + arrange: Charm with two different cache-config integrations. + act: Fire relation-changed for each relation in a shared state. + assert: Each relation gets a unique port in cache-backends URL. """ - from state import LocationConfig, get_nginx_config - - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + # Override the mock to return a port-aware URL so we can distinguish allocations. + monkeypatch.setattr( + "charm.get_cache_backend_url", + lambda charm, relation, port, has_cache_cert=False: f"http://10.0.0.1:{port}", ) + rel1 = scenario.Relation( + endpoint=CACHE_CONFIG_INTEGRATION_NAME, + remote_app_name="config1", + remote_app_data=SAMPLE_INTEGRATION_DATA, + ) + rel2 = scenario.Relation( + endpoint=CACHE_CONFIG_INTEGRATION_NAME, + remote_app_name="config2", + remote_app_data=SAMPLE_INTEGRATION_DATA, + ) + state_with_two = scenario.State(relations={rel1, rel2}) - config = get_nginx_config(charm) + out1 = ctx.run(ctx.on.relation_changed(rel1), state_with_two) + # Pass stored state from out1 so the port_map for rel1 is visible to rel2 allocation. + state_after_rel1 = scenario.State( + relations={rel1, rel2}, + stored_states=out1.stored_states, + ) + out2 = ctx.run(ctx.on.relation_changed(rel2), state_after_rel1) - assert relation_id in config - assert isinstance(config[relation_id], LocationConfig) - assert config[relation_id].backends[0].host == "10.10.1.1" - assert config[relation_id].backends[1].host == "10.10.2.2" + def extract_port(out: scenario.State, relation_id: int) -> int: + """Extract the allocated port number from the cache-backend URL in relation data. + Args: + out: The output state from scenario run. + relation_id: The relation ID to look up. -def test_unique_port_allocated_per_relation(harness: Harness, charm: ContentCacheCharm): - """ - arrange: Charm with two different cache-config integrations. - act: Add both integrations and query their ports. - assert: Each relation gets a unique port in the expected range. - """ - rel_id_1 = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config1", - app_data=SAMPLE_INTEGRATION_DATA, - ) - rel_id_2 = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config2", - app_data=SAMPLE_INTEGRATION_DATA, - ) + Returns: + The port number, or -1 if no cache-backend is set. + """ + rel = out.get_relation(relation_id) + url = rel.local_unit_data.get("cache-backend", "") + return int(url.rsplit(":", 1)[-1]) if url else -1 - port_1 = charm._get_port_for_relation(rel_id_1) - port_2 = charm._get_port_for_relation(rel_id_2) + port1 = extract_port(out1, rel1.id) + port2 = extract_port(out2, rel2.id) - assert port_1 != port_2 - assert port_1 >= 8080 - assert port_2 >= 8080 + assert port1 >= 8080 + assert port2 >= 8080 + assert port1 != port2 -def test_port_stable_for_same_relation(harness: Harness, charm: ContentCacheCharm): +def test_port_stable_for_same_relation( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, +): """ arrange: Charm with a cache-config integration. - act: Query the port for the same relation twice. - assert: Same port is returned both times (stable allocation). + act: Fire relation-changed twice for the same relation. + assert: Same port (URL) is in cache-backends both times. """ - rel_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, - ) + state_in = scenario.State(relations={cache_config_relation}) + out1 = ctx.run(ctx.on.relation_changed(cache_config_relation), state_in) + out2 = ctx.run(ctx.on.relation_changed(cache_config_relation), state_in) - port_first = charm._get_port_for_relation(rel_id) - port_second = charm._get_port_for_relation(rel_id) + rel1 = out1.get_relation(cache_config_relation.id) + rel2 = out2.get_relation(cache_config_relation.id) - assert port_first == port_second + assert rel1.local_unit_data.get("cache-backend") == rel2.local_unit_data.get("cache-backend") def test_load_nginx_config_writes_cache_backend( - harness: Harness, charm: ContentCacheCharm, mock_nginx_manager: MagicMock + ctx: scenario.Context, + cache_config_relation: scenario.Relation, ): """ arrange: A working charm with get_cache_backend_url mocked in the fixture. - act: Add a cache-config relation with valid data. + act: Fire relation-changed with valid data. assert: cache-backend is written to unit relation data with the expected URL. """ - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + out = ctx.run( + ctx.on.relation_changed(cache_config_relation), + scenario.State(relations={cache_config_relation}), ) - - assert charm.unit.status == ops.ActiveStatus() - rel_data = harness.get_relation_data(relation_id, charm.unit.name) - cache_backend = rel_data.get("cache-backend", "") + assert out.unit_status == scenario.ActiveStatus() + out_rel = out.get_relation(cache_config_relation.id) + cache_backend = out_rel.local_unit_data.get("cache-backend", "") assert cache_backend == "http://10.0.0.1:8080" def test_relation_broken_clears_cache_backends( - harness: Harness, charm: ContentCacheCharm, mock_nginx_manager: MagicMock + ctx: scenario.Context, + cache_config_relation: scenario.Relation, ): """ arrange: A charm with a cache-config relation that has cache-backends written. - act: Remove the relation. + act: Fire relation-broken. assert: Charm returns to blocked status. """ - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + out = ctx.run( + ctx.on.relation_broken(cache_config_relation), + scenario.State(relations={cache_config_relation}), ) + assert out.unit_status == scenario.BlockedStatus(WAIT_FOR_CONFIG_MESSAGE) - assert charm.unit.status == ops.ActiveStatus() - harness.remove_relation(relation_id) +def test_cache_backends_cleared_when_config_fails(ctx: scenario.Context): + """ + arrange: A charm with an invalid cache-config relation. + act: Fire relation-changed with invalid backends. + assert: Charm enters blocked status. + """ + bad_data = dict(SAMPLE_INTEGRATION_DATA) + bad_data[BACKENDS_FIELD_NAME] = "" + rel = scenario.Relation( + endpoint=CACHE_CONFIG_INTEGRATION_NAME, + remote_app_name="config", + remote_app_data=bad_data, + ) + out = ctx.run(ctx.on.relation_changed(rel), scenario.State(relations={rel})) + assert isinstance(out.unit_status, scenario.BlockedStatus) - assert charm.unit.status == ops.BlockedStatus(WAIT_FOR_CONFIG_MESSAGE) +def test_certificate_available_writes_cert_and_reloads( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, + mock_nginx_manager: MagicMock, +): + """ + arrange: A charm with an active cache-config relation and CA cert. + act: Fire relation-changed on receive-ca-cert with cert data. + assert: Charm remains Active (CA bundle is written and nginx reloads). + """ + cert_rel = scenario.Relation( + endpoint=CERT_TRANSFER_INTEGRATION_NAME, + remote_app_name="cert-provider", + remote_app_data={"certificates": json.dumps([SAMPLE_CA_CERT])}, + ) + out = ctx.run( + ctx.on.relation_changed(cert_rel), + scenario.State(relations={cache_config_relation, cert_rel}), + ) + assert out.unit_status == scenario.ActiveStatus() -def test_cache_backend_cleared_when_config_fails( - harness: Harness, charm: ContentCacheCharm, mock_nginx_manager: MagicMock + +def test_certificate_removed_deletes_cert_and_sets_waiting( + ctx: scenario.Context, + mock_nginx_manager: MagicMock, ): """ - arrange: A charm with an active relation that has cache-backend written. - act: Simulate a config validation failure by clearing the relation data. - assert: cache-backend is cleared on the relation. + arrange: A charm with HTTPS backends and a CA cert via certificate-transfer. + act: Fire relation-broken on certificate-transfer. + assert: Charm moves to WaitingStatus. """ - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + https_data = dict(SAMPLE_INTEGRATION_DATA) + https_data[BACKENDS_FIELD_NAME] = '["https://10.10.1.1:443"]' + config_rel = scenario.Relation( + endpoint=CACHE_CONFIG_INTEGRATION_NAME, + remote_app_name="config", + remote_app_data=https_data, + ) + cert_rel = scenario.Relation( + endpoint=CERT_TRANSFER_INTEGRATION_NAME, + remote_app_name="cert-provider", + remote_app_data={"certificates": json.dumps([SAMPLE_CA_CERT])}, ) - assert charm.unit.status == ops.ActiveStatus() - assert harness.get_relation_data(relation_id, charm.unit.name).get("cache-backend") != "" + out = ctx.run( + ctx.on.relation_broken(cert_rel), + scenario.State(relations={config_rel, cert_rel}), + ) + assert isinstance(out.unit_status, scenario.WaitingStatus) - # Clear the relation data to trigger a config validation failure (blocked) - harness.update_relation_data(relation_id, "config", {"backends": ""}) - assert isinstance(charm.unit.status, ops.BlockedStatus) - cache_backend = harness.get_relation_data(relation_id, charm.unit.name).get("cache-backend") - # Setting to "" removes the key in Juju/Harness, so None means cleared - assert not cache_backend +def test_https_backends_without_ca_bundle_sets_waiting(ctx: scenario.Context): + """ + arrange: A charm with no certificate_transfer relation. + act: Fire relation-changed with HTTPS backends. + assert: Charm enters WaitingStatus waiting for CA certificate. + """ + https_data = dict(SAMPLE_INTEGRATION_DATA) + https_data[BACKENDS_FIELD_NAME] = '["https://10.10.1.1:443"]' + rel = scenario.Relation( + endpoint=CACHE_CONFIG_INTEGRATION_NAME, + remote_app_name="config", + remote_app_data=https_data, + ) + out = ctx.run(ctx.on.relation_changed(rel), scenario.State(relations={rel})) + assert isinstance(out.unit_status, scenario.WaitingStatus) + assert "CA certificate" in out.unit_status.message -def test_cache_backend_not_written_when_unchanged( - harness: Harness, charm: ContentCacheCharm, mock_nginx_manager: MagicMock +def test_http_backends_without_ca_bundle_stays_active( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, ): """ - arrange: A charm with an active relation that already has cache-backend written. - act: Trigger update-status (re-runs _load_nginx_config). - assert: cache-backend is not re-written when the value hasn't changed. + arrange: A charm with no certificate_transfer relation. + act: Fire relation-changed with HTTP backends. + assert: Charm remains Active (no CA cert required for HTTP). """ - from unittest.mock import MagicMock, patch - - relation_id = harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, + out = ctx.run( + ctx.on.relation_changed(cache_config_relation), + scenario.State(relations={cache_config_relation}), ) - assert charm.unit.status == ops.ActiveStatus() + assert out.unit_status == scenario.ActiveStatus() - mock_setitem = MagicMock() - rel = charm.model.get_relation(CACHE_CONFIG_INTEGRATION_NAME, relation_id) - with patch.object(type(rel.data[charm.unit]), "__setitem__", mock_setitem): - charm.on.update_status.emit() - cache_backend_writes = [c for c in mock_setitem.call_args_list if c.args[1] == "cache-backend"] - assert len(cache_backend_writes) == 0, "cache-backend should not be written when unchanged" +def test_certificate_available_file_error_sets_blocked( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, + mock_nginx_manager: MagicMock, + monkeypatch, +): + """ + arrange: A charm with an active cache-config relation. + act: Fire certificate_set_updated event when writing raises CACertificateFileError. + assert: Charm enters BlockedStatus and nginx is not reloaded. + """ + mock_nginx_manager.update_and_load_config.reset_mock() + monkeypatch.setattr( + "charm.ca_certs.write_ca_bundle", + MagicMock(side_effect=CACertificateFileError("disk error")), + ) + cert_rel = scenario.Relation( + endpoint=CERT_TRANSFER_INTEGRATION_NAME, + remote_app_name="cert-provider", + remote_app_data={"certificates": json.dumps(["cert-A"])}, + ) + out = ctx.run( + ctx.on.relation_changed(cert_rel), + scenario.State(relations={cache_config_relation, cert_rel}), + ) + assert isinstance(out.unit_status, scenario.BlockedStatus) + assert "CA certificate" in out.unit_status.message + mock_nginx_manager.update_and_load_config.assert_not_called() -def test_tls_certificates_relation_broken_reverts_to_http( - harness: Harness, - charm: ContentCacheCharm, +def test_certificate_removed_file_error_sets_blocked( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, mock_nginx_manager: MagicMock, monkeypatch, - tmp_path, ): """ - arrange: A charm with a certificates relation and a cert file on disk (simulating a TLS - cert that was previously issued). - act: Remove the certificates relation (relation_broken). - assert: The charm does not get stuck in WaitingStatus — it calls update_and_load_config - and ends in ActiveStatus, not WaitingStatus("Waiting for TLS certificate"). + arrange: A charm with a CA cert added via certificate-transfer. + act: Remove the relation when deleting the cert raises CACertificateFileError. + assert: Charm enters BlockedStatus and nginx is not reloaded. """ - certs_path = tmp_path / "certs" - certs_path.mkdir() - monkeypatch.setattr("charm.nginx_manager.NGINX_CERTIFICATES_PATH", certs_path) + cert_rel = scenario.Relation( + endpoint=CERT_TRANSFER_INTEGRATION_NAME, + remote_app_name="cert-provider", + remote_app_data={"certificates": json.dumps(["cert-A"])}, + ) + mock_nginx_manager.update_and_load_config.reset_mock() + monkeypatch.setattr( + "charm.ca_certs.write_ca_bundle", + MagicMock(side_effect=CACertificateFileError("disk error")), + ) + out = ctx.run( + ctx.on.relation_broken(cert_rel), + scenario.State(relations={cache_config_relation, cert_rel}), + ) + assert isinstance(out.unit_status, scenario.BlockedStatus) + assert "CA certificate" in out.unit_status.message + mock_nginx_manager.update_and_load_config.assert_not_called() + - harness.add_relation( - CACHE_CONFIG_INTEGRATION_NAME, - remote_app="config", - app_data=SAMPLE_INTEGRATION_DATA, +def test_tls_certificates_relation_added_sets_waiting( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, + mock_nginx_manager: MagicMock, +): + """ + arrange: A charm with an active cache-config relation. + act: Fire relation-created on certificates (cert not yet available). + assert: Charm enters WaitingStatus waiting for TLS certificate. + """ + cert_rel = scenario.Relation( + endpoint=CERTIFICATE_INTEGRATION_NAME, + remote_app_name="cache-lego", ) - assert charm.unit.status == ops.ActiveStatus() + out = ctx.run( + ctx.on.relation_created(cert_rel), + scenario.State(relations={cache_config_relation, cert_rel}), + ) + assert isinstance(out.unit_status, scenario.WaitingStatus) + assert "TLS certificate" in out.unit_status.message - cert_rel_id = harness.add_relation(CERTIFICATE_INTEGRATION_NAME, remote_app="lego") - cert_file = certs_path / "content-cache-charm.pem" - cert_file.write_text("fake-cert", encoding="utf-8") - mock_nginx_manager.update_and_load_config.reset_mock() - harness.remove_relation(cert_rel_id) +# The following two tests call _on_tls_certificate_available directly because the +# TLS certificates library's 'certificate_available' custom event cannot be easily +# triggered via scenario's standard event API. +def test_tls_certificate_available_writes_cert_and_reloads( + mock_nginx_manager: MagicMock, +): + """ + arrange: A charm with cache-config and certificates relations. + act: Call _on_tls_certificate_available directly (bypasses x509 snapshot/restore). + assert: write_certificates is called and nginx is reloaded. + """ + from ops.testing import Harness - assert charm.unit.status != ops.WaitingStatus( - WAIT_FOR_TLS_CERT_MESSAGE - ), "Charm must not be stuck in WaitingStatus after certificates relation is removed" + harness = Harness(ContentCacheCharm) + harness.add_network("10.0.0.1", endpoint="certificates") + harness.begin_with_initial_hooks() + + try: + harness.add_relation( + CACHE_CONFIG_INTEGRATION_NAME, + remote_app="config", + app_data=SAMPLE_INTEGRATION_DATA, + ) + harness.add_relation(CERTIFICATE_INTEGRATION_NAME, remote_app="cache-lego") + mock_nginx_manager.update_and_load_config.reset_mock() + + with ( + patch( + "charm.certificates.write_certificate", + return_value={"10.0.0.1": Path("/etc/nginx/certs/10.0.0.1.pem")}, + ) as mock_write, + patch.object( + harness.charm, "_get_cache_cert_path", return_value=Path("/fake/cert.pem") + ), + ): + harness.charm._on_tls_certificate_available(MagicMock()) + + mock_write.assert_called_once() + mock_nginx_manager.update_and_load_config.assert_called() + finally: + harness.cleanup() + + +def test_tls_certificate_available_write_error_sets_blocked( + mock_nginx_manager: MagicMock, +): + """ + arrange: A charm with certificates relation, cert write raises TLSCertificateFileError. + act: Call _on_tls_certificate_available with write_certificates patched to raise. + assert: Charm enters BlockedStatus; nginx is not reloaded. + """ + from ops.testing import Harness + + harness = Harness(ContentCacheCharm) + harness.add_network("10.0.0.1", endpoint="certificates") + harness.begin_with_initial_hooks() + + try: + harness.add_relation( + CACHE_CONFIG_INTEGRATION_NAME, + remote_app="config", + app_data=SAMPLE_INTEGRATION_DATA, + ) + harness.add_relation(CERTIFICATE_INTEGRATION_NAME, remote_app="cache-lego") + mock_nginx_manager.update_and_load_config.reset_mock() + + with patch( + "charm.certificates.write_certificate", + side_effect=TLSCertificateFileError("disk error"), + ): + harness.charm._on_tls_certificate_available(MagicMock()) + + assert isinstance(harness.charm.unit.status, ops.BlockedStatus) + assert "TLS certificate" in harness.charm.unit.status.message + mock_nginx_manager.update_and_load_config.assert_not_called() + finally: + harness.cleanup() + + +def test_tls_certificates_relation_broken_reverts_to_http( + ctx: scenario.Context, + cache_config_relation: scenario.Relation, + mock_nginx_manager: MagicMock, +): + """ + arrange: A charm with a TLS cert active (cert written, CN stored). + act: Remove the certificates relation. + assert: Nginx reloads serving HTTP (no cache_cert_path) and charm is Active. + """ + cert_rel = scenario.Relation( + endpoint=CERTIFICATE_INTEGRATION_NAME, + remote_app_name="cache-lego", + ) + stored = scenario.StoredState( + name="_stored", + owner_path="ContentCacheCharm", + content={"cache_cert_cn": "10.0.0.1", "port_map": {}}, + ) + mock_nginx_manager.update_and_load_config.reset_mock() + out = ctx.run( + ctx.on.relation_broken(cert_rel), + scenario.State( + relations={cache_config_relation, cert_rel}, + stored_states={stored}, + ), + ) mock_nginx_manager.update_and_load_config.assert_called() + call_kwargs = mock_nginx_manager.update_and_load_config.call_args.kwargs + assert call_kwargs.get("cache_cert_path") is None + assert out.unit_status == scenario.ActiveStatus() diff --git a/content-cache/tests/unit/test_nginx_manager.py b/content-cache/tests/unit/test_nginx_manager.py index 7478f94f..539fdc2c 100644 --- a/content-cache/tests/unit/test_nginx_manager.py +++ b/content-cache/tests/unit/test_nginx_manager.py @@ -203,7 +203,8 @@ def test_get_location_config_keys_https_with_ca_bundle( patch_nginx_manager: None, tmp_path, monkeypatch ): """ - arrange: A LocationConfig with https backends and a CA bundle present on disk. + arrange: A LocationConfig with https backends, a CA bundle present on disk, + and ssl_verify=true. act: Call _get_location_config_keys. assert: proxy_ssl_trusted_certificate, proxy_ssl_verify on, and proxy_ssl_name set to the first backend hostname so nginx verifies against the actual host, not the @@ -233,9 +234,10 @@ def test_get_location_config_keys_https_with_ca_bundle_ssl_verify_false( patch_nginx_manager: None, tmp_path, monkeypatch ): """ - arrange: A LocationConfig with https backends, a CA bundle present, and ssl_verify=false. + arrange: A LocationConfig with https backends, a CA bundle present on disk, + and ssl_verify=false. act: Call _get_location_config_keys. - assert: proxy_ssl directives are present — healthcheck ssl_verify does not affect proxy SSL. + assert: No proxy_ssl_verify directives added — CA bundle only used when ssl_verify is true. """ ca_bundle = tmp_path / "ca-bundle.pem" ca_bundle.write_text("cert", encoding="utf-8") @@ -251,9 +253,7 @@ def test_get_location_config_keys_https_with_ca_bundle_ssl_verify_false( keys = nginx_manager._get_location_config_keys(config, "upstream") key_strings = [k.as_strings for k in keys] - assert any("proxy_ssl_trusted_certificate" in s for s in key_strings) - assert any("proxy_ssl_verify" in s and "on" in s for s in key_strings) - assert any("proxy_ssl_name" in s and "10.10.1.1" in s for s in key_strings) + assert not any("proxy_ssl" in s for s in key_strings) def test_get_location_config_keys_https_without_ca_bundle(patch_nginx_manager: None, monkeypatch): @@ -391,3 +391,111 @@ def test_update_config_without_cache_cert_no_ssl_directives( config_content = nginx_manager._get_sites_enabled_path(str(port)).read_text() assert "ssl" not in config_content + + +def test_proxy_cache_path_includes_inactive(monkeypatch, patch_nginx_manager: None): + """ + arrange: Configuration with cache_inactive set. + act: Generate nginx site config. + assert: proxy_cache_path directive contains inactive= parameter. + """ + mock_instance_name = "mock-test_0" + monkeypatch.setattr("nginx_manager.execute_command", MagicMock()) + monkeypatch.setattr("nginx_manager._systemctl_status_check", MagicMock(return_value=True)) + port = 8080 + sample_data = { + 1: ( + port, + LocationConfig.from_integration_data( + { + **SAMPLE_INTEGRATION_DATA, + "backends": '["http://10.10.10.1:80"]', + "cache_inactive": "1h", + } + ), + ) + } + + nginx_manager.update_and_load_config(sample_data, mock_instance_name) + + config_content = nginx_manager._get_sites_enabled_path(str(port)).read_text() + assert "inactive=1h" in config_content + + +def test_proxy_cache_path_includes_max_size(monkeypatch, patch_nginx_manager: None): + """ + arrange: Configuration with cache_max_size set. + act: Generate nginx site config. + assert: proxy_cache_path directive contains max_size= parameter. + """ + mock_instance_name = "mock-test_0" + monkeypatch.setattr("nginx_manager.execute_command", MagicMock()) + monkeypatch.setattr("nginx_manager._systemctl_status_check", MagicMock(return_value=True)) + port = 8080 + sample_data = { + 1: ( + port, + LocationConfig.from_integration_data( + { + **SAMPLE_INTEGRATION_DATA, + "backends": '["http://10.10.10.1:80"]', + "cache_max_size": "2g", + } + ), + ) + } + + nginx_manager.update_and_load_config(sample_data, mock_instance_name) + + config_content = nginx_manager._get_sites_enabled_path(str(port)).read_text() + assert "max_size=2g" in config_content + + +def test_proxy_cache_path_no_max_size_when_empty(monkeypatch, patch_nginx_manager: None): + """ + arrange: Configuration with empty cache_max_size. + act: Generate nginx site config. + assert: proxy_cache_path directive does not contain max_size= parameter. + """ + mock_instance_name = "mock-test_0" + monkeypatch.setattr("nginx_manager.execute_command", MagicMock()) + monkeypatch.setattr("nginx_manager._systemctl_status_check", MagicMock(return_value=True)) + port = 8080 + sample_data = { + 1: ( + port, + LocationConfig.from_integration_data( + {**SAMPLE_INTEGRATION_DATA, "backends": '["http://10.10.10.1:80"]'} + ), + ) + } + + nginx_manager.update_and_load_config(sample_data, mock_instance_name) + + config_content = nginx_manager._get_sites_enabled_path(str(port)).read_text() + assert "max_size" not in config_content + + +def test_location_contains_proxy_cache_lock(monkeypatch, patch_nginx_manager: None): + """ + arrange: Valid configuration. + act: Generate nginx site config. + assert: Location block contains proxy_cache_lock on. + """ + mock_instance_name = "mock-test_0" + monkeypatch.setattr("nginx_manager.execute_command", MagicMock()) + monkeypatch.setattr("nginx_manager._systemctl_status_check", MagicMock(return_value=True)) + port = 8080 + sample_data = { + 1: ( + port, + LocationConfig.from_integration_data( + {**SAMPLE_INTEGRATION_DATA, "backends": '["http://10.10.10.1:80"]'} + ), + ) + } + + nginx_manager.update_and_load_config(sample_data, mock_instance_name) + + config_content = nginx_manager._get_sites_enabled_path(str(port)).read_text() + assert "proxy_cache_lock on" in config_content diff --git a/content-cache/tox.ini b/content-cache/tox.ini index 199a7743..11823ed6 100644 --- a/content-cache/tox.ini +++ b/content-cache/tox.ini @@ -43,13 +43,13 @@ deps = flake8-docstrings-complete>=1.0.3 flake8-test-docs>=1.0 isort + jubilant>=1.10.0,<2.0.0 mypy pep8-naming pydocstyle>=2.10 pylint pyproject-flake8 pytest - pytest-asyncio pytest-operator requests types-PyYAML @@ -104,9 +104,8 @@ description = Run integration tests deps = allure-pytest>=2.8.18 git+https://github.com/canonical/data-platform-workflows@v24.0.0\#subdirectory=python/pytest_plugins/allure_pytest_collection_report - juju + jubilant>=1.10.0,<2.0.0 pytest - pytest-asyncio pytest-operator -r{toxinidir}/requirements.txt commands =