Skip to content

Commit 44fc973

Browse files
committed
Add config updates via SSE
1 parent bb8d088 commit 44fc973

8 files changed

Lines changed: 896 additions & 2 deletions

File tree

aikido_zen/background_process/cloud_connection_manager/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
""" This file simply exports the CloudConnectionManager class"""
1+
"""This file simply exports the CloudConnectionManager class"""
22

33
from aikido_zen.background_process.heartbeats import send_heartbeats_every_x_secs
44
from aikido_zen.background_process.routes import Routes
@@ -10,10 +10,12 @@
1010
from aikido_zen.storage.users import Users
1111
from aikido_zen.storage.hostnames import Hostnames
1212
from ..realtime.start_polling_for_changes import start_polling_for_changes
13+
from ..realtime.listen_for_config_updates import listen_for_config_updates
1314
from ...helpers.get_current_unixtime_ms import get_unixtime_ms
1415
from ...storage.ai_statistics import AIStatistics
1516
from ...storage.firewall_lists import FirewallLists
1617
from ...storage.statistics import Statistics
18+
from aikido_zen.helpers.env_vars.feature_flags import is_feature_enabled
1719

1820
# Import functions :
1921
from .get_manager_info import get_manager_info
@@ -67,6 +69,9 @@ def start(self, event_scheduler):
6769
send_heartbeats_every_x_secs(self, self.heartbeat_secs, event_scheduler)
6870
start_polling_for_changes(self, event_scheduler)
6971

