Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ISD-2586 Support for ha in active/passive mode #50

Merged
merged 37 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1290266
update test and add logic to open ports
Thanhphan1147 Oct 23, 2024
6cb5ba3
fix lint
Thanhphan1147 Oct 23, 2024
6305ef4
set ports
Thanhphan1147 Oct 23, 2024
8560a27
use type hint for required_ports
Thanhphan1147 Oct 24, 2024
584bdf9
skip invalid port
Thanhphan1147 Nov 19, 2024
bb27397
Merge branch 'main' into manage_open_ports
amandahla Dec 3, 2024
120370f
make validate_port a private method
Thanhphan1147 Dec 4, 2024
5055c88
Merge branch 'manage_open_ports' of github.com:canonical/haproxy-oper…
Thanhphan1147 Dec 4, 2024
68f9328
Merge branch 'main' into manage_open_ports
Thanhphan1147 Dec 10, 2024
c3a202d
Merge branch 'main' into manage_open_ports
Thanhphan1147 Jan 5, 2025
168d319
block if services request invalid ports
Thanhphan1147 Jan 6, 2025
15e5018
Merge branch 'manage_open_ports' of github.com:canonical/haproxy-oper…
Thanhphan1147 Jan 6, 2025
47b9380
block and don't update config if service requested invalid ports
Thanhphan1147 Jan 6, 2025
ea57a26
fix import
Thanhphan1147 Jan 6, 2025
d123241
Merge branch 'main' into manage_open_ports
Thanhphan1147 Jan 6, 2025
67c54fd
test block on invalid port
Thanhphan1147 Jan 6, 2025
e2aa693
Merge branch 'manage_open_ports' of github.com:canonical/haproxy-oper…
Thanhphan1147 Jan 6, 2025
741818c
update test
Thanhphan1147 Jan 6, 2025
03917d0
add vip charm config
Thanhphan1147 Jan 8, 2025
331246e
support ha in active-passive with hacluster
Thanhphan1147 Jan 10, 2025
d8a2975
Merge branch 'main' into add_hacluster_interface
Thanhphan1147 Jan 10, 2025
4f1394b
bump pylibjuju to 3.6.1.0 to support series=noble
Thanhphan1147 Jan 10, 2025
337779c
Merge branch 'add_hacluster_interface' of github.com:canonical/haprox…
Thanhphan1147 Jan 10, 2025
2fac27f
update ha test to cover more cases
Thanhphan1147 Jan 13, 2025
5c0da34
Merge branch 'main' into add_hacluster_interface
Thanhphan1147 Jan 13, 2025
3c039e9
pin deps with tag, check that the charm is correctly blocked after re…
Thanhphan1147 Jan 13, 2025
6f6dacc
Merge branch 'main' into add_hacluster_interface
Thanhphan1147 Jan 14, 2025
5eccaa7
fix lint
Thanhphan1147 Jan 14, 2025
392f428
Merge branch 'add_hacluster_interface' of github.com:canonical/haprox…
Thanhphan1147 Jan 14, 2025
c351dbf
update juju version
Thanhphan1147 Jan 16, 2025
3df20e1
write previously configured vip to peer relation data. Delete vip res…
Thanhphan1147 Jan 16, 2025
cc3d4cf
fix lint
Thanhphan1147 Jan 17, 2025
706a91c
fix vip peer relation logic, update state + tests
Thanhphan1147 Jan 17, 2025
60c3831
set vip in "local-unit"
Thanhphan1147 Jan 17, 2025
7d1f750
correctly write and update vip in peer relation data
Thanhphan1147 Jan 18, 2025
d440f2b
fix lint
Thanhphan1147 Jan 18, 2025
93f3dae
Merge branch 'main' into add_hacluster_interface
Thanhphan1147 Jan 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/integration_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ jobs:
juju-channel: 3.3/stable
self-hosted-runner: true
charmcraft-channel: latest/edge
modules: '["test_action.py", "test_config.py", "test_charm.py", "test_http_interface.py", "test_cos.py", "test_ingress.py"]'
modules: '["test_action.py", "test_config.py", "test_charm.py", "test_http_interface.py", "test_cos.py", "test_ingress.py", "test_ha.py"]'
8 changes: 7 additions & 1 deletion charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ parts:
- pkg-config
- libffi-dev
- libssl-dev
- git
build-snaps:
- rustup
override-build: |
Expand Down Expand Up @@ -51,6 +52,8 @@ requires:
limit: 1
reverseproxy:
interface: http
ha:
interface: hacluster

