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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ jobs:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5730b14e8b0bc39f62ade545785cb7e6f44b97c # v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

- name: Build image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b4b5 # v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: linux/amd64
Expand Down Expand Up @@ -70,7 +70,7 @@ jobs:

- name: Push to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b4b5 # v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: linux/amd64
Expand Down
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@ and principal IDs remain deployment-orchestration outputs and are not applicatio
- CAS correlation IDs are attached to workflow spans and canonical events preserve W3C trace context.
- Broad Azure SDK and outbound HTTP auto-instrumentation is disabled to avoid capturing prompt or
output content. The application records only explicit boundary spans and safe identifiers.
# Loop execution boundaries

The public HTTP ingress is an Azure Functions Flex Consumption Linux app. It validates the canonical prompt envelope and returns `202` only after handing the message to Azure Queue Storage. It never invokes Foundry, executes tools, or runs sandbox work in the request process. A separately scaled worker owns those long-running operations.

The ingress uses a system-assigned managed identity. Bicep grants Storage Queue Data Contributor on the workload storage account and Azure AI User on the specific Foundry project. No account keys, API keys, or connection-string credentials are materialized. Local development continues to use `DefaultAzureCredential`; Azure environments use `ManagedIdentityCredential`.

Loop spans use W3C trace context and the fixed stages `control_plane`, `worker`, `tool`, `verifier`, and `foundry`. Attributes are limited to correlation, goal, and work-item identifiers; prompts and model output are not telemetry attributes.
10 changes: 10 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,13 @@ Build a Linux AMD64 image and pass its immutable image reference to the `contain
The Docker build context excludes local `.env` files and development artifacts. The application does
not consume platform resource IDs; deployment orchestration retains those outputs for RBAC and
operations workflows.
# Flex ingress validation

Build the infrastructure locally before any reviewed deployment:

```powershell
az bicep build --file infra/main.bicep
az deployment group what-if --resource-group <resource-group> --template-file infra/main.bicep --parameters foundryProjectResourceId=<resource-id>
```

Deployment is intentionally not performed by repository validation. A rollout must publish the function package separately, verify the system-assigned principal's project-scoped and storage-scoped role assignments, submit one synthetic envelope, and confirm a queue message and correlated Application Insights spans. Rollback disables ingress or restores the prior function package; storage and identity resources are retained to avoid destructive replacement.
24 changes: 24 additions & 0 deletions functions/ingress/function_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import azure.functions as func

from cas_reference_product.ingress import InvalidIngressRequest, create_worker_message

app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)


@app.route(route="workflows", methods=["POST"])
@app.queue_output(
arg_name="worker_message",
queue_name="%WORK_QUEUE_NAME%",
connection="WORK_QUEUE_STORAGE",
)
def submit_workflow(
request: func.HttpRequest,
worker_message: func.Out[str],
) -> func.HttpResponse:
try:
message = create_worker_message(request.get_body())
except InvalidIngressRequest:
return func.HttpResponse("Invalid request.", status_code=400)

worker_message.set(message)
return func.HttpResponse(status_code=202)
11 changes: 11 additions & 0 deletions functions/ingress/host.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "2.0",
Comment on lines +1 to +2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Declare the Storage Queues extension bundle

When this Python function app is published, submit_workflow depends on @app.queue_output, but this host.json doesn't load an extension bundle. For Python/non-.NET Functions apps, the Storage Queues binding is provided by the Microsoft.Azure.Functions.ExtensionBundle; without it the host can't load/index the queue output binding, so valid workflow submissions won't enqueue work in Azure or Core Tools.

Useful? React with 👍 / 👎.

"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
}
}
2 changes: 2 additions & 0 deletions functions/ingress/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
azure-functions>=1.23.0
cas-reference-product
70 changes: 70 additions & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
targetScope = 'resourceGroup'

@description('Short lowercase application name used in resource names.')
param applicationName string = 'casloop'

@description('Deployment environment name.')
@allowed(['dev', 'test', 'staging', 'prod'])
param environment string = 'dev'

@description('Azure region for all regional resources.')
param location string = resourceGroup().location

@description('Foundry project resource ID receiving the agent caller role assignment.')
param foundryProjectResourceId string

var suffix = uniqueString(resourceGroup().id, applicationName, environment)
var foundrySegments = split(foundryProjectResourceId, '/')
var tags = {
application: applicationName
environment: environment
managedBy: 'bicep'
dataClassification: 'internal'
}

module observability 'modules/observability.bicep' = {
name: 'observability'
params: {
name: '${applicationName}-${environment}'
location: location
tags: tags
}
}

module storage 'modules/storage.bicep' = {
name: 'storage'
params: {
name: take('st${applicationName}${environment}${suffix}', 24)
location: location
tags: tags
}
}

module ingress 'modules/function-ingress.bicep' = {
name: 'function-ingress'
params: {
name: '${applicationName}-${environment}-${suffix}'
location: location
storageAccountName: storage.outputs.name
storageAccountId: storage.outputs.id
queueName: storage.outputs.queueName
applicationInsightsConnectionString: observability.outputs.connectionString
tags: tags
}
}

module foundryRbac 'modules/foundry-rbac.bicep' = {
name: 'foundry-rbac'
scope: resourceGroup(foundrySegments[2], foundrySegments[4])
params: {
accountName: foundrySegments[8]
projectName: foundrySegments[10]
principalId: ingress.outputs.principalId
}
}

@description('System-assigned principal used by ingress for queue and Foundry access.')
output ingressPrincipalId string = ingress.outputs.principalId

@description('Ingress function resource ID.')
output ingressResourceId string = ingress.outputs.id
24 changes: 24 additions & 0 deletions infra/modules/foundry-rbac.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
targetScope = 'resourceGroup'

param accountName string
param projectName string
param principalId string

resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = {
name: accountName
}

resource foundryProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = {
parent: foundryAccount
name: projectName
}

resource foundryUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(foundryProject.id, principalId, 'foundry-project-user')
scope: foundryProject
properties: {
principalId: principalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d')
}
}
57 changes: 57 additions & 0 deletions infra/modules/function-ingress.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
param name string
param location string
param storageAccountName string
param storageAccountId string
param queueName string
param applicationInsightsConnectionString string
param tags object

resource plan 'Microsoft.Web/serverfarms@2024-04-01' = {
name: 'plan-${name}'
location: location
tags: tags
kind: 'functionapp'
sku: { name: 'FC1', tier: 'FlexConsumption' }
properties: { reserved: true }
}

resource functionApp 'Microsoft.Web/sites@2024-04-01' = {
name: 'func-${name}'
location: location
tags: tags
kind: 'functionapp,linux'
identity: { type: 'SystemAssigned' }
properties: {
serverFarmId: plan.id
httpsOnly: true
publicNetworkAccess: 'Enabled'
siteConfig: {
linuxFxVersion: 'Python|3.12'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use functionAppConfig for the Flex runtime

For the FC1 Flex Consumption plan declared just above, siteConfig.linuxFxVersion is the legacy Linux stack setting and isn't valid for Flex apps; Flex takes the language runtime from properties.functionAppConfig.runtime and package storage from functionAppConfig.deployment. When infra/main.bicep is deployed, ARM rejects this function app or leaves it without the required runtime/deployment configuration, so the new ingress cannot be created or published reliably.

Useful? React with 👍 / 👎.

minTlsVersion: '1.2'
ftpsState: 'Disabled'
appSettings: [
{ name: 'FUNCTIONS_WORKER_RUNTIME', value: 'python' }
{ name: 'WORK_QUEUE_NAME', value: queueName }
{ name: 'WORK_QUEUE_STORAGE__queueServiceUri', value: 'https://${storageAccountName}.queue.${environment().suffixes.storage}' }
{ name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: applicationInsightsConnectionString }
]
}
}
}

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = {
name: storageAccountName
}

resource queueContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storageAccountId, functionApp.id, 'queue-contributor')
scope: storageAccount
properties: {
principalId: functionApp.identity.principalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88')
}
}

output id string = functionApp.id
output principalId string = functionApp.identity.principalId
27 changes: 27 additions & 0 deletions infra/modules/observability.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
param name string
param location string
param tags object

resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
name: 'log-${name}'
location: location
tags: tags
properties: {
retentionInDays: 30
features: { enableLogAccessUsingOnlyResourcePermissions: true }
}
}

resource insights 'Microsoft.Insights/components@2020-02-02' = {
name: 'appi-${name}'
location: location
tags: tags
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: workspace.id
IngestionMode: 'LogAnalytics'
}
}

output connectionString string = insights.properties.ConnectionString
32 changes: 32 additions & 0 deletions infra/modules/storage.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
@description('Globally unique storage account name.')
param name string
param location string
param tags object

resource account 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: name
location: location
tags: tags
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
properties: {
allowBlobPublicAccess: false
allowSharedKeyAccess: false
minimumTlsVersion: 'TLS1_2'
publicNetworkAccess: 'Enabled'
}
}

resource queueService 'Microsoft.Storage/storageAccounts/queueServices@2023-05-01' = {
parent: account
name: 'default'
}

resource workQueue 'Microsoft.Storage/storageAccounts/queueServices/queues@2023-05-01' = {
parent: queueService
name: 'cas-work-items'
}

output id string = account.id
output name string = account.name
output queueName string = workQueue.name
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ readme = "README.md"
requires-python = ">=3.12,<3.15"
license = { text = "MIT" }
dependencies = [
"azure-functions>=1.23.0",
"azure-ai-projects>=1.0.0",
"azure-identity>=1.19.0",
"azure-monitor-opentelemetry>=1.6.0",
Expand Down
15 changes: 12 additions & 3 deletions scripts/validate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ $ErrorActionPreference = 'Stop'

function Invoke-Checked {
param([scriptblock]$Command)
$global:LASTEXITCODE = 0
& $Command
if ($LASTEXITCODE -ne 0) {
throw "Command failed with exit code $LASTEXITCODE"
if ($null -ne $global:LASTEXITCODE -and $global:LASTEXITCODE -ne 0) {
throw "Command failed with exit code $global:LASTEXITCODE"
}
}

Expand All @@ -17,4 +18,12 @@ Invoke-Checked { & .\.venv\Scripts\python.exe -m ruff check . }
Invoke-Checked { & .\.venv\Scripts\python.exe -m mypy }
Invoke-Checked { & .\.venv\Scripts\python.exe -m pytest }
Invoke-Checked { & .\.venv\Scripts\python.exe -m cas_reference_product.evidence }
Invoke-Checked { git -c safe.directory="$PWD" diff --check }
$git = Get-Command git -ErrorAction SilentlyContinue
if ($null -eq $git -and (Test-Path 'C:\Program Files\Git\cmd\git.exe')) {
$git = Get-Item 'C:\Program Files\Git\cmd\git.exe'
}
if ($null -eq $git) {
throw 'Git executable was not found.'
}
$gitPath = if ($git.Source) { $git.Source } else { $git.FullName }
Invoke-Checked { & $gitPath -c safe.directory="$PWD" diff --check }
18 changes: 18 additions & 0 deletions src/cas_reference_product/ingress.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import json

from pydantic import ValidationError

from .models import PromptEnvelope


class InvalidIngressRequest(ValueError):
"""The ingress payload does not satisfy the canonical prompt envelope."""


def create_worker_message(payload: bytes) -> str:
try:
envelope = PromptEnvelope.model_validate_json(payload)
except (ValidationError, ValueError) as error:
raise InvalidIngressRequest("invalid CAS prompt envelope") from error

return json.dumps(envelope.model_dump(mode="json"), separators=(",", ":"))
29 changes: 29 additions & 0 deletions src/cas_reference_product/telemetry.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from collections.abc import Awaitable, Callable
from contextlib import AbstractContextManager
from enum import StrEnum
from typing import Any

from opentelemetry import context, propagate, trace
from opentelemetry.propagators.composite import CompositePropagator
Expand All @@ -13,6 +16,32 @@
tracer = trace.get_tracer(__name__)


class LoopStage(StrEnum):
CONTROL_PLANE = "control_plane"
WORKER = "worker"
TOOL = "tool"
VERIFIER = "verifier"
FOUNDRY = "foundry"


def start_loop_span(
stage: LoopStage,
correlation_id: str,
*,
goal_id: str | None = None,
work_item_id: str | None = None,
) -> AbstractContextManager[Any]:
attributes = {
"cas.stage": stage.value,
"cas.correlation_id": correlation_id,
}
if goal_id is not None:
attributes["cas.goal_id"] = goal_id
if work_item_id is not None:
attributes["cas.work_item_id"] = work_item_id
return tracer.start_as_current_span(f"cas.loop.{stage.value}", attributes=attributes)


# ---------------------------------------------------------------------------
# W3C trace-context propagation middleware
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading