diff --git a/docs/release-notes/artifacts/pr0475.yaml b/docs/release-notes/artifacts/pr0475.yaml new file mode 100644 index 00000000..79cab296 --- /dev/null +++ b/docs/release-notes/artifacts/pr0475.yaml @@ -0,0 +1,21 @@ +version_schema: 2 + +changes: + - title: Publish proxied endpoint URL to policy provider and auto-add host to + allowed-hosts + author: tphan025 + type: minor + description: > + Add a new proxied_endpoint field on the requirer app data. The + haproxy-operator now publishes the generated hostname. On the + policy-operator side, the charm extracts the host from the proxied + endpoint and appends it to the Django allowed-hosts list. Refactored + HaproxyRoutePolicyInformation to rename allowed_hosts to + extra_allowed_hosts. Updated unit tests accordingly. + urls: + pr: + - https://github.com/canonical/haproxy-operator/pull/475 + related_doc: + related_issue: + visibility: public + highlight: false diff --git a/haproxy-operator/lib/charms/haproxy_route_policy/v0/haproxy_route_policy.py b/haproxy-operator/lib/charms/haproxy_route_policy/v0/haproxy_route_policy.py index 879afe77..65e3131a 100644 --- a/haproxy-operator/lib/charms/haproxy_route_policy/v0/haproxy_route_policy.py +++ b/haproxy-operator/lib/charms/haproxy_route_policy/v0/haproxy_route_policy.py @@ -40,7 +40,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 4 +LIBPATCH = 8 def valid_domain_with_wildcard(value: str) -> str: @@ -61,6 +61,17 @@ def valid_domain_with_wildcard(value: str) -> str: return value +def valid_domain(value: str) -> str: + """Validate if value is a valid domain without wildcards. + + Raises: + ValueError: When value is not a valid domain. + """ + if not bool(domain(value)): + raise ValueError(f"Invalid domain: {value}") + return value + + logger = logging.getLogger(__name__) HAPROXY_ROUTE_POLICY_RELATION_NAME = "haproxy-route-policy" @@ -101,6 +112,9 @@ class HaproxyRoutePolicyRequirerAppData: backend_requests: list[HaproxyRoutePolicyBackendRequest] = Field( description="List of backends to be evaluated by the policy service." ) + proxied_endpoint: Annotated[str, BeforeValidator(valid_domain)] | None = Field( + description=("URL for the proxied endpoint that's exposing the Django web UI."), + ) @model_validator(mode="after") def validate_unique_backend_names(self): @@ -201,7 +215,9 @@ def relation(self) -> Relation | None: return self.charm.model.get_relation(self._relation_name) def provide_haproxy_route_policy_requests( - self, backend_requests: list[HaproxyRoutePolicyBackendRequest] + self, + backend_requests: list[HaproxyRoutePolicyBackendRequest], + proxied_endpoint: str | None, ) -> None: """Set and publish route policy requests.""" relation = self.relation @@ -209,7 +225,10 @@ def provide_haproxy_route_policy_requests( return try: - app_data = HaproxyRoutePolicyRequirerAppData(backend_requests=backend_requests) + app_data = HaproxyRoutePolicyRequirerAppData( + backend_requests=backend_requests, + proxied_endpoint=proxied_endpoint, + ) relation.save(app_data, self.charm.app) except ( ValidationError, diff --git a/haproxy-operator/src/charm.py b/haproxy-operator/src/charm.py index 1b46e73f..43ab1f46 100755 --- a/haproxy-operator/src/charm.py +++ b/haproxy-operator/src/charm.py @@ -369,7 +369,10 @@ def _configure_haproxy_route( ) if self.unit.is_leader() and self.haproxy_route_policy.relation is not None: self.haproxy_route_policy.provide_haproxy_route_policy_requests( - haproxy_route_requirers_information.backend_requests_for_policy + haproxy_route_requirers_information.backend_requests_for_policy, + haproxy_route_requirers_information.policy_provider_backend.hostname + if haproxy_route_requirers_information.policy_provider_backend + else None, ) # We ONLY allow the charm to run with no certificate requested if: # 1. there's only haproxy-route-tcp relations @@ -409,9 +412,6 @@ def _configure_haproxy_route( ), ) if self.unit.is_leader(): - self.haproxy_route_policy.provide_haproxy_route_policy_requests( - haproxy_route_requirers_information.backend_requests_for_policy - ) self._publish_haproxy_route_proxied_endpoints(haproxy_route_requirers_information) self._publish_haproxy_route_tcp_proxied_endpoints( haproxy_route_requirers_information, ha_information diff --git a/haproxy-route-policy-operator/lib/charms/haproxy_route_policy/v0/haproxy_route_policy.py b/haproxy-route-policy-operator/lib/charms/haproxy_route_policy/v0/haproxy_route_policy.py index 879afe77..65e3131a 100644 --- a/haproxy-route-policy-operator/lib/charms/haproxy_route_policy/v0/haproxy_route_policy.py +++ b/haproxy-route-policy-operator/lib/charms/haproxy_route_policy/v0/haproxy_route_policy.py @@ -40,7 +40,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 4 +LIBPATCH = 8 def valid_domain_with_wildcard(value: str) -> str: @@ -61,6 +61,17 @@ def valid_domain_with_wildcard(value: str) -> str: return value +def valid_domain(value: str) -> str: + """Validate if value is a valid domain without wildcards. + + Raises: + ValueError: When value is not a valid domain. + """ + if not bool(domain(value)): + raise ValueError(f"Invalid domain: {value}") + return value + + logger = logging.getLogger(__name__) HAPROXY_ROUTE_POLICY_RELATION_NAME = "haproxy-route-policy" @@ -101,6 +112,9 @@ class HaproxyRoutePolicyRequirerAppData: backend_requests: list[HaproxyRoutePolicyBackendRequest] = Field( description="List of backends to be evaluated by the policy service." ) + proxied_endpoint: Annotated[str, BeforeValidator(valid_domain)] | None = Field( + description=("URL for the proxied endpoint that's exposing the Django web UI."), + ) @model_validator(mode="after") def validate_unique_backend_names(self): @@ -201,7 +215,9 @@ def relation(self) -> Relation | None: return self.charm.model.get_relation(self._relation_name) def provide_haproxy_route_policy_requests( - self, backend_requests: list[HaproxyRoutePolicyBackendRequest] + self, + backend_requests: list[HaproxyRoutePolicyBackendRequest], + proxied_endpoint: str | None, ) -> None: """Set and publish route policy requests.""" relation = self.relation @@ -209,7 +225,10 @@ def provide_haproxy_route_policy_requests( return try: - app_data = HaproxyRoutePolicyRequirerAppData(backend_requests=backend_requests) + app_data = HaproxyRoutePolicyRequirerAppData( + backend_requests=backend_requests, + proxied_endpoint=proxied_endpoint, + ) relation.save(app_data, self.charm.app) except ( ValidationError, diff --git a/haproxy-route-policy-operator/src/charm.py b/haproxy-route-policy-operator/src/charm.py index 53bac213..52d76cd4 100644 --- a/haproxy-route-policy-operator/src/charm.py +++ b/haproxy-route-policy-operator/src/charm.py @@ -5,6 +5,7 @@ """haproxy-route-policy-operator charm.""" +import json import logging from typing import Any @@ -22,6 +23,7 @@ configure_snap, create_or_update_user, install_snap, + is_service_active, run_migrations, start_gunicorn_service, ) @@ -94,9 +96,24 @@ def _reconcile(self, _: ops.EventBase) -> None: self.unit.status = ops.MaintenanceStatus("configuring haproxy-route-policy") database_information = DatabaseInformation.from_requirer(self, self.database) haproxy_route_policy_information = HaproxyRoutePolicyInformation.from_charm(self) + + allowed_hosts = haproxy_route_policy_information.allowed_hosts_configuration + if relation := self.haproxy_route_policy.relation: + haproxy_route_policy_requirer_data = relation.load( + HaproxyRoutePolicyRequirerAppData, relation.app + ) + if is_service_active(): + # We can only send requests to the policy API if the service is active. + self._fetch_and_refresh_backend_requests( + haproxy_route_policy_information, haproxy_route_policy_requirer_data + ) + + if proxied_endpoint := haproxy_route_policy_requirer_data.proxied_endpoint: + allowed_hosts.append(proxied_endpoint) + configure_snap( { - **haproxy_route_policy_information.allowed_hosts_snap_configuration, + **{"allowed-hosts": json.dumps(allowed_hosts)}, **database_information.haproxy_route_policy_snap_configuration, } ) @@ -116,9 +133,6 @@ def _reconcile(self, _: ops.EventBase) -> None: self.unit.open_port("tcp", HAPROXY_ROUTE_POLICY_PORT) - if relation := self.haproxy_route_policy.relation: - self._fetch_and_refresh_backend_requests(haproxy_route_policy_information, relation) - self.unit.status = ops.ActiveStatus() def _on_get_admin_credentials_action(self, event: ops.ActionEvent) -> None: @@ -140,12 +154,10 @@ def _on_get_admin_credentials_action(self, event: ops.ActionEvent) -> None: def _fetch_and_refresh_backend_requests( self, haproxy_route_policy_information: HaproxyRoutePolicyInformation, - haproxy_route_policy_relation: ops.Relation, + haproxy_route_policy_requirer_data: HaproxyRoutePolicyRequirerAppData, ) -> None: """Fetch backend requests from relation and refresh their status via the policy API.""" - backend_requests = haproxy_route_policy_relation.load( - HaproxyRoutePolicyRequirerAppData, haproxy_route_policy_relation.app - ).backend_requests + backend_requests = haproxy_route_policy_requirer_data.backend_requests client = HaproxyRoutePolicyClient( username=haproxy_route_policy_information.admin_username, diff --git a/haproxy-route-policy-operator/src/policy.py b/haproxy-route-policy-operator/src/policy.py index 62aa672f..f53e6284 100644 --- a/haproxy-route-policy-operator/src/policy.py +++ b/haproxy-route-policy-operator/src/policy.py @@ -59,6 +59,12 @@ def start_gunicorn_service() -> None: package.start() +def is_service_active() -> bool: + """Check if the snap gunicorn app is active.""" + package = snap.SnapCache()[SNAP_NAME] + return package.services["haproxy-route-policy"].get("active", False) + + def create_or_update_user(username: str, password: str) -> None: """Create or update the HTTP proxy policy superuser. diff --git a/haproxy-route-policy-operator/src/state/policy.py b/haproxy-route-policy-operator/src/state/policy.py index 23013638..70ffc6be 100644 --- a/haproxy-route-policy-operator/src/state/policy.py +++ b/haproxy-route-policy-operator/src/state/policy.py @@ -5,7 +5,6 @@ """Charm state for HAProxy route policy information.""" -import json import secrets from typing import Annotated, cast @@ -66,19 +65,19 @@ class HaproxyRoutePolicyInformation: secret_key: Django secret key. """ - allowed_hosts: list[FQDN | IPvAnyAddress] = Field() + extra_allowed_hosts: list[FQDN | IPvAnyAddress] = Field() admin_username: str = Field() admin_password: str = Field() secret_key: str = Field() @property - def allowed_hosts_snap_configuration(self) -> dict[str, str]: - """Return snap configuration keys and values.""" - return { - "allowed-hosts": json.dumps( - DEFAULT_ALLOWED_HOSTS + [str(host) for host in self.allowed_hosts] - ), - } + def allowed_hosts_configuration(self) -> list[str]: + """Get the allowed hosts snap configuration. + + Returns: + list: The allowed hosts to set in snap configuration. + """ + return DEFAULT_ALLOWED_HOSTS + [str(host) for host in self.extra_allowed_hosts] @classmethod def from_charm(cls, charm: ops.CharmBase) -> "HaproxyRoutePolicyInformation": @@ -94,7 +93,7 @@ def from_charm(cls, charm: ops.CharmBase) -> "HaproxyRoutePolicyInformation": if not peer_relation: raise PeerRelationMissingError("Peer relation is missing.") - allowed_hosts = ( + extra_allowed_hosts = ( [ cast(IPvAnyAddress | FQDN, address) for address in cast(str, charm.config.get("extra-allowed-hosts")).split(",") @@ -109,7 +108,7 @@ def from_charm(cls, charm: ops.CharmBase) -> "HaproxyRoutePolicyInformation": ) secret_key = _get_django_secret_key(charm, peer_relation)["secret-key"] return cls( - allowed_hosts=allowed_hosts, + extra_allowed_hosts=extra_allowed_hosts, admin_username=credentials["username"], admin_password=credentials["password"], secret_key=secret_key, diff --git a/haproxy-route-policy-operator/tests/unit/test_haproxy_route_policy_information.py b/haproxy-route-policy-operator/tests/unit/test_haproxy_route_policy_information.py index 979bfa44..e197da5a 100644 --- a/haproxy-route-policy-operator/tests/unit/test_haproxy_route_policy_information.py +++ b/haproxy-route-policy-operator/tests/unit/test_haproxy_route_policy_information.py @@ -14,7 +14,7 @@ def _build_state(allowed_hosts: list[str]) -> HaproxyRoutePolicyInformation: """Build a valid state instance with overridable allowed hosts.""" return HaproxyRoutePolicyInformation( - allowed_hosts=cast(list[Any], allowed_hosts), + extra_allowed_hosts=cast(list[Any], allowed_hosts), admin_username="admin", # Ignore bandit warning as this is for testing. admin_password="secret", # nosec @@ -25,15 +25,15 @@ def _build_state(allowed_hosts: list[str]) -> HaproxyRoutePolicyInformation: @pytest.mark.parametrize( "allowed_hosts, expected_allowed_hosts", [ - pytest.param([], [], id="empty-list"), - pytest.param(["example.com"], ["example.com"], id="single-fqdn"), + pytest.param([], ["localhost"], id="empty-list"), + pytest.param(["example.com"], ["localhost", "example.com"], id="single-fqdn"), pytest.param( ["example.com", "api.example.com"], - ["example.com", "api.example.com"], + ["localhost", "example.com", "api.example.com"], id="multiple-fqdn", ), - pytest.param(["10.0.0.10"], ["10.0.0.10"], id="ipv4-address"), - pytest.param(["2001:db8::1"], ["2001:db8::1"], id="ipv6-address"), + pytest.param(["10.0.0.10"], ["localhost", "10.0.0.10"], id="ipv4-address"), + pytest.param(["2001:db8::1"], ["localhost", "2001:db8::1"], id="ipv6-address"), ], ) def test_haproxy_route_policy_information_init_valid_allowed_hosts( @@ -46,7 +46,7 @@ def test_haproxy_route_policy_information_init_valid_allowed_hosts( """ state = _build_state(allowed_hosts) - assert [str(host) for host in state.allowed_hosts] == expected_allowed_hosts + assert [str(host) for host in state.allowed_hosts_configuration] == expected_allowed_hosts @pytest.mark.parametrize( @@ -94,25 +94,3 @@ def test_haproxy_route_policy_information_init_rejects_none_string_fields( with pytest.raises(ValidationError): HaproxyRoutePolicyInformation(**payload) - - -@pytest.mark.parametrize( - "allowed_hosts, expected", - [ - pytest.param([], {"allowed-hosts": '["localhost"]'}, id="empty"), - pytest.param( - ["example.com", "api.example.com"], - {"allowed-hosts": '["localhost", "example.com", "api.example.com"]'}, - id="multiple-fqdn", - ), - ], -) -def test_allowed_hosts_snap_configuration(allowed_hosts: list[str], expected: dict[str, str]): - """ - arrange: initialize state with valid allowed hosts. - act: read snap configuration property. - assert: allowed-hosts is serialized to expected JSON string. - """ - state = _build_state(allowed_hosts) - - assert state.allowed_hosts_snap_configuration == expected diff --git a/haproxy-route-policy-operator/tests/unit/test_haproxy_route_policy_lib.py b/haproxy-route-policy-operator/tests/unit/test_haproxy_route_policy_lib.py index 389a7b55..a81b5546 100644 --- a/haproxy-route-policy-operator/tests/unit/test_haproxy_route_policy_lib.py +++ b/haproxy-route-policy-operator/tests/unit/test_haproxy_route_policy_lib.py @@ -100,7 +100,9 @@ def test_requirer_app_data_model_accepts_valid_payload(): assert: payload is validated and fields are preserved. """ request = HaproxyRoutePolicyBackendRequest(**VALID_BACKEND_REQUEST) - app_data = HaproxyRoutePolicyRequirerAppData(backend_requests=[request]) + app_data = HaproxyRoutePolicyRequirerAppData( + backend_requests=[request], proxied_endpoint="example.com" + ) assert len(app_data.backend_requests) == 1 assert app_data.backend_requests[0].backend_name == "backend-a" @@ -142,4 +144,6 @@ def test_requirer_app_data_rejects_duplicate_backend_names(): ] with pytest.raises(ValidationError): - HaproxyRoutePolicyRequirerAppData(backend_requests=duplicated_requests) + HaproxyRoutePolicyRequirerAppData( + backend_requests=duplicated_requests, proxied_endpoint=None + ) diff --git a/tests/integration/test_haproxy_route_policy.py b/tests/integration/test_haproxy_route_policy.py index fa7b2263..fae200f5 100644 --- a/tests/integration/test_haproxy_route_policy.py +++ b/tests/integration/test_haproxy_route_policy.py @@ -8,10 +8,15 @@ import jubilant import pytest +from typing import Callable, Any +import json +import requests +from .helper import get_unit_ip_address logger = logging.getLogger(__name__) TEST_HOSTNAME = "example.com" +HAPROXY_ROUTE_REQUIRER_NAME = "haproxy-route-requirer" @pytest.mark.abort_on_fail @@ -20,10 +25,46 @@ def test_haproxy_route_policy( haproxy_route_policy: str, lxd_juju: jubilant.Juju, postgresql: str, + any_charm_haproxy_route_deployer: Callable[[str], Any], ): """Test the HAProxy route policy integration.""" + any_charm_haproxy_route_deployer(HAPROXY_ROUTE_REQUIRER_NAME) lxd_juju.integrate(f"{haproxy_route_policy}:database", f"{postgresql}:database") lxd_juju.integrate( f"{configured_application_with_tls}:haproxy-route-policy", haproxy_route_policy, ) + lxd_juju.integrate( + f"{HAPROXY_ROUTE_REQUIRER_NAME}:require-haproxy-route", configured_application_with_tls + ) + lxd_juju.run( + f"{HAPROXY_ROUTE_REQUIRER_NAME}/0", + "rpc", + { + "method": "update_relation", + "args": json.dumps( + [ + { + "service": HAPROXY_ROUTE_REQUIRER_NAME, + "ports": [80], + "hostname": TEST_HOSTNAME, + } + ] + ), + }, + ) + lxd_juju.wait(jubilant.all_active) + admin_credentials = lxd_juju.run( + f"{haproxy_route_policy}/0", + "get-admin-credentials", + ) + logger.info(f"Admin credentials: {admin_credentials}") + haproxy_unit_ip = get_unit_ip_address(lxd_juju, configured_application_with_tls) + + response = requests.get( + f"https://{str(haproxy_unit_ip)}/api/v1/requests", + headers={"Host": TEST_HOSTNAME}, + auth=("admin", admin_credentials["password"]), + verify=False, + ) + logger.info(f"Response from HAProxy route policy: {response.json()}")