Skip to content
Open
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
13 changes: 12 additions & 1 deletion run-samples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ SAMPLES=(
"samples/web-app-custom-image/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh"
"samples/aci-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh"
"samples/container-apps-blob-storage/python|bash scripts/deploy.sh|bash scripts/validate.sh"
"samples/container-apps-blob-storage/python|MANAGED_IDENTITY_TYPE=SystemAssigned bash scripts/deploy.sh|MANAGED_IDENTITY_TYPE=SystemAssigned bash scripts/validate.sh"
"samples/container-apps-blob-storage/python|MANAGED_IDENTITY_TYPE=UserAssigned bash scripts/deploy.sh|MANAGED_IDENTITY_TYPE=UserAssigned bash scripts/validate.sh"
"samples/url-shortener/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-web-app.sh"
)

Expand All @@ -79,6 +81,8 @@ TERRAFORM_SAMPLES=(
"samples/web-app-app-configuration/dotnet/terraform|bash deploy.sh"
"samples/aci-blob-storage/python/terraform|bash deploy.sh"
"samples/container-apps-blob-storage/python/terraform|bash deploy.sh"
"samples/container-apps-blob-storage/python/terraform|MANAGED_IDENTITY_TYPE=SystemAssigned bash deploy.sh"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW | L84, L111 | Terraform UserAssigned and Bicep SystemAssigned never run in CI

Description: CI covers four of the six managed-identity deployments, and the two it skips are the ones the PR description lists as not yet verified on real Azure. Each has wiring of its own (the counted identity and AZURE_CLIENT_ID env in Terraform, the post-app role assignment in Bicep), so a provider or emulator change can break it while every job stays green. Registering both adds two entries (four jobs of about 4 minutes).

Rules applied: General best practice: changed behaviour needs coverage, and run-samples.sh is the only entry point CI drives.

Also add this entry after L111:

  "samples/container-apps-blob-storage/python/bicep|MANAGED_IDENTITY_TYPE=SystemAssigned bash deploy.sh"
Suggested change
"samples/container-apps-blob-storage/python/terraform|MANAGED_IDENTITY_TYPE=SystemAssigned bash deploy.sh"
"samples/container-apps-blob-storage/python/terraform|MANAGED_IDENTITY_TYPE=SystemAssigned bash deploy.sh"
"samples/container-apps-blob-storage/python/terraform|MANAGED_IDENTITY_TYPE=UserAssigned bash deploy.sh"

"samples/container-apps-blob-storage/python/terraform|MANAGED_IDENTITY_TYPE=UserAssigned bash deploy.sh"
"samples/url-shortener/python/terraform|bash deploy.sh|bash ../scripts/validate.sh"
)

Expand All @@ -105,6 +109,8 @@ BICEP_SAMPLES=(
"samples/web-app-app-configuration/dotnet/bicep|bash deploy.sh"
"samples/aci-blob-storage/python/bicep|bash deploy.sh"
"samples/container-apps-blob-storage/python/bicep|bash deploy.sh"
"samples/container-apps-blob-storage/python/bicep|MANAGED_IDENTITY_TYPE=SystemAssigned bash deploy.sh"
"samples/container-apps-blob-storage/python/bicep|MANAGED_IDENTITY_TYPE=UserAssigned bash deploy.sh"
"samples/url-shortener/python/bicep|bash deploy.sh|bash ../scripts/validate.sh"
)

