-
Notifications
You must be signed in to change notification settings - Fork 6
Add realtime config updates via SSE #326
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
63 changes: 63 additions & 0 deletions
63
agent_api/src/main/java/dev/aikido/agent_api/background/RealtimeSSETask.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| package dev.aikido.agent_api.background; | ||
|
|
||
| import com.google.gson.JsonObject; | ||
| import com.google.gson.JsonParser; | ||
| import dev.aikido.agent_api.background.cloud.RealtimeSSEAPI; | ||
| import dev.aikido.agent_api.background.cloud.SSEParser; | ||
| import dev.aikido.agent_api.background.cloud.api.APIResponse; | ||
| import dev.aikido.agent_api.background.cloud.api.ReportingApi; | ||
| import dev.aikido.agent_api.background.cloud.api.ReportingApiHTTP; | ||
| import dev.aikido.agent_api.helpers.logging.LogManager; | ||
| import dev.aikido.agent_api.helpers.logging.Logger; | ||
| import dev.aikido.agent_api.storage.ServiceConfigStore; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| public class RealtimeSSETask extends Thread { | ||
| private static final Logger logger = LogManager.getLogger(RealtimeSSETask.class); | ||
| private final RealtimeSSEAPI realtimeSSEApi; | ||
| private final ReportingApiHTTP reportingApi; | ||
| private Optional<Long> configLastUpdatedAt; | ||
|
|
||
| public RealtimeSSETask(RealtimeSSEAPI realtimeSSEApi, ReportingApiHTTP reportingApi) { | ||
| super("RealtimeSSETask"); | ||
| this.realtimeSSEApi = realtimeSSEApi; | ||
| this.reportingApi = reportingApi; | ||
| this.configLastUpdatedAt = Optional.empty(); | ||
| setDaemon(true); | ||
| } | ||
|
|
||
| @Override | ||
| public void run() { | ||
| realtimeSSEApi.listen(this::onEvent); | ||
| } | ||
|
|
||
| public void onEvent(SSEParser.Event event) { | ||
| logger.trace("SSE event received: %s", event.event()); | ||
| if (!"config-updated".equals(event.event())) { | ||
| return; | ||
| } | ||
|
|
||
| long configUpdatedAt; | ||
| try { | ||
| JsonObject payload = JsonParser.parseString(event.data()).getAsJsonObject(); | ||
| configUpdatedAt = payload.get("configUpdatedAt").getAsLong(); | ||
| } catch (RuntimeException e) { | ||
| logger.debug("SSE config-updated event has invalid payload: %s", event.data()); | ||
| return; | ||
| } | ||
|
|
||
| if (configLastUpdatedAt.isPresent() && configUpdatedAt <= configLastUpdatedAt.get()) { | ||
| return; | ||
| } | ||
| configLastUpdatedAt = Optional.of(configUpdatedAt); | ||
|
|
||
| Optional<APIResponse> newConfig = reportingApi.fetchNewConfig(); | ||
| newConfig.ifPresent(ServiceConfigStore::updateFromAPIResponse); | ||
|
|
||
| Optional<ReportingApi.APIListsResponse> blockedListsRes = reportingApi.fetchBlockedLists(); | ||
| blockedListsRes.ifPresent(ServiceConfigStore::updateFromAPIListsResponse); | ||
|
|
||
| logger.debug("Config updated via SSE"); | ||
| } | ||
| } |
129 changes: 129 additions & 0 deletions
129
agent_api/src/main/java/dev/aikido/agent_api/background/cloud/RealtimeSSEAPI.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| package dev.aikido.agent_api.background.cloud; | ||
|
|
||
| import dev.aikido.agent_api.Config; | ||
| import dev.aikido.agent_api.helpers.env.Token; | ||
| import dev.aikido.agent_api.helpers.logging.LogManager; | ||
| import dev.aikido.agent_api.helpers.logging.Logger; | ||
|
|
||
| import java.io.BufferedReader; | ||
| import java.io.IOException; | ||
| import java.io.InputStreamReader; | ||
| import java.net.HttpURLConnection; | ||
| import java.net.URI; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.Optional; | ||
| import java.util.function.Consumer; | ||
|
|
||
| import static dev.aikido.agent_api.helpers.env.Endpoints.getAikidoAPIEndpoint; | ||
|
|
||
| public class RealtimeSSEAPI { | ||
| private static final Logger logger = LogManager.getLogger(RealtimeSSEAPI.class); | ||
| private static final int DEFAULT_INITIAL_RECONNECT_MS = 5_000; | ||
| private static final int DEFAULT_MAX_RECONNECT_MS = 60_000; | ||
| private static final int DEFAULT_STABLE_CONNECTION_MS = 30_000; | ||
| private static final int DEFAULT_READ_TIMEOUT_MS = 70_000; | ||
| private static final int CONNECT_TIMEOUT_MS = 10_000; | ||
|
|
||
| private final Token token; | ||
| private final String realtimeEndpoint; | ||
| private final int initialReconnectMs; | ||
| private final int maxReconnectMs; | ||
| private final int stableConnectionMs; | ||
| private final int readTimeoutMs; | ||
|
|
||
| public RealtimeSSEAPI(Token token) { | ||
| this(token, getAikidoAPIEndpoint(token), DEFAULT_INITIAL_RECONNECT_MS, DEFAULT_MAX_RECONNECT_MS, DEFAULT_STABLE_CONNECTION_MS, DEFAULT_READ_TIMEOUT_MS); | ||
| } | ||
|
|
||
| public RealtimeSSEAPI(Token token, String realtimeEndpoint, int initialReconnectMs, int maxReconnectMs, int stableConnectionMs, int readTimeoutMs) { | ||
| this.token = token; | ||
| this.realtimeEndpoint = realtimeEndpoint; | ||
| this.initialReconnectMs = initialReconnectMs; | ||
| this.maxReconnectMs = maxReconnectMs; | ||
| this.stableConnectionMs = stableConnectionMs; | ||
| this.readTimeoutMs = readTimeoutMs; | ||
| } | ||
|
|
||
| private enum Outcome { ERROR, DISCONNECTED, REJECTED } | ||
|
|
||
| private record ConnectResult(Outcome outcome, int statusCode) {} | ||
|
|
||
| public void listen(Consumer<SSEParser.Event> onEvent) { | ||
| int reconnectMs = initialReconnectMs; | ||
| while (!Thread.currentThread().isInterrupted()) { | ||
| long start = System.currentTimeMillis(); | ||
| ConnectResult result = connect(onEvent); | ||
|
|
||
| if (result.outcome() == Outcome.REJECTED) { | ||
| logger.info("SSE connection rejected with status %s, stopping", result.statusCode()); | ||
| return; | ||
| } | ||
|
|
||
| if (System.currentTimeMillis() - start >= stableConnectionMs) { | ||
| reconnectMs = initialReconnectMs; | ||
| } | ||
|
|
||
| long jitter = (long) (Math.random() * (reconnectMs / 2.0)); | ||
| long delayMs = reconnectMs + jitter; | ||
| reconnectMs = Math.min(reconnectMs * 2, maxReconnectMs); | ||
|
|
||
| logger.trace("SSE scheduling reconnect in %sms", delayMs); | ||
| try { | ||
| Thread.sleep(delayMs); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| return; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private ConnectResult connect(Consumer<SSEParser.Event> onEvent) { | ||
| logger.trace("SSE connecting to realtime endpoint"); | ||
| HttpURLConnection connection; | ||
| try { | ||
| URI uri = URI.create(realtimeEndpoint + "api/runtime/stream"); | ||
| connection = (HttpURLConnection) uri.toURL().openConnection(); | ||
| connection.setRequestMethod("GET"); | ||
| connection.setRequestProperty("Authorization", token.get()); | ||
| connection.setRequestProperty("Accept", "text/event-stream"); | ||
| connection.setRequestProperty("Cache-Control", "no-cache"); | ||
| connection.setRequestProperty("X-Agent-Platform", "java"); | ||
| connection.setRequestProperty("X-Agent-Version", Config.pkgVersion); | ||
| connection.setConnectTimeout(CONNECT_TIMEOUT_MS); | ||
| connection.setReadTimeout(readTimeoutMs); | ||
| } catch (IOException e) { | ||
| logger.debug("SSE connection error: %s", e.getMessage()); | ||
| return new ConnectResult(Outcome.ERROR, -1); | ||
| } | ||
|
|
||
| try { | ||
| int statusCode = connection.getResponseCode(); | ||
| if (statusCode != 200) { | ||
| if (statusCode == 401 || statusCode == 403) { | ||
| return new ConnectResult(Outcome.REJECTED, statusCode); | ||
| } | ||
| return new ConnectResult(Outcome.DISCONNECTED, statusCode); | ||
| } | ||
|
|
||
| logger.debug("SSE connected successfully"); | ||
| try (BufferedReader reader = new BufferedReader( | ||
| new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) { | ||
| SSEParser parser = new SSEParser(reader); | ||
| Optional<SSEParser.Event> event; | ||
| while ((event = parser.nextEvent()).isPresent()) { | ||
| onEvent.accept(event.get()); | ||
| } | ||
| } | ||
| logger.debug("SSE connection closed by server"); | ||
| return new ConnectResult(Outcome.DISCONNECTED, statusCode); | ||
| } catch (IOException e) { | ||
| logger.debug("SSE stream error: %s", e.getMessage()); | ||
| return new ConnectResult(Outcome.ERROR, -1); | ||
| } catch (RuntimeException e) { | ||
| logger.debug("SSE parser or callback error: %s", e.getMessage()); | ||
| return new ConnectResult(Outcome.ERROR, -1); | ||
| } finally { | ||
| connection.disconnect(); | ||
| } | ||
| } | ||
| } | ||
59 changes: 59 additions & 0 deletions
59
agent_api/src/main/java/dev/aikido/agent_api/background/cloud/SSEParser.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| package dev.aikido.agent_api.background.cloud; | ||
|
|
||
| import java.io.BufferedReader; | ||
| import java.io.IOException; | ||
| import java.util.Optional; | ||
|
|
||
| public class SSEParser { | ||
| private final BufferedReader reader; | ||
|
|
||
| public SSEParser(BufferedReader reader) { | ||
| this.reader = reader; | ||
| } | ||
|
|
||
| public record Event(String event, String data) {} | ||
|
|
||
| public Optional<Event> nextEvent() throws IOException { | ||
| String eventType = ""; | ||
| StringBuilder data = new StringBuilder(); | ||
| boolean hasData = false; | ||
|
|
||
| String line; | ||
| while ((line = reader.readLine()) != null) { | ||
| if (line.isEmpty()) { | ||
| if (hasData) { | ||
| if (data.charAt(data.length() - 1) == '\n') { | ||
| data.setLength(data.length() - 1); | ||
| } | ||
| return Optional.of(new Event(eventType, data.toString())); | ||
| } | ||
| eventType = ""; | ||
| data.setLength(0); | ||
| continue; | ||
| } | ||
| if (line.startsWith(":")) { | ||
| continue; | ||
| } | ||
|
|
||
| int sep = line.indexOf(':'); | ||
| String field = sep == -1 ? line : line.substring(0, sep); | ||
| String value; | ||
| if (sep == -1) { | ||
| value = ""; | ||
| } else { | ||
| value = line.substring(sep + 1); | ||
| if (value.startsWith(" ")) { | ||
| value = value.substring(1); | ||
| } | ||
| } | ||
|
|
||
| if (field.equals("event")) { | ||
| eventType = value; | ||
| } else if (field.equals("data")) { | ||
| data.append(value).append("\n"); | ||
| hasData = true; | ||
| } | ||
| } | ||
| return Optional.empty(); | ||
| } | ||
| } |
9 changes: 9 additions & 0 deletions
9
agent_api/src/main/java/dev/aikido/agent_api/helpers/env/FeatureFlags.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package dev.aikido.agent_api.helpers.env; | ||
|
|
||
| public enum FeatureFlags { | ||
| AIKIDO_FEATURE_SSE; | ||
|
|
||
| public boolean isEnabled() { | ||
| return new BooleanEnv(name(), false).getValue(); | ||
| } | ||
| } |
84 changes: 84 additions & 0 deletions
84
agent_api/src/test/java/background/RealtimeSSETaskTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package background; | ||
|
|
||
| import dev.aikido.agent_api.background.RealtimeSSETask; | ||
| import dev.aikido.agent_api.background.cloud.RealtimeSSEAPI; | ||
| import dev.aikido.agent_api.background.cloud.SSEParser; | ||
| import dev.aikido.agent_api.background.cloud.api.APIResponse; | ||
| import dev.aikido.agent_api.background.cloud.api.ReportingApi; | ||
| import dev.aikido.agent_api.background.cloud.api.ReportingApiHTTP; | ||
| import dev.aikido.agent_api.helpers.env.Token; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import static org.mockito.Mockito.*; | ||
|
|
||
| public class RealtimeSSETaskTest { | ||
| private ReportingApiHTTP reportingApi; | ||
| private RealtimeSSETask task; | ||
|
|
||
| @BeforeEach | ||
| public void setUp() { | ||
| reportingApi = mock(ReportingApiHTTP.class); | ||
| task = new RealtimeSSETask(new RealtimeSSEAPI(new Token("token")), reportingApi); | ||
| } | ||
|
|
||
| private APIResponse sampleConfig(long configUpdatedAt) { | ||
| return new APIResponse(true, null, configUpdatedAt, List.of(), List.of(), List.of(), false, List.of(), true, true, List.of()); | ||
| } | ||
|
|
||
| @Test | ||
| public void testIgnoresEventsThatAreNotConfigUpdated() { | ||
| task.onEvent(new SSEParser.Event("ping", "")); | ||
|
|
||
| verify(reportingApi, never()).fetchNewConfig(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testIgnoresInvalidJsonPayload() { | ||
| task.onEvent(new SSEParser.Event("config-updated", "not json")); | ||
|
|
||
| verify(reportingApi, never()).fetchNewConfig(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testIgnoresPayloadMissingConfigUpdatedAt() { | ||
| task.onEvent(new SSEParser.Event("config-updated", "{\"foo\":\"bar\"}")); | ||
|
|
||
| verify(reportingApi, never()).fetchNewConfig(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testFetchesAndAppliesNewConfigOnNewerEvent() { | ||
| when(reportingApi.fetchNewConfig()).thenReturn(Optional.of(sampleConfig(200))); | ||
| when(reportingApi.fetchBlockedLists()).thenReturn(Optional.empty()); | ||
|
|
||
| task.onEvent(new SSEParser.Event("config-updated", "{\"configUpdatedAt\":200}")); | ||
|
|
||
| verify(reportingApi, times(1)).fetchNewConfig(); | ||
| verify(reportingApi, times(1)).fetchBlockedLists(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testIgnoresEventThatIsNotNewer() { | ||
| when(reportingApi.fetchNewConfig()).thenReturn(Optional.of(sampleConfig(200))); | ||
| when(reportingApi.fetchBlockedLists()).thenReturn(Optional.empty()); | ||
|
|
||
| task.onEvent(new SSEParser.Event("config-updated", "{\"configUpdatedAt\":200}")); | ||
| task.onEvent(new SSEParser.Event("config-updated", "{\"configUpdatedAt\":150}")); | ||
|
|
||
| verify(reportingApi, times(1)).fetchNewConfig(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testHandlesFetchFailureGracefully() { | ||
| when(reportingApi.fetchNewConfig()).thenReturn(Optional.empty()); | ||
| when(reportingApi.fetchBlockedLists()).thenReturn(Optional.empty()); | ||
|
|
||
| task.onEvent(new SSEParser.Event("config-updated", "{\"configUpdatedAt\":200}")); | ||
|
|
||
| verify(reportingApi, times(1)).fetchNewConfig(); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.