diff --git a/ops/ops/interface_openstack_integration/model.py b/ops/ops/interface_openstack_integration/model.py index e0c513a..9e7b6d1 100644 --- a/ops/ops/interface_openstack_integration/model.py +++ b/ops/ops/interface_openstack_integration/model.py @@ -10,41 +10,101 @@ import configparser import contextlib import io -from typing import Dict, Optional +from typing import Any, Dict, Optional -from pydantic import BaseModel, Json, SecretStr, validator +from pydantic import BaseModel, Field, Json, SecretStr, validator + + +class LBClassOptions(BaseModel): + """Options for LoadBalancerClass section in cloud config.""" + + floating_network_id: Optional[str] = Field( + None, + alias="floating-network-id", + description="floating-network-id. The same with floating-network-id option above.", + ) + floating_subnet_id: Optional[str] = Field( + None, + alias="floating-subnet-id", + description="floating-subnet-id. The same with floating-subnet-id option above.", + ) + floating_subnet: Optional[str] = Field( + None, + alias="floating-subnet", + description="floating-subnet. The same with floating-subnet option above.", + ) + floating_subnet_tags: Optional[str] = Field( + None, + alias="floating-subnet-tags", + description="floating-subnet-tags. The same with floating-subnet-tags option above.", + ) + network_id: Optional[str] = Field( + None, alias="network-id", description="network-id. The same with network-id option above." + ) + subnet_id: Optional[str] = Field( + None, alias="subnet-id", description="subnet-id. The same with subnet-id option above." + ) + member_subnet_id: Optional[str] = Field( + None, + alias="member-subnet-id", + description="member-subnet-id. The same with member-subnet-id option above.", + ) class Data(BaseModel): """Databag for information shared over the relation.""" - # Required config + # Global Config auth_url: Json[str] + endpoint_tls_ca: Json[Optional[str]] + username: Json[str] password: Json[SecretStr] - project_domain_name: Json[str] - project_name: Json[str] region: Json[str] - username: Json[str] - user_domain_name: Json[str] - - # Optional config - bs_version: Json[Optional[str]] domain_id: Json[Optional[str]] = None domain_name: Json[Optional[str]] = None - endpoint_tls_ca: Json[Optional[str]] - floating_network_id: Json[Optional[str]] - has_octavia: Json[Optional[bool]] - ignore_volume_az: Json[Optional[bool]] - internal_lb: Json[Optional[bool]] - lb_enabled: Json[Optional[bool]] - lb_method: Json[Optional[str]] project_id: Json[Optional[str]] = None + project_name: Json[str] project_domain_id: Json[Optional[str]] = None + project_domain_name: Json[str] + user_domain_id: Json[Optional[str]] + user_domain_name: Json[str] + + # LoadBalancer config + has_octavia: Json[Optional[bool]] + lb_enabled: Json[Optional[bool]] + floating_network_id: Json[Optional[str]] + floating_subnet_id: Json[Optional[str]] = None + floating_subnet: Json[Optional[str]] = None + floating_subnet_tags: Json[Optional[str]] = None + lb_provider: Json[Optional[str]] = None + lb_method: Json[Optional[str]] = None + subnet_id: Json[Optional[str]] = None + member_subnet_id: Json[Optional[str]] = None + network_id: Json[Optional[str]] = None + manage_security_groups: Json[Optional[bool]] = None + create_monitor: Json[Optional[bool]] = None + monitor_delay: Json[Optional[int]] = None + monitor_max_retries: Json[Optional[int]] = None + monitor_max_retries_down: Json[Optional[int]] = None + monitor_timeout: Json[Optional[int]] = None + internal_lb: Json[Optional[bool]] = None + node_selector: Json[Optional[str]] = None + cascade_delete: Json[bool] = Field(default=True) + flavor_id: Json[Optional[str]] = None + availability_zone: Json[Optional[str]] = None + lb_classes: Json[Dict[str, "LBClassOptions"]] = Field(default_factory=dict) + enable_ingress_hostname: Json[Optional[bool]] = None + ingress_hostname_suffix: Json[Optional[str]] = None + default_tls_container_ref: Json[Optional[str]] = None + container_store: Json[Optional[str]] = None + max_shared_lb: Json[Optional[int]] = None + provider_requires_serial_api_calls: Json[Optional[bool]] = None + + bs_version: Json[Optional[str]] + trust_device_path: Json[Optional[bool]] = None + ignore_volume_az: Json[Optional[bool]] = None + proxy_config: Json[Optional[Dict[str, str]]] = None - manage_security_groups: Json[Optional[bool]] - subnet_id: Json[Optional[str]] - trust_device_path: Json[Optional[bool]] - user_domain_id: Json[Optional[str]] = None version: Json[Optional[int]] = None @validator("endpoint_tls_ca") @@ -60,10 +120,13 @@ def must_be_b64_cert(cls, s: Json[str]): def cloud_config(self) -> str: # noqa: C901 """Render as an openstack cloud config ini. - https://github.com/kubernetes/cloud-provider-openstack/blob/75b1fbb91a2566a869b8922ad62e1c03ab5e6eac/docs/openstack-cloud-controller-manager/using-openstack-cloud-controller-manager.md#global + https://github.com/kubernetes/cloud-provider-openstack/blob/0973c523d13210ca7499ee30ba2b564808b48d54/docs/openstack-cloud-controller-manager/using-openstack-cloud-controller-manager.md#global + + https://github.com/kubernetes/cloud-provider-openstack/blob/0973c523d13210ca7499ee30ba2b564808b48d54/docs/openstack-cloud-controller-manager/using-openstack-cloud-controller-manager.md#load-balancer """ _global, _loadbalancer, _blockstorage = {}, {}, {} + _s: Any if self.auth_url: _global["auth-url"] = self.auth_url if self.endpoint_tls_ca: @@ -93,26 +156,76 @@ def cloud_config(self) -> str: # noqa: C901 if not self.lb_enabled: _loadbalancer["enabled"] = "false" - if self.has_octavia in (True, None): - # Newer integrator charm will detect whether underlying OpenStack has - # Octavia enabled so we can set this intelligently. If we're still - # related to an older integrator, though, default to assuming Octavia - # is available. - _loadbalancer["use-octavia"] = "true" - else: - _loadbalancer["use-octavia"] = "false" - _loadbalancer["lb-provider"] = "haproxy" - if _s := self.subnet_id: - _loadbalancer["subnet-id"] = _s if _s := self.floating_network_id: _loadbalancer["floating-network-id"] = _s - if _s := self.lb_method: + if _s := self.floating_subnet_id: + _loadbalancer["floating-subnet-id"] = _s + if _s := self.floating_subnet: + _loadbalancer["floating-subnet"] = _s + if _s := self.floating_subnet_tags: + _loadbalancer["floating-subnet-tags"] = _s + + octavia_available = self.has_octavia in (True, None) + _loadbalancer["use-octavia"] = "true" if octavia_available else "false" + + if _s := self.lb_provider: + _loadbalancer["lb-provider"] = _s + else: + default_provider = "amphora" if octavia_available else "haproxy" + _loadbalancer["lb-provider"] = default_provider + + if _loadbalancer["lb-provider"] == "ovn": + _loadbalancer["lb-method"] = "SOURCE_IP_PORT" + elif _s := self.lb_method: _loadbalancer["lb-method"] = _s - if self.internal_lb: - _loadbalancer["internal-lb"] = "true" + elif _loadbalancer["lb-provider"] in ("amphora", "octavia"): + _loadbalancer["lb-method"] = "ROUND_ROBIN" + + if _s := self.subnet_id: + _loadbalancer["subnet-id"] = _s + if _s := self.member_subnet_id: + _loadbalancer["member-subnet-id"] = _s + if _s := self.network_id: + _loadbalancer["network-id"] = _s if self.manage_security_groups: _loadbalancer["manage-security-groups"] = "true" + if self.create_monitor: + _loadbalancer["create-monitor"] = "true" + if _s := self.monitor_delay: + _loadbalancer["monitor-delay"] = str(_s) + if _s := self.monitor_max_retries: + _loadbalancer["monitor-max-retries"] = str(_s) + if _s := self.monitor_max_retries_down: + _loadbalancer["monitor-max-retries-down"] = str(_s) + if _s := self.monitor_timeout: + _loadbalancer["monitor-timeout"] = str(_s) + + if self.internal_lb: + _loadbalancer["internal-lb"] = "true" + + if _s := self.node_selector: + _loadbalancer["node-selector"] = _s + if not self.cascade_delete: + _loadbalancer["cascade-delete"] = "false" + if _s := self.flavor_id: + _loadbalancer["flavor-id"] = _s + if _s := self.availability_zone: + _loadbalancer["availability-zone"] = _s + + if self.enable_ingress_hostname: + _loadbalancer["enable-ingress-hostname"] = "true" + if _s := self.ingress_hostname_suffix: + _loadbalancer["ingress-hostname-suffix"] = _s + if octavia_available and (_s := self.default_tls_container_ref): + _loadbalancer["default-tls-container-ref"] = _s + if _s := self.container_store: + _loadbalancer["container-store"] = _s + if _s := self.max_shared_lb: + _loadbalancer["max-shared-lb"] = str(_s) + if self.provider_requires_serial_api_calls: + _loadbalancer["provider-requires-serial-api-calls"] = "true" + if _os := self.bs_version: _blockstorage["bs-version"] = _os if self.trust_device_path: @@ -123,7 +236,12 @@ def cloud_config(self) -> str: # noqa: C901 config = configparser.ConfigParser() config["Global"] = _global config["LoadBalancer"] = _loadbalancer + for lb_class, lb_class_opts in self.lb_classes.items(): + if as_dict := {k: v for k, v in lb_class_opts.dict(by_alias=True).items() if v}: + config[f'LoadBalancerClass "{lb_class}"'] = as_dict + config["BlockStorage"] = _blockstorage + with contextlib.closing(io.StringIO()) as sio: config.write(sio) output_text = sio.getvalue() diff --git a/ops/ops/interface_openstack_integration/requires.py b/ops/ops/interface_openstack_integration/requires.py index 5f9a02f..8a40cc0 100644 --- a/ops/ops/interface_openstack_integration/requires.py +++ b/ops/ops/interface_openstack_integration/requires.py @@ -9,6 +9,7 @@ import logging from typing import Dict, Optional +import pydantic from backports.cached_property import cached_property from ops.charm import CharmBase, RelationBrokenEvent from ops.framework import Object @@ -50,8 +51,11 @@ def _raw_data(self): @cached_property def _data(self) -> Optional[Data]: - raw = self._raw_data - return Data(**raw) if raw else None + if raw := self._raw_data: + if pydantic.VERSION.startswith("1."): + return Data.parse_obj(raw) + return Data.model_validate(raw) + return None def evaluate_relation(self, event) -> Optional[str]: """Determine if relation is ready.""" @@ -87,24 +91,31 @@ def is_ready(self): ] ) + @property + def data(self) -> Optional[Data]: + """Return parsed data from integrator relation.""" + if self.is_ready: + return self._data + return None + @property def cloud_conf(self) -> Optional[str]: """Return cloud.conf from integrator relation.""" - if self.is_ready and (data := self._data): + if data := self.data: return data.cloud_config return None @property def cloud_conf_b64(self) -> Optional[bytes]: """Return cloud.conf from integrator relation as base64-encoded bytes.""" - if self.is_ready and (data := self.cloud_conf): + if data := self.cloud_conf: return base64.b64encode(data.encode()) return None @property def endpoint_tls_ca(self) -> Optional[bytes]: - """Return cloud.conf from integrator relation.""" - if self.is_ready and (data := self._data): + """Return endpoint tls ca from integrator relation.""" + if data := self.data: if data.endpoint_tls_ca: return data.endpoint_tls_ca.encode() return None @@ -113,6 +124,6 @@ def endpoint_tls_ca(self) -> Optional[bytes]: def proxy_config(self) -> Dict[str, str]: """Return proxy_config from integrator relation.""" config = None - if self.is_ready and (data := self._data): + if data := self.data: config = data.proxy_config return config or {} diff --git a/ops/tests/data/cloud_conf.ini b/ops/tests/data/cloud_conf.ini index 88287ac..e7377ea 100644 --- a/ops/tests/data/cloud_conf.ini +++ b/ops/tests/data/cloud_conf.ini @@ -15,8 +15,12 @@ user-domain-name = user [LoadBalancer] use-octavia = true +lb-provider = amphora lb-method = ROUND_ROBIN +[LoadBalancerClass "default"] +member-subnet-id = something + [BlockStorage] bs-version = v2 ignore-volume-az = true diff --git a/ops/tests/data/openstack_integration_data.yaml b/ops/tests/data/openstack_integration_data.yaml index 6fb00e5..ea90ead 100644 --- a/ops/tests/data/openstack_integration_data.yaml +++ b/ops/tests/data/openstack_integration_data.yaml @@ -22,3 +22,15 @@ user_domain_name: '"user"' user_domain_id: '"90123456789012345678901234567890"' username: '"admin"' version: '"3"' +lb_classes: > #intentionally a string encoding a JSON structure + { + "default": { + "floating-network-id": "", + "floating-subnet-id": "", + "floating-subnet": "", + "floating-subnet-tags": "", + "network-id": "", + "subnet-id": "", + "member-subnet-id": "something" + } + }