provides:
ingress:
Expand All @@ -65,14 +68,17 @@ config:
options:
external-hostname:
default: ""
description: Hostname of haproxy.
type: string
description: Hostname of haproxy.
global-maxconn:
default: 4096
type: int
description: |
Sets the maximum per-process number of concurrent connections to
<number>. Must be greater than 0.
vip:
type: string
description: Virtual IP address, used in active-passive ha mode.

actions:
get-certificate:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ jsonschema==4.23.0
ops==2.17.1
pydantic==2.10.4
cosl==0.0.47
interface_hacluster @ git+https://github.com/charmed-kubernetes/charm-interface-hacluster@main
Thanhphan1147 marked this conversation as resolved.
Show resolved Hide resolved
60 changes: 59 additions & 1 deletion src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@
IngressPerAppDataRemovedEvent,
IngressPerAppProvider,
)
from interface_hacluster.ops_ha_interface import HAServiceReadyEvent, HAServiceRequires
from ops.charm import ActionEvent
from ops.model import Port

from haproxy import HAProxyService
from haproxy import HAPROXY_SERVICE, HAProxyService
from http_interface import (
HTTPBackendAvailableEvent,
HTTPBackendRemovedEvent,
HTTPProvider,
HTTPRequirer,
)
from state.config import CharmConfig
from state.ha import HACLUSTER_RELATION, HAInformation
from state.ingress import IngressRequirersInformation
from state.tls import TLSInformation, TLSNotReadyError
from state.validation import validate_config_and_tls
Expand Down Expand Up @@ -63,6 +66,19 @@ class ProxyMode(StrEnum):
INVALID = "invalid"


def _validate_port(port: int) -> bool:
"""Validate if the given value is a valid TCP port.

Args:
port: The port number to validate.

Returns:
bool: True if valid, False otherwise.
"""
return 0 <= port <= 65535


# pylint: disable=too-many-instance-attributes
class HAProxyCharm(ops.CharmBase):
"""Charm haproxy."""

Expand Down Expand Up @@ -94,6 +110,7 @@ def __init__(self, *args: typing.Any):
dashboard_dirs=["./src/grafana_dashboards"],
)

self.hacluster = HAServiceRequires(self, HACLUSTER_RELATION)
self.framework.observe(self.on.install, self._on_install)
self.framework.observe(self.on.config_changed, self._on_config_changed)
self.framework.observe(self.on.get_certificate_action, self._on_get_certificate_action)
Expand All @@ -112,6 +129,7 @@ def __init__(self, *args: typing.Any):
self.framework.observe(
self._ingress_provider.on.data_removed, self._on_ingress_data_removed
)
self.framework.observe(self.hacluster.on.ha_ready, self._on_ha_ready)

def _on_install(self, _: typing.Any) -> None:
"""Install the haproxy package."""
Expand Down Expand Up @@ -163,6 +181,9 @@ def _on_http_backend_removed(self, _: HTTPBackendRemovedEvent) -> None:

def _reconcile(self) -> None:
"""Render the haproxy config and restart the service."""
ha_information = HAInformation.from_charm(self)
self._reconcile_ha(ha_information)

