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
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.027"
VERSION = "0.261.028"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
318 changes: 71 additions & 247 deletions deployers/azure.yaml

Large diffs are not rendered by default.

25 changes: 15 additions & 10 deletions deployers/bicep/cosmosDb-postDeployPerms.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@
#!/usr/bin/env bash
set -euo pipefail

RG_NAME="${var_rgName}"

COSMOS_URI="${var_cosmosDb_uri}"
ACCOUNT_NAME=$(echo "$COSMOS_URI" | sed -E 's#https://([^.]*)\.documents\.azure\.com.*#\1#')
RG_NAME="${var_rgName:?Deployment resource group is required}"
ACCOUNT_NAME="${var_cosmosDb_accountName:?Deployment Cosmos account is required}"
SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID:?Deployment subscription is required}"

echo "==============================="
echo "Cosmos DB Account Name: $ACCOUNT_NAME"

UPN=$(az account show --query user.name -o tsv)
IDENTITY_TYPE=$(az account show --subscription "$SUBSCRIPTION_ID" --query user.type -o tsv)
if [ "$IDENTITY_TYPE" != "user" ]; then
echo "Non-user runner: using preassigned Cosmos data-plane permissions; postconfig will verify access."
exit 0
fi
UPN=$(az account show --subscription "$SUBSCRIPTION_ID" --query user.name -o tsv)
OBJECT_ID=$(az ad signed-in-user show --query id -o tsv)
SUBSCRIPTION_ID=$(az account show --query id -o tsv)

is_mfa_error() {
printf '%s' "$1" | grep -qiE 'AADSTS50076|multi-factor authentication|claims challenge'
Expand All @@ -33,9 +36,8 @@ handle_role_assignment_result() {

if is_mfa_error "$command_output"; then
echo "⚠ Azure CLI requires multi-factor authentication before it can complete $description." >&2
echo " Continuing with Cosmos DB key-based post-deployment configuration." >&2
echo " If you want the signed-in user to keep Cosmos DB access, run 'az login --scope https://management.azure.com//.default' and rerun the deployment later." >&2
return 0
echo " Reauthenticate Azure CLI for the deployment tenant and retry. No key-auth fallback will be attempted." >&2
return 1
fi

echo "✗ ERROR: Failed to complete $description" >&2
Expand All @@ -47,10 +49,11 @@ handle_role_assignment_result() {
SCOPE="/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG_NAME/providers/Microsoft.DocumentDB/databaseAccounts/$ACCOUNT_NAME"

ROLE_NAME="Contributor"
ROLE_ID=$(az role definition list --name "$ROLE_NAME" --query "[0].id" -o tsv)
ROLE_ID=$(az role definition list --subscription "$SUBSCRIPTION_ID" --name "$ROLE_NAME" --query "[0].id" -o tsv)

echo "Assigning role '$ROLE_NAME' to user '$UPN' on scope '$SCOPE'..."
CONTROL_PLANE_OUTPUT=$(az role assignment create \
--subscription "$SUBSCRIPTION_ID" \
--assignee-object-id "$OBJECT_ID" \
--assignee-principal-type "User" \
--role "$ROLE_ID" \
Expand All @@ -60,13 +63,15 @@ CONTROL_PLANE_OUTPUT=$(az role assignment create \
# Data-plane assignment
DP_ROLE_NAME="Cosmos DB Built-in Data Contributor"
DP_ROLE_ID=$(az cosmosdb sql role definition list \
--subscription "$SUBSCRIPTION_ID" \
--account-name "$ACCOUNT_NAME" \
--resource-group "$RG_NAME" \
--query "[?roleName=='$DP_ROLE_NAME'].id | [0]" -o tsv)

echo "Assigning data-plane role '$DP_ROLE_NAME' to user '$UPN' on Cosmos DB account '$ACCOUNT_NAME'..."

DATA_PLANE_OUTPUT=$(az cosmosdb sql role assignment create \
--subscription "$SUBSCRIPTION_ID" \
--account-name "$ACCOUNT_NAME" \
--resource-group "$RG_NAME" \
--scope "/" \
Expand Down
207 changes: 207 additions & 0 deletions deployers/bicep/deployment_configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# deployment_configuration.py
"""Safe post-provision configuration without importing the Flask application."""

import copy
import importlib.util
import io
import json
import os
from pathlib import Path
import shutil
import subprocess

from azure.core.credentials import AzureKeyCredential
from azure.core.exceptions import ClientAuthenticationError, HttpResponseError, ResourceNotFoundError
from azure.search.documents.indexes import SearchIndexClient
from azure.search.documents.indexes.models import SearchIndex
from dotenv import dotenv_values
from redis import Redis
from redis.exceptions import RedisError


APP_DIRECTORY = Path(__file__).resolve().parents[2] / "application" / "single_app"
DEPLOYERS_DIRECTORY = Path(__file__).resolve().parents[1]
REQUIRED_VALUES = (
"AZURE_ENV_NAME", "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID",
"var_authenticationType", "var_cosmosDb_accountName", "var_rgName",
"var_openAIEndpoint", "var_openAIGPTModels", "var_openAIEmbeddingModels",
"var_searchServiceEndpoint", "var_blobStorageEndpoint",
"var_documentIntelligenceServiceEndpoint",
)
REDIS_FIELDS = (
"enable_redis_cache", "redis_url", "redis_service_type", "redis_port",
"redis_auth_type", "redis_key",
)


def load_app_module(name):
spec = importlib.util.spec_from_file_location(name, APP_DIRECTORY / f"{name}.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


settings_store = load_app_module("app_settings_store")
redis_helpers = load_app_module("functions_redis_client")


def load_deployment_environment():
name = os.environ.get("AZURE_ENV_NAME")
if not name:
raise ValueError("AZURE_ENV_NAME is required; select an explicit AZD environment.")
executable = shutil.which("azd")
if not executable:
raise RuntimeError("AZD is required to resolve deployment outputs.")
result = subprocess.run(
[executable, "-C", str(DEPLOYERS_DIRECTORY), "env", "get-values", "--environment", name],
capture_output=True, text=True, check=False,
)
if result.returncode:
raise RuntimeError("Unable to load the selected AZD environment; no configuration was written.")
values = dict(dotenv_values(stream=io.StringIO(result.stdout), interpolate=False))
missing = [key for key in REQUIRED_VALUES if not values.get(key)]
if missing:
raise ValueError("Missing required deployment outputs: " + ", ".join(missing))
if values["AZURE_ENV_NAME"] != name:
raise ValueError("AZD returned a different environment than requested.")
if values["var_authenticationType"] not in ("managed_identity", "key"):
raise ValueError("Unsupported deployment authentication type.")
for key in ("var_openAIGPTModels", "var_openAIEmbeddingModels"):
models = json.loads(values[key])
if not isinstance(models, list) or not models or any(
not isinstance(model, dict) or not model.get("modelName") for model in models
):
raise ValueError(f"Invalid model deployment output: {key}")
for key in list(os.environ):
if key.lower().startswith("var_"):
os.environ.pop(key)
os.environ.update({key: value for key, value in values.items() if value is not None})
os.environ["var_subscriptionId"] = values["AZURE_SUBSCRIPTION_ID"]
return values


def redis_client_for_settings(settings, credential):
host = redis_helpers.normalize_redis_host(settings.get("redis_url"))
if not host:
raise ValueError("Enabled Redis requires a host before settings can be published.")
options = {
"host": host,
"port": redis_helpers.resolve_redis_port(settings),
"ssl": True,
"socket_connect_timeout": 10,
"socket_timeout": 10,
}
mode = settings.get("redis_auth_type", "key")
if mode == "managed_identity":
options["credential_provider"] = redis_helpers.RedisManagedIdentityCredentialProvider(
credential=credential, scope=redis_helpers.get_redis_entra_token_scope(settings),
)
elif mode == "key" and settings.get("redis_key"):
options["password"] = settings["redis_key"]
else:
raise ValueError("Postconfig requires managed_identity or key access to the configured Redis cache.")
return Redis(**options)


def configure_redis(item, host, kind, port, authentication_type, keys):
host = (host or "").strip()
if not host:
return
if kind not in ("managed", "classic"):
raise ValueError("Unknown deployed Redis service type.")
if authentication_type not in ("managed_identity", "key"):
raise ValueError("Unknown deployed Redis authentication type.")
if authentication_type == "key" and not keys.get("redis_key"):
raise ValueError("Redis key was not resolved; settings were not saved.")
resolved_port = int(port or (10000 if kind == "managed" else 6380))
if not 1 <= resolved_port <= 65535:
raise ValueError("Deployed Redis port is out of range.")
item.update({
"enable_redis_cache": True,
"redis_url": host,
"redis_service_type": "azure_managed_redis" if kind == "managed" else "azure_cache_for_redis",
"redis_port": str(resolved_port),
"redis_auth_type": authentication_type,
"redis_key": keys.get("redis_key", "") if authentication_type == "key" else "",
})


def redis_connection_signature(settings):
mode = str(settings.get("redis_auth_type") or "key").strip().lower()
return (
bool(settings.get("enable_redis_cache")),
redis_helpers.normalize_redis_host(settings.get("redis_url")),
redis_helpers.resolve_redis_port(settings),
mode,
settings.get("redis_key") if mode != "managed_identity" else None,
)


def persist_settings(container, original, desired, credential):
excluded = settings_store.COSMOS_METADATA_FIELDS | {settings_store.SETTINGS_REVISION_FIELD, "public_workspace_labels"}
updates = {
key: copy.deepcopy(value)
for key, value in desired.items()
if key not in excluded and (key not in original or original[key] != value)
}
before_redis = {key: original.get(key) for key in REDIS_FIELDS}
if original.get("enable_redis_cache") and redis_connection_signature(original) != redis_connection_signature(desired):
raise ValueError("Changing an active Redis configuration requires an administrator-managed cache migration.")
redis_client = None
try:
if desired.get("enable_redis_cache"):
redis_client = redis_client_for_settings(desired, credential)
try:
redis_client.ping()
except (RedisError, ClientAuthenticationError):
raise settings_store.SettingsUnavailableError(
"Deployment runner cannot publish settings to Redis. Check its Redis data access policy "
"and network connectivity; no Cosmos settings were written."
) from None
store = settings_store.AppSettingsStore(
container, redis_client, redis_required=bool(desired.get("enable_redis_cache")),
)

def merge(current):
if {key: current.get(key) for key in REDIS_FIELDS} != before_redis:
raise settings_store.SettingsConflictError("Redis settings changed during deployment; reload and retry.")
current.update(copy.deepcopy(updates))
current.pop("public_workspace_labels", None)
return current

stored = store.write(merge, defaults={"id": "app_settings", "partition_key": "app_settings"})
verified = store.read(use_cosmos=True)
if any(verified.get(key) != value for key, value in updates.items()):
raise settings_store.SettingsConflictError("Settings changed after deployment; verify before retrying.")
return stored
finally:
if redis_client is not None:
redis_client.close()


def ensure_search_indexes(client, schemas_directory=None):
schemas_directory = schemas_directory or APP_DIRECTORY / "static" / "json"
created = []
for kind in ("user", "group", "public"):
schema = json.loads((schemas_directory / f"ai_search-index-{kind}.json").read_text(encoding="utf-8"))
name = schema["name"]
try:
client.get_index(name)
continue
except ResourceNotFoundError:
pass
try:
client.create_index(SearchIndex.deserialize(schema))
created.append(name)
except HttpResponseError as error:
if error.status_code != 409:
raise
client.get_index(name)
return created


def configure_search(settings, credential):
if settings.get("azure_ai_search_authentication_type") == "key":
credential = AzureKeyCredential(settings["azure_ai_search_key"])
with SearchIndexClient(settings["azure_ai_search_endpoint"], credential) as client:
return ensure_search_indexes(client)
85 changes: 85 additions & 0 deletions deployers/bicep/deployment_cosmos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# deployment_cosmos.py
"""Deployment-time Cosmos authentication and non-mutating reachability checks."""

import json
import os
import shutil
import subprocess

import azure.cosmos as azure_cosmos
from azure.cosmos.exceptions import CosmosHttpResponseError
from azure.identity import AzureCliCredential
from azure.core.exceptions import ClientAuthenticationError, ServiceRequestError


def run_cli(arguments):
executable = shutil.which("az") or shutil.which("az.cmd")
if not executable:
raise RuntimeError("Azure CLI is required for post-provision configuration.")
result = subprocess.run(
[executable, *arguments, "--output", "json"],
capture_output=True,
text=True,
check=False,
)
if result.returncode:
raise RuntimeError("Azure CLI operation failed. Check deployment identity and subscription access.")
return json.loads(result.stdout)


def create_deployment_cosmos_client(environment=None):
environment = os.environ if environment is None else environment
subscription = environment.get("AZURE_SUBSCRIPTION_ID") or environment.get("var_subscriptionId")
group = environment.get("var_rgName")
account_name = environment.get("var_cosmosDb_accountName")
mode = environment.get("var_authenticationType", "").strip().lower()
if not all((subscription, group, account_name)) or mode not in {"managed_identity", "key"}:
raise ValueError("Cosmos subscription, resource group, account and authentication type are required.")

account = run_cli([
"cosmosdb", "show", "--subscription", subscription,
"--resource-group", group, "--name", account_name,
])
if account.get("publicNetworkAccess") == "Disabled":
print("Cosmos public access is disabled; this runner must reach a private endpoint.")
endpoint = account["documentEndpoint"]
if mode == "managed_identity":
tenant = environment.get("AZURE_TENANT_ID")
credential = AzureCliCredential(**({"tenant_id": tenant} if tenant else {}))
else:
if account.get("disableLocalAuth"):
raise RuntimeError("Cosmos key authentication is disabled. Select managed_identity authentication.")
credential = run_cli([
"cosmosdb", "keys", "list", "--subscription", subscription,
"--resource-group", group, "--name", account_name,
])["primaryMasterKey"]

client = None
try:
client = azure_cosmos.CosmosClient(endpoint, credential=credential, connection_timeout=15, read_timeout=30)
next(client.list_databases(), None)
return client
except CosmosHttpResponseError as error:
if client is not None:
client.close()
message = str(error).lower()
if "firewall" in message or "public internet" in message:
detail = "Cosmos network access is blocked. Use a network-connected runner or an approved firewall rule."
elif error.status_code in (401, 403):
detail = "Cosmos authentication or data-plane RBAC failed. Check the selected credential and Cosmos data role."
else:
detail = "Cosmos data-plane check failed. Check service availability and deployment diagnostics."
raise RuntimeError(detail + " No firewall rules were changed.") from None
except ClientAuthenticationError:
if client is not None:
client.close()
raise RuntimeError("Azure CLI authentication failed. Sign in to the deployment tenant and retry.") from None
except ServiceRequestError:
if client is not None:
client.close()
raise RuntimeError("Cosmos endpoint could not be reached. Check DNS and runner network connectivity.") from None


if __name__ == "__main__":
with create_deployment_cosmos_client() as cosmos_client:
print("Cosmos data-plane access verified; firewall settings were not changed.")
Loading
Loading