72+
if is_feature_enabled("sse"):
73+
listen_for_config_updates(self, event_scheduler)
74+
7075
def report_initial_stats(self):
7176
"""
7277
This is run 1m after startup, and checks if we should send out

aikido_zen/background_process/realtime/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def get_realtime_url():
1515
if not realtime_url.endswith("/"):
1616
realtime_url += "/"
1717
return realtime_url
18-
return "https://runtime.aikido.dev/"
18+
return get_api_url()
1919

2020

2121
def get_config(token):
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""
2+
Mainly exports `listen_for_config_updates`
3+
"""
4+
5+
import json
6+
7+
from aikido_zen.helpers.token import Token
8+
from aikido_zen.helpers.logging import logger
9+
import aikido_zen.background_process.realtime as realtime
10+
from .sse_client import connect_to_sse
11+
12+
13+
def listen_for_config_updates(connection_manager, event_scheduler):
14+
"""
15+
Connects to the realtime SSE endpoint and fetches the new config whenever
16+
the server signals, through a "config-updated" event, that it has changed.
17+
"""
18+
if not isinstance(connection_manager.token, Token):
19+
logger.info("No token provided, not listening for config updates")
20+
return
21+
if connection_manager.serverless:
22+
logger.info(
23+
"Running in serverless environment, not listening for config updates"
24+
)
25+
return
26+
27+
token = connection_manager.token
28+
last_updated_at = connection_manager.conf.last_updated_at
29+
30+
def on_event(event):
31+
nonlocal last_updated_at
32+
33+
logger.debug("SSE event received: %s", event.event)
34+
if event.event != "config-updated":
35+
return
36+
37+
try:
38+
payload = json.loads(event.data)
39+
config_updated_at = payload["configUpdatedAt"]
40+
if config_updated_at <= last_updated_at:
41+
return
42+
except (ValueError, KeyError, TypeError):
43+
logger.debug("SSE config-updated event has invalid payload: %s", event.data)
44+
return
45+
46+
logger.debug("SSE config-updated event, fetching new config")
47+
48+
try:
49+
config = realtime.get_config(token)
50+
logger.debug(
51+
"SSE config fetched, configUpdatedAt: %s", config.get("configUpdatedAt")
52+
)
53+
last_updated_at = config.get("configUpdatedAt", config_updated_at)
54+
connection_manager.update_service_config({**config, "success": True})
55+
connection_manager.update_firewall_lists()
56+
except Exception as e:
57+
logger.error("Failed to fetch config after SSE event : %s", e)
58+
59+
connect_to_sse(token=token, on_event=on_event)
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import json
2+
import logging
3+
from types import SimpleNamespace
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
8+
from aikido_zen.helpers.token import Token
9+
from .listen_for_config_updates import listen_for_config_updates
10+
11+
12+
def make_event(event="config-updated", data=None):
13+
return SimpleNamespace(
14+
event=event, data=json.dumps(data) if data is not None else ""
15+
)
16+
17+
18+
@pytest.fixture
19+
def connection_manager():
20+
return MagicMock(
21+
token=Token("123"),
22+
serverless=None,
23+
conf=MagicMock(last_updated_at=0),
24+
)
25+
26+
27+
def test_no_token(caplog):
28+
with patch(
29+
"aikido_zen.background_process.realtime.listen_for_config_updates.connect_to_sse"
30+
) as mock_connect:
31+
listen_for_config_updates(
32+
connection_manager=MagicMock(token=None, serverless=None),
33+
event_scheduler=MagicMock(),
34+
)
35+
36+
assert "No token provided, not listening for config updates" in caplog.text
37+
mock_connect.assert_not_called()
38+
39+
40+
def test_serverless_environment(caplog):
41+
with patch(
42+
"aikido_zen.background_process.realtime.listen_for_config_updates.connect_to_sse"
43+
) as mock_connect:
44+
listen_for_config_updates(
45+
connection_manager=MagicMock(token=Token("123"), serverless=True),
46+
event_scheduler=MagicMock(),
47+
)
48+
49+
assert (
50+
"Running in serverless environment, not listening for config updates"
51+
in caplog.text
52+
)
53+
mock_connect.assert_not_called()
54+
55+
56+
def test_connects_to_sse_with_token(connection_manager):
57+
with patch(
58+
"aikido_zen.background_process.realtime.listen_for_config_updates.connect_to_sse"
59+
) as mock_connect:
60+
listen_for_config_updates(
61+
connection_manager=connection_manager, event_scheduler=MagicMock()
62+
)
63+
64+
mock_connect.assert_called_once()
65+
assert mock_connect.call_args.kwargs["token"] is connection_manager.token
66+
assert callable(mock_connect.call_args.kwargs["on_event"])
67+
68+
69+
def get_on_event(connection_manager):
70+
with patch(
71+
"aikido_zen.background_process.realtime.listen_for_config_updates.connect_to_sse"
72+
) as mock_connect:
73+
listen_for_config_updates(
74+
connection_manager=connection_manager, event_scheduler=MagicMock()
75+
)
76+
return mock_connect.call_args.kwargs["on_event"]
77+
78+
79+
def test_ignores_events_that_are_not_config_updated(connection_manager):
80+
on_event = get_on_event(connection_manager)
81+
82+
with patch("aikido_zen.background_process.realtime.get_config") as mock_get_config:
83+
on_event(make_event(event="ping"))
84+
85+
mock_get_config.assert_not_called()
86+
connection_manager.update_service_config.assert_not_called()
87+
88+
89+
def test_ignores_config_updated_event_with_invalid_json(connection_manager, caplog):
90+
caplog.set_level(logging.DEBUG, logger="Zen")
91+
on_event = get_on_event(connection_manager)
92+
93+
with patch("aikido_zen.background_process.realtime.get_config") as mock_get_config:
94+
on_event(SimpleNamespace(event="config-updated", data="not json"))
95+
96+
mock_get_config.assert_not_called()
97+
connection_manager.update_service_config.assert_not_called()
98+
assert "SSE config-updated event has invalid payload" in caplog.text
99+
100+
101+
def test_ignores_config_updated_event_missing_config_updated_at(connection_manager):
102+
on_event = get_on_event(connection_manager)
103+
104+
with patch("aikido_zen.background_process.realtime.get_config") as mock_get_config:
105+
on_event(make_event(data={"foo": "bar"}))
106+
107+
mock_get_config.assert_not_called()
108+
connection_manager.update_service_config.assert_not_called()
109+
110+
111+
def test_ignores_config_updated_event_that_is_not_newer(connection_manager):
112+
connection_manager.conf.last_updated_at = 100
113+
on_event = get_on_event(connection_manager)
114+
115+
with patch("aikido_zen.background_process.realtime.get_config") as mock_get_config:
116+
on_event(make_event(data={"configUpdatedAt": 100}))
117+
118+
mock_get_config.assert_not_called()
119+
connection_manager.update_service_config.assert_not_called()
120+
121+
122+
def test_fetches_and_applies_new_config_on_newer_event(connection_manager):
123+
connection_manager.conf.last_updated_at = 100
124+
on_event = get_on_event(connection_manager)
125+
126+
new_config = {"endpoints": [], "configUpdatedAt": 200}
127+
with patch(
128+
"aikido_zen.background_process.realtime.get_config", return_value=new_config
129+
) as mock_get_config:
130+
on_event(make_event(data={"configUpdatedAt": 200}))
131+
132+
mock_get_config.assert_called_once_with(connection_manager.token)
133+
connection_manager.update_service_config.assert_called_once_with(
134+
{**new_config, "success": True}
135+
)
136+
connection_manager.update_firewall_lists.assert_called_once()
137+
138+
139+
def test_updates_last_updated_at_so_a_second_stale_event_is_ignored(
140+
connection_manager,
141+
):
142+
connection_manager.conf.last_updated_at = 100
143+
on_event = get_on_event(connection_manager)
144+
145+
new_config = {"endpoints": [], "configUpdatedAt": 200}
146+
with patch(
147+
"aikido_zen.background_process.realtime.get_config", return_value=new_config
148+
) as mock_get_config:
149+
on_event(make_event(data={"configUpdatedAt": 200}))
150+
# A second event claiming to be newer than the original lastUpdatedAt
151+
# (100), but not newer than what we just fetched (200), should now be
152+
# ignored without fetching again.
153+
on_event(make_event(data={"configUpdatedAt": 150}))
154+
155+
mock_get_config.assert_called_once()
156+
157+
158+
def test_handles_get_config_failure_gracefully(connection_manager, caplog):
159+
connection_manager.conf.last_updated_at = 100
160+
on_event = get_on_event(connection_manager)
161+
162+
with patch(
163+
"aikido_zen.background_process.realtime.get_config",
164+
side_effect=ValueError("Request timed out"),
165+
):
166+
on_event(make_event(data={"configUpdatedAt": 200}))
167+
168+
connection_manager.update_service_config.assert_not_called()
169+
assert "Failed to fetch config after SSE event" in caplog.text
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""
2+
SSE (Server-Sent Events) client that connects to the realtime endpoint and
3+
dispatches events, automatically reconnecting with exponential backoff and
4+
jitter on disconnects.
5+
"""
6+
7+
import http.client
8+
import random
9+
import socket
10+
import threading
11+
import time
12+
import urllib.error
13+
import urllib.request
14+
15+
import aikido_zen.background_process.realtime as realtime
16+
from aikido_zen.helpers.logging import logger
17+
from .parser import SSEParser
18+
19+
INITIAL_RECONNECT_SECS = 5
20+
MAX_RECONNECT_SECS = 60
21+
STABLE_CONNECTION_SECS = 30
22+
READ_TIMEOUT_SECS = 70
23+
24+
_CONNECTION_ERRORS = (urllib.error.URLError, OSError, http.client.HTTPException)
25+
26+
27+
def connect_to_sse(
28+
token,
29+
on_event,
30+
initial_reconnect_secs=INITIAL_RECONNECT_SECS,
31+
read_timeout_secs=READ_TIMEOUT_SECS,
32+
):
33+
"""
34+
Starts a daemon thread that connects to the realtime SSE endpoint and calls
35+
`on_event(event)` for every event received. Reconnects with exponential
36+
backoff (and jitter) on disconnect, until the server rejects the connection
37+
with a 401 or 403 status, at which point it stops.
38+
39+
Returns the thread that was started.
40+
"""
41+
thread = threading.Thread(
42+
target=_reconnect_loop,
43+
args=(token, on_event, initial_reconnect_secs, read_timeout_secs),
44+
daemon=True,
45+
)
46+
thread.start()
47+
return thread
48+
49+
50+
def _reconnect_loop(token, on_event, initial_reconnect_secs, read_timeout_secs):
51+
reconnect_secs = initial_reconnect_secs
52+
try:
53+
while True:
54+
start = time.monotonic()
55+
outcome, status_code = _connect(token, on_event, read_timeout_secs)
56+
57+
if outcome == "disconnected" and status_code in (401, 403):
58+
logger.info(
59+
"SSE connection rejected with status %s, stopping", status_code
60+
)
61+
return
62+
63+
if time.monotonic() - start >= STABLE_CONNECTION_SECS:
64+
reconnect_secs = initial_reconnect_secs
65+
66+
jitter = random.random() * (reconnect_secs / 2)
67+
delay_secs = reconnect_secs + jitter
68+
69+
logger.debug("SSE scheduling reconnect in %sms", round(delay_secs * 1000))
70+
71+
reconnect_secs = min(reconnect_secs * 2, MAX_RECONNECT_SECS)
72+
73+
time.sleep(delay_secs)
74+
except Exception as e:
75+
logger.error("SSE loop error : %s", e)
76+
77+
78+
def _connect(token, on_event, read_timeout_secs):
79+
"""
80+
Opens a single SSE connection and dispatches events until the connection is
81+
closed, errors out, or goes idle for longer than `read_timeout_secs`.
82+
83+
Returns a tuple of (outcome, status_code), where outcome is either "error"
84+
(the connection could not be made, or timed out) or "disconnected" (a
85+
response was received, but the connection has since ended).
86+
"""
87+
url = f"{realtime.get_realtime_url()}api/runtime/stream"
88+
logger.debug("SSE connecting to %s", url)
89+
90+
request = urllib.request.Request(
91+
url,
92+
method="GET",
93+
headers={
94+
"Authorization": str(token),
95+
"Accept": "text/event-stream",
96+
"Cache-Control": "no-cache",
97+
},
98+
)
99+
100+
try:
101+
with urllib.request.urlopen(request, timeout=read_timeout_secs) as response:
102+
status_code = response.getcode()
103+
if status_code != 200:
104+
return "disconnected", status_code
105+
106+
logger.debug("SSE connected successfully")
107+
108+
parser = SSEParser(response)
109+
try:
110+
for event in parser.events():
111+
logger.debug("SSE received event : %s", event)
112+
on_event(event)
113+
except socket.timeout:
114+
logger.debug("SSE read timeout")
115+
return "error", None
116+
except _CONNECTION_ERRORS as e:
117+
logger.debug("SSE stream error : %s", e)
118+
return "disconnected", status_code
119+
except urllib.error.HTTPError as e:
120+
logger.debug("SSE connection rejected with status %s", e.code)
121+
return "disconnected", e.code
122+
except _CONNECTION_ERRORS as e:
123+
logger.debug("SSE connection error : %s", e)
124+
return "error", None
125+
126+
logger.debug("SSE connection closed by server")
127+
return "disconnected", status_code

0 commit comments

Comments
 (0)