proxy_mode = self._validate_state()
if proxy_mode == ProxyMode.INVALID:
# We don't raise any exception/set status here as it should already be handled
Expand All @@ -176,14 +197,33 @@ def _reconcile(self) -> None:
self._ingress_provider
)
tls_information = TLSInformation.from_charm(self, self.certificates)
self.unit.set_ports(80, 443)
self.haproxy_service.reconcile_ingress(
config, ingress_requirers_information, tls_information.external_hostname
)
case ProxyMode.LEGACY:
legacy_invalid_requested_port: list[str] = []
required_ports: set[Port] = set()
for service in self.reverseproxy_requirer.get_services_definition().values():
port = service["service_port"]
if not _validate_port(port):
logger.error("Requested port: %s is not a valid tcp port. Skipping", port)
legacy_invalid_requested_port.append(f"{service['service_name']:{port}}")
continue
required_ports.add(Port(protocol="tcp", port=port))

if legacy_invalid_requested_port:
self.unit.status = ops.BlockedStatus(
f"Invalid ports requested: {','.join(legacy_invalid_requested_port)}"
)
return

self.unit.set_ports(*required_ports)
self.haproxy_service.reconcile_legacy(
config, self.reverseproxy_requirer.get_services()
)
case _:
self.unit.set_ports(80)
self.haproxy_service.reconcile_default(config)
self.unit.status = ops.ActiveStatus()

Expand Down Expand Up @@ -253,6 +293,24 @@ def _validate_state(self) -> ProxyMode:

return ProxyMode.NOPROXY

@validate_config_and_tls(defer=False, block_on_tls_not_ready=False)
def _on_ha_ready(self, _: HAServiceReadyEvent) -> None:
"""Handle the ha-ready event."""
self._reconcile()

def _reconcile_ha(self, ha_information: HAInformation) -> None:
"""Update ha configuration.

Args:
ha_information: HAInformation charm state component.
"""
if not ha_information.integration_ready:
logger.info("ha integration is not ready, skipping.")
return
self.hacluster.add_vip(self.app.name, str(ha_information.vip))
self.hacluster.add_systemd_service(f"{self.app.name}-{HAPROXY_SERVICE}", HAPROXY_SERVICE)
amandahla marked this conversation as resolved.
Show resolved Hide resolved
self.hacluster.bind_resources()


if __name__ == "__main__": # pragma: nocover
ops.main(HAProxyCharm)
72 changes: 72 additions & 0 deletions src/state/ha.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.

"""haproxy-operator charm tls information."""

import typing

import ops
from pydantic import IPvAnyAddress, ValidationError, model_validator
from pydantic.dataclasses import dataclass
from typing_extensions import Self

from .exception import CharmStateValidationBaseError

HACLUSTER_RELATION = "ha"


class HAInformationValidationError(CharmStateValidationBaseError):
"""Exception raised when validation of the ha_information state component failed."""


@dataclass(frozen=True)
class HAInformation:
"""A component of charm state containing information about TLS.

Attributes:
integration_ready: Whether the ha relation is established.
vip: The configured virtual IP address.
"""

integration_ready: bool
vip: typing.Optional[IPvAnyAddress]

@model_validator(mode="after")
def validate_vip_not_none_when_ha_integration_active(self) -> Self:
"""Validate that vip is configured when ha integration is active.

Raises:
ValueError: When ha integration is active but vip is not configured.

Returns:
Self: Validated model.
"""
if self.integration_ready and not self.vip:
raise ValueError("vip needs to be configured in ha mode.")
return self

@classmethod
def from_charm(cls, charm: ops.CharmBase) -> "HAInformation":
"""Get ha information from a charm instance.

Args:
charm: The haproxy charm.

Raises:
HAInformationValidationError: When validation of the state component failed.

Returns:
HAInformation: Information needed to configure ha.
"""
ha_integration = charm.model.get_relation(HACLUSTER_RELATION)
integration_ready = bool(ha_integration and ha_integration.units)
vip = charm.config.get("vip")

try:
# Ignore arg-type here because we want to pass a str and let pydantic do the validation
return cls(
integration_ready=integration_ready,
vip=vip if vip else None, # type: ignore
)
except ValidationError as exc:
raise HAInformationValidationError(str(exc)) from exc
Loading
Loading