-
Notifications
You must be signed in to change notification settings - Fork 10
Add control plane connectivity control for offline mode #305
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
Show all changes
3 commits
Select commit
Hold shift + click to select a range
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
Empty file.
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,115 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import logging | ||
| from typing import TYPE_CHECKING, Literal | ||
|
|
||
| from fastapi import APIRouter, Depends, HTTPException, status | ||
| from pydantic import BaseModel | ||
|
|
||
| from horizon.authentication import enforce_pdp_token | ||
|
|
||
| if TYPE_CHECKING: | ||
| from opal_client.client import OpalClient | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class ConnectivityStatus(BaseModel): | ||
| control_plane_connectivity_disabled: bool | ||
| offline_mode_enabled: bool | ||
|
|
||
|
|
||
| class ConnectivityActionResult(BaseModel): | ||
| status: Literal[ | ||
| "enabled", | ||
| "disabled", | ||
| "already_enabled", | ||
| "already_disabled", | ||
| ] | ||
|
|
||
|
|
||
| def init_connectivity_router(opal_client: OpalClient): | ||
| router = APIRouter( | ||
| prefix="/control-plane", | ||
| dependencies=[Depends(enforce_pdp_token)], | ||
|
omer9564 marked this conversation as resolved.
omer9564 marked this conversation as resolved.
|
||
| ) | ||
| _lock = asyncio.Lock() | ||
|
|
||
| @router.get( | ||
| "/connectivity", | ||
| status_code=status.HTTP_200_OK, | ||
| response_model=ConnectivityStatus, | ||
| summary="Get control plane connectivity status", | ||
| description="Returns the current connectivity state to the control plane and whether offline mode is enabled.", | ||
| ) | ||
| async def get_connectivity_status(): | ||
| return ConnectivityStatus( | ||
| control_plane_connectivity_disabled=opal_client.opal_server_connectivity_disabled, | ||
| offline_mode_enabled=opal_client.offline_mode_enabled, | ||
| ) | ||
|
|
||
| @router.post( | ||
|
omer9564 marked this conversation as resolved.
|
||
| "/connectivity/enable", | ||
| status_code=status.HTTP_200_OK, | ||
| response_model=ConnectivityActionResult, | ||
| responses={ | ||
| 400: {"description": "Offline mode is not enabled"}, | ||
| 500: {"description": "Failed to enable control plane connectivity"}, | ||
| }, | ||
| summary="Enable control plane connectivity", | ||
| description="Starts the policy and data updaters, reconnecting to the control plane. " | ||
| "Triggers a full rehydration (policy refetch + data refetch). " | ||
| "Requires offline mode to be enabled.", | ||
| ) | ||
| async def enable_connectivity(): | ||
| if not opal_client.offline_mode_enabled: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail="Cannot enable control plane connectivity: offline mode is not enabled", | ||
| ) | ||
|
omer9564 marked this conversation as resolved.
|
||
| async with _lock: | ||
| if not opal_client.opal_server_connectivity_disabled: | ||
| return ConnectivityActionResult(status="already_enabled") | ||
| try: | ||
| await opal_client.enable_opal_server_connectivity() | ||
| except Exception: | ||
| logger.exception("Failed to enable control plane connectivity") | ||
| raise HTTPException( | ||
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
| detail="Failed to enable control plane connectivity", | ||
| ) from None | ||
| return ConnectivityActionResult(status="enabled") | ||
|
|
||
| @router.post( | ||
| "/connectivity/disable", | ||
| status_code=status.HTTP_200_OK, | ||
| response_model=ConnectivityActionResult, | ||
| responses={ | ||
| 400: {"description": "Offline mode is not enabled"}, | ||
| 500: {"description": "Failed to disable control plane connectivity"}, | ||
| }, | ||
| summary="Disable control plane connectivity", | ||
| description="Stops the policy and data updaters, disconnecting from the control plane. " | ||
| "Requires offline mode to be enabled. The policy store continues serving from its current state.", | ||
| ) | ||
| async def disable_connectivity(): | ||
| if not opal_client.offline_mode_enabled: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_400_BAD_REQUEST, | ||
| detail="Cannot disable control plane connectivity: offline mode is not enabled", | ||
| ) | ||
| async with _lock: | ||
| if opal_client.opal_server_connectivity_disabled: | ||
| return ConnectivityActionResult(status="already_disabled") | ||
| try: | ||
| await opal_client.disable_opal_server_connectivity() | ||
| except Exception: | ||
| logger.exception("Failed to disable control plane connectivity") | ||
| raise HTTPException( | ||
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
| detail="Failed to disable control plane connectivity", | ||
| ) from None | ||
| return ConnectivityActionResult(status="disabled") | ||
|
|
||
| return router | ||
|
omer9564 marked this conversation as resolved.
|
||
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
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,119 @@ | ||
| from unittest.mock import AsyncMock, PropertyMock | ||
|
|
||
| from fastapi import FastAPI | ||
| from fastapi.testclient import TestClient | ||
| from horizon.authentication import enforce_pdp_token | ||
| from horizon.connectivity.api import init_connectivity_router | ||
|
|
||
|
|
||
| def _noop_auth(): | ||
| pass | ||
|
|
||
|
|
||
| def _create_test_app(opal_client_mock): | ||
| """Create a test FastAPI app with the connectivity router (no auth).""" | ||
| app = FastAPI() | ||
| router = init_connectivity_router(opal_client_mock) | ||
| app.include_router(router) | ||
| app.dependency_overrides[enforce_pdp_token] = _noop_auth | ||
| return app | ||
|
|
||
|
|
||
| def _make_opal_mock(*, offline_mode_enabled=True, connectivity_disabled=False): | ||
| mock = AsyncMock() | ||
| type(mock).offline_mode_enabled = PropertyMock(return_value=offline_mode_enabled) | ||
| type(mock).opal_server_connectivity_disabled = PropertyMock(return_value=connectivity_disabled) | ||
| return mock | ||
|
|
||
|
|
||
| class TestGetConnectivityStatus: | ||
| def test_returns_status(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=True, connectivity_disabled=True) | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.get("/control-plane/connectivity") | ||
| assert resp.status_code == 200 | ||
| data = resp.json() | ||
| assert data["control_plane_connectivity_disabled"] is True | ||
| assert data["offline_mode_enabled"] is True | ||
|
|
||
| def test_returns_status_when_connected(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=True, connectivity_disabled=False) | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.get("/control-plane/connectivity") | ||
| assert resp.status_code == 200 | ||
| data = resp.json() | ||
| assert data["control_plane_connectivity_disabled"] is False | ||
|
|
||
|
|
||
| class TestEnableConnectivity: | ||
| def test_enable_success(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=True, connectivity_disabled=True) | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.post("/control-plane/connectivity/enable") | ||
| assert resp.status_code == 200 | ||
| assert resp.json()["status"] == "enabled" | ||
| mock.enable_opal_server_connectivity.assert_awaited_once() | ||
|
|
||
| def test_enable_already_enabled(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=True, connectivity_disabled=False) | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.post("/control-plane/connectivity/enable") | ||
| assert resp.status_code == 200 | ||
| assert resp.json()["status"] == "already_enabled" | ||
| mock.enable_opal_server_connectivity.assert_not_awaited() | ||
|
|
||
| def test_enable_returns_400_when_offline_mode_disabled(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=False) | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.post("/control-plane/connectivity/enable") | ||
| assert resp.status_code == 400 | ||
|
|
||
| def test_enable_returns_500_on_opal_error(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=True, connectivity_disabled=True) | ||
| mock.enable_opal_server_connectivity.side_effect = RuntimeError("boom") | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.post("/control-plane/connectivity/enable") | ||
| assert resp.status_code == 500 | ||
| assert "Failed to enable" in resp.json()["detail"] | ||
|
|
||
|
|
||
| class TestDisableConnectivity: | ||
| def test_disable_success(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=True, connectivity_disabled=False) | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.post("/control-plane/connectivity/disable") | ||
| assert resp.status_code == 200 | ||
| assert resp.json()["status"] == "disabled" | ||
| mock.disable_opal_server_connectivity.assert_awaited_once() | ||
|
|
||
| def test_disable_already_disabled(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=True, connectivity_disabled=True) | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.post("/control-plane/connectivity/disable") | ||
| assert resp.status_code == 200 | ||
| assert resp.json()["status"] == "already_disabled" | ||
| mock.disable_opal_server_connectivity.assert_not_awaited() | ||
|
|
||
| def test_disable_returns_400_when_offline_mode_disabled(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=False) | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.post("/control-plane/connectivity/disable") | ||
| assert resp.status_code == 400 | ||
|
|
||
| def test_disable_returns_500_on_opal_error(self): | ||
| mock = _make_opal_mock(offline_mode_enabled=True, connectivity_disabled=False) | ||
| mock.disable_opal_server_connectivity.side_effect = RuntimeError("boom") | ||
| client = TestClient(_create_test_app(mock)) | ||
|
|
||
| resp = client.post("/control-plane/connectivity/disable") | ||
| assert resp.status_code == 500 | ||
| assert "Failed to disable" in resp.json()["detail"] |
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
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.