Expand Down Expand Up @@ -196,7 +202,7 @@ host_arch() {
if [[ "${1:-}" == "--list" ]]; then
echo "["
for (( i=0; i<TOTAL; i++ )); do
IFS='|' read -r path _ _ <<< "${ALL_SAMPLES[$i]}"
IFS='|' read -r path deploy _ <<< "${ALL_SAMPLES[$i]}"

if [[ "$path" == */terraform || "$path" == */bicep ]]; then
# An IaC sample deploys from its own folder, but its test command may run the
Expand All @@ -212,6 +218,11 @@ if [[ "${1:-}" == "--list" ]]; then
name="${path#samples/}/scripts"
fi

# A sample registered more than once differs by the VAR=value its deploy command starts with.
if [[ "$deploy" =~ ^([A-Z_]+=[^ ]+)\ ]]; then
name="$name (${BASH_REMATCH[1]})"
fi

folders=$(printf '"%s",' "${watch[@]}")

if supports_arm64 "$path"; then
Expand Down
1 change: 0 additions & 1 deletion samples/aci-blob-storage/python/bicep/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ IMAGE_TAG="v1"
echo "=================================================="
echo "DEBUG: Starting bicep deployment for aci-blob-storage"
echo "DEBUG: Resource Group: $RESOURCE_GROUP_NAME"
echo "DEBUG: Environment: $ENVIRONMENT"
echo "=================================================="

# Change the current directory to the script's directory
Expand Down
15 changes: 1 addition & 14 deletions samples/aci-blob-storage/python/scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,21 +107,9 @@ else
exit 1
fi

# Build the connection string using the original blob endpoint (resolvable from the host).
STORAGE_CONN_STRING="DefaultEndpointsProtocol=http;AccountName=${STORAGE_ACCOUNT_NAME};AccountKey=${STORAGE_ACCOUNT_KEY};BlobEndpoint=${BLOB_ENDPOINT}"
echo "Connection string built successfully."

# For LocalStack, the ACI emulator configures containers with LocalStack's DNS
# server, so *.localhost.localstack.cloud resolves to the LocalStack container.
# We only need to downgrade HTTPS to HTTP (containers don't have the LS TLS cert).
if [[ $ENVIRONMENT == "LocalStack" ]]; then
CONTAINER_BLOB_ENDPOINT="${BLOB_ENDPOINT/https:\/\//http:\/\/}"
CONTAINER_CONN_STRING="DefaultEndpointsProtocol=http;AccountName=${STORAGE_ACCOUNT_NAME};AccountKey=${STORAGE_ACCOUNT_KEY};BlobEndpoint=${CONTAINER_BLOB_ENDPOINT}"
echo "Container blob endpoint: $CONTAINER_BLOB_ENDPOINT"
else
CONTAINER_CONN_STRING="$STORAGE_CONN_STRING"
fi

# =============================================================================
# Step 5: Create Blob Container
# =============================================================================
Expand Down Expand Up @@ -174,11 +162,10 @@ echo ""
echo "============================================================"
echo "Step 7: Storing storage connection string in Key Vault..."
echo "============================================================"
# Store the container-friendly connection string so ACI can reach LocalStack
az keyvault secret set \
--vault-name "$KEY_VAULT_NAME" \
--name "storage-conn" \
--value "$CONTAINER_CONN_STRING" \
--value "$STORAGE_CONN_STRING" \
--only-show-errors 1>/dev/null

if [ $? -eq 0 ]; then
Expand Down
15 changes: 14 additions & 1 deletion samples/container-apps-blob-storage/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ flowchart TB
```

- **Deployment flow:** The deploy script creates Storage first, then ACR, builds and pushes the container image, creates a Container Apps managed environment, and finally creates a container app that pulls from ACR. The storage connection string is stored as a Container Apps secret and injected into the container through a `secretref` environment variable.
- **Managed identity (optional):** With `MANAGED_IDENTITY_TYPE=SystemAssigned` or `UserAssigned`, the app gets a managed identity with the `Storage Blob Data Contributor` role on the storage account instead of the connection string secret, and authenticates to Blob Storage with `DefaultAzureCredential`.
- **At runtime:** The Flask app reads the storage connection string from its environment, connects to Blob Storage, and provides a web UI for signing and reading the guestbook. The revision that served each response is shown in the UI, so rolling out a new revision with `az containerapp update` is observable over HTTP.

## Prerequisites
Expand All @@ -56,6 +57,15 @@ bash scripts/deploy.sh
bash scripts/validate.sh
```

### Managed Identity

Every deployment method accepts `MANAGED_IDENTITY_TYPE` (`None` by default, `SystemAssigned` or `UserAssigned`) to have the app reach Blob Storage with a managed identity instead of a connection string:

```bash
MANAGED_IDENTITY_TYPE=UserAssigned bash scripts/deploy.sh
MANAGED_IDENTITY_TYPE=UserAssigned bash scripts/validate.sh
```

## Alternative Deployments

### Bicep
Expand Down Expand Up @@ -90,13 +100,15 @@ The Guestbook is a Flask web application that lets visitors sign a guestbook. En
| `/` | GET | View all guestbook entries |
| `/` | POST | Sign the guestbook |
| `/delete/<id>` | POST | Delete an entry |
| `/health` | GET | Health check (reports the serving revision) |
| `/health` | GET | Health check (reports the serving revision and the storage auth mode) |

### Environment Variables

| Variable | Description |
|----------|-------------|
| `AZURE_STORAGE_CONNECTION_STRING` | Blob Storage connection string (injected via `secretref:storage-conn`) |
| `AZURE_STORAGE_ACCOUNT_URL` | Blob endpoint reached with the managed identity (set instead of the connection string) |
| `AZURE_CLIENT_ID` | Client ID of the user-assigned managed identity (`UserAssigned` only) |
| `BLOB_CONTAINER_NAME` | Name of the blob container for entries |
| `APP_REVISION` | Revision label shown in the UI and `/health` (default: "v1") |

Expand All @@ -117,6 +129,7 @@ The Guestbook is a Flask web application that lets visitors sign a guestbook. En
| Managed environment create | deploy.sh |
| App create from a private registry (ACR) | deploy.sh |
| Secrets (`--secrets` + `secretref:` env var) | deploy.sh |
| System-assigned / user-assigned managed identity + RBAC on Storage | deploy.sh (`MANAGED_IDENTITY_TYPE`) |
| External HTTP ingress + FQDN | deploy.sh |
| Multiple revisions mode + revision suffix | deploy.sh |
| Min / max replicas + HTTP scale rule | deploy.sh |
Expand Down
6 changes: 6 additions & 0 deletions samples/container-apps-blob-storage/python/bicep/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ cd samples/container-apps-blob-storage/python
bash bicep/deploy.sh
```

To have the app reach Blob Storage with a managed identity instead of the connection string secret, set `MANAGED_IDENTITY_TYPE` (`SystemAssigned` or `UserAssigned`), which deploy.sh passes as the `managedIdentityType` parameter. The template then creates the identity (user-assigned only), grants it the `Storage Blob Data Contributor` role on the storage account, and passes the app the blob endpoint, plus `AZURE_CLIENT_ID` for a user-assigned identity:

```bash
MANAGED_IDENTITY_TYPE=UserAssigned bash bicep/deploy.sh
```

## Cleanup

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ SUBSCRIPTION_NAME=$(az account show --query name --output tsv)
CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)"
IMAGE_NAME="guestbook"
IMAGE_TAG="v1"
MANAGED_IDENTITY_TYPE="${MANAGED_IDENTITY_TYPE:-None}"

echo "=================================================="
echo "DEBUG: Starting bicep deployment for container-apps-blob-storage"
echo "DEBUG: Resource Group: $RESOURCE_GROUP_NAME"
echo "DEBUG: Environment: $ENVIRONMENT"
echo "=================================================="

# Change the current directory to the script's directory
Expand Down Expand Up @@ -126,6 +126,7 @@ if [[ $VALIDATE_TEMPLATE == 1 ]]; then
--parameters location=$LOCATION \
prefix=$PREFIX \
suffix=$SUFFIX \
managedIdentityType=$MANAGED_IDENTITY_TYPE \
--only-show-errors

if [[ $? == 0 ]]; then
Expand All @@ -144,6 +145,7 @@ if [[ $VALIDATE_TEMPLATE == 1 ]]; then
--parameters location=$LOCATION \
prefix=$PREFIX \
suffix=$SUFFIX \
managedIdentityType=$MANAGED_IDENTITY_TYPE \
--only-show-errors)

if [[ $? == 0 ]]; then
Expand All @@ -166,6 +168,7 @@ if DEPLOYMENT_OUTPUTS=$(az deployment group create \
--parameters location=$LOCATION \
prefix=$PREFIX \
suffix=$SUFFIX \
managedIdentityType=$MANAGED_IDENTITY_TYPE \
--query 'properties.outputs' -o json); then
echo "Bicep template [$TEMPLATE] deployed successfully. Outputs:"
# Strip any non-JSON prefix (e.g. Bicep CLI messages) before parsing
Expand All @@ -176,12 +179,15 @@ if DEPLOYMENT_OUTPUTS=$(az deployment group create \
ENVIRONMENT_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.environmentName.value')
APP_NAME=$(echo "$DEPLOYMENT_JSON" | jq -r '.appName.value')
FQDN=$(echo "$DEPLOYMENT_JSON" | jq -r '.fqdn.value')
PRINCIPAL_ID=$(echo "$DEPLOYMENT_JSON" | jq -r '.principalId.value')
echo "Deployment details:"
echo "- storageAccountName: $STORAGE_ACCOUNT_NAME"
echo "- acrLoginServer: $ACR_LOGIN_SERVER"
echo "- environmentName: $ENVIRONMENT_NAME"
echo "- appName: $APP_NAME"
echo "- fqdn: $FQDN"
echo "- managedIdentityType: $MANAGED_IDENTITY_TYPE"
echo "- principalId: $PRINCIPAL_ID"
else
echo "Failed to deploy Bicep template [$TEMPLATE]"
exit 1
Expand Down
119 changes: 95 additions & 24 deletions samples/container-apps-blob-storage/python/bicep/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ param maxReplicas int = 3
@description('Specifies the revision suffix of the container app template.')
param revisionSuffix string = 'v1'

@description('Specifies how the app authenticates to Blob Storage: None uses a connection string secret, SystemAssigned or UserAssigned a managed identity.')
@allowed([
'None'
'SystemAssigned'
'UserAssigned'
])
param managedIdentityType string = 'None'

@description('Specifies the tags to be applied to the resources.')
param tags object = {
environment: 'test'
Expand All @@ -56,6 +64,8 @@ var storageAccountName = '${prefix}acastorage${suffix}'
var acrName = '${prefix}acaacr${suffix}'
var environmentName = '${prefix}-aca-env-${suffix}'
var appName = '${prefix}-aca-guestbook-${suffix}'
var managedIdentityName = '${prefix}-aca-identity-${suffix}'
var storageBlobDataContributorRoleId = 'ba92f5b4-2d11-453d-a403-e96b0029c9fe'

// Storage Account
resource storageAccount 'Microsoft.Storage/storageAccounts@2025-01-01' = {
Expand All @@ -81,6 +91,22 @@ resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/container
name: containerName
}

resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = if (managedIdentityType == 'UserAssigned') {
name: managedIdentityName
location: location
tags: tags
}

resource userAssignedRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (managedIdentityType == 'UserAssigned') {
name: guid(storageAccount.id, managedIdentityName, storageBlobDataContributorRoleId)
scope: storageAccount
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataContributorRoleId)
principalId: userAssignedIdentity!.properties.principalId
principalType: 'ServicePrincipal'
}
}

// Container Registry
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
name: acrName
Expand All @@ -107,20 +133,35 @@ resource containerApp 'Microsoft.App/containerApps@2025-07-01' = {
name: appName
location: location
tags: tags
identity: managedIdentityType == 'None'
? null
: {
type: managedIdentityType
userAssignedIdentities: managedIdentityType == 'UserAssigned' ? { '${userAssignedIdentity!.id}': {} } : null
}
dependsOn: [
userAssignedRoleAssignment
]
properties: {
managedEnvironmentId: managedEnvironment.id
configuration: {
activeRevisionsMode: 'Multiple'
secrets: [
{
name: 'storage-conn'
value: 'DefaultEndpointsProtocol=http;AccountName=${storageAccountName};AccountKey=${storageAccount.listKeys().keys[0].value};BlobEndpoint=${storageAccount.properties.primaryEndpoints.blob}'
}
{
name: 'registry-password'
value: containerRegistry.listCredentials().passwords[0].value
}
]
secrets: concat(
managedIdentityType == 'None'
? [
{
name: 'storage-conn'
value: 'DefaultEndpointsProtocol=http;AccountName=${storageAccountName};AccountKey=${storageAccount.listKeys().keys[0].value};BlobEndpoint=${storageAccount.properties.primaryEndpoints.blob}'
}
]
: [],
[
{
name: 'registry-password'
value: containerRegistry.listCredentials().passwords[0].value
}
]
)
registries: [
{
server: containerRegistry.properties.loginServer
Expand Down Expand Up @@ -151,20 +192,39 @@ resource containerApp 'Microsoft.App/containerApps@2025-07-01' = {
cpu: json(containerCpu)
memory: containerMemory
}
env: [
{
name: 'AZURE_STORAGE_CONNECTION_STRING'
secretRef: 'storage-conn'
}
{
name: 'BLOB_CONTAINER_NAME'
value: containerName
}
{
name: 'APP_REVISION'
value: revisionSuffix
}
]
env: concat(
managedIdentityType == 'None'
? [
{
name: 'AZURE_STORAGE_CONNECTION_STRING'
secretRef: 'storage-conn'
}
]
: [
{
name: 'AZURE_STORAGE_ACCOUNT_URL'
value: storageAccount.properties.primaryEndpoints.blob
}
],
managedIdentityType == 'UserAssigned'
? [
{
name: 'AZURE_CLIENT_ID'
value: userAssignedIdentity!.properties.clientId
}
]
: [],
[
{
name: 'BLOB_CONTAINER_NAME'
value: containerName
}
{
name: 'APP_REVISION'
value: revisionSuffix
}
]
)
}
]
scale: {
Expand All @@ -185,10 +245,21 @@ resource containerApp 'Microsoft.App/containerApps@2025-07-01' = {
}
}

resource systemAssignedRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (managedIdentityType == 'SystemAssigned') {
name: guid(storageAccount.id, appName, storageBlobDataContributorRoleId)
scope: storageAccount
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', storageBlobDataContributorRoleId)
principalId: containerApp.identity.principalId
principalType: 'ServicePrincipal'
}
}

output storageAccountName string = storageAccount.name
output acrName string = containerRegistry.name
output acrLoginServer string = containerRegistry.properties.loginServer
output environmentName string = managedEnvironment.name
output appName string = containerApp.name
output fqdn string = containerApp.properties.configuration.ingress.fqdn
output latestRevisionName string = containerApp.properties.latestRevisionName
output principalId string = managedIdentityType == 'None' ? '' : (managedIdentityType == 'SystemAssigned' ? containerApp.identity.principalId : userAssignedIdentity!.properties.principalId)
Loading
Loading