Skip to content

Commit 5b6f02f

Browse files
committed
pipeline fail and give insight when pyrit initialization fails
1 parent 3f729ac commit 5b6f02f

10 files changed

Lines changed: 334 additions & 12 deletions

File tree

‎infra/README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ Community users can deploy `infrastructure.bicep` followed by `application.bicep
102102

103103
## Security
104104

105+
The deployment-only `/api/ready` endpoint is also a public, compatibility-neutral exception. It exposes only readiness, runtime state, and the serving revision; initialization exception details remain in access-controlled logs.
106+
105107
- **Authentication**: [MSAL](https://learn.microsoft.com/en-us/entra/msal/) [PKCE](https://oauth.net/2/pkce/) on the frontend (`@azure/msal-browser`) and public-client device-code authentication for the PyRIT CLI, backed by Microsoft Graph middleware on the backend. Both clients send delegated Graph tokens, and the backend authenticates them through Graph `/me`. These public-client flows require no client secrets or certificates.
106108
- **Authorization**: Entra group checks use `allowedGroupObjectIds` for application access and `adminGroupObjectId` for backend configuration routes. Requires delegated Graph `User.Read`; the backend calls `/me/checkMemberGroups` and compares the returned transitive memberships with the configured group IDs. Each security group must also be assigned to the enterprise app (see Prerequisites §3). Authenticated deployments require at least one allowed group and fail to start without one. `/api/health`, `/api/auth/config`, and `/api/media` are intentional public exceptions; other `/api` routes require authentication when auth is enabled. Successful identity and membership results are cached in-process for 60 seconds, keyed by a SHA-256 token digest, to reduce Graph latency and throttling. Bearer tokens themselves are not stored in the cache.
107109
- **Identity**: `deploy_instance.py` creates its user-assigned managed identity (UAMI) and grants AcrPull and Storage Blob Data Contributor before deploying Bicep. A direct Bicep deployment can create `<appName>-identity`, but the template creates no role assignments, so its first revision can remain unhealthy until required roles are granted and the revision is restarted. A healthy one-pass direct deployment uses an existing, pre-authorized UAMI. `AZURE_CLIENT_ID` is set to the UAMI's client ID so `DefaultAzureCredential` selects the correct identity.
@@ -441,7 +443,11 @@ The scripts share scope validation, preview, and readiness helpers in `deploymen
441443

442444
**App-only is not image-only:** it reconciles the image and Bicep-defined application configuration, including environment variables, identity attachment, ingress, and scaling. It requires the same variable groups and existing topology as infrastructure-enabled deployment. Shared infrastructure resources are referenced rather than redeployed, and the app-only preview rejects writes outside the existing Container App. The existing ACA environment public/private access mode and Front Door resources are preserved.
443445

444-
The script verifies the exact requested revision and its access mode: direct ACA `/api/health` when public access is enabled, or Front Door `/api/health` when public access is disabled. It never falls back from a failed private path to public access. A public-mode success is **not** certification of Private Link readiness. App-only data-plane verification has a five-minute budget after revision readiness.
446+
The script verifies the exact requested image and its access mode: direct ACA `/api/health` when public access is enabled, or Front Door `/api/health` when public access is disabled. It never falls back from a failed private path to public access. A public-mode success is **not** certification of Private Link readiness. App-only liveness verification has a five-minute budget after ACA revision readiness.
447+
448+
The app stage then checks `/api/ready` through that same entry point, with a separate five-minute budget. This minimal public endpoint returns only runtime readiness, state, and the serving ACA revision, with caching disabled. Deployment passes only on HTTP 200 with `ready: true` from the expected revision. The script waits for initialization, transient connection failures, or an older serving revision, but fails immediately if the expected revision reports `failed`, `restart-required`, or `stopping`. Invalid responses and timeouts fail deployment rather than being accepted as healthy. Infrastructure-only stages continue to use `/api/health` and do not require runtime readiness.
449+
450+
On readiness failure, the pipeline prints the revision, the failure state or timeout reason, and a revision-specific `az containerapp logs show` command. Use that command or **Azure Portal > Container App > Monitoring > Log stream**, selecting the failed revision and console logs, to find `PyRIT startup failed` and its traceback. For historical startup errors, use **Monitoring > Logs** in the connected Log Analytics workspace. Exception details remain in access-controlled logs, not the public readiness endpoint or pipeline output. `/api/health` and configuration recovery remain available when runtime initialization fails; repair the underlying source, permissions, or data, then restart the backend and retry deployment.
445451

446452
App deployment does not downgrade or create a database; application startup still follows the image's normal migration behavior. App-stage failures do not invoke infrastructure, image, or database rollback because migrations may make the previous image incompatible. The previous image digest is logged for an explicit recovery decision.
447453

‎infra/pipelines/deploy_app.sh‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ main() {
118118
--name "$deployment_name" --resource-group "$PYRIT_DEPLOYMENT_RESOURCE_GROUP" \
119119
--template-file "$template_file" --mode Incremental --parameters "${parameters[@]}"
120120
verify_readiness 300 "$expected_public_access" "$immutable_image"
121+
wait_for_runtime_readiness "${health_url%/api/health}/api/ready" "$revision" 300
121122
echo "Deployment healthy: $revision; verified $health_url; ACA public access: $expected_public_access; egress IPv4: $egress_ip"
122123
}
123124

‎infra/pipelines/deployment_common.sh‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,71 @@ wait_for_http_health() {
211211
fi
212212
}
213213

214+
runtime_readiness_error() {
215+
local expected_revision=$1 reason=$2
216+
printf 'Inspect startup logs: az containerapp logs show --name %q --resource-group %q --revision %q --type console --tail 100\n' \
217+
"$PYRIT_APP_NAME" "$PYRIT_DEPLOYMENT_RESOURCE_GROUP" "$expected_revision"
218+
echo "Or use Azure Portal -> Container App $PYRIT_APP_NAME -> Monitoring -> Log stream; select revision $expected_revision and console logs."
219+
echo "For older startup errors, use Monitoring -> Logs in the connected Log Analytics workspace."
220+
echo "Look for 'PyRIT startup failed' and its traceback. Initialization errors remain in access-controlled container logs."
221+
deployment_error "PyRIT runtime readiness failed for revision $expected_revision: $reason"
222+
}
223+
224+
wait_for_runtime_readiness() {
225+
local readiness_url=$1 expected_revision=$2 timeout_seconds=$3
226+
local deadline=$((SECONDS + timeout_seconds)) attempt=0
227+
local remaining_seconds request_timeout sleep_seconds raw_response http_status body parsed
228+
local response_revision state ready last_result="No readiness response"
229+
while ((SECONDS < deadline)); do
230+
((attempt += 1))
231+
remaining_seconds=$((deadline - SECONDS))
232+
request_timeout=$((remaining_seconds < 30 ? remaining_seconds : 30))
233+
if raw_response=$(curl \
234+
--silent --show-error --header 'Cache-Control: no-cache' --write-out $'\n%{http_code}' \
235+
--max-time "$request_timeout" "$readiness_url"); then
236+
http_status=${raw_response##*$'\n'}
237+
body=${raw_response%$'\n'*}
238+
if [[ "$http_status" != "200" && "$http_status" != "503" ]]; then
239+
last_result="Unexpected readiness HTTP status $http_status"
240+
elif ! parsed=$(jq -ser '
241+
if length != 1 then error("Invalid readiness response") else .[0] end
242+
| if type != "object"
243+
or (.ready | type) != "boolean"
244+
or (.state | type) != "string"
245+
or (.revision | type) != "string"
246+
then error("Invalid readiness response")
247+
elif (.revision | test("^[a-z][a-z0-9-]*--[a-z0-9-]+$") | not)
248+
or (.state as $state | ["ready", "initializing", "failed", "restart-required", "stopping"] | index($state) == null)
249+
or .ready != (.state == "ready")
250+
then error("Inconsistent readiness response")
251+
else [.revision, .state, (.ready | tostring)] | @tsv
252+
end' <<< "$body" 2> /dev/null); then
253+
last_result="Invalid or inconsistent readiness response"
254+
else
255+
IFS=$'\t' read -r response_revision state ready <<< "$parsed"
256+
if [[ "$response_revision" != "$expected_revision" ]]; then
257+
last_result="Waiting for revision $expected_revision; received $response_revision"
258+
elif [[ "$http_status" == "200" && "$state" == "ready" && "$ready" == "true" ]]; then
259+
echo "PyRIT runtime is ready on revision $expected_revision."
260+
return 0
261+
elif [[ "$http_status" == "503" && "$state" =~ ^(failed|restart-required|stopping)$ ]]; then
262+
runtime_readiness_error "$expected_revision" "Runtime state is $state"
263+
else
264+
last_result="Runtime state is $state (HTTP $http_status)"
265+
fi
266+
fi
267+
else
268+
last_result="Readiness endpoint connection failed"
269+
fi
270+
echo "PyRIT runtime readiness attempt $attempt: $last_result"
271+
remaining_seconds=$((deadline - SECONDS))
272+
((remaining_seconds > 0)) || break
273+
sleep_seconds=$((remaining_seconds < 5 ? remaining_seconds : 5))
274+
sleep "$sleep_seconds"
275+
done
276+
runtime_readiness_error "$expected_revision" "Timed out after ${timeout_seconds}s. $last_result"
277+
}
278+
214279
verify_readiness() {
215280
local timeout_seconds=$1 expected_public_access=$2 expected_image=$3 unchanged_revision=${4:-}
216281
local public_network_access

‎pyrit/backend/middleware/auth.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ class EntraAuthMiddleware(BaseHTTPMiddleware):
8080
# Paths that bypass authentication
8181
_PUBLIC_PATHS: ClassVar[set[str]] = {
8282
"/api/health",
83+
"/api/ready",
8384
"/api/auth/config",
8485
"/api/media",
8586
}

‎pyrit/backend/middleware/compatibility.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class CompatibilityMiddleware:
1818
"""Enforce one exact packaged compatibility identity on business API requests."""
1919

2020
_NEUTRAL_PATHS: ClassVar[frozenset[str]] = frozenset(
21-
{"/api/health", "/api/auth/config", "/api/version", "/api/media"}
21+
{"/api/health", "/api/ready", "/api/auth/config", "/api/version", "/api/media"}
2222
)
2323

2424
def __init__(self, app: ASGIApp) -> None:

‎pyrit/backend/middleware/runtime.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
3333
or path
3434
in (
3535
"/api/health",
36+
"/api/ready",
3637
"/api/runtime",
3738
"/api/version",
3839
"/api/auth/config",

‎pyrit/backend/routes/health.py‎

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,25 @@
55
Health check endpoints.
66
"""
77

8+
import os
89
from datetime import UTC, datetime
910

10-
from fastapi import APIRouter, Request
11+
from fastapi import APIRouter, Request, Response
1112

1213
router = APIRouter()
1314

1415

16+
def _runtime_readiness(request: Request) -> dict[str, str | bool]:
17+
runtime = getattr(request.app.state, "runtime_lifecycle", None)
18+
if runtime is None:
19+
return {"ready": False, "state": "failed", "generation": ""}
20+
return {
21+
"ready": runtime.reported_state == "ready",
22+
"state": runtime.reported_state,
23+
"generation": runtime.generation,
24+
}
25+
26+
1527
@router.get("/runtime")
1628
async def runtime_readiness_async(request: Request) -> dict[str, str | bool]:
1729
"""
@@ -20,13 +32,24 @@ async def runtime_readiness_async(request: Request) -> dict[str, str | bool]:
2032
Returns:
2133
dict[str, str | bool]: Lightweight runtime readiness.
2234
"""
23-
runtime = getattr(request.app.state, "runtime_lifecycle", None)
24-
if runtime is None:
25-
return {"ready": False, "state": "failed", "generation": ""}
35+
return _runtime_readiness(request)
36+
37+
38+
@router.get("/ready", responses={503: {"description": "PyRIT runtime is not ready"}})
39+
async def deployment_readiness_async(*, request: Request, response: Response) -> dict[str, str | bool | None]:
40+
"""
41+
Report uncached deployment readiness without configuration or exception details.
42+
43+
Returns:
44+
dict[str, str | bool | None]: Runtime readiness and the serving ACA revision, when available.
45+
"""
46+
readiness = _runtime_readiness(request)
47+
response.status_code = 200 if readiness["ready"] else 503
48+
response.headers["Cache-Control"] = "no-store"
2649
return {
27-
"ready": runtime.reported_state == "ready",
28-
"state": runtime.reported_state,
29-
"generation": runtime.generation,
50+
"ready": readiness["ready"],
51+
"state": readiness["state"],
52+
"revision": os.getenv("CONTAINER_APP_REVISION"),
3053
}
3154

3255

‎tests/unit/backend/test_compatibility.py‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def graph_user() -> AuthenticatedUser:
8080

8181
def test_openapi_documents_the_required_header_only_on_business_operations() -> None:
8282
schema = app.openapi()
83-
neutral_paths = {"/api/health", "/api/auth/config", "/api/version", "/api/media"}
83+
neutral_paths = {"/api/health", "/api/ready", "/api/auth/config", "/api/version", "/api/media"}
8484
business_operations = set()
8585
for path, path_item in schema["paths"].items():
8686
for method, operation in path_item.items():
@@ -230,13 +230,13 @@ def test_matching_identity_does_not_bypass_group_authorization(
230230
assert guarded_app.state.effects == []
231231

232232

233-
@pytest.mark.parametrize("path", ["/api/health", "/api/auth/config", "/api/media"])
233+
@pytest.mark.parametrize("path", ["/api/health", "/api/ready", "/api/auth/config", "/api/media"])
234234
@pytest.mark.parametrize("marker", [None, "invalid", "0.14.0+g" + "b" * 40])
235235
def test_public_neutral_routes_bypass_guard_and_auth(guarded_client: TestClient, path: str, marker: str | None) -> None:
236236
headers = {_compatibility.COMPATIBILITY_HEADER: marker} if marker is not None else {}
237237
with patch.object(EntraAuthMiddleware, "_authenticate_request_async", new_callable=AsyncMock) as authenticate:
238238
response = guarded_client.get(path, headers=headers)
239-
assert response.status_code == (422 if path == "/api/media" else 200)
239+
assert response.status_code == {"/api/media": 422, "/api/ready": 503}.get(path, 200)
240240
authenticate.assert_not_awaited()
241241

242242

@@ -246,6 +246,8 @@ def test_public_neutral_routes_bypass_guard_and_auth(guarded_client: TestClient,
246246
"/api",
247247
"/api/health/",
248248
"/api/health/extra",
249+
"/api/ready/",
250+
"/api/ready/extra",
249251
"/api/auth/config/",
250252
"/api/version/",
251253
"/api/media/",
@@ -258,6 +260,29 @@ def test_neutral_exemptions_are_exact(guarded_client: TestClient, path: str) ->
258260
assert response.json()["type"] == _compatibility.INVALID_COMPATIBILITY_TYPE
259261

260262

263+
@pytest.mark.parametrize("state", ["ready", "initializing", "failed", "restart-required", "stopping"])
264+
def test_deployment_readiness_is_public_without_exposing_runtime_details(
265+
*, guarded_app: FastAPI, state: str, tmp_path: Path
266+
) -> None:
267+
runtime = RuntimeLifecycle(
268+
app=guarded_app, source=ConfigurationFileService(config_file_value=str(tmp_path / "config.yaml"))
269+
)
270+
runtime.state = state
271+
runtime.message = "private failure details"
272+
runtime.generation = "private generation"
273+
guarded_app.state.runtime_lifecycle = runtime
274+
with (
275+
patch.dict("os.environ", {"CONTAINER_APP_REVISION": "copyrit-test--new"}),
276+
patch.object(EntraAuthMiddleware, "_authenticate_request_async", new_callable=AsyncMock) as authenticate,
277+
TestClient(guarded_app) as client,
278+
):
279+
response = client.get("/api/ready", headers={_compatibility.COMPATIBILITY_HEADER: "invalid"})
280+
assert response.status_code == (200 if state == "ready" else 503)
281+
assert response.json() == {"ready": state == "ready", "state": state, "revision": "copyrit-test--new"}
282+
assert response.headers["cache-control"] == "no-store"
283+
authenticate.assert_not_awaited()
284+
285+
261286
@pytest.mark.parametrize("path", ["/", "/assets/app.js", "/apikeys", "/docs"])
262287
def test_non_api_routes_are_not_guarded(guarded_client: TestClient, path: str) -> None:
263288
response = guarded_client.get(path)

‎tests/unit/backend/test_runtime_lifecycle.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,51 @@ async def test_runtime_readiness_reports_state_async(*, runtime: RuntimeLifecycl
9494
}
9595

9696

97+
@pytest.mark.parametrize("state", ["initializing", "ready", "failed", "restart-required", "stopping", None])
98+
async def test_deployment_readiness_reports_state_and_revision_async(
99+
*, runtime: RuntimeLifecycle, state: str | None
100+
) -> None:
101+
if state is None:
102+
del runtime.app.state.runtime_lifecycle
103+
else:
104+
runtime.state = state
105+
revision = "copyrit-test--new"
106+
with patch.dict(os.environ, {"CONTAINER_APP_REVISION": revision}):
107+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
108+
response = await client.get("/api/ready")
109+
liveness = await client.get("/api/health")
110+
assert response.status_code == (200 if state == "ready" else 503)
111+
assert response.headers["cache-control"] == "no-store"
112+
assert response.json() == {
113+
"ready": state == "ready",
114+
"state": state if state is not None else "failed",
115+
"revision": revision,
116+
}
117+
assert liveness.status_code == 200
118+
119+
120+
async def test_deployment_readiness_does_not_invent_a_revision_async(runtime: RuntimeLifecycle) -> None:
121+
with patch.dict(os.environ, {}, clear=True):
122+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
123+
response = await client.get("/api/ready")
124+
assert response.status_code == 200
125+
assert response.json()["revision"] is None
126+
127+
128+
async def test_failed_startup_keeps_liveness_and_configuration_recovery_async(runtime: RuntimeLifecycle) -> None:
129+
with patch.object(runtime, "_load_async", AsyncMock(side_effect=ValueError("private startup error"))):
130+
await runtime.startup_async()
131+
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
132+
response = await client.get("/api/ready")
133+
liveness = await client.get("/api/health")
134+
saved_configuration = await client.get("/api/config")
135+
assert response.status_code == 503
136+
assert response.json()["state"] == "restart-required"
137+
assert "private startup error" not in response.text
138+
assert liveness.status_code == 200
139+
assert saved_configuration.status_code == 200
140+
141+
97142
@pytest.mark.parametrize(
98143
"failures", [(), ("scenarios",), ("services",), ("memory",), ("scenarios", "services", "memory")]
99144
)

0 commit comments

Comments
 (0)