Skip to content

Commit f53baab

Browse files
committed
update charmcode, fix state management + add spoe-auth relation
1 parent 192929b commit f53baab

10 files changed

Lines changed: 250 additions & 190 deletions

File tree

haproxy_spoe_auth_operator/pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ classifiers = [
1414
"Programming Language :: Python :: 3.14",
1515
]
1616
dependencies = [
17+
"charmlibs-interfaces-haproxy-spoe-auth",
1718
"charmlibs-snap==1.0.0",
1819
"jinja2==3.1.6",
1920
"ops==3.3.1",
@@ -43,6 +44,9 @@ integration = [
4344
[tool.uv]
4445
package = false
4546

47+
[tool.uv.sources]
48+
charmlibs-interfaces-haproxy-spoe-auth = { git = "https://github.com/Thanhphan1147/charmlibs", subdirectory = "interfaces/haproxy_spoe_auth", rev = "feat/add-spoe-auth-lib" }
49+
4650
[tool.ruff]
4751
target-version = "py310"
4852
line-length = 99

haproxy_spoe_auth_operator/src/charm.py

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,25 @@
99
import typing
1010

1111
import ops
12+
from charmlibs.interfaces.haproxy_spoe_auth import HaproxyEvent, SpoeAuthProvider
13+
from haproxy_spoe_auth_operator.lib.charms.hydra.v0.oauth import ClientConfig, OAuthRequirer
1214
from haproxy_spoe_auth_operator.src.haproxy_spoe_auth_service import (
1315
SpoeAuthService,
1416
SpoeAuthServiceConfigError,
1517
)
1618

17-
from state.charm_state import CharmState, InvalidCharmConfigError, ProxyMode
18-
from state.exception import CharmStateValidationBaseError
19-
from state.oauth import OAuthInformation
19+
from .state import CharmState, InvalidCharmConfigError, OauthInformation
2020

2121
logger = logging.getLogger(__name__)
2222

2323
OAUTH_RELATION = "oauth"
24+
OIDC_CALLBACK_PATH = "/oauth2/callback"
25+
SPOP_PORT = 8081
26+
OIDC_CALLBACK_PORT = 5000
27+
OIDC_SCOPE = "openid email profile"
28+
VAR_AUTHENTICATED = "sess.auth.is_authenticated"
29+
VAR_REDIRECT_URL = "sess.auth.redirect_url"
30+
COOKIE_NAME = "authsession"
2431

2532

2633
class HaproxySpoeAuthCharm(ops.CharmBase):
@@ -34,38 +41,47 @@ def __init__(self, *args: typing.Any):
3441
"""
3542
super().__init__(*args)
3643
self.service = SpoeAuthService()
37-
38-
# OAuth requirer will be added here once the library is fetched
39-
# Example: self.oauth = OAuthRequirer(self, relation_name=OAUTH_RELATION)
44+
self._spoe_auth_provider = SpoeAuthProvider(self, relation_name="spoe-auth")
45+
self._oauth = OAuthRequirer(self, relation_name=OAUTH_RELATION)
4046

4147
self.framework.observe(self.on.install, self._reconcile)
4248
self.framework.observe(self.on.config_changed, self._reconcile)
43-
self.framework.observe(
44-
self.on[OAUTH_RELATION].relation_changed, self._reconcile
45-
)
46-
self.framework.observe(
47-
self.on[OAUTH_RELATION].relation_broken, self._reconcile
48-
)
49-
49+
self.framework.observe(self._oauth.on.oauth_info_changed, self._reconcile)
50+
self.framework.observe(self._oauth.on.oauth_info_removed, self._reconcile)
5051

5152
def _reconcile(self, _: ops.EventBase) -> None:
5253
"""Reconcile the charm state and service configuration."""
5354
try:
54-
charm_state = self._get_charm_state()
55-
oauth_info = OAuthInformation.from_charm(self)
56-
57-
self.service.reconcile(charm_state, oauth_info)
58-
59-
except CharmStateValidationBaseError as exc:
55+
state = CharmState.from_charm(self)
56+
57+
self._oauth.update_client_config(
58+
client_config=ClientConfig(
59+
redirect_uri=f"https://{state.hostname}{OIDC_CALLBACK_PATH}",
60+
scope=OIDC_SCOPE,
61+
grant_types=["authorization_code"],
62+
)
63+
)
64+
oauth_information = OauthInformation.from_charm(self, self._oauth)
65+
self._spoe_auth_provider.provide_spoe_auth_requirements(
66+
relation=oauth_information.spoe_auth_relation,
67+
spop_port=SPOP_PORT,
68+
event=HaproxyEvent.ON_FRONTEND_HTTP_REQUEST,
69+
oidc_callback_port=OIDC_CALLBACK_PORT,
70+
var_authenticated=VAR_AUTHENTICATED,
71+
var_redirect_url=VAR_REDIRECT_URL,
72+
cookie_name=COOKIE_NAME,
73+
oidc_callback_hostname=state.hostname,
74+
oidc_callback_path=OIDC_CALLBACK_PATH,
75+
)
76+
self.service.reconcile(charm_state=state, oauth_information=oauth_information)
77+
self.unit.status = ops.ActiveStatus()
78+
79+
except InvalidCharmConfigError as exc:
6080
logger.exception("Charm state validation failed")
6181
self.unit.status = ops.BlockedStatus(f"Configuration error: {exc}")
6282
except SpoeAuthServiceConfigError as exc:
6383
logger.exception("Service configuration failed")
6484
self.unit.status = ops.BlockedStatus(f"Service configuration failed: {exc}")
65-
except Exception as exc:
66-
logger.exception("Unexpected error during reconciliation")
67-
self.unit.status = ops.BlockedStatus(f"Unexpected error: {exc}")
68-
6985

7086
if __name__ == "__main__": # pragma: nocover
7187
ops.main(HaproxySpoeAuthCharm)

haproxy_spoe_auth_operator/src/haproxy_spoe_auth_service.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99
from charmlibs import snap
1010
from jinja2 import Environment, FileSystemLoader, select_autoescape
1111

12-
from state.charm_state import CharmState
13-
from state.oauth import OAuthInformation
12+
from .state import CharmState, OauthInformation
1413

1514
SNAP_NAME = "haproxy-spoe-auth"
1615
CONFIG_PATH = Path("/var/snap/haproxy-spoe-auth/current/config.yaml")
@@ -49,43 +48,45 @@ def install(self) -> None:
4948
except snap.SnapError as e:
5049
logger.error("An exception occurred when installing charmcraft. Reason: %s", e.message)
5150

52-
53-
def reconcile(self, charm_state: CharmState, oauth_info: OAuthInformation) -> None:
51+
def reconcile(self, charm_state: CharmState, oauth_information: OauthInformation) -> None:
5452
"""Reconcile the service configuration.
5553
5654
Args:
57-
charm_state: The current charm state.
58-
oauth_info: OAuth integration information.
55+
charm_state: The charm state.
56+
oauth_information: OAuth integration information.
5957
6058
Raises:
6159
SpoeAuthServiceConfigError: When configuration fails.
6260
"""
6361
try:
64-
self._render_config(charm_state, oauth_info)
62+
self._render_config(charm_state, oauth_information)
6563
self.haproxy_spoe_auth_snap.restart(reload=True)
6664
except Exception as exc:
6765
raise SpoeAuthServiceConfigError(f"Failed to reconcile service: {exc}") from exc
6866

69-
def _render_config(self, charm_state: CharmState, oauth_info: OAuthInformation) -> None:
67+
def _render_config(self, charm_state: CharmState, oauth_information: OauthInformation) -> None:
7068
"""Render the configuration file.
7169
7270
Args:
73-
charm_state: The current charm state.
74-
oauth_info: OAuth integration information.
71+
charm_state: The charm state.
72+
oauth_information: OAuth integration information.
7573
"""
7674
env = Environment(
77-
loader=FileSystemLoader(str(self._template_dir)),
75+
loader=FileSystemLoader("templates"),
7876
autoescape=select_autoescape(),
77+
keep_trailing_newline=True,
78+
trim_blocks=True,
79+
lstrip_blocks=True,
7980
)
8081
template = env.get_template(CONFIG_TEMPLATE)
81-
8282
config_content = template.render(
83-
spoe_address=charm_state.spoe_address,
84-
oauth_enabled=oauth_info.oauth_data is not None,
85-
oauth_data=oauth_info.oauth_data if oauth_info.oauth_data else {},
83+
hostname=charm_state.hostname,
84+
issuer_url=oauth_information.issuer_url,
85+
client_id=oauth_information.client_id,
86+
client_secret=oauth_information.client_secret,
87+
signature_secret=charm_state.signature_secret,
88+
encryption_secret=charm_state.encryption_secret,
8689
)
8790

8891
# Ensure parent directory exists
89-
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
9092
CONFIG_PATH.write_text(config_content, encoding="utf-8")
91-
logger.info("Configuration written to %s", CONFIG_PATH)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Copyright 2025 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
"""haproxy-spoe-auth-operator charm state."""
5+
6+
import logging
7+
import typing
8+
import secrets
9+
import ops
10+
from pydantic import Field, ValidationError
11+
from pydantic.dataclasses import dataclass
12+
13+
from lib.charms.hydra.v0.oauth import OAuthRequirer
14+
15+
logger = logging.getLogger(__name__)
16+
17+
SPOE_AUTH_RELATION = "spoe-auth"
18+
AGENT_SECRETS_LABEL = "agent-secrets"
19+
20+
21+
class InvalidCharmConfigError(Exception):
22+
"""Exception raised when a charm configuration is found to be invalid."""
23+
24+
25+
@dataclass(frozen=True)
26+
class CharmState:
27+
"""A component of charm state that contains the charm's configuration and mode.
28+
29+
Attributes:
30+
hostname: The hostname of the charm.
31+
client_id: The OAuth client ID.
32+
client_secret: The OAuth client secret.
33+
issuer_url: The OAuth issuer URL.
34+
"""
35+
36+
hostname: str = Field(description="The hostname part of the redirect URL.")
37+
signature_secret: str = Field(
38+
description="Secret used for signing by the SPOE agent.", min_length=1
39+
)
40+
encryption_secret: str = Field(
41+
description="Secret used for encrypting by the SPOE agent.", min_length=1
42+
)
43+
44+
@classmethod
45+
def from_charm(
46+
cls,
47+
charm: ops.CharmBase,
48+
) -> "CharmState":
49+
"""Create a CharmState class from a charm instance.
50+
51+
Args:
52+
charm: The spoe-auth agent charm.
53+
54+
Raises:
55+
InvalidCharmConfigError: When the charm's config is invalid.
56+
57+
Returns:
58+
CharmState: Instance of the charm state component.
59+
"""
60+
hostname = typing.cast(str, charm.config.get("hostname"))
61+
signature_secret = None
62+
encryption_secret = None
63+
try:
64+
secret = charm.model.get_secret(label=AGENT_SECRETS_LABEL)
65+
signing_and_encryption_secrets = secret.get_content(refresh=True)
66+
signature_secret = signing_and_encryption_secrets.get("signature_secret")
67+
encryption_secret = signing_and_encryption_secrets.get("encryption_secret")
68+
except ops.SecretNotFoundError:
69+
signature_secret = secrets.token_urlsafe(32)
70+
encryption_secret = secrets.token_urlsafe(32)
71+
secret = charm.model.app.add_secret(
72+
content={
73+
"signature_secret": signature_secret,
74+
"encryption_secret": encryption_secret,
75+
},
76+
label=AGENT_SECRETS_LABEL,
77+
)
78+
79+
if signature_secret is None or encryption_secret is None:
80+
raise InvalidCharmConfigError("Error fetching agent secrets.")
81+
82+
try:
83+
return cls(
84+
hostname=hostname,
85+
signature_secret=signature_secret,
86+
encryption_secret=encryption_secret,
87+
)
88+
except ValidationError as exc:
89+
raise InvalidCharmConfigError("Invalid configuration") from exc
90+
91+
92+
@dataclass(frozen=True)
93+
class OauthInformation:
94+
"""A component of charm state that contains the charm's configuration and mode.
95+
96+
Attributes:
97+
client_id: The OAuth client ID.
98+
client_secret: The OAuth client secret.
99+
issuer_url: The OAuth issuer URL.
100+
spoe_auth_relation: The spoe-auth relation.
101+
"""
102+
103+
issuer_url: str = Field(description="The OAuth issuer URL.", min_length=1)
104+
client_id: str = Field(description="The OAuth client ID.", min_length=1)
105+
client_secret: str = Field(description="The OAuth client secret.", min_length=1)
106+
spoe_auth_relation: ops.Relation = Field(description="The spoe-auth relation.")
107+
108+
@classmethod
109+
def from_charm(
110+
cls,
111+
charm: ops.CharmBase,
112+
oauth: OAuthRequirer,
113+
) -> "OauthInformation":
114+
"""Create a CharmState class from a charm instance.
115+
116+
Args:
117+
charm: The haproxy charm.
118+
oauth: The OAuthRequirer instance.
119+
120+
Raises:
121+
InvalidCharmConfigError: When the charm's config is invalid.
122+
123+
Returns:
124+
CharmState: Instance of the charm state component.
125+
"""
126+
spoe_auth_relation = charm.model.get_relation(SPOE_AUTH_RELATION)
127+
if spoe_auth_relation is None:
128+
logger.error("spoe-auth relation missing.")
129+
raise InvalidCharmConfigError("spoe-auth relation missing.")
130+
131+
oauth_provider_information = oauth.get_provider_info()
132+
if (
133+
oauth_provider_information is None
134+
or oauth_provider_information.client_id is None
135+
or oauth_provider_information.client_secret is None
136+
):
137+
raise InvalidCharmConfigError("Waiting for complete oauth relation data.")
138+
139+
try:
140+
return cls(
141+
issuer_url=oauth_provider_information.issuer_url,
142+
client_id=oauth_provider_information.client_id,
143+
client_secret=oauth_provider_information.client_secret,
144+
spoe_auth_relation=spoe_auth_relation,
145+
)
146+
except ValidationError as exc:
147+
raise InvalidCharmConfigError("Invalid configuration") from exc

haproxy_spoe_auth_operator/src/state/__init__.py

Lines changed: 0 additions & 4 deletions
This file was deleted.

haproxy_spoe_auth_operator/src/state/charm_state.py

Lines changed: 0 additions & 43 deletions
This file was deleted.

haproxy_spoe_auth_operator/src/state/exception.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

0 commit comments

Comments
 (0)