Skip to content

Commit f537c4e

Browse files
authored
feat(server): enable authentication to webhook endpoint (#1015)
* feat(server): add webhook charm options with typed config * feat(server): update server API with webhook authentication * tests: add charm unit tests for typed config * tests: add server tests for webhook authentication * tests: add missing tests based on copilot review * add reviewer recommendations
1 parent 074d638 commit f537c4e

10 files changed

Lines changed: 570 additions & 207 deletions

File tree

server/charm/charmcraft.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,15 @@ config:
112112
type: string
113113
default: "localhost,127.0.0.1,::1"
114114
description: Resources that we should be able to access bypassing proxy
115+
webhook_url:
116+
type: string
117+
default: "http://test-observer-api.local/"
118+
description: The URL for the webhook to send notifications to.
119+
webhook_auth:
120+
type: string
121+
default: ""
122+
description: Optional bearer token for authenticating with the webhook endpoint
123+
115124

116125
parts:
117126
charm:

server/charm/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ dependencies = [
99
"pymongo<4.17.1",
1010
"urllib3>=2.6.3",
1111
"cosl>=0.0.53",
12+
"pydantic>=2.12,<3",
1213
]
1314

1415
[dependency-groups]
@@ -63,4 +64,4 @@ ignore = [
6364
]
6465

6566
[tool.ruff.lint.pydocstyle]
66-
convention = "pep257"
67+
convention = "pep257"

server/charm/src/charm.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
from pymongo import MongoClient
3838
from pymongo.errors import PyMongoError
3939

40+
from config import TestflingerServerConfig
41+
4042
logger = logging.getLogger(__name__)
4143

4244
TESTFLINGER_ADMIN_ID = "testflinger-admin"
@@ -54,6 +56,9 @@ class TestflingerCharm(ops.CharmBase):
5456
def __init__(self, *args):
5557
"""Initialize the charm."""
5658
super().__init__(*args)
59+
self.typed_config = self.load_config(
60+
TestflingerServerConfig, errors="blocked"
61+
)
5762
self.pebble_service_name = "testflinger"
5863
self.pebble_check_name = "v1_up"
5964
self.container = self.unit.get_container("testflinger")
@@ -143,7 +148,7 @@ def _require_nginx_route(self):
143148
# TODO: Remove nginx route when migration to traefik route is completed
144149
require_nginx_route(
145150
charm=self,
146-
service_hostname=self.config["external_hostname"],
151+
service_hostname=self.typed_config.external_hostname,
147152
service_name=self.app.name,
148153
service_port=DEFAULT_PORT,
149154
)
@@ -193,7 +198,7 @@ def _configure_traefik_route(self):
193198

194199
testflinger_base_url = (
195200
self.traefik_route.external_host
196-
or self.config.get("external_hostname", "")
201+
or self.typed_config.external_hostname
197202
)
198203
if not testflinger_base_url:
199204
self.unit.status = ops.BlockedStatus(
@@ -345,7 +350,7 @@ def _run_rotation(self, app_env: dict) -> str:
345350

346351
def _on_config_changed(self, _: ops.framework.EventBase) -> None:
347352
"""Handle config changed event."""
348-
new_key = self.config["testflinger_secrets_master_key"]
353+
new_key = self.typed_config.testflinger_secrets_master_key
349354
stored_key = self._stored.previous_master_key
350355

351356
# Rotate only if master key was previously defined, has changed,
@@ -389,7 +394,7 @@ def _on_retry_key_rotation_action(self, event: ops.ActionEvent) -> None:
389394
unit in BlockedStatus. The old key is read from StoredState and the
390395
new key from the current config.
391396
"""
392-
current_key = self.config["testflinger_secrets_master_key"]
397+
current_key = self.typed_config.testflinger_secrets_master_key
393398
stored_key = self._stored.previous_master_key
394399

395400
if not current_key:
@@ -423,7 +428,7 @@ def _on_retry_key_rotation_action(self, event: ops.ActionEvent) -> None:
423428
@property
424429
def _pebble_layer(self):
425430
"""Return a dictionary representing a Pebble layer."""
426-
keepalive = str(self.config["keepalive"])
431+
keepalive = str(self.typed_config.keepalive)
427432
command = " ".join(
428433
[
429434
"gunicorn",
@@ -482,15 +487,15 @@ def app_environment(self) -> dict:
482487
"MONGODB_USERNAME": db_data.get("db_username"),
483488
"MONGODB_PASSWORD": db_data.get("db_password"),
484489
"MONGODB_DATABASE": db_data.get("db_database"),
485-
"MONGODB_MAX_POOL_SIZE": str(self.config["max_pool_size"]),
486-
"JWT_SIGNING_KEY": self.config["jwt_signing_key"],
487-
"TESTFLINGER_SECRETS_MASTER_KEY": self.config[
488-
"testflinger_secrets_master_key"
489-
],
490+
"MONGODB_MAX_POOL_SIZE": str(self.typed_config.max_pool_size),
491+
"JWT_SIGNING_KEY": self.typed_config.jwt_signing_key,
492+
"TESTFLINGER_SECRETS_MASTER_KEY": self.typed_config.testflinger_secrets_master_key, # noqa: E501
490493
"TESTFLINGER_KEY_VAULT_URI": self._fetch_keyvault_uri() or "",
491-
"HTTP_PROXY": self.config["http_proxy"],
492-
"HTTPS_PROXY": self.config["https_proxy"],
493-
"NO_PROXY": self.config["no_proxy"],
494+
"HTTP_PROXY": self.typed_config.http_proxy,
495+
"HTTPS_PROXY": self.typed_config.https_proxy,
496+
"NO_PROXY": self.typed_config.no_proxy,
497+
"WEBHOOK_URL": self.typed_config.webhook_url,
498+
"WEBHOOK_AUTH": self.typed_config.webhook_auth,
494499
}
495500
return env
496501

server/charm/src/config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright 2026 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
"""Charm configuration."""
4+
5+
from urllib.parse import urlparse
6+
7+
import pydantic
8+
9+
10+
class TestflingerServerConfig(pydantic.BaseModel):
11+
"""Testflinger Server Charm configuration."""
12+
13+
external_hostname: str = "testflinger.local"
14+
keepalive: int = 10
15+
max_pool_size: int = 100
16+
jwt_signing_key: str = ""
17+
testflinger_secrets_master_key: str = ""
18+
http_proxy: str = ""
19+
https_proxy: str = ""
20+
no_proxy: str = "localhost,127.0.0.1,::1"
21+
webhook_url: str = "http://test-observer-api.local/"
22+
webhook_auth: str = ""
23+
24+
@pydantic.field_validator("external_hostname")
25+
@classmethod
26+
def validate_external_hostname(cls, value):
27+
"""Validate that external_hostname does not include HTTP scheme.
28+
29+
Protocol to be used is defined by the ingress controller.
30+
"""
31+
if value.startswith(("http://", "https://")):
32+
raise ValueError(
33+
"external_hostname must not include protocol (http:// or https://)"
34+
)
35+
return value
36+
37+
@pydantic.field_validator("webhook_url")
38+
@classmethod
39+
def validate_webhook_url(cls, value):
40+
"""Validate that webhook_url includes a protocol and no paths."""
41+
parsed_webhook = urlparse(value)
42+
if parsed_webhook.scheme not in {"http", "https"}:
43+
raise ValueError(
44+
"webhook_url must include protocol (http:// or https://)"
45+
)
46+
if not parsed_webhook.netloc:
47+
raise ValueError("webhook_url must include a host")
48+
49+
return value
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright 2026 Canonical
2+
# See LICENSE file for licensing details.
3+
"""Unit tests for config.py."""
4+
5+
import pytest
6+
7+
from config import TestflingerServerConfig
8+
9+
10+
def test_valid_external_hostname():
11+
"""Test that valid external hostname are accepted."""
12+
config = TestflingerServerConfig(external_hostname="testflinger.local")
13+
assert config.external_hostname == "testflinger.local"
14+
15+
config = TestflingerServerConfig(
16+
external_hostname="testflinger.canonical.com"
17+
)
18+
assert config.external_hostname == "testflinger.canonical.com"
19+
20+
21+
def test_invalid_external_hostname():
22+
"""Test that invalid external hostname are rejected."""
23+
# Protocol is defined by ingress controller
24+
# Reject hostnames that include protocol
25+
with pytest.raises(ValueError):
26+
TestflingerServerConfig(external_hostname="http://testflinger.local")
27+
28+
with pytest.raises(ValueError):
29+
TestflingerServerConfig(external_hostname="https://testflinger.local")
30+
31+
32+
def test_valid_webhook_url():
33+
"""Test that valid webhook urls are accepted."""
34+
config = TestflingerServerConfig(
35+
webhook_url="https://test-observer-api.local/"
36+
)
37+
assert config.webhook_url == "https://test-observer-api.local/"
38+
39+
config = TestflingerServerConfig(
40+
webhook_url="http://test-observer-api.local"
41+
)
42+
assert config.webhook_url == "http://test-observer-api.local"
43+
44+
45+
def test_invalid_webhook_url():
46+
"""Test that invalid webhook urls are rejected."""
47+
# Reject webhook url that do not include protocol
48+
with pytest.raises(ValueError):
49+
TestflingerServerConfig(webhook_url="test-observer-api.local")
50+
51+
# Reject webhook url that does not include hostname
52+
with pytest.raises(ValueError):
53+
TestflingerServerConfig(webhook_url="https:///v1/test-executions/")

0 commit comments

Comments
 (0)