-
Notifications
You must be signed in to change notification settings - Fork 0
Add identity-first Flex ingress and loop telemetry #5
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "version": "2.0", | ||
| "logging": { | ||
| "applicationInsights": { | ||
| "samplingSettings": { | ||
| "isEnabled": true, | ||
| "excludedTypes": "Request" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| azure-functions>=1.23.0 | ||
| cas-reference-product |
| 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 |
| 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') | ||
| } | ||
| } |
| 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' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For the FC1 Flex Consumption plan declared just above, 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 | ||
| 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 |
| 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 |
| 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=(",", ":")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this Python function app is published,
submit_workflowdepends 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 theMicrosoft.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 👍 / 👎.