Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 33 additions & 14 deletions api/src/learn_to_cloud/services/durable_verification_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from typing import Any

import httpx
from azure.core.exceptions import AzureError
from learn_to_cloud_shared.core.azure_auth import get_token as get_azure_token
from learn_to_cloud_shared.core.config import get_web_settings
from learn_to_cloud_shared.verification_job_executor import PreparedVerificationJob

Expand All @@ -22,6 +24,10 @@ class DurableVerificationStatusError(Exception):
"""Raised when Durable status cannot be fetched or parsed."""


class DurableVerificationAuthError(Exception):
"""Raised when a verification Function access token cannot be acquired."""


@dataclass(frozen=True, slots=True)
class DurableStartResult:
instance_id: str
Expand All @@ -48,16 +54,12 @@ async def start_verification_orchestration(
*definition* and ``github_username`` snapshot.
"""
settings = get_web_settings()
base_url = settings.verification_functions.base_url.rstrip("/")
function_key = settings.verification_functions.key
base_url, token_scope = _verification_endpoint_config(settings)

if not base_url or not function_key:
raise DurableVerificationConfigError(
"Verification Functions endpoint is not configured."
)
token = await _get_verification_token(token_scope)

url = f"{base_url}/api/verification/jobs/{prepared.id}/start"
headers = {"x-functions-key": function_key}
headers = {"Authorization": f"Bearer {token}"}
body: dict[str, Any] = prepared.to_payload()

timeout = settings.http.external_api_timeout
Expand Down Expand Up @@ -93,16 +95,12 @@ async def get_verification_orchestration_status(
) -> DurableStatusResult:
"""Fetch Durable orchestration status through the Function app proxy."""
settings = get_web_settings()
base_url = settings.verification_functions.base_url.rstrip("/")
function_key = settings.verification_functions.key
base_url, token_scope = _verification_endpoint_config(settings)

if not base_url or not function_key:
raise DurableVerificationConfigError(
"Verification Functions endpoint is not configured."
)
token = await _get_verification_token(token_scope)

url = f"{base_url}/api/verification/jobs/{instance_id}/status"
headers = {"x-functions-key": function_key}
headers = {"Authorization": f"Bearer {token}"}

timeout = settings.http.external_api_timeout
try:
Expand Down Expand Up @@ -134,3 +132,24 @@ async def get_verification_orchestration_status(
output=payload.get("output"),
custom_status=payload.get("customStatus"),
)


def _verification_endpoint_config(settings: Any) -> tuple[str, str]:
base_url = settings.verification_functions.base_url.rstrip("/")
token_scope = settings.verification_functions.token_scope

if not base_url or not token_scope:
raise DurableVerificationConfigError(
"Verification Functions endpoint is not configured."
)

return base_url, token_scope


async def _get_verification_token(token_scope: str) -> str:
try:
return await get_azure_token(token_scope)
except AzureError as exc:
raise DurableVerificationAuthError(
"Verification Functions access token could not be acquired."
) from exc
64 changes: 57 additions & 7 deletions api/tests/services/test_durable_verification_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

import httpx
import pytest
from azure.core.exceptions import ClientAuthenticationError
from learn_to_cloud_shared.testing.requirement_factories import (
github_profile_requirement,
)
from learn_to_cloud_shared.verification_job_executor import PreparedVerificationJob

from learn_to_cloud.services.durable_verification_client import (
DurableVerificationAuthError,
DurableVerificationConfigError,
DurableVerificationStartError,
DurableVerificationStatusError,
Expand All @@ -25,10 +27,13 @@
def _settings(
*,
base_url: str = "http://localhost:7071/",
key: str = "function-key",
token_scope: str = "api://verification-functions/.default",
) -> SimpleNamespace:
return SimpleNamespace(
verification_functions=SimpleNamespace(base_url=base_url, key=key),
verification_functions=SimpleNamespace(
base_url=base_url,
token_scope=token_scope,
),
http=SimpleNamespace(external_api_timeout=3.0),
)

Expand Down Expand Up @@ -58,7 +63,7 @@ def _async_client(response: httpx.Response | Exception):
return client, context_manager


async def test_starts_orchestration_with_function_key_header_and_payload():
async def test_starts_orchestration_with_bearer_token_header_and_payload():
prepared = _prepared()
client, context_manager = _async_client(httpx.Response(202, json={"id": "abc"}))

Expand All @@ -67,6 +72,10 @@ async def test_starts_orchestration_with_function_key_header_and_payload():
"learn_to_cloud.services.durable_verification_client.get_web_settings",
return_value=_settings(),
),
patch(
"learn_to_cloud.services.durable_verification_client.get_azure_token",
new=AsyncMock(return_value="access-token"),
),
patch(
"learn_to_cloud.services.durable_verification_client.httpx.AsyncClient",
return_value=context_manager,
Expand All @@ -78,7 +87,7 @@ async def test_starts_orchestration_with_function_key_header_and_payload():
async_client.assert_called_once_with(timeout=3.0)
client.post.assert_awaited_once_with(
f"http://localhost:7071/api/verification/jobs/{prepared.id}/start",
headers={"x-functions-key": "function-key"},
headers={"Authorization": "Bearer access-token"},
json=prepared.to_payload(),
)

Expand All @@ -87,7 +96,7 @@ async def test_missing_config_fails_before_http_call():
with (
patch(
"learn_to_cloud.services.durable_verification_client.get_web_settings",
return_value=_settings(base_url="", key=""),
return_value=_settings(base_url="", token_scope=""),
),
patch(
"learn_to_cloud.services.durable_verification_client.httpx.AsyncClient"
Expand All @@ -99,6 +108,31 @@ async def test_missing_config_fails_before_http_call():
async_client.assert_not_called()


async def test_token_error_fails_before_http_call():
with (
patch(
"learn_to_cloud.services.durable_verification_client.get_web_settings",
return_value=_settings(),
),
patch(
"learn_to_cloud.services.durable_verification_client.get_azure_token",
new=AsyncMock(
side_effect=ClientAuthenticationError("identity unavailable")
),
),
patch(
"learn_to_cloud.services.durable_verification_client.httpx.AsyncClient"
) as async_client,
pytest.raises(
DurableVerificationAuthError,
match="token could not be acquired",
),
):
await start_verification_orchestration(_prepared())

async_client.assert_not_called()


async def test_http_error_status_raises_start_error():
_, context_manager = _async_client(httpx.Response(500, json={"error": "boom"}))

Expand All @@ -107,6 +141,10 @@ async def test_http_error_status_raises_start_error():
"learn_to_cloud.services.durable_verification_client.get_web_settings",
return_value=_settings(),
),
patch(
"learn_to_cloud.services.durable_verification_client.get_azure_token",
new=AsyncMock(return_value="access-token"),
),
patch(
"learn_to_cloud.services.durable_verification_client.httpx.AsyncClient",
return_value=context_manager,
Expand All @@ -125,6 +163,10 @@ async def test_transport_error_raises_start_error():
"learn_to_cloud.services.durable_verification_client.get_web_settings",
return_value=_settings(),
),
patch(
"learn_to_cloud.services.durable_verification_client.get_azure_token",
new=AsyncMock(return_value="access-token"),
),
patch(
"learn_to_cloud.services.durable_verification_client.httpx.AsyncClient",
return_value=context_manager,
Expand All @@ -134,7 +176,7 @@ async def test_transport_error_raises_start_error():
await start_verification_orchestration(_prepared())


async def test_gets_orchestration_status_with_function_key_header():
async def test_gets_orchestration_status_with_bearer_token_header():
instance_id = str(uuid4())
client, context_manager = _async_client(
httpx.Response(200, json={"runtimeStatus": "Running", "customStatus": None})
Expand All @@ -145,6 +187,10 @@ async def test_gets_orchestration_status_with_function_key_header():
"learn_to_cloud.services.durable_verification_client.get_web_settings",
return_value=_settings(),
),
patch(
"learn_to_cloud.services.durable_verification_client.get_azure_token",
new=AsyncMock(return_value="access-token"),
),
patch(
"learn_to_cloud.services.durable_verification_client.httpx.AsyncClient",
return_value=context_manager,
Expand All @@ -156,7 +202,7 @@ async def test_gets_orchestration_status_with_function_key_header():
async_client.assert_called_once_with(timeout=3.0)
client.get.assert_awaited_once_with(
f"http://localhost:7071/api/verification/jobs/{instance_id}/status",
headers={"x-functions-key": "function-key"},
headers={"Authorization": "Bearer access-token"},
)


Expand All @@ -168,6 +214,10 @@ async def test_status_without_runtime_status_raises_status_error():
"learn_to_cloud.services.durable_verification_client.get_web_settings",
return_value=_settings(),
),
patch(
"learn_to_cloud.services.durable_verification_client.get_azure_token",
new=AsyncMock(return_value="access-token"),
),
patch(
"learn_to_cloud.services.durable_verification_client.httpx.AsyncClient",
return_value=context_manager,
Expand Down
2 changes: 1 addition & 1 deletion apps/verification-functions/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def _configure_function_logging() -> None:

configure_observability()

app = df.DFApp(http_auth_level=func.AuthLevel.FUNCTION)
app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS)

_DEFAULT_ORCHESTRATOR_NAME = "verification_orchestrator"
# Async-only submission types: phase 3+ verifications that involve LLM
Expand Down
25 changes: 25 additions & 0 deletions infra/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 2 additions & 7 deletions infra/container-apps.tf
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,6 @@ resource "azurerm_container_app" "api" {
key_vault_secret_id = "${azurerm_key_vault.main.vault_uri}secrets/labs-verification-secret"
}

secret {
name = "verification-functions-key"
value = data.azurerm_function_app_host_keys.verification.default_function_key
}

ingress {
external_enabled = true
target_port = 8000
Expand Down Expand Up @@ -178,8 +173,8 @@ resource "azurerm_container_app" "api" {
}

env {
name = "VERIFICATION_FUNCTIONS__KEY"
secret_name = "verification-functions-key"
name = "VERIFICATION_FUNCTIONS__TOKEN_SCOPE"
value = local.verification_functions_auth_scope
}

env {
Expand Down
13 changes: 13 additions & 0 deletions infra/entra.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
resource "azuread_application" "verification_functions" {
display_name = local.verification_functions_auth_app_name
identifier_uris = [local.verification_functions_auth_audience]
sign_in_audience = "AzureADMyOrg"

api {
requested_access_token_version = 2
}
}

resource "azuread_service_principal" "verification_functions" {
client_id = azuread_application.verification_functions.client_id
}
Loading