diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b63fb43a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +**/.git +**/.gitignore +**/.azure +**/.vscode +**/.vs +**/bin +**/obj +**/node_modules +README.md +project_purpose_overview.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..6007d2ee6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ + +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index c216ec51d..75905b192 100644 --- a/.gitignore +++ b/.gitignore @@ -357,3 +357,11 @@ MigrationBackup/ # OSX .DS_Store +# Terraform +.terraform/ +*.tfstate +*.tfstate.* +crash.log +*.tfvars.local +infra-tf/terraform.exe +infra-tf/tfplan diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..ad42aed0c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY DotNetCoreSqlDb.csproj ./ +RUN dotnet restore + +COPY . ./ + +# Build EF migration bundle for container startup migration. +RUN dotnet tool install --global dotnet-ef --version 8.0.6 +ENV PATH="${PATH}:/root/.dotnet/tools" +RUN dotnet ef migrations bundle --target-runtime linux-x64 --force -o /src/migrationsbundle + +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app + +COPY --from=build /app/publish ./ +COPY --from=build /src/migrationsbundle ./migrationsbundle +COPY docker-entrypoint.sh /app/docker-entrypoint.sh + +RUN chmod +x /app/docker-entrypoint.sh /app/migrationsbundle + +EXPOSE 8080 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 38eb4df4d..bdb3253ff 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ description: "A sample application you can use to follow along with Tutorial: De This is an ASP.NET Core application that you can use to follow along with the tutorial at [Tutorial: Deploy an ASP.NET Core and Azure SQL Database app to Azure App Service](https://learn.microsoft.com/azure/app-service/tutorial-dotnetcore-sqldb-app) or by using the [Azure Developer CLI (azd)](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview) according to the instructions below. -## Run the sample - -This project has a [dev container configuration](.devcontainer/), which makes it easier to develop apps locally, deploy them to Azure, and monitor them. The easiest way to run this sample application is inside a GitHub codespace. Follow these steps: +## Run the sample + +This project has a [dev container configuration](.devcontainer/), which makes it easier to develop apps locally, deploy them to Azure, and monitor them. The easiest way to run this sample application is inside a GitHub codespace. Follow these steps: 1. Fork this repository to your account. For instructions, see [Fork a repo](https://docs.github.com/get-started/quickstart/fork-a-repo). @@ -35,9 +35,238 @@ This project has a [dev container configuration](.devcontainer/), which makes it dotnet run ``` -1. When you see the message `Your application running on port 5093 is available.`, click **Open in Browser**. - -## Quick deploy +1. When you see the message `Your application running on port 5093 is available.`, click **Open in Browser**. + +## Run with Docker (local SQL + Redis) + +From the repository root: + +```shell +docker compose up --build +``` + +Then open: + +```text +http://localhost:8080 +``` + +The `web` container runs EF migrations at startup, then launches the app. SQL Server and Redis are started by `docker-compose.yml`. + +## What changed in this branch + +This branch migrated the AZD deployment target from **Azure App Service** to **Azure Container Apps (ACA)** and kept local Docker workflow available. + +- `azure.yaml`: service host changed to `containerapp`, Docker registry is explicitly configured. +- `infra/resources.bicep`: compute resources changed from `Microsoft.Web/*` to `Microsoft.App/*` and `Microsoft.ContainerRegistry/*`. +- `infra/main.bicep`: outputs aligned to ACA deployment (container app and registry endpoint). +- `Dockerfile` + `docker-compose.yml` + `docker-entrypoint.sh`: local containerized workflow added (web + SQL Server + Redis). + +## Run modes (5 scenarios) + +You can switch between these five modes independently. They do not conflict as long as you deploy to different Azure resource groups or environments. + +| Mode | Main command | Compute | Data/cache | Notes | +|---|---|---|---|---| +| Local (.NET) | `dotnet run` | Local process | Local SQL (from `ConnectionStrings:MyDbConnection`), local memory cache in Development | Fastest for debugging code only. | +| Local Docker | `docker compose up --build` | Local Docker container | Local SQL Server container + local Redis container | Closer to cloud runtime than plain `dotnet run`. | +| Azure App Service (Bicep/AZD) | `azd up` (on App Service branch/template) | App Service (Linux Web App) | Azure SQL + Azure Redis + Key Vault (private endpoints) | Original template path. | +| Azure Container Apps (Bicep/AZD) | `azd up -e ` | Azure Container Apps | Azure SQL + Azure Redis + Key Vault (private endpoints) + ACR image pull | Current Bicep/AZD ACA path. | +| Azure Container Apps (Terraform) | `terraform apply` in `infra-tf` | Azure Container Apps | Azure SQL + Azure Redis + Log Analytics + ACR image pull | Current Terraform ACA path. | + +### Recommended commands by mode + +Local (.NET): + +```shell +dotnet ef database update +dotnet run +``` + +Local Docker: + +```shell +docker compose up --build +``` + +Azure Container Apps (current branch): + +```shell +azd env new VSCodeFirstDemoAca +azd up -e VSCodeFirstDemoAca --no-prompt +``` + +Update ACA after code change: + +```shell +azd deploy -e VSCodeFirstDemoAca --no-prompt +``` + +Azure Container Apps (Terraform): + +```shell +cd infra-tf +terraform init +terraform plan -out tfplan +terraform apply tfplan +``` + +Update ACA (Terraform) after code change: + +```shell +cd infra-tf +terraform plan -out tfplan +terraform apply tfplan +``` + +## ACA deployment troubleshooting (real issues and fixes) + +These are issues encountered during the actual migration/deployment workflow and how they were resolved. + +### 1) `ContainerAppInvalidName` (name must be lowercase) + +Symptom: +- `Invalid ContainerApp name ... must consist of lower case ...` + +Root cause: +- Environment name contained uppercase letters, and generated Container App name reused it directly. + +Fix: +- Keep shared resource naming as-is, but use a dedicated lowercase variable for Container App name only: + - `var containerAppName = toLower(appName)` + - `web.name = containerAppName` + +### 2) Resource provider not registered + +Symptom: +- `SubscriptionIsNotRegistered ... Microsoft.App and Microsoft.ContainerService` + +Fix: + +```shell +az provider register --namespace Microsoft.App +az provider register --namespace Microsoft.ContainerService +``` + +Wait until both return `Registered`. + +### 3) ACA subnet size requirement + +Symptom: +- `ManagedEnvironmentInvalidNetworkConfiguration ... subnet must have a size of at least /23` + +Root cause: +- ACA environment infrastructure subnet used `/24`. + +Fix: +- Use a dedicated ACA infrastructure subnet with at least `/23` (example: `10.0.4.0/23`). + +### 4) Existing subnet/environment conflicts + +Symptoms: +- `InUseSubnetCannotBeUpdated` +- `InUseSubnetCannotBeDeleted` +- `ManagedEnvironmentInfraSubnetIdCannotBeUpdated` + +Root cause: +- Previous failed/partial deployments left resources bound to old subnet settings. + +Fix: +- For test environments, reset and redeploy cleanly: + +```shell +azd down -e VSCodeFirstDemoAca --force --no-prompt +az keyvault purge --name --location # if needed +azd up -e VSCodeFirstDemoAca --no-prompt +``` + +### 5) Revision provisioning timeout (`Operation expired`) + +Symptom: +- `ContainerAppOperationError ... Failed to provision revision ... Operation expired` + +Root causes observed: +- Ingress `targetPort` mismatch for initial container image. +- Registry/auth configuration mismatch caused non-starting revisions. + +Fixes: +- Align `targetPort` to actual container listening port. +- Ensure container registry settings are valid and deploy path can push/pull image. + +### 6) `azd deploy` cannot determine registry endpoint + +Symptom: +- `could not determine container registry endpoint ...` + +Fix: +- Ensure `AZURE_CONTAINER_REGISTRY_ENDPOINT` is present in environment outputs. +- Set registry explicitly in `azure.yaml`: + +```yaml +services: + web: + docker: + path: ./Dockerfile + registry: ${AZURE_CONTAINER_REGISTRY_ENDPOINT} +``` + +### 7) ACR image pull unauthorized during revision update + +Symptom: +- `UNAUTHORIZED: authentication required` when ACA pulls image from ACR. + +Fix: +- Configure ACA registry with explicit credentials (secret + username from ACR credentials), or ensure managed identity pull path is fully wired and propagated before deployment. + +### 8) Post-provision hook parsing failure + +Symptom: +- PowerShell `ConvertFrom-Json` failed because `CONNECTION_SETTINGS` was null. + +Fix: +- Guard the hook: + - If value exists, parse JSON. + - Else print `(none)` and continue. + +### 9) Still seeing the default "Your Azure Container Apps app is live" page + +Symptom: +- ACA endpoint returns the default welcome page instead of the Todo app. + +Root cause: +- `azd provision` applies infra template state. If the template uses a bootstrap image for initial provisioning, the Container App image can be reset to that default image. + +Fix: +- Run deploy after provision to publish your app image revision: + +```shell +azd provision -e VSCodeFirstDemoAca --no-prompt +azd deploy -e VSCodeFirstDemoAca --no-prompt +``` + +- Verify active revision image: + +```shell +az containerapp revision list -g VSCodeFirstDemoAca_group -n vscodefirstdemoaca-koqndqit3fkna -o table +``` + +### 10) App starts, migrations succeed, but requests still fail with connection string errors + +Symptom: +- Runtime error page with `The ConnectionString property has not been initialized.` +- Migrations may still succeed in startup. + +Root cause: +- The app uses `builder.Configuration.GetConnectionString("AZURE_SQL_CONNECTIONSTRING")`, which maps to configuration key: + - `ConnectionStrings__AZURE_SQL_CONNECTIONSTRING` +- Only `AZURE_SQL_CONNECTIONSTRING` was set in container env, so EF runtime query path had empty connection string. + +Fix: +- Set both env vars in ACA template to the same secret: + - `AZURE_SQL_CONNECTIONSTRING` + - `ConnectionStrings__AZURE_SQL_CONNECTIONSTRING` + +## Quick deploy This project is designed to work well with the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview), which makes it easier to develop apps locally, deploy them to Azure, and monitor them. @@ -83,6 +312,6 @@ Two types of secrets are involved: the SQL Database administrator password and t To simplify the scenario, the AZD template generates a new database password each time you run `azd provision` or `azd up`, and the database connection string in the key vault is modified too. If you want to fully utilize `secretOrRandomPassword` in the [parameter file](infra/main.parameters.json) by committing the automatically generated password to the key vault the first time and reading it on subsequent `azd` commands, you must relax the networking restriction of the key vault to allow traffic from public networks. For more information, see [What is the behavior of the `secretOrRandomPassword` function?](https://learn.microsoft.com/azure/developer/azure-developer-cli/faq#what-is-the-behavior-of-the--secretorrandompassword--function). -## Getting help - -If you're working with this project and running into issues, please post in [Issues](/issues). +## Getting help + +If you're working with this project and running into issues, please post in [Issues](/issues). diff --git a/appsettings.Development.json b/appsettings.Development.json index ff66ba6b2..e212649e3 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ConnectionStrings": { + "MyDbConnection": "Server= (local)\\SQLEXPRESS;Database=ApplicationDB;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true" } } diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 000000000..8e3bf0807 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,102 @@ +trigger: + branches: + include: + - main + +pr: + branches: + include: + - main + +pool: + vmImage: ubuntu-latest + +variables: + AZURE_LOCATION: canadacentral + AZURE_SUBSCRIPTION_ID: b4686626-5082-4d6b-b0aa-0ee897b1b029 + DEV_ENV_NAME: VSCodeFirstDemoAca-dev + PROD_ENV_NAME: VSCodeFirstDemoAca-prod + +stages: +- stage: PR_Validate + displayName: PR Validate + condition: eq(variables['Build.Reason'], 'PullRequest') + jobs: + - job: validate + steps: + - checkout: self + + - task: UseDotNet@2 + inputs: + packageType: sdk + version: '8.0.x' + + - script: | + dotnet restore + dotnet build -c Release --no-restore + displayName: Dotnet Build + + - task: AzureCLI@2 + displayName: Bicep Validate + inputs: + azureSubscription: 'sc-aca-dev' + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + az bicep build --file infra/main.bicep + az bicep build --file infra/resources.bicep + +- stage: Deploy_Dev + displayName: Deploy Dev + condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + jobs: + - deployment: deploy_dev + environment: aca-dev + strategy: + runOnce: + deploy: + steps: + - checkout: self + + - task: AzureCLI@2 + displayName: Provision+Deploy Dev by AZD + inputs: + azureSubscription: 'sc-aca-dev' + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + curl -fsSL https://aka.ms/install-azd.sh | bash + export PATH="$PATH:$HOME/.azd/bin" + azd version + + azd env new $(DEV_ENV_NAME) --location $(AZURE_LOCATION) --subscription $(AZURE_SUBSCRIPTION_ID) || true + azd provision -e $(DEV_ENV_NAME) --no-prompt + azd deploy -e $(DEV_ENV_NAME) --no-prompt + +- stage: Deploy_Prod + displayName: Deploy Prod + dependsOn: Deploy_Dev + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + jobs: + - deployment: deploy_prod + environment: aca-prod + strategy: + runOnce: + deploy: + steps: + - checkout: self + + - task: AzureCLI@2 + displayName: Provision+Deploy Prod by AZD + inputs: + azureSubscription: 'sc-aca-prod' + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + curl -fsSL https://aka.ms/install-azd.sh | bash + export PATH="$PATH:$HOME/.azd/bin" + azd version + + azd env new $(PROD_ENV_NAME) --location $(AZURE_LOCATION) --subscription $(AZURE_SUBSCRIPTION_ID) || true + azd provision -e $(PROD_ENV_NAME) --no-prompt + azd deploy -e $(PROD_ENV_NAME) --no-prompt diff --git a/azure.yaml b/azure.yaml index 73af2a859..de7b76b2d 100644 --- a/azure.yaml +++ b/azure.yaml @@ -1,51 +1,26 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json -# azure.yaml is an azd configuration file and isn't used by the sample application. +# azure.yaml is an azd configuration file and isn't used by the sample application. -name: dotnet-app-service-sqldb-redis-infra +name: dotnet-containerapps-sqldb-redis-infra metadata: - template: dotnet-app-service-sqldb-redis-infra@0.0.1-beta + template: dotnet-containerapps-sqldb-redis-infra@0.0.1-local services: web: project: . language: csharp - host: appservice + host: containerapp + docker: + path: ./Dockerfile + registry: ${AZURE_CONTAINER_REGISTRY_ENDPOINT} hooks: - # The prepackage hook is used to create a runnable migrations bundle to be run in App Service. - # The migrationsbundle file is included in DotNetCoreSqlDb.csproj so it can be included in the deploy package. - prepackage: - posix: - shell: sh - run: dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle - interactive: true - continueOnError: true - # The postpackage hook is used to remove the migrationsbundle file after deploy package is created. - postpackage: - posix: - shell: sh - run: rm migrationsbundle - interactive: true - continueOnError: true - # The postprovision hook is used to display the connection strings and app settings for the App Service app. postprovision: - posix: + posix: shell: sh - run: printf '\nApp Service app has the following connection strings:\n' && printf "$CONNECTION_SETTINGS" | jq -r '.[]' | sed 's/\(.*\)/\t- \1/' && printf "\nSee the settings in the portal:\n\033[1;36m$WEB_APP_CONNECTIONSTRINGS\n$WEB_APP_APPSETTINGS\n" + run: printf '\nContainer App has the following generated settings:\n' && printf "$CONNECTION_SETTINGS" | jq -r '.[]' | sed 's/\(.*\)/\t- \1/' interactive: true continueOnError: true windows: shell: pwsh - run: Write-Host "`n`nApp Service app has the following connection strings:`n" $CONNECTION_SETTINGS | ConvertFrom-Json | ForEach-Object { Write-Host "\t- $_" } - interactive: true - continueOnError: true - # The postdeploy hook is used to display the SSH session and log stream URLs for the App Service app. - postdeploy: - posix: - shell: sh - run: printf "Open SSH session to App Service container at:\033[1;36m $WEB_APP_SSH\033[0m\nStream App Service logs at:\033[1;36m $WEB_APP_LOG_STREAM\n" + run: Write-Host "`n`nContainer App has the following generated settings:`n"; if ($env:CONNECTION_SETTINGS) { $env:CONNECTION_SETTINGS | ConvertFrom-Json | ForEach-Object { Write-Host "`t- $_" } } else { Write-Host "`t- (none)" } interactive: true continueOnError: true - windows: - shell: pwsh - run: Write-Host "`n`nOpen SSH session to App Service container at:`n" $WEB_APP_SSH; Write-Host "Stream App Service logs at:`n" $WEB_APP_LOG_STREAM - interactive: true - continueOnError: true \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..955e0832d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + sql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: first-app-sql + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: "Your_password123!" + ports: + - "1433:1433" + healthcheck: + test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P $$SA_PASSWORD -Q 'SELECT 1' -C || exit 1"] + interval: 10s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + container_name: first-app-redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + web: + build: + context: . + dockerfile: Dockerfile + container_name: first-app-web + depends_on: + sql: + condition: service_healthy + redis: + condition: service_healthy + environment: + ASPNETCORE_ENVIRONMENT: "Production" + ASPNETCORE_URLS: "http://+:8080" + ConnectionStrings__AZURE_SQL_CONNECTIONSTRING: "Server=sql,1433;Database=ApplicationDB;User Id=sa;Password=Your_password123!;TrustServerCertificate=True;MultipleActiveResultSets=true" + AZURE_SQL_CONNECTIONSTRING: "Server=sql,1433;Database=ApplicationDB;User Id=sa;Password=Your_password123!;TrustServerCertificate=True;MultipleActiveResultSets=true" + AZURE_REDIS_CONNECTIONSTRING: "redis:6379,abortConnect=false" + ports: + - "8080:8080" diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 000000000..ff82d618d --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +set -eu + +echo "Applying EF migrations bundle..." +tries=0 +max_tries=30 +if [ -n "${AZURE_SQL_CONNECTIONSTRING:-}" ]; then + migrate_cmd="./migrationsbundle --connection \"$AZURE_SQL_CONNECTIONSTRING\" -- --environment Production" +else + migrate_cmd="./migrationsbundle -- --environment Production" +fi + +until sh -c "$migrate_cmd"; do + tries=$((tries + 1)) + if [ "$tries" -ge "$max_tries" ]; then + echo "Migrations failed after $max_tries attempts." + exit 1 + fi + echo "Migration attempt $tries failed; retrying in 5s..." + sleep 5 +done + +echo "Starting web app..." +exec dotnet DotNetCoreSqlDb.dll diff --git a/infra-tf/.terraform.lock.hcl b/infra-tf/.terraform.lock.hcl new file mode 100644 index 000000000..7bd1522d9 --- /dev/null +++ b/infra-tf/.terraform.lock.hcl @@ -0,0 +1,42 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.63.0" + constraints = "~> 4.0" + hashes = [ + "h1:jjqAsWVyu+nPkwmyk3vxNEF/6ZMfWs8cIxfkj1QGcxA=", + "zh:08415ed33647642c59a73102a1443ef37b0e97d96c027d1364ad7c25b4d4dfbb", + "zh:0cedb8fa48d74e1673bb17efd8eb9e251fc5cf4699450f14c83d94299ce099fd", + "zh:4ded2cc9013ea3dc75fde071505d97d494477a491c601b2e5dd7bfd83189c01d", + "zh:5b5c2bc5a2fe0bc87b3b3800fb724887b0ef04ed99c01622eee8dd6a19fbf201", + "zh:5f720813486ffbe93ffa0382416a4eeb2a7cc5c6788fad5895bb119ec34d0cec", + "zh:6c6be72a06f30b27d0bf80905c2204d5e81bdf26d01bcb87bdca9bd531f65b62", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:8709312ff03cd2023faccde77a0ff791eb1d8012cbc59bf09ffb0c4521584e3c", + "zh:a903b41fa3a48ee686f432e67365da3e3ed0b0af9d06d19641aba6f5d430bff0", + "zh:e9496eae3e66a1ae365b4be7b0f57593f288808e0a37df9f54dfcd7991d0f8fb", + "zh:eb9bc4ea472bbbf74ecf7937272f36d169484ebd0c46c5134c297c9ffcdd072d", + "zh:fe0ab911629ed1cfa5dc0890e6e5fc724492166130bf3367225eac2ef47c8690", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.8.1" + constraints = "~> 3.6" + hashes = [ + "h1:osH3aBqEARwOz3VBJKdpFKJJCNIdgRC6k8vPojkLmlY=", + "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", + "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", + "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57", + "zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0", + "zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66", + "zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9", + "zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05", + "zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8", + "zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b", + "zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699", + ] +} diff --git a/infra-tf/README.md b/infra-tf/README.md new file mode 100644 index 000000000..1c4126425 --- /dev/null +++ b/infra-tf/README.md @@ -0,0 +1,40 @@ +# Terraform (Parallel to Bicep) + +This folder is a Terraform-first deployment path that can run in parallel with existing `infra/` Bicep files. + +## Included resources (baseline app path) + +- Resource Group +- Log Analytics Workspace +- Azure Container Registry (ACR) +- Container Apps Environment (ACA Env) +- Container App +- Azure SQL Server + SQL Database +- Azure Cache for Redis + +## Prerequisites + +- Terraform 1.5+ +- Azure CLI login (`az login`) +- Target subscription selected (`az account set --subscription `) + +## Commands + +```powershell +cd infra-tf +terraform init +terraform plan +terraform apply +``` + +Destroy: + +```powershell +terraform destroy +``` + +## Notes + +- This is a Terraform baseline for running the sample app on ACA with SQL + Redis. +- It intentionally does not yet include private endpoint networking and Key Vault integration. +- Add private network hardening in phase 2 after validating deploy flow. diff --git a/infra-tf/main.tf b/infra-tf/main.tf new file mode 100644 index 000000000..14fc5a035 --- /dev/null +++ b/infra-tf/main.tf @@ -0,0 +1,165 @@ +locals { + name_base = lower(replace(var.environment_name, "-", "")) + sql_admin_password_effective = coalesce(var.sql_admin_password, random_password.sql_admin.result) + sql_connection_string = "Server=tcp:${azurerm_mssql_server.sql.fully_qualified_domain_name},1433;Initial Catalog=${azurerm_mssql_database.appdb.name};Persist Security Info=False;User ID=${azurerm_mssql_server.sql.administrator_login};Password=${local.sql_admin_password_effective};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + redis_connection_string = "${azurerm_redis_cache.redis.hostname}:${azurerm_redis_cache.redis.ssl_port},password=${azurerm_redis_cache.redis.primary_access_key},ssl=True,abortConnect=False" + tags = merge({ + managed_by = "terraform" + env = var.environment_name + }, var.tags) +} + +resource "random_string" "suffix" { + length = 6 + lower = true + upper = false + numeric = true + special = false +} + +resource "random_password" "sql_admin" { + length = 24 + special = true + override_special = "_%@" +} + +resource "azurerm_resource_group" "rg" { + name = "${var.environment_name}_group_tf" + location = var.location + tags = local.tags +} + +resource "azurerm_log_analytics_workspace" "law" { + name = "${substr(local.name_base, 0, 20)}${random_string.suffix.result}law" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + sku = "PerGB2018" + retention_in_days = 30 + tags = local.tags +} + +resource "azurerm_container_registry" "acr" { + name = "acr${substr(local.name_base, 0, 14)}${random_string.suffix.result}" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + sku = "Basic" + admin_enabled = true + tags = local.tags +} + +resource "azurerm_mssql_server" "sql" { + name = "sql-${substr(local.name_base, 0, 20)}${random_string.suffix.result}" + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + version = "12.0" + administrator_login = var.sql_admin_username + administrator_login_password = local.sql_admin_password_effective + minimum_tls_version = "1.2" + tags = local.tags +} + +resource "azurerm_mssql_database" "appdb" { + name = "${substr(local.name_base, 0, 20)}-db" + server_id = azurerm_mssql_server.sql.id + sku_name = "Basic" + tags = local.tags +} + +resource "azurerm_mssql_firewall_rule" "allow_azure_services" { + name = "AllowAzureServices" + server_id = azurerm_mssql_server.sql.id + start_ip_address = "0.0.0.0" + end_ip_address = "0.0.0.0" +} + +resource "azurerm_redis_cache" "redis" { + name = "redis-${substr(local.name_base, 0, 20)}${random_string.suffix.result}" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + capacity = 0 + family = "C" + sku_name = "Basic" + minimum_tls_version = "1.2" + non_ssl_port_enabled = false + tags = local.tags +} + +resource "azurerm_container_app_environment" "cae" { + name = "${var.environment_name}-cae-tf" + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + log_analytics_workspace_id = azurerm_log_analytics_workspace.law.id + tags = local.tags +} + +resource "azurerm_container_app" "web" { + name = lower("${var.environment_name}-web-tf") + resource_group_name = azurerm_resource_group.rg.name + container_app_environment_id = azurerm_container_app_environment.cae.id + revision_mode = "Single" + tags = local.tags + + ingress { + external_enabled = true + target_port = var.container_port + traffic_weight { + latest_revision = true + percentage = 100 + } + } + + registry { + server = azurerm_container_registry.acr.login_server + username = azurerm_container_registry.acr.admin_username + password_secret_name = "acr-password" + } + + secret { + name = "acr-password" + value = azurerm_container_registry.acr.admin_password + } + + secret { + name = "sql-connection-string" + value = local.sql_connection_string + } + + secret { + name = "redis-connection-string" + value = local.redis_connection_string + } + + template { + container { + name = "web" + image = var.container_image + cpu = var.container_cpu + memory = var.container_memory + + env { + name = "ASPNETCORE_ENVIRONMENT" + value = "Production" + } + + env { + name = "ASPNETCORE_URLS" + value = "http://+:${var.container_port}" + } + + env { + name = "AZURE_SQL_CONNECTIONSTRING" + secret_name = "sql-connection-string" + } + + env { + name = "ConnectionStrings__AZURE_SQL_CONNECTIONSTRING" + secret_name = "sql-connection-string" + } + + env { + name = "AZURE_REDIS_CONNECTIONSTRING" + secret_name = "redis-connection-string" + } + } + } +} diff --git a/infra-tf/outputs.tf b/infra-tf/outputs.tf new file mode 100644 index 000000000..25adbd6bc --- /dev/null +++ b/infra-tf/outputs.tf @@ -0,0 +1,27 @@ +output "resource_group_name" { + value = azurerm_resource_group.rg.name +} + +output "container_registry_login_server" { + value = azurerm_container_registry.acr.login_server +} + +output "container_app_name" { + value = azurerm_container_app.web.name +} + +output "container_app_url" { + value = "https://${azurerm_container_app.web.latest_revision_fqdn}" +} + +output "sql_server_fqdn" { + value = azurerm_mssql_server.sql.fully_qualified_domain_name +} + +output "sql_database_name" { + value = azurerm_mssql_database.appdb.name +} + +output "redis_hostname" { + value = azurerm_redis_cache.redis.hostname +} diff --git a/infra-tf/providers.tf b/infra-tf/providers.tf new file mode 100644 index 000000000..8cac50f7c --- /dev/null +++ b/infra-tf/providers.tf @@ -0,0 +1,4 @@ +provider "azurerm" { + features {} +} + diff --git a/infra-tf/terraform.tfvars b/infra-tf/terraform.tfvars new file mode 100644 index 000000000..9e2da8ffa --- /dev/null +++ b/infra-tf/terraform.tfvars @@ -0,0 +1,5 @@ +environment_name = "VSCodeFirstDemoAcaTf" +location = "canadacentral" + +# Use a test image first; replace with your app image from ACR later. +container_image = "acrvscodefirstdemtwxp89.azurecr.io/first-app-service:tf-first-01" diff --git a/infra-tf/variables.tf b/infra-tf/variables.tf new file mode 100644 index 000000000..441c0ef8e --- /dev/null +++ b/infra-tf/variables.tf @@ -0,0 +1,55 @@ +variable "environment_name" { + description = "Environment name prefix for Terraform-managed resources." + type = string + default = "VSCodeFirstDemoAcaTf" +} + +variable "location" { + description = "Azure location." + type = string + default = "canadacentral" +} + +variable "container_image" { + description = "Container image for the Container App." + type = string + default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" +} + +variable "container_cpu" { + description = "CPU for the Container App container." + type = number + default = 0.5 +} + +variable "container_memory" { + description = "Memory for the Container App container (Gi/Mi)." + type = string + default = "1Gi" +} + +variable "container_port" { + description = "Container port exposed by the application." + type = number + default = 80 +} + +variable "sql_admin_username" { + description = "SQL administrator username." + type = string + default = "sqladminuser" +} + +variable "sql_admin_password" { + description = "Optional SQL administrator password. If null, one is generated." + type = string + default = null + sensitive = true + nullable = true +} + +variable "tags" { + description = "Optional resource tags." + type = map(string) + default = {} +} diff --git a/infra-tf/versions.tf b/infra-tf/versions.tf new file mode 100644 index 000000000..305a03ec5 --- /dev/null +++ b/infra-tf/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} + diff --git a/infra/main.bicep b/infra/main.bicep index 7c0d28323..e81cd6936 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -38,7 +38,7 @@ module resources 'resources.bicep' = { output AZURE_LOCATION string = location output WEB_URI string = resources.outputs.WEB_URI output CONNECTION_SETTINGS array = resources.outputs.CONNECTION_SETTINGS -output WEB_APP_LOG_STREAM string = resources.outputs.WEB_APP_LOG_STREAM -output WEB_APP_SSH string = resources.outputs.WEB_APP_SSH -output WEB_APP_CONNECTIONSTRINGS string = resources.outputs.WEB_APP_CONNECTIONSTRINGS -output WEB_APP_APPSETTINGS string = resources.outputs.WEB_APP_APPSETTINGS +output CONTAINER_APP_NAME string = resources.outputs.CONTAINER_APP_NAME +output CONTAINER_APP_ID string = resources.outputs.CONTAINER_APP_ID +output CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.CONTAINER_REGISTRY_ENDPOINT +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT diff --git a/infra/resources.bicep b/infra/resources.bicep index e7f1ad6b4..311271bf4 100644 --- a/infra/resources.bicep +++ b/infra/resources.bicep @@ -6,6 +6,9 @@ param principalId string param databasePassword string var appName = '${name}-${resourceToken}' +var containerAppName = toLower(appName) +var containerRegistryName = toLower('acr${resourceToken}001') +var sqlConnectionString = 'Server=tcp:${dbserver.name}.database.windows.net,1433;Initial Catalog=${dbserver::db.name};Persist Security Info=False;User ID=${dbserver.properties.administratorLogin};Password=${databasePassword};MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=True;Connection Timeout=30;' resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = { location: location @@ -23,14 +26,28 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = { } } { - name: 'webapp-subnet' + name: 'aca-subnet' properties: { addressPrefix: '10.0.1.0/24' delegations: [ { - name: 'dlg-appServices' + name: 'dlg-containerapps' properties: { - serviceName: 'Microsoft.Web/serverfarms' + serviceName: 'Microsoft.App/environments' + } + } + ] + } + } + { + name: 'aca-infra-subnet' + properties: { + addressPrefix: '10.0.4.0/23' + delegations: [ + { + name: 'dlg-containerapps-infra' + properties: { + serviceName: 'Microsoft.App/environments' } } ] @@ -58,15 +75,14 @@ resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-01-01' = { resource subnetForVault 'subnets' existing = { name: 'vault-subnet' } - resource subnetForApp 'subnets' existing = { - name: 'webapp-subnet' + resource subnetForAca 'subnets' existing = { + name: 'aca-infra-subnet' } resource subnetForCache 'subnets' existing = { name: 'cache-subnet' } } -// Resources needed to secure Key Vault behind a private endpoint resource privateDnsZoneKeyVault 'Microsoft.Network/privateDnsZones@2020-06-01' = { name: 'privatelink.vaultcore.azure.net' location: 'global' @@ -81,6 +97,58 @@ resource privateDnsZoneKeyVault 'Microsoft.Network/privateDnsZones@2020-06-01' = } } } + +resource privateDnsZoneDB 'Microsoft.Network/privateDnsZones@2020-06-01' = { + name: 'privatelink.database.windows.net' + location: 'global' + dependsOn: [ + virtualNetwork + ] + resource privateDnsZoneLinkDB 'virtualNetworkLinks@2020-06-01' = { + name: '${appName}-dblink' + location: 'global' + properties: { + virtualNetwork: { + id: virtualNetwork.id + } + registrationEnabled: false + } + } +} + +resource privateDnsZoneCache 'Microsoft.Network/privateDnsZones@2020-06-01' = { + name: 'privatelink.redis.cache.windows.net' + location: 'global' + dependsOn: [ + virtualNetwork + ] + resource privateDnsZoneLinkCache 'virtualNetworkLinks@2020-06-01' = { + name: '${appName}-cachelink' + location: 'global' + properties: { + virtualNetwork: { + id: virtualNetwork.id + } + registrationEnabled: false + } + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { + name: '${take(replace(appName, '-', ''), 17)}-vault' + location: location + properties: { + enableRbacAuthorization: true + tenantId: subscription().tenantId + sku: { family: 'A', name: 'standard' } + publicNetworkAccess: 'Disabled' + networkAcls: { + defaultAction: 'Deny' + bypass: 'None' + } + } +} + resource vaultPrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = { name: '${appName}-vault-privateEndpoint' location: location @@ -113,7 +181,42 @@ resource vaultPrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = } } -// Resources needed to secure Azure SQL Database behind a private endpoint +resource keyVaultSecretUserRoleRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { + scope: subscription() + name: '4633458b-17de-408a-b874-0445c86b69e6' +} + +resource keyVaultSecretUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-08-01-preview' = if (!empty(principalId)) { + scope: keyVault + name: guid(resourceGroup().id, principalId, keyVaultSecretUserRoleRoleDefinition.id) + properties: { + roleDefinitionId: keyVaultSecretUserRoleRoleDefinition.id + principalId: principalId + principalType: 'User' + } +} + +resource dbserver 'Microsoft.Sql/servers@2023-05-01-preview' = { + location: location + name: '${appName}-server' + properties: { + administratorLogin: '${appName}-server-admin' + administratorLoginPassword: databasePassword + publicNetworkAccess: 'Disabled' + restrictOutboundNetworkAccess: 'Disabled' + } + resource db 'databases@2023-05-01-preview' = { + location: location + name: '${appName}-database' + sku: { + name: 'GP_S_Gen5' + tier: 'GeneralPurpose' + family: 'Gen5' + capacity: 1 + } + } +} + resource dbPrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = { name: '${appName}-db-privateEndpoint' location: location @@ -145,25 +248,23 @@ resource dbPrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = { } } } -resource privateDnsZoneDB 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: 'privatelink.database.windows.net' - location: 'global' - dependsOn: [ - virtualNetwork - ] - resource privateDnsZoneLinkDB 'virtualNetworkLinks@2020-06-01' = { - name: '${appName}-dblink' - location: 'global' - properties: { - virtualNetwork: { - id: virtualNetwork.id - } - registrationEnabled: false + +resource redisCache 'Microsoft.Cache/Redis@2023-08-01' = { + name: '${appName}-cache' + location: location + properties: { + sku: { + name: 'Basic' + family: 'C' + capacity: 0 } + redisConfiguration: {} + enableNonSslPort: false + redisVersion: '6' + publicNetworkAccess: 'Disabled' } } -// Resources needed to secure Redis Cache behind a private endpoint resource cachePrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = { name: '${appName}-cache-privateEndpoint' location: location @@ -195,310 +296,163 @@ resource cachePrivateEndpoint 'Microsoft.Network/privateEndpoints@2023-04-01' = } } } -resource privateDnsZoneCache 'Microsoft.Network/privateDnsZones@2020-06-01' = { - name: 'privatelink.redis.cache.windows.net' - location: 'global' - dependsOn: [ - virtualNetwork - ] - resource privateDnsZoneLinkCache 'virtualNetworkLinks@2020-06-01' = { - name: '${appName}-cachelink' - location: 'global' - properties: { - virtualNetwork: { - id: virtualNetwork.id - } - registrationEnabled: false - } - } -} -// The Key Vault is used to manage SQL database and redis secrets. -// Current user has the admin permissions to configure key vault secrets, but by default doesn't have the permissions to read them. -resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { - name: '${take(replace(appName, '-', ''), 17)}-vault' +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { + name: '${appName}-workspace' location: location - properties: { - enableRbacAuthorization: true - tenantId: subscription().tenantId - sku: { family: 'A', name: 'standard' } - // Only allow requests from the private endpoint in the VNET. - publicNetworkAccess: 'Disabled' // To see the secret in the portal, change to 'Enabled' - networkAcls: { - defaultAction: 'Deny' // To see the secret in the portal, change to 'Allow' - bypass: 'None' + properties: any({ + retentionInDays: 30 + features: { + searchVersion: 1 } - } -} - -// Grant the current user with key vault secret user role permissions over the key vault. This lets you inspect the secrets, such as in the portal -// If you remove this section, you can't read the key vault secrets, but the app still has access with its managed identity. -resource keyVaultSecretUserRoleRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { - scope: subscription() - name: '4633458b-17de-408a-b874-0445c86b69e6' // The built-in Key Vault Secret User role -} -resource keyVaultSecretUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-08-01-preview' = { - scope: keyVault - name: guid(resourceGroup().id, principalId, keyVaultSecretUserRoleRoleDefinition.id) - properties: { - roleDefinitionId: keyVaultSecretUserRoleRoleDefinition.id - principalId: principalId - principalType: 'User' - } -} - -// The SQL Database server is configured to be the minimum pricing tier -resource dbserver 'Microsoft.Sql/servers@2023-05-01-preview' = { - location: location - name: '${appName}-server' - properties: { - administratorLogin: '${appName}-server-admin' - administratorLoginPassword: databasePassword - publicNetworkAccess: 'Disabled' - restrictOutboundNetworkAccess: 'Disabled' - } - resource db 'databases@2023-05-01-preview' = { - location: location - name: '${appName}-database' sku: { - name: 'GP_S_Gen5' - tier: 'GeneralPurpose' - family: 'Gen5' - capacity: 1 + name: 'PerGB2018' } - } + }) } -// The Redis cache is configured to the minimum pricing tier -resource redisCache 'Microsoft.Cache/Redis@2023-08-01' = { - name: '${appName}-cache' +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-11-01-preview' = { + name: containerRegistryName location: location - properties: { - sku: { - name: 'Basic' - family: 'C' - capacity: 0 - } - redisConfiguration: {} - enableNonSslPort: false - redisVersion: '6' - publicNetworkAccess: 'Disabled' + sku: { + name: 'Basic' } -} - -// The App Service plan is configured to the B1 pricing tier -resource appServicePlan 'Microsoft.Web/serverfarms@2022-09-01' = { - name: '${appName}-plan' - location: location - kind: 'linux' properties: { - reserved: true - } - sku: { - name: 'B1' + adminUserEnabled: true } } -resource web 'Microsoft.Web/sites@2022-09-01' = { - name: appName +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = { + name: '${appName}-cae' location: location - tags: {'azd-service-name': 'web'} // Needed by AZD properties: { - siteConfig: { - linuxFxVersion: 'DOTNETCORE|8.0' // Set to .NET 8 (LTS) - vnetRouteAllEnabled: true // Route outbound traffic to the VNET - ftpsState: 'Disabled' - appCommandLine: './migrationsbundle -- --environment Production && dotnet "DotNetCoreSqlDb.dll"' - } - serverFarmId: appServicePlan.id - httpsOnly: true - } - - // Disable basic authentication for FTP and SCM - resource ftp 'basicPublishingCredentialsPolicies@2023-12-01' = { - name: 'ftp' - properties: { - allow: false - } - } - resource scm 'basicPublishingCredentialsPolicies@2023-12-01' = { - name: 'scm' - properties: { - allow: false - } - } - - // Enable App Service native logs - resource logs 'config' = { - name: 'logs' - properties: { - applicationLogs: { - fileSystem: { - level: 'Information' - } - } - detailedErrorMessages: { - enabled: true - } - failedRequestsTracing: { - enabled: true + workloadProfiles: [ + { + name: 'Consumption' + workloadProfileType: 'Consumption' } - httpLogs: { - fileSystem: { - enabled: true - retentionInDays: 1 - retentionInMb: 35 - } + ] + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.properties.customerId + sharedKey: listKeys(logAnalyticsWorkspace.id, logAnalyticsWorkspace.apiVersion).primarySharedKey } } - } - - // Enable VNET integration - resource webappVnetConfig 'networkConfig' = { - name: 'virtualNetwork' - properties: { - subnetResourceId: virtualNetwork::subnetForApp.id + vnetConfiguration: { + infrastructureSubnetId: virtualNetwork::subnetForAca.id + internal: false } } - - dependsOn: [virtualNetwork] } -// Service Connector from the app to the key vault, which generates the connection settings for the App Service app -// The application code doesn't make any direct connections to the key vault, but the setup expedites the managed identity access -// so that the cache connector can be configured with key vault references. -resource vaultConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { - scope: web - name: 'vaultConnector' - properties: { - clientType: 'dotnet' - targetService: { - type: 'AzureResource' - id: keyVault.id - } - authInfo: { - authType: 'systemAssignedIdentity' // Use a system-assigned managed identity. No password is used. - } - vNetSolution: { - type: 'privateLink' - } +resource web 'Microsoft.App/containerApps@2024-03-01' = { + name: containerAppName + location: location + tags: { + 'azd-service-name': 'web' } - dependsOn: [ - vaultPrivateEndpoint - ] -} - -// Connector to the SQL Server database, which generates the connection string for the ASP.NET Core application -resource dbConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { - scope: web - name: 'defaultConnector' - properties: { - targetService: { - type: 'AzureResource' - id: dbserver::db.id - } - authInfo: { - authType: 'secret' - name: '${appName}-server-admin' - secretInfo: { - secretType: 'rawValue' - value: databasePassword - } - } - secretStore: { - keyVaultId: keyVault.id // Configure secrets as key vault references. No secret is exposed in App Service. - } - clientType: 'dotnet-connectionString' // Generate a .NET connection string. For app setting, use 'dotnet' instead - vNetSolution: { - type: 'privateLink' - } + identity: { + type: 'SystemAssigned' } -} - -// Service Connector from the app to the cache, which generates an app setting for the ASP.NET Core application -resource cacheConnector 'Microsoft.ServiceLinker/linkers@2024-04-01' = { - scope: web - name: 'RedisConnector' properties: { - clientType: 'dotnet' - targetService: { - type: 'AzureResource' - id: resourceId('Microsoft.Cache/Redis/Databases', redisCache.name, '0') - } - authInfo: { - authType: 'accessKey' // Configure secrets as key vault references. No secret is exposed in App Service. - } - secretStore: { - keyVaultId: keyVault.id + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + ingress: { + external: true + targetPort: 80 + allowInsecure: false + transport: 'auto' + } + registries: [ + { + server: containerRegistry.properties.loginServer + username: listCredentials(containerRegistry.id, containerRegistry.apiVersion).username + passwordSecretRef: 'acr-password' + } + ] + secrets: [ + { + name: 'acr-password' + value: listCredentials(containerRegistry.id, containerRegistry.apiVersion).passwords[0].value + } + { + name: 'sql-connection-string' + value: sqlConnectionString + } + { + name: 'redis-connection-string' + value: '${redisCache.name}.redis.cache.windows.net:6380,password=${listKeys(redisCache.id, redisCache.apiVersion).primaryKey},ssl=True,abortConnect=False' + } + ] } - vNetSolution: { - type: 'privateLink' + template: { + containers: [ + { + name: 'web' + image: 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + env: [ + { + name: 'ASPNETCORE_ENVIRONMENT' + value: 'Production' + } + { + name: 'ASPNETCORE_URLS' + value: 'http://+:80' + } + { + name: 'AZURE_SQL_CONNECTIONSTRING' + secretRef: 'sql-connection-string' + } + { + name: 'ConnectionStrings__AZURE_SQL_CONNECTIONSTRING' + secretRef: 'sql-connection-string' + } + { + name: 'AZURE_REDIS_CONNECTIONSTRING' + secretRef: 'redis-connection-string' + } + ] + resources: { + cpu: json('0.5') + memory: '1Gi' + } + } + ] + scale: { + minReplicas: 1 + maxReplicas: 3 + } } } dependsOn: [ + dbPrivateEndpoint cachePrivateEndpoint + vaultPrivateEndpoint ] } -resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { - name: '${appName}-workspace' - location: location - properties: any({ - retentionInDays: 30 - features: { - searchVersion: 1 - } - sku: { - name: 'PerGB2018' - } - }) +resource acrPullRoleDefinition 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = { + scope: subscription() + name: '7f951dda-4ed3-4680-a7ca-43fe172d538d' } -// Enable log shipping from the App Service app to the Log Analytics workspace. -resource webdiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { - name: 'AllLogs' - scope: web +resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: containerRegistry + name: guid(containerRegistry.id, web.name, acrPullRoleDefinition.id) properties: { - workspaceId: logAnalyticsWorkspace.id - logs: [ - { - category: 'AppServiceHTTPLogs' - enabled: true - } - { - category: 'AppServiceConsoleLogs' - enabled: true - } - { - category: 'AppServiceAppLogs' - enabled: true - } - { - category: 'AppServiceAuditLogs' - enabled: true - } - { - category: 'AppServiceIPSecAuditLogs' - enabled: true - } - { - category: 'AppServicePlatformLogs' - enabled: true - } - ] - metrics: [ - { - category: 'AllMetrics' - enabled: true - } - ] + roleDefinitionId: acrPullRoleDefinition.id + principalId: web.identity.principalId + principalType: 'ServicePrincipal' } } -output WEB_URI string = 'https://${web.properties.defaultHostName}' - -output CONNECTION_SETTINGS array = map(concat(dbConnector.listConfigurations().configurations, cacheConnector.listConfigurations().configurations, vaultConnector.listConfigurations().configurations), config => config.name) -output WEB_APP_LOG_STREAM string = format('https://portal.azure.com/#@/resource{0}/logStream', web.id) -output WEB_APP_SSH string = format('https://{0}.scm.azurewebsites.net/webssh/host', web.name) -output WEB_APP_CONNECTIONSTRINGS string = format('https://portal.azure.com/#@/resource{0}/connectionStrings', web.id) -output WEB_APP_APPSETTINGS string = format('https://portal.azure.com/#@/resource{0}/environmentVariablesAppSettings', web.id) +output WEB_URI string = 'https://${web.properties.configuration.ingress.fqdn}' +output CONNECTION_SETTINGS array = [ + 'AZURE_SQL_CONNECTIONSTRING' + 'AZURE_REDIS_CONNECTIONSTRING' +] +output CONTAINER_APP_NAME string = web.name +output CONTAINER_APP_ID string = web.id +output CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.properties.loginServer +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.properties.loginServer diff --git a/project_purpose_overview.md b/project_purpose_overview.md new file mode 100644 index 000000000..036596d73 --- /dev/null +++ b/project_purpose_overview.md @@ -0,0 +1,2114 @@ +# Project purpose overview +_Exported on 3/3/2026 at 22:02:52 EST from Cursor (2.6.11)_ + +--- + +**User** + +What this projects do? + +--- + +**Cursor** + +Reviewing key files to understand the project. + + +## Project Overview + +ASP.NET Core 8.0 Todo web app, designed to deploy to Azure App Service with Azure SQL Database. + +### Core Functionality + +- Todo CRUD: Create, Read, Update, Delete +- Todo model: ID, Description, CreatedDate +- MVC structure: Controllers, Views, Models + +### Technical Features + +1. Database integration: + - Entity Framework Core with SQL Server + - Development: local SQL Server + - Production: Azure SQL Database (via `AZURE_SQL_CONNECTIONSTRING`) + +2. Caching: + - Development: in-memory cache + - Production: Redis (`AZURE_REDIS_CONNECTIONSTRING`) + - Cache-aside pattern for Todo items + +3. Azure integration: + - Azure App Service logging + - Database migrations via migrations bundle + - Virtual network integration with private endpoints + +4. Additional features: + - Action timer filter for performance monitoring + - HTTPS redirection and HSTS + - Error handling + +### Purpose + +This is a sample/demo app for deploying an ASP.NET Core app to Azure App Service with SQL Database. It demonstrates: +- Azure deployment workflows +- Database migrations in Azure +- Caching strategies (local vs Redis) +- Azure App Service integration + +The default route is `Todos/Index`, so the app opens to the Todo list page. + +--- + +**User** + +If I need publish to Azure, what I need change? + +--- + +**Cursor** + +Checking configuration files and Azure deployment setup to identify what needs to change for publishing. + + +The code is already set up for Azure. Configure these in Azure: + +## What to change/configure + +### 1. Azure App Service configuration (connection strings) + +Set these App Settings in Azure App Service: + +- `AZURE_SQL_CONNECTIONSTRING` — Azure SQL Database connection string + - Format: `Server=tcp:.database.windows.net,1433;Initial Catalog=;Persist Security Info=False;User ID=;Password=;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;` +- `AZURE_REDIS_CONNECTIONSTRING` — Azure Redis Cache connection string (optional but recommended) + - Format: `.redis.cache.windows.net:6380,password=,ssl=True,abortConnect=False` + +### 2. Azure resources to create + +1. Azure App Service (Linux, .NET 8) +2. Azure SQL Database +3. Azure Redis Cache (optional; if not used, the app will fail in production) + +### 3. Database migrations + +The app expects a migrations bundle. Options: + +- Option A: Run migrations manually + ```bash + dotnet ef database update + ``` +- Option B: Use migrations bundle (as mentioned in README) + - The app can run migrations automatically if configured + +### 4. Code changes (optional) + +If you want to make Redis optional, update `Program.cs` to handle missing Redis gracefully: + +```csharp +// Current code requires Redis in production +// You could add error handling or make it optional +``` + +### 5. Environment variable + +Ensure `ASPNETCORE_ENVIRONMENT` is not set to `Development` in Azure App Service (or set it to `Production`). + +## Quick deployment steps + +1. Create Azure resources: + - App Service + - SQL Database + - Redis Cache (optional) + +2. Configure App Settings in App Service: + - Add `AZURE_SQL_CONNECTIONSTRING` + - Add `AZURE_REDIS_CONNECTIONSTRING` (if using Redis) + +3. Deploy your code: + - Use Visual Studio Publish + - Use Azure CLI: `az webapp deploy` + - Use GitHub Actions (workflow file exists) + +4. Run database migrations: + - Either manually via Azure Cloud Shell or configure automatic migration + +The code already switches between local (Development) and Azure (Production) configurations based on the environment. + +Should I help you set up any of these configurations or modify the code to make Redis optional? + +--- + + +**User** + +详细分析一下本程序的结构与功能 + +--- + +**Cursor** + +### 总体概览 + +这是一个基于 **ASP.NET Core MVC + EF Core + SQL Server** 的示例应用,用来演示在 **Azure App Service + Azure SQL Database(可选 Redis 缓存)** 上部署一个简单的 Todo(待办事项)应用。 + +核心功能: +- **Todo 列表的增删改查(CRUD)** +- **使用 EF Core 操作 SQL 数据库** +- **在生产环境用 Redis 做缓存,在开发环境用内存缓存** +- **集成 Azure App Service 日志和迁移机制** + +--- + +### 程序入口与基础配置:`Program.cs` + +**主要职责:** +- 创建 `WebApplication` +- 根据环境(Development / 非 Development)配置数据库和缓存 +- 配置 MVC、日志、路由、中间件 + +**环境分支:** + +- **开发环境 (`builder.Environment.IsDevelopment() == true`)** + - 数据库上下文使用 `ConnectionStrings:MyDbConnection` + - 缓存:`AddDistributedMemoryCache()`(进程内内存缓存) +- **生产/其他环境** + - 数据库使用 `builder.Configuration.GetConnectionString("AZURE_SQL_CONNECTIONSTRING")` + - Redis 缓存: + - `options.Configuration = builder.Configuration["AZURE_REDIS_CONNECTIONSTRING"]` + - `options.InstanceName = "SampleInstance"` + +**中间件管道:** +- 非开发环境启用 `UseExceptionHandler("/Home/Error")` 和 `UseHsts()` +- `UseHttpsRedirection()`,`UseStaticFiles()`,`UseRouting()`,`UseAuthorization()` +- 路由规则: + - 默认:`{controller=Todos}/{action=Index}/{id?}` + - 即默认打开 `TodosController.Index`(Todo 列表页) + +--- + +### 项目结构与各层职责 + +#### 1. 模型层:`Models/Todo.cs` + +`Todo` 实体对应数据库中的 Todo 表: + +- `ID`:主键(int) +- `Description`:待办事项描述(string?) +- `CreatedDate`:创建日期(`DateTime`,并通过 `DisplayFormat` 指定显示格式为 `yyyy-MM-dd`) + +用于 EF Core 映射及 MVC 模型绑定/验证。 + +--- + +#### 2. 数据访问层:`Data/MyDatabaseContext.cs`(通过搜索结果可知) + +- 继承自 `DbContext` +- 包含 `DbSet Todo` 属性 +- 在 `Program.cs` 中通过 `AddDbContext` 注入 +- 负责对 SQL Server 进行增删改查、迁移等操作 + +配合 `Migrations/*` 中的迁移类完成数据库结构创建和更新。 + +--- + +#### 3. 控制器层:`Controllers/TodosController.cs` + +这是应用的核心业务控制器,带有 `[ActionTimerFilter]`(用于统计请求耗时的自定义过滤器)。 + +**依赖注入的服务:** +- `MyDatabaseContext _context`:数据库访问 +- `IDistributedCache _cache`:分布式缓存(开发环境为内存缓存,生产环境为 Redis) +- `ILogger _logger`:日志记录器 +- 缓存键前缀:`_TodoItemsCacheKey = "TodoItemsList"` + +**主要动作(Action):** + +- `Index()`(GET) + - 逻辑带缓存: + - 先从 `_cache.GetAsync(_TodoItemsCacheKey)` 读取 Todo 列表 + - 若有缓存:反序列化 JSON,返回视图,日志记录“Data from cache.” + - 若无缓存: + - 从 `_context.Todo.ToListAsync()` 查询数据库 + - 序列化为 JSON 存入缓存 + - 返回视图,日志记录“Data from database.” + +- `Details(int? id)`(GET) + - 先从缓存按 `TodoItemsList_{id}` 查单条记录 + - 有缓存则直接返回视图;否则查数据库、写入缓存后返回 + +- `Create()`(GET) + - 返回创建表单页面 + +- `Create(Todo todo)`(POST) + - 验证 `ModelState` + - 有效则: + - `_context.Add(todo)` + `SaveChangesAsync()` + - 删除列表缓存 `_cache.RemoveAsync(_TodoItemsCacheKey)`,保证下次列表显示最新数据 + - 重定向到 `Index` + +- `Edit(int? id)`(GET) + - 同样先查缓存,再查数据库,查不到返回 404 + +- `Edit(int id, Todo todo)`(POST) + - 校验 `id == todo.ID` 和 `ModelState` + - 更新数据库后: + - 移除当前条目缓存 `GetTodoItemCacheKey(id)` + - 移除列表缓存 `_TodoItemsCacheKey` + - 出现并发问题时进行存在性检查,抛出或返回 404 + +- `Delete(int? id)`(GET) + - 获取要删除的记录(同样先缓存、后数据库),展示确认页 + +- `DeleteConfirmed(int id)`(POST) + - 从数据库删除该 Todo + - 移除该条目的缓存和列表缓存 + - 重定向到 `Index` + +**辅助方法:** +- `TodoExists(int id)`:是否存在该主键 +- `GetTodoItemCacheKey(int? id)`:生成形如 `TodoItemsList_5` 的单条缓存键 + +> 总结:`TodosController` 实现了完整的 Todo CRUD 以及**缓存一致性**策略(写操作完成后清理相关缓存)。 + +--- + +#### 4. 视图层:`Views/*` + +与 `TodosController` 对应的一组 Razor 视图(`.cshtml`): + +- `Views/Todos/Index.cshtml`:Todo 列表页(展示所有任务) +- `Views/Todos/Create.cshtml`:新建 Todo 表单 +- `Views/Todos/Edit.cshtml`:编辑 Todo 表单 +- `Views/Todos/Details.cshtml`:详情页 +- `Views/Todos/Delete.cshtml`:删除确认页 + +以及: + +- `Views/Home/Index.cshtml`:主页(可能提供导航或简单介绍) +- `Views/Shared/_Layout.cshtml`:全局布局(导航栏、引用 CSS/JS) +- `Views/Shared/Error.cshtml`:错误页面 + +--- + +#### 5. 过滤器:`ActionTimerFilter.cs` + +- 通过 `[ActionTimerFilter]` 应用在 `TodosController` 上 +- 典型用途是: + - 在 Action 执行前记录开始时间 + - 在执行后记录耗时,并用 `ILogger` 输出日志 +- 用于监控接口性能、请求耗时情况 + +--- + +#### 6. 配置文件 + +- `appsettings.json` + - 日志级别 + - `ConnectionStrings:MyDbConnection`:本地/容器开发环境默认连接字符串 +- `appsettings.Development.json` + - 覆盖或补充开发环境的配置 +- `Properties/launchSettings.json` + - 本地启动配置(IIS Express / Kestrel),环境变量 `ASPNETCORE_ENVIRONMENT=Development` +- `.devcontainer/*`、`docker-compose.yml` + - 用于在 Dev Container / Codespaces 中开发(包含 SQL Server 容器、postCreate 脚本等) +- `.github/workflows/starter-no-infra_mydoc-core-sql.yml` + - GitHub Actions 部署流水线(build + publish + 迁移 bundle + 部署到 Azure Web App) + +--- + +#### 7. 项目文件:`DotNetCoreSqlDb.csproj` + +- 目标框架:`.NET 8.0` +- 主要依赖: + - `Microsoft.EntityFrameworkCore.SqlServer` + - `Microsoft.EntityFrameworkCore.Tools` + - `Microsoft.Extensions.Caching.StackExchangeRedis` + - `Microsoft.Extensions.Logging.AzureAppServices` + - `Microsoft.VisualStudio.Web.CodeGeneration.Design` +- 包含 `migrationsbundle` 文件(支持在 Azure 上通过 bundle 运行 EF 迁移) + +--- + +### 运行时行为总结 + +- **开发环境:** + - 使用 `appsettings.json` 中的本地 SQL 连接串 + - 使用内存缓存(不依赖 Redis) + - 启用开发者友好的错误信息(在 `Program.cs` 中通过环境判断决定是否用 HSTS 和全局错误页) +- **生产(Azure)环境:** + - 通过 App Service 的配置(环境变量/连接字符串)提供: + - `AZURE_SQL_CONNECTIONSTRING`:Azure SQL 数据库连接 + - `AZURE_REDIS_CONNECTIONSTRING`:Redis 缓存连接 + - 使用 Redis 实现分布式缓存 + - 使用 `AddAzureWebAppDiagnostics()` 把日志发送到 Azure 日志 + +--- + +### 功能小结 + +- **业务功能:** + - Todo 事项的创建、查看、修改、删除 + - 列表视图和详情视图 +- **技术特性:** + - EF Core + SQL Server 数据持久化 + - Redis/内存缓存加速查询、减轻数据库压力 + - ASP.NET Core MVC 分层结构清晰(模型 / 控制器 / 视图) + - 可直接部署到 Azure App Service,配合 GitHub Actions 与迁移 bundle + +如果你愿意,我可以下一步帮你画一张简单的架构图(文字版)或者带你逐步看一遍关键代码(例如从浏览器请求开始,到控制器、数据库、缓存的整个调用链)。 + + + +### 好的 👌 + +那我给你一个**文字版架构图 + 一次完整请求链路说明**,帮助你从宏观上记住这个项目。 + +--- + +### 1. 文字版架构图(逻辑结构) + +- **浏览器** + - 访问 `https://你的域名/` → 实际命中路由 `Todos/Index` + +- **ASP.NET Core MVC** + - `Program.cs` + - 配置中间件(路由、静态文件、错误处理、HTTPS 等) + - 注册服务: + - `MyDatabaseContext`(EF Core) + - `IDistributedCache`(开发:内存;生产:Redis) + - 日志(含 Azure 日志) + - `TodosController` + - Action: + - `Index / Details / Create / Edit / Delete / DeleteConfirmed` + - 使用服务: + - `_context`(EF Core 上下文) + - `_cache`(分布式缓存) + - `_logger`(日志) + - `[ActionTimerFilter]`:统计每个 Action 的耗时 + +- **数据访问层** + - `MyDatabaseContext`(EF Core `DbContext`) + - `DbSet Todo` + - **Migrations** + - `20240621154946_InitialCreate` 等迁移类 + - `MyDatabaseContextModelSnapshot` + +- **领域模型** + - `Models/Todo` + - `ID` + - `Description` + - `CreatedDate`(带显示格式) + +- **视图层(Razor Views)** + - `Views/Todos/*.cshtml` + - `Index`:列表 + - `Create`:新增 + - `Edit`:编辑 + - `Details`:详情 + - `Delete`:删除确认 + - 公共布局:`Views/Shared/_Layout.cshtml` + - 错误页:`Views/Shared/Error.cshtml` + +- **基础设施/配置** + - `appsettings*.json`:日志 + 连接字符串 + - `launchSettings.json`:本地调试配置 + - `.devcontainer/*` + `docker-compose.yml`:容器化开发环境(带 SQL Server) + - `.github/workflows/*.yml`:GitHub Actions CI/CD(构建 + 迁移 bundle + 部署) + +--- + +### 2. 一次完整请求链路(访问 Todo 列表) + +以访问 `Todos/Index` 为例: + +1. **浏览器发起请求** + - URL:`https://xxx/Todos/Index`(或根 `/`,由默认路由映射) + +2. **`Program.cs` 中间件处理** + - `UseHttpsRedirection` / `UseStaticFiles` / `UseRouting` / `UseAuthorization` + - 匹配到路由:`controller=Todos`, `action=Index` + +3. **进入 `TodosController.Index()`** + - 先尝试:`_cache.GetAsync("TodoItemsList")` + - 如果缓存命中: + - 反序列化 JSON 为 `List` + - 日志:`Data from cache.` + - 返回 `View(todoList)` + - 如果缓存未命中: + - `var todoList = await _context.Todo.ToListAsync();`(查 SQL) + - 序列化写入缓存:`_cache.SetAsync("TodoItemsList", ...)` + - 日志:`Data from database.` + - 返回 `View(todoList)` + +4. **视图渲染** + - 使用 `Views/Todos/Index.cshtml` 把 `todoList` 渲染成 HTML + - 包装在 `_Layout.cshtml` 布局中(导航、样式等) + +5. **响应返回给浏览器** + - 用户看到 Todo 列表页面 + +--- + +如果你愿意,下一步我可以: + +- 带你逐行看 `TodosController` 的代码,讲每个方法为什么这么写 +或 +- 手把手教你在本地跑起来(包括数据库初始化、运行命令) + +你更想先看哪一个? + +--- + +**User** + +一步步来。mvc routing是如何实现的? + +--- + +**Cursor** + +### 总体思路先说清楚 + +在这个项目里,**MVC 路由**是通过 ASP.NET Core 的 **Endpoint Routing** 实现的,关键代码就在你现在打开的 `Program.cs` 里: + +```csharp +app.UseRouting(); + +app.UseAuthorization(); + +app.MapControllerRoute( + name: "default", + pattern: "{controller=Todos}/{action=Index}/{id?}"); +``` + +下面我一步一步拆开讲“从浏览器发请求 → 命中某个 Controller.Action”的全过程。 + +--- + +### 第一步:中间件把请求交给路由系统 + +`Program.cs` 中间件顺序大致是: + +1. `app.UseHttpsRedirection();` +2. `app.UseStaticFiles();` +3. `app.UseRouting();` ← 开启路由匹配 +4. `app.UseAuthorization();` +5. `app.MapControllerRoute(...);` ← 注册 MVC 路由端点 + +关键点: + +- `app.UseRouting()`:把请求交给路由系统去**匹配 URL**,找到“哪个 endpoint(终结点)应该处理这个请求”。 +- 终结点可以是: + - MVC Controller Action + - Razor Pages + - Minimal API 等 + +--- + +### 第二步:`MapControllerRoute` 定义了 MVC 的“默认路由规则” + +你这里的配置是: + +```csharp +app.MapControllerRoute( + name: "default", + pattern: "{controller=Todos}/{action=Index}/{id?}"); +``` + +逐个解释: + +- `pattern: "{controller=Todos}/{action=Index}/{id?}"` + + - `{controller=Todos}` + - URL 的第 1 段 → 控制器名(去掉 “Controller” 后缀) + - 如果 URL 里 **没写** 这一段,就默认用 `Todos` + - 比如: + - `/Todos/Index` → `TodosController` + - `/Home/Index` → `HomeController` + - `/` → 因为没写 controller,默认 `TodosController` + + - `{action=Index}` + - URL 的第 2 段 → Action 方法名 + - 如果 **没写**,默认 `Index` + - 比如: + - `/Todos/Index` → `Index` 方法 + - `/Todos` → 默认 `Index` + - `/Home/Privacy` → `HomeController.Privacy` + + - `{id?}` + - URL 的第 3 段 → 可选参数 `id` + - 后面的 `?` 表示**可选** + - 常见用法: + - `/Todos/Details/5` → `Details(int id=5)` + - `/Todos/Edit/10` → `Edit(int id=10)` + - `/Todos/Edit` → 因为没提供 `id`,会根据方法签名决定是 404 还是 ModelState 错误 + +--- + +### 第三步:请求如何被映射到具体的 Controller.Action + +以你项目的默认首页为例: + +#### 情况一:访问根路径 `/` + +1. URL:`/` +2. 按路由模板 `{controller=Todos}/{action=Index}/{id?}` 来补默认值: + - `controller`:没写 → 默认 `Todos` + - `action`:没写 → 默认 `Index` + - `id`:没写 → `null` +3. 匹配到: + - 控制器:`TodosController` + - 方法:`Index()` +4. 实际调用:`TodosController.Index()`,返回 `Views/Todos/Index.cshtml` + +#### 情况二:访问 `/Todos/Details/5` + +1. URL 分段:`Todos` / `Details` / `5` +2. 对应: + - `controller` = `Todos` → `TodosController` + - `action` = `Details` → `Details(int? id)` 或 `Details(int id)` + - `id` = `5` +3. MVC 创建控制器实例,做模型绑定,把 `5` 绑定到 `id` 参数,然后调用 `Details(id:5)`。 + +--- + +### 第四步:约定式路由 vs 属性路由 + +你这个项目**主要使用的是“约定式路由”**(conventional routing): + +- 在 `Program.cs` 统一写一条规则,适用于所有 Controller。 +- Controller/Action 的名字要按照约定来起: + - 类名以 `Controller` 结尾:`TodosController` + - 方法名是 `Index/Details/Create/...` + +ASP.NET Core 也支持**属性路由**(attribute routing),比如: + +```csharp +[Route("api/todos")] +public class TodosController : Controller +{ + [HttpGet("{id}")] + public IActionResult Get(int id) { ... } +} +``` + +但在这个项目中,`TodosController` 是走默认的 MVC 路由,没有使用 `Route`/`HttpGet` 这种自定义 URL 模式来改变默认规则。 + +--- + +### 第五步:路由和环境(Development/Production)无关 + +注意: + +- `if (!app.Environment.IsDevelopment()) { ... }` 这一段,只影响**错误处理和 HSTS**。 +- `app.UseRouting()` / `app.MapControllerRoute(...)` 在环境里是一样的。 + +也就是说: + +- 不管你在本地开发还是部署到 Azure, + **`/Todos/Index` 都是进 `TodosController.Index()`**, + **`/Todos/Edit/3` 都是进 `TodosController.Edit(id:3)`**。 + +--- + +如果你愿意,下一步我可以: + +- 具体拿 `TodosController` 的几个方法(比如 `Index/Details/Edit`),给你逐条画出“URL → 方法参数 → 查询数据库/缓存 → 返回视图”的序列流程。 + + + +现在环境下,如何本地运行测试? + +--- + +**Cursor** + +### 前置条件 + +- **已安装 .NET 8 SDK** +- **有 SQL Server 可用**(本机 SQL Server / Docker / Dev Container) +- 当前项目目录:`e:\DemoProjects\first-app-service` + +--- + +### 方式一:在当前 Windows 主机直接运行 + +1. **检查连接字符串** + + 打开 `appsettings.json`: + + ```json + "ConnectionStrings": { + "MyDbConnection": "Server=localhost;Database=ApplicationDB;User Id=sa;Password=P@ssw0rd;TrustServerCertificate=True;MultipleActiveResultSets=true" + } + ``` + + - 确认: + - `Server=...` 指向你本机/容器里实际可连的 SQL Server + - `User Id` / `Password` 正确 + - 如果你用的是本机 SQL Server Express,可以改成: + - `Server=(localdb)\\mssqllocaldb;...`(但这会要求你本机有 LocalDB) + +2. **初始化数据库** + + 在 PowerShell / CMD 中,进入项目目录: + + ```powershell + cd e:\DemoProjects\first-app-service + + # 安装 EF 工具(只需一次) + dotnet tool install -g dotnet-ef + + # 执行迁移,创建数据库 + 表 + dotnet ef database update + ``` + +3. **运行应用** + + ```powershell + dotnet run + ``` + + 运行成功后,终端会提示类似: + + ```text + Now listening on: https://localhost:7291 + Now listening on: http://localhost:5093 + ``` + + 用浏览器打开对应地址,比如 `https://localhost:7291` 或 `http://localhost:5093`,就能看到 Todo 页面。 + +--- + +### 方式二:用 Dev Container / Docker(用 `.devcontainer`) + +如果你用的是 VS Code + Dev Containers 或 GitHub Codespaces,这个项目已经配置好了: + +1. 用 VS Code 打开项目后,选择: + - **“Reopen in Container”**(或在 Codespaces 中自动进入容器) + +2. 容器内终端执行(README 同步的步骤): + + ```bash + dotnet ef database update + dotnet run + ``` + +3. 看到端口提示后,浏览器访问对应端口即可。 + +--- + +### 本地测试小结 + +- **关键三步**:连上 SQL Server → `dotnet ef database update` → `dotnet run` +- 跑起来以后,在浏览器里对 Todo 做增删改查,就算是对业务流程的“集成测试”了。 + +如果你告诉我你现在是用 VS、VS Code 还是命令行,我可以按你的工具给一套更细的“点哪儿、按什么键”的步骤。 + +--- + +**User** + +本地测试slqserver express 是否就够了? + +--- + +**Cursor** + +### 简短回答 + +**是的,本地测试用 SQL Server Express(或 LocalDB)就完全够了。** +它和 Azure SQL 在协议层兼容,EF Core 的代码、迁移脚本在本地和 Azure 上是一套。 + +--- + +### 稍微说细一点 + +- **本地只需要:** + - 安装任意一个: + - SQL Server Express(含本机实例) + - 或 SQL Server Developer Edition + - 或 LocalDB(通常随 Visual Studio 安装) + - 让连接字符串能连上这个实例即可。 + +- **连接字符串怎么改:** + - 如果你装的是 **SQL Server Express / Developer**,通常是: + ```json + "MyDbConnection": "Server=localhost;Database=ApplicationDB;User Id=sa;Password=你的密码;TrustServerCertificate=True;MultipleActiveResultSets=true" + ``` + - 如果你用的是 **LocalDB**,可以用示例里注释那条: + ```json + "MyDbConnection": "Server=(localdb)\\mssqllocaldb;Database=MyDatabaseContext-xxx;Trusted_Connection=True;MultipleActiveResultSets=true" + ``` + +- **迁移 & 运行:** + 1. `dotnet ef database update`(在你的本地 SQL 实例上创建/更新数据库) + 2. `dotnet run` + 3. 浏览器访问本地地址,做 Todo 的增删改查测试即可。 + +--- + +### 与 Azure 的关系 + +- 本地的 SQL Server Express / LocalDB 只是**开发测试环境**; +- 部署到 Azure 时,只需要把连接字符串换成 Azure SQL 的那条即可(`AZURE_SQL_CONNECTIONSTRING`),代码和迁移逻辑不用改。 + +如果你告诉我你机器上装的是哪种 SQL(Express / LocalDB / 别的),我可以帮你写一条**最适合你当前环境的连接字符串**。 + +--- + +**User** + +没定下装哪套,我都有 + +--- + +**Cursor** + +### 简短结论 + +你现在有好几套,其实**任选一套就够用**,关键只是**连接字符串写对**。 +如果只是本地开发/学习,这个项目里推荐优先级可以这样排: + +1. **LocalDB(最省事,配合 Visual Studio)** +2. **SQL Server Express 默认实例** +3. **完整 SQL Server 实例(Developer / Standard 等)** + +--- + +### 各种选择怎么用(给你现成连接串模版) + +假设数据库名都用 `ApplicationDB`: + +#### 1. 用 LocalDB(推荐:你装了 VS 而且不想管账户密码) + +`appsettings.json` 里: + +```json +"ConnectionStrings": { + "MyDbConnection": "Server=(localdb)\\mssqllocaldb;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" +} +``` + +特点: +- 不用设用户名密码,跟 Windows 登录集成 +- 适合单机开发、Demo + +#### 2. 用 SQL Server Express(有 `.\SQLEXPRESS` 之类实例) + +```json +"ConnectionStrings": { + "MyDbConnection": "Server=localhost\\SQLEXPRESS;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" +} +``` + +- 如果你给 SQL Server Express 配了 `sa` 账号,也可以用 SQL 登录: + +```json +"ConnectionStrings": { + "MyDbConnection": "Server=localhost\\SQLEXPRESS;Database=ApplicationDB;User Id=sa;Password=你的密码;TrustServerCertificate=True;MultipleActiveResultSets=true" +} +``` + +#### 3. 用完整 SQL Server 实例(默认实例) + +```json +"ConnectionStrings": { + "MyDbConnection": "Server=localhost;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" +} +``` + +或者 SQL 登录方式: + +```json +"ConnectionStrings": { + "MyDbConnection": "Server=localhost;Database=ApplicationDB;User Id=sa;Password=你的密码;TrustServerCertificate=True;MultipleActiveResultSets=true" +} +``` + +--- + +### 一次完整本地流程(不管你选哪套 SQL) + +1. 先确保你选择的那个实例“能连得上”(用 SSMS 试连一下)。 +2. 在 `appsettings.json` 里,把 `MyDbConnection` 改成对应那种连接串(只保留一条生效即可)。 +3. 打开终端,切到项目目录: + + ```powershell + cd e:\DemoProjects\first-app-service + dotnet ef database update + dotnet run + ``` + +4. 浏览器打开终端里提示的地址(比如 `https://localhost:7291`)。 + +--- + +如果你告诉我: +- 你“更想用哪一个”(LocalDB / SQLEXPRESS / 完整实例名), +我可以给你一条**完全替你填好实例名的最终版连接字符串**,你直接复制粘贴用就行。 + +--- + +**User** + +azure extension for sql server 需要装吗? + +--- + +**Cursor** + +### 简短结论 + +**不需要**。 +要本地跑这个示例,只要有 **SQL Server 实例 + .NET SDK + EF 工具** 就够了,**不依赖任何 Azure SQL 扩展**。 + +### 什么时候才有用 + +- 只有在这些场景下,Azure 相关扩展才会帮上忙: + - 在 VS Code / Azure Data Studio 里 **直接管理 Azure SQL 数据库**(建库、看表、跑查询) + - 从本地工具里一键连接 Azure、配置防火墙、导入导出数据 + +对你现在的目标(本地测试、将来用连接字符串连 Azure SQL)来说: +- **可以不用装**,以后真要图形化管理 Azure SQL 再加也不迟。 + +--- + +**User** + +装了VS 的机器,localDB 如何找到与管理? + +--- + +**Cursor** + +### 一句话概括 + +装了 Visual Studio 之后,**LocalDB 已经在你机器上了**,常用名是 ` (localdb)\MSSQLLocalDB`,你可以用 **VS 自带的 SQL Server Object Explorer** 或 **SSMS / 命令行** 来找到和管理它。 + +--- + +### 1. 在 Visual Studio 里找到 LocalDB(最简单) + +1. 打开 Visual Studio +2. 菜单栏:**View(视图) → SQL Server Object Explorer** +3. 左侧树里找到: + - `SQL Server` → 展开 + - 下面会有一条类似:`(localdb)\MSSQLLocalDB` +4. 展开它: + - `Databases` 下能看到你用 LocalDB 创建的所有数据库 + - 右键数据库可以: + - 新建查询 + - 查看表、列 + - 删除数据库等 + +> 以后你在连接字符串里用:`Server=(localdb)\MSSQLLocalDB;Database=ApplicationDB;...` +> 就能在这里看到 `ApplicationDB` 这个库。 + +--- + +### 2. 用 SQL Server Management Studio(SSMS)连接 LocalDB + +如果你装了 SSMS: + +1. 打开 SSMS +2. “连接到服务器”窗口: + - **服务器类型**:数据库引擎 + - **服务器名称**:`(localdb)\MSSQLLocalDB` + - **身份验证**:Windows 身份验证 +3. 点“连接” +4. 之后的管理方式和普通 SQL Server 一样:可以建库、建表、写 SQL 等。 + +--- + +### 3. 用命令行查看 LocalDB 实例 + +在 PowerShell / CMD 里可以用 `sqllocaldb` 工具: + +1. 查看所有 LocalDB 实例: + + ```powershell + sqllocaldb i + ``` + + 通常会看到:`MSSQLLocalDB` + +2. 查看某个实例的详细信息: + + ```powershell + sqllocaldb i MSSQLLocalDB + ``` + +3. 如果实例没启动,可以启动一下: + + ```powershell + sqllocaldb s MSSQLLocalDB + ``` + +--- + +### 4. 在你的项目里使用 LocalDB + +在 `appsettings.json` 的 `ConnectionStrings` 部分,用这一条就可以: + +```json +"ConnectionStrings": { + "MyDbConnection": "Server=(localdb)\\MSSQLLocalDB;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" +} +``` + +之后按这三个步骤走: + +```powershell +cd e:\DemoProjects\first-app-service +dotnet ef database update +dotnet run +``` + +--- + +如果你愿意,你可以先告诉我: +你更习惯用 **Visual Studio 自带的 SQL Server Object Explorer** 还是 **SSMS**,我可以按那个工具给你写一套“点哪里做什么”的更细操作步骤。 + +--- + +**User** + +authentication mode 怎么最方便? + +--- + +**Cursor** + +### 最省心的做法(结论) + +**本地开发:优先用 Windows 身份验证(Trusted_Connection / Integrated Security)** +**连 Azure SQL:用 SQL 登录(用户名+密码)** + +--- + +### 1. 本地最方便的模式:Windows 身份验证 + +不需要记账号密码,只要你当前登录的 Windows 用户有权限即可。 + +**示例连接串:** + +- LocalDB: + +```json +"Server=(localdb)\\MSSQLLocalDB;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" +``` + +- 本机 SQL Server / Express 默认实例: + +```json +"Server=localhost;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" +``` + +特点: +- 不用在字符串里写密码 +- 不用管理 `sa` 账号、密码策略 +- 对单机开发最省心 + +--- + +### 2. 什么时候用 SQL 身份验证(用户名+密码) + +- 连接 **Azure SQL** +- 或你本地的 SQL Server 没配好 Windows 登录,只开放了 SQL 登录 + +**示例:** + +```json +"Server=localhost;Database=ApplicationDB;User Id=sa;Password=你的密码;TrustServerCertificate=True;MultipleActiveResultSets=true" +``` + +在 Azure 上: + +```json +"Server=tcp:.database.windows.net,1433;Initial Catalog=;User ID=;Password=;Encrypt=True;..." +``` + +--- + +### 推荐组合 + +- **本地开发**:Windows 身份验证(`Trusted_Connection=True`) +- **Azure 生产**:SQL 登录(连接串放到 Azure 配置里,代码不写死密码) + +如果你告诉我“现在是连 LocalDB 还是某个实例名”,我可以直接给你一条可用的最终版连接串。 + +--- + +**User** + +local sqlexpress, mixed authentication mode + +--- + +**Cursor** + +很好,这样最灵活。你本地用 `SQLEXPRESS` + Mixed Mode 时,可以有两种常用写法:**Windows 身份验证** 和 **SQL 登录**,任选其一。 + +### 1. 用 Windows 身份验证(推荐做本地开发) + +`appsettings.json` 里的连接串类似: + +```json +"ConnectionStrings": { + "MyDbConnection": "Server=localhost\\SQLEXPRESS;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" +} +``` + +或: + +```json +"Server=.\\SQLEXPRESS;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" +``` + +只要你当前 Windows 用户对 `ApplicationDB` 有权限即可,不用写账号密码。 + +--- + +### 2. 用 SQL 登录(Mixed Mode 才支持) + +如果你想用 `sa` 或自建登录账户: + +```json +"ConnectionStrings": { + "MyDbConnection": "Server=localhost\\SQLEXPRESS;Database=ApplicationDB;User Id=sa;Password=你的密码;TrustServerCertificate=True;MultipleActiveResultSets=true" +} +``` + +要点: + +- 确保: + - SQL Server 处于 Mixed Mode + - 该登录名对 `ApplicationDB` 有 `db_owner`(至少 `db_datareader` / `db_datawriter`)权限 +- `TrustServerCertificate=True` 主要是避免本地开发时的证书警告。 + +--- + +### 3. EF Core 使用方式(与你现在项目一致) + +只要把上面的连接串填到 `MyDbConnection`: + +- `Program.cs` 里这句就会连到你的 SQLEXPRESS: + +```csharp +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("MyDbConnection"))); +``` + +然后: + +```bash +cd e:\DemoProjects\first-app-service +dotnet ef database update +dotnet run +``` + +就可以用 SQLEXPRESS + Mixed Mode 本地完整跑通。 + +--- + +**User** + +dotnet ef database update + +--- + +**Cursor** + +**`dotnet ef database update` 是干什么的?** + +在这个项目里,这条命令会根据 `Migrations` 里的迁移,把你的 SQL Server 里对应的数据库(`ApplicationDB` 或连接串里的库名)**创建/更新到最新结构**。简单说:**帮你建库、建表(比如 Todo 表)**,让程序能正常读写。 + +--- + +### 一步步怎么用(结合你现在的环境) + +1. **确认连接字符串指向 SQLEXPRESS** + + 在 `appsettings.json` 里,把 `MyDbConnection` 改成类似(根据你选的认证方式): + + - Windows 身份验证: + + ```json + "MyDbConnection": "Server=localhost\\SQLEXPRESS;Database=ApplicationDB;Trusted_Connection=True;MultipleActiveResultSets=true" + ``` + + - 或 SQL 登录(sa 账号): + + ```json + "MyDbConnection": "Server=localhost\\SQLEXPRESS;Database=ApplicationDB;User Id=sa;Password=你的密码;TrustServerCertificate=True;MultipleActiveResultSets=true" + ``` + +2. **安装 EF 工具(只需一次)** + + 在 PowerShell 里执行: + + ```powershell + dotnet tool install -g dotnet-ef + ``` + +3. **在项目目录运行迁移命令** + + ```powershell + cd e:\DemoProjects\first-app-service + dotnet ef database update + ``` + + - 成功时:会看到类似 “Done.”、“Applying migration …” 的输出,数据库中会出现对应的表(可在 SSMS 或 VS 里查看)。 + - 失败时:通常是 + - 连接字符串错误(连不上 SQL Server) + - SQL 登录无权限 + 把报错完整贴给我,我可以帮你逐条看。 + +4. **之后运行应用** + + ```powershell + dotnet run + ``` + +如果你已经跑了 `dotnet ef database update`,可以把终端输出贴出来,我帮你确认是否执行成功。 + +--- + + +### 2. 解决证书错误(SSL Provider, certificate chain not trusted) + +这个错误是因为客户端不信任 SQL Server 的加密证书。在**本地开发**环境下,最简单的做法是在连接串里加上: + +**`TrustServerCertificate=True`** + +这样客户端会信任服务器证书,不再校验证书链,错误就会消失。 + + + 发布到Azure 上,使用Azure 的 sql 数据库,并且本地和Azrue 使用同一套代码,通过 Development flag 或者 production flag 区分 + +--- + +**Cursor** + +确认当前配置是否已支持“同一套代码、用环境区分”,并列出在 Azure 上要做的设置。 + + +当前项目已经按 **Development / Production** 用同一套代码区分了,只需在 Azure 上配好环境即可。下面按「本地 vs Azure」和「在 Azure 要做的」说明。 + +--- + +## 1. 当前代码如何区分本地和 Azure(同一套代码) + +`Program.cs` 里已经按 **`ASPNETCORE_ENVIRONMENT`** 分支: + +| 环境 | 条件 | 数据库 | 缓存 | +|------|------|--------|------| +| **本地开发** | `IsDevelopment() == true` | `MyDbConnection`(appsettings.json) | 内存缓存 | +| **Azure 生产** | `IsDevelopment() == false` | `AZURE_SQL_CONNECTIONSTRING` | Redis(`AZURE_REDIS_CONNECTIONSTRING`) | + +- **本地**:`Properties/launchSettings.json` 里通常是 `ASPNETCORE_ENVIRONMENT=Development`,所以用本机 SQL(你现在的 `(local)\SQLEXPRESS`)和内存缓存。 +- **Azure**:在 App Service 里把环境设为 **Production**(或不设,默认就是 Production),并只配置 Azure 的连线和 Redis,不碰本地配置。 + +因此:**不需要改业务代码**,只要保证「本地 = Development」「Azure = Production」且 Azure 配置正确即可。 + +--- + +## 2. 在 Azure 上要做的事(用 Azure SQL,并保持同一套代码) + +### 2.1 创建资源(如还没有) + +- **Azure App Service**(例如 Linux + .NET 8,或 Windows 按你习惯)。 +- **Azure SQL Database**:建好服务器和数据库,记下: + - 服务器名:`.database.windows.net` + - 数据库名、用户名、密码。 +- **Azure Cache for Redis**(可选但当前代码在 Production 下会用到,见下文)。 + +### 2.2 在 App Service 里配置「Production」和连接 + +在 Azure 门户 → 你的 **App Service** → **Configuration** → **Application settings** / **Connection strings** 中设置: + +1. **环境变量(建议显式设一次)** + - 名称:`ASPNETCORE_ENVIRONMENT` + - 值:`Production` + (这样即使用同一套代码,也会走 `else` 分支,用 Azure SQL + Redis。) + +2. **数据库连接字符串(Production 用)** + - 类型:**Connection strings** + - 名称:**`AZURE_SQL_CONNECTIONSTRING`** + - 值:Azure SQL 的 ADO.NET 连接串,例如: + ```text + Server=tcp:.database.windows.net,1433;Initial Catalog=;Persist Security Info=False;User ID=;Password=;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30; + ``` + - 在门户里可以选「SQL Azure」然后填服务器、数据库、用户名、密码,自动生成。 + +3. **Redis 连接(当前 Production 代码必须)** + - 类型:**Application settings**(应用设置) + - 名称:**`AZURE_REDIS_CONNECTIONSTRING`** + - 值:Redis 的连接串,例如: + ```text + .redis.cache.windows.net:6380,password=,ssl=True,abortConnect=False + ``` + (若暂时不想用 Redis,见下文「可选:不在 Azure 用 Redis」。) + +保存后 App Service 会重启,之后请求会走 Production 分支,用 Azure SQL + Redis。 + +### 2.3 数据库结构(迁移)在 Azure 上执行一次 + +和本地一样,只是连到 Azure SQL。任选一种方式: + +- **在本地执行(推荐,简单)** + 临时把本机的 `appsettings.json` 里改成 Azure SQL 连接串(或单独建一个 `appsettings.Production.json` 仅本地跑迁移用),然后: + ```bash + dotnet ef database update + ``` + 再改回本地连接串,部署代码。 + +- **或在 Azure 上执行** + 用 App Service 的「高级工具」Kudu 或 SSH,在容器里用 `dotnet ef database update`(需在部署包里有 EF 工具或使用 migrations bundle,和你们现有 CI/CD 一致即可)。 + +这样本地和 Azure 共用同一套迁移和代码,只是连接串不同。 + +--- + +## 3. 小结:同一套代码、Development / Production 区分 + +- **本地** + - `ASPNETCORE_ENVIRONMENT=Development`(launchSettings 默认) + - 用 `appsettings.json` 的 **`MyDbConnection`**(你现在的 `(local)\SQLEXPRESS`) + - 缓存:内存 + → 不需要改现有 appsettings,你已经跑通了。 + +- **Azure** + - `ASPNETCORE_ENVIRONMENT=Production`(在 App Service 配置里设) + - 用 **`AZURE_SQL_CONNECTIONSTRING`**(Azure SQL) + - 用 **`AZURE_REDIS_CONNECTIONSTRING`**(Redis) + → 同一套代码,只通过环境变量 + 上述两个配置区分。 + +如果你希望 **Azure 上暂时不用 Redis**,只用到 Azure SQL,可以在 `Program.cs` 里加一个小判断:当 `AZURE_REDIS_CONNECTIONSTRING` 为空时用内存缓存,否则用 Redis。需要的话我可以按你当前的 `Program.cs` 写一版具体改法(仍保持 Development / Production 同一套代码)。 + +--- + +**User** + +需要将ConnectionStrings 从 @appsettings.json 移动到 @appsettings.json + +--- + +**Cursor** + +将 `ConnectionStrings` 从 `appsettings.json` 移到 `appsettings.Development.json`,使本地连接串仅存在于开发配置。 + + +当前是 **Ask 模式**,不能直接改文件,下面给你改法,你可以自己改或切到 **Agent 模式** 让我来改。 + +--- + +### 1. 从 `appsettings.json` 里删掉 `ConnectionStrings` + +把 `appsettings.json` 改成(只保留 Logging 和 AllowedHosts,去掉整段 ConnectionStrings): + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +``` + +--- + +### 2. 在 `appsettings.Development.json` 里加上 `ConnectionStrings` + +把 `appsettings.Development.json` 改成: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "MyDbConnection": "Server=(local)\\SQLEXPRESS;Database=ApplicationDB;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true" + } +} +``` + +(你原来在 `appsettings.json` 里是 `Server= (local)\\SQLEXPRESS`,这里我写成 `Server=(local)\\SQLEXPRESS`,若你本机必须用带空格的 `(local)` 再改回去即可。) + +--- + +### 3. 效果 + +- **Development**:会加载 `appsettings.json` + `appsettings.Development.json`,所以能读到 `MyDbConnection`,本地运行不变。 +- **Production(Azure)**:不加载 `appsettings.Development.json`,只用 Azure 里配置的 `AZURE_SQL_CONNECTIONSTRING` 等,连接串不会从仓库带上去。 + +如果你希望我直接在项目里改这两处,请切换到 **Agent 模式** 再说一次“把 ConnectionStrings 从 appsettings 移到 appsettings.Development”。 + +--- + +**User** + +把 ConnectionStrings 从 appsettings 移到 appsettings.Development” + +--- + +**Cursor** + +已按你的要求改好: + +1. **`appsettings.json`** + - 已删除整个 `ConnectionStrings` 节点,只保留 `Logging` 和 `AllowedHosts`。 + +2. **`appsettings.Development.json`** + - 已新增 `ConnectionStrings`,并把 `MyDbConnection` 移到这里。 + - 顺带修了原来连接串里少分号的问题:`TrustServerCertificate=TrueTrusted_Connection` → `TrustServerCertificate=True;Trusted_Connection=True`。 + - 服务器名写的是 `Server=(local)\\SQLEXPRESS`。如果你本机必须用带空格的 ` (local) `,把 `(local)` 改成 ` (local) ` 即可。 + +本地以 Development 运行时会从 `appsettings.Development.json` 读取 `MyDbConnection`;Production(例如 Azure)不会加载该文件,只会用你在 Azure 里配置的 `AZURE_SQL_CONNECTIONSTRING`。 + + +不需要额外权限,我已经能正常**只读**这些文件了(`azure.yaml` 和 `infra` 下面的几个 bicep/parameters 都看过),Ask 模式只禁止我“改文件 / 改配置”,不影响阅读和帮你分析部署流程。 + +结合现在的结构: + +- `azure.yaml` 在 `first-app-service` 根目录,`project: .` 指的就是当前这个 ASP.NET Core 项目。 +- `infra/main.bicep` + `infra/resources.bicep` 定义了整套基础设施(App Service + SQL + Redis + VNet + Key Vault 等),并通过 Service Linker 自动给你的应用生成: + - 数据库连接字符串(供 `AZURE_SQL_CONNECTIONSTRING` 使用) + - Redis 连接字符串(供 `AZURE_REDIS_CONNECTIONSTRING` 使用) +- 你要用这套配置部署时,只需要在 **`e:\DemoProjects\first-app-service` 目录下** 执行: + ```bash + azd auth login + azd up + ``` + 后续更新代码则用: + ```bash + azd deploy + ``` +后面如果你想,我可以按这套 `azure.yaml` + `infra`,给你列一份“从零开始到部署完成”的逐步清单(包括会问哪些参数、在 Azure 门户能看到哪些资源和配置)。 + +--- + +**User** + +好 + +--- + +**Cursor** + +### 用现有 `azure.yaml` + `infra` 完整部署到 Azure(一步步) + +#### 前置准备 + +- 本机已安装: + - **.NET 8 SDK** + - **Azure CLI**(`az`) + - **Azure Developer CLI**(`azd`) +- 当前目录:`e:\DemoProjects\first-app-service`(这里有 `azure.yaml` 和 `infra/`) + +--- + +### 第一步:登录 Azure + +在终端(PowerShell)执行: + +```powershell +cd e:\DemoProjects\first-app-service + +azd auth login +``` + +按提示在浏览器里登录你的 Azure 账号。 + +--- + +### 第二步:首轮部署(创建资源 + 部署代码) + +```powershell +azd up +``` + +它会依次询问你: + +1. **Environment name**(环境名) + - 比如:`dev` 或 `demo` + - 这个名字会参与资源命名(`-xxxxx-server` 之类) + +2. **Azure subscription**(订阅) + - 选你要部署到的订阅 + +3. **Location**(区域) + - 如:`westeurope`、`eastasia`、`centralus` 等 + +然后它会自动做: + +- 用 `infra/main.bicep` / `resources.bicep` 创建: + - Resource Group + - VNet + 子网 + - **Azure SQL Server + Database** + - **Azure Cache for Redis** + - **Key Vault** + - **App Service Plan(Linux, B1)** + - **Web App(.NET 8)** + - Log Analytics + 诊断日志 +- 为 Web App 配置: + - 启动命令:`./migrationsbundle -- --environment Production && dotnet "DotNetCoreSqlDb.dll"` + - Service Linker 生成的连接串 & app settings: + - 一个给数据库(供你代码里的 `AZURE_SQL_CONNECTIONSTRING` 使用) + - 一个给 Redis(供你代码里的 `AZURE_REDIS_CONNECTIONSTRING` 使用) +- 在打包阶段执行: + - `dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle` +- 部署应用并启动容器,先执行迁移,再启动 ASP.NET Core 程序。 + +执行完后,`azd up` 输出中会包含: + +- `WEB_URI`:你应用的访问 URL +- Portal 中: + - 连接串页面:`WEB_APP_CONNECTIONSTRINGS` + - App Settings 页面:`WEB_APP_APPSETTINGS` +- 日志 / SSH 链接:`WEB_APP_LOG_STREAM`、`WEB_APP_SSH` + +--- + +### 第三步:验证应用 + +1. 浏览器打开 `WEB_URI`(例如 `https://.azurewebsites.net`) +2. 看到 Todo 列表页面,尝试: + - 新建 Todo + - 编辑 / 删除 Todo +3. 确认数据持久化正常(即连上 Azure SQL,迁移也已执行)。 + +--- + +### 第四步:之后更新代码 + +以后你改动 C# 代码或视图: + +```powershell +cd e:\DemoProjects\first-app-service +azd deploy +``` + +- 不会重新创建资源,只会: + - 重新打包(再次执行 migrations bundle) + - 再部署到现有的 Web App。 + +--- + +如果你愿意,下一步我可以帮你“对着 Portal”解释:在 **App Service → Configuration** 里会自动出现哪些键(以及它们如何对应 `Program.cs` 里的 `AZURE_SQL_CONNECTIONSTRING` / `AZURE_REDIS_CONNECTIONSTRING`)。 + +--- + +**User** + +ERROR: error executing step command 'package --all': failed running pre hooks: failed running scripts for hooks 'azd package', hook configuration for 'prepackage' is invalid, run is always required + +--- + +**Cursor** + +你这个报错意思是:在执行 `azd package` 的时候,它要跑 `hooks.prepackage`,但是在 **Windows 环境** 下找不到对应的 `run` 脚本配置,所以直接报: + +> hook configuration for 'prepackage' is invalid, run is always required + +原因是你当前的 `azure.yaml` 里 **只有 `posix`,没有 `windows` 的 prepackage 配置**: + +```4:20:e:\DemoProjects\first-app-service\azure.yaml +hooks: + prepackage: + posix: + shell: sh + run: dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + interactive: true + continueOnError: true +``` + +在 Windows 上跑 `azd` 时,它会用 `windows` 这一节,如果没有 `run`,就报你看到的错。 + +--- + +### 修复方法:给 `prepackage` 加上 `windows` 块 + +打开 `azure.yaml`,把 `hooks.prepackage` 改成同时包含 `posix` 和 `windows`,例如: + +```yaml +hooks: + # The prepackage hook is used to create a runnable migrations bundle to be run in App Service. + # The migrationsbundle file is included in DotNetCoreSqlDb.csproj so it can be included in the deploy package. + prepackage: + posix: + shell: sh + run: dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + interactive: true + continueOnError: true + windows: + shell: pwsh + run: dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + interactive: true + continueOnError: true +``` + +说明: + +- `shell: pwsh`:让它在 PowerShell Core / Windows PowerShell 下执行。 +- `run` 用同一条命令生成针对 **Linux-x64** 的迁移 bundle(因为部署目标是 Linux App Service)。 + +保存后,再在 `e:\DemoProjects\first-app-service` 下重跑: + +```powershell +azd package --all +# 或者直接 +azd up +``` + +如果接下来又有新错误(比如 `dotnet ef` 没装好、连接串有问题),把新的报错贴出来,我可以继续帮你看。 + +--- + +**User** + +依然同样错误, azd up +Build started... +Build succeeded. +Building bundle... +Done. Migrations Bundle: E:\DemoProjects\first-app-service\migrationsbundle +Don't forget to copy appsettings.json alongside your bundle if you need it to apply migrations. + +Packaging services (azd package) + + (✓) Done: Packaging service web + - Build Output: C:\Users\chu\AppData\Local\Temp\azd2652905450 + - Package Output: C:\Users\chu\AppData\Local\Temp\dotnet-app-service-sqldb-redis-infra-web-azddeploy-1772588290.zip + + +ERROR: error executing step command 'package --all': failed running post hooks: failed running scripts for hooks 'azd package', hook configuration for 'postpackage' is invalid, run is always required + +--- + +**Cursor** + +错误信息现在变成 **`postpackage` 的配置无效,run 是必需的**,原因和刚才 prepackage 一样: +`postpackage` 只有 `posix`,没有 `windows`,在 Windows 上跑 `azd` 时就报错。 + +你当前 `azure.yaml` 里的这一段是: + +```yaml +hooks: + prepackage: + posix: + shell: sh + run: dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + interactive: true + continueOnError: true + windows: + shell: pwsh + run: dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + interactive: true + continueOnError: true + # The postpackage hook is used to remove the migrationsbundle file after deploy package is created. + postpackage: + posix: + shell: sh + run: rm migrationsbundle + interactive: true + continueOnError: true +``` + +在 Windows 上跑时,它需要一个 **`postpackage.windows.run`**。 + +你有两种修法,二选一: + +--- + +### 方案 A:给 `postpackage` 加 `windows`(推荐) + +把 `postpackage` 改成这样(手动在 VS/Cursor 里编辑): + +```yaml + postpackage: + posix: + shell: sh + run: rm migrationsbundle + interactive: true + continueOnError: true + windows: + shell: pwsh + run: Remove-Item migrationsbundle -ErrorAction SilentlyContinue + interactive: true + continueOnError: true +``` + +然后在 `e:\DemoProjects\first-app-service` 下再跑: + +```powershell +azd up +# 或 azd package --all +``` + +--- + +### 方案 B:暂时去掉 `postpackage` 整块 + +如果你不在意本地留下一个 `migrationsbundle` 文件,可以直接把 `postpackage:` 那几行(含 posix 子块)整段注释掉或删掉,只保留 `prepackage`、`postprovision`、`postdeploy`,再运行 `azd up` 即可。 + +--- + +你现在已经看到: + +```text +Building bundle... +Done. Migrations Bundle: E:\DemoProjects\first-app-service\migrationsbundle +... +(✓) Done: Packaging service web +``` + +说明 **prepackage 已经成功**,只要补上 `postpackage.windows`(或移除 postpackage),这一步错误就会消失。 + +--- + +**User** + +WARNING: 'prepackage' hook failed with exit code: '1', Path: 'C:\Users\chu\AppData\Local\Temp\azd-prepackage-2489603193.ps1'. : exit code: 1 +Execution will continue since ContinueOnError has been set to true. + + +--- + +**Cursor** + +这条 WARNING 的含义可以拆成两点: + +1. **发生了什么?** + - `azd up` 在 “Packaging services (azd package)” 之前,会执行你在 `azure.yaml` 里配置的 **`prepackage` hook**: + ```yaml + run: dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + ``` + - 这条命令在生成 `migrationsbundle` 时返回了 **退出码 1(失败)**,所以出现: + > `'prepackage' hook failed with exit code: '1'` + - 因为你在 hook 里写了 `continueOnError: true`,所以 azd 没中断,而是继续往下打包/部署。 + +2. **影响是什么?** + - 如果 `prepackage` 失败了,**`migrationsbundle` 文件可能没成功生成或是无效**。 + - 但你的基础设施 `resources.bicep` 里的 Web App 启动命令是: + ```bicep + appCommandLine: './migrationsbundle -- --environment Production && dotnet "DotNetCoreSqlDb.dll"' + ``` + 也就是说:容器启动时会先运行 `./migrationsbundle` 再启动应用。 + - 如果 bundle 不存在或是损坏,App Service 容器启动时就可能报错(例如 “file not found” 或迁移执行失败)。 + +--- + +### 下一步该怎么查具体问题? + +现在最关键的是弄清楚 **`dotnet ef migrations bundle` 为什么失败**,建议你在本地单独跑一遍同样的命令,看完整错误信息: + +在 PowerShell 中: + +```powershell +cd e:\DemoProjects\first-app-service +dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle +``` + +然后: + +- 如果成功,会看到类似: + - `Build started...` + - `Build succeeded.` + - `Done. Migrations Bundle: E:\DemoProjects\first-app-service\migrationsbundle` +- 如果失败,会在终端输出具体错误(比如: + - `No executable found matching command "dotnet-ef"` → 没装 `dotnet-ef` + - `No migrations were found` → 没有迁移 + - 或 DbContext / 连接字符串配置问题) + +请把这条命令的**完整输出**贴给我,我就能针对性告诉你是哪个问题,以及怎么修。 + +--- + +**User** + +还在运行中, azd up +Build started... +Build succeeded. +Building bundle... +Build failed. Use --verbose to see errors. +WARNING: 'prepackage' hook failed with exit code: '1', Path: 'C:\Users\chu\AppData\Local\Temp\azd-prepackage-2489603193.ps1'. : exit code: 1 +Execution will continue since ContinueOnError has been set to true. + +Packaging services (azd package) + + (✓) Done: Packaging service web + - Build Output: C:\Users\chu\AppData\Local\Temp\azd2950540 + - Package Output: C:\Users\chu\AppData\Local\Temp\dotnet-app-service-sqldb-redis-infra-web-azddeploy-1772588550.zip + + +Provisioning Azure resources (azd provision) +Provisioning Azure resources can take some time. + +Subscription: Azure subscription 1 (b4686626-5082-4d6b-b0aa-0ee897b1b029) +Location: Canada Central + + You can view detailed progress in the Azure Portal: + https://portal.azure.com/#view/HubsExtension/DeploymentDetailsBlade/~/overview/id/%2Fsubscriptions%2Fb4686626-5082-4d6b-b0aa-0ee897b1b029%2Fproviders%2FMicrosoft.Resources%2Fdeployments%2FVSCodeFirstDemo-1772588566 + + (✓) Done: Private Endpoint: VSCodeFirstDemo-5gndizok44hto-vault-privateEndpoint (31.772s) + |== | Creating/Updating resources (Private Endpoint) + +--- + +**Cursor** + +现在这个输出说明几件事: + +1. **azd up 还在正常往下跑** + - `prepackage` 里的 `dotnet ef migrations bundle` 构建失败(所以有 WARNING),但因为 `continueOnError: true`,azd 继续执行了打包和资源创建。 + - 下面的 `Packaging service web` 和 `Provisioning Azure resources` 都在正常进行,Azure 资源正在创建中。 + +2. **问题核心:EF 的 `migrationsbundle` 没成功生成** + - 将来 App Service 容器启动时会执行:`./migrationsbundle -- --environment Production && dotnet "DotNetCoreSqlDb.dll"`。 + - 如果 bundle 文件没生成好,第一次启动时可能报错(找不到文件或执行失败),所以我们最好把这一步修好。 + +3. **下一步你需要单独看清楚 bundle 构建的真实错误** + +在 `e:\DemoProjects\first-app-service` 目录打开一个新终端,手动执行同一条命令,并把完整输出贴给我: + +```powershell +cd e:\DemoProjects\first-app-service + +dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle --verbose +``` + +如果这条命令一跑就挂,常见几类问题(你可以先留意一下终端里的提示): + +- 没安装 `dotnet-ef` 工具 → 先运行 `dotnet tool install -g dotnet-ef` +- 编译失败(某个 C# 文件有错)→ 输出里会有具体编译错误行号 +- 找不到迁移 / DbContext 设计时创建失败 → 会提示类似 “No migrations were found” 或 “Unable to create an object of type 'MyDatabaseContext'” + +请把这条 `dotnet ef migrations bundle ...` 命令的完整错误输出贴出来,我可以根据具体内容告诉你是哪个原因以及怎么改。与此同时,`azd up` 可以先让它把 Azure 资源创建完,不影响我们后面修 bundle。 + +--- + +**User** + +PS E:\DemoProjects\first-app-service> dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle --verbose +Using project 'E:\DemoProjects\first-app-service\DotNetCoreSqlDb.csproj'. +Using startup project 'E:\DemoProjects\first-app-service\DotNetCoreSqlDb.csproj'. +dotnet msbuild /getProperty:ProjectName /getProperty:AssemblyName /getProperty:DesignAssembly /getProperty:Language /getProperty:OutputPath /getProperty:PlatformTarget /getProperty:ProjectAssetsFile /getProperty:ProjectDir /getProperty:RootNamespace /getProperty:RuntimeFrameworkVersion /getProperty:TargetFileName /getProperty:TargetFrameworkMoniker /getProperty:Nullable /getProperty:TargetFramework /getProperty:TargetPlatformIdentifier /getProperty:Platform /t:ResolvePackageAssets /getItem:RuntimeCopyLocalItems E:\DemoProjects\first-app-service\DotNetCoreSqlDb.csproj +dotnet msbuild /getProperty:ProjectName /getProperty:AssemblyName /getProperty:DesignAssembly /getProperty:Language /getProperty:OutputPath /getProperty:PlatformTarget /getProperty:ProjectAssetsFile /getProperty:ProjectDir /getProperty:RootNamespace /getProperty:RuntimeFrameworkVersion /getProperty:TargetFileName /getProperty:TargetFrameworkMoniker /getProperty:Nullable /getProperty:TargetFramework /getProperty:TargetPlatformIdentifier /getProperty:Platform /t:ResolvePackageAssets /getItem:RuntimeCopyLocalItems E:\DemoProjects\first-app-service\DotNetCoreSqlDb.csproj +Build started... +dotnet build E:\DemoProjects\first-app-service\DotNetCoreSqlDb.csproj /verbosity:quiet /nologo /p:PublishAot=false + +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:01.77 + +Build succeeded. +dotnet exec --depsfile E:\DemoProjects\first-app-service\bin\Debug\net8.0\DotNetCoreSqlDb.deps.json --additionalprobingpath C:\Users\chu\.nuget\packages --additionalprobingpath "C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages" --additionalprobingpath "C:\Program Files\dotnet\sdk\NuGetFallbackFolder" --runtimeconfig E:\DemoProjects\first-app-service\bin\Debug\net8.0\DotNetCoreSqlDb.runtimeconfig.json C:\Users\chu\.dotnet\tools\.store\dotnet-ef\10.0.3\dotnet-ef\10.0.3\tools\net8.0\any\tools\net8.0\any\ef.dll migrations bundle --target-runtime linux-x64 --force -o migrationsbundle --assembly E:\DemoProjects\first-app-service\bin\Debug\net8.0\DotNetCoreSqlDb.dll --project E:\DemoProjects\first-app-service\DotNetCoreSqlDb.csproj --startup-assembly E:\DemoProjects\first-app-service\bin\Debug\net8.0\DotNetCoreSqlDb.dll --startup-project E:\DemoProjects\first-app-service\DotNetCoreSqlDb.csproj --project-dir E:\DemoProjects\first-app-service\ --root-namespace DotNetCoreSqlDb --language C# --framework net8.0 --design-assembly C:\Users\chu\.nuget\packages\microsoft.entityframeworkcore.design\8.0.6\lib\net8.0\Microsoft.EntityFrameworkCore.Design.dll --nullable --working-dir E:\DemoProjects\first-app-service --verbose +Using assembly 'DotNetCoreSqlDb'. +Using startup assembly 'DotNetCoreSqlDb'. +Using application base 'E:\DemoProjects\first-app-service\bin\Debug\net8.0'. +Using working directory 'E:\DemoProjects\first-app-service'. +Using root namespace 'DotNetCoreSqlDb'. +Using project directory 'E:\DemoProjects\first-app-service\'. +Remaining arguments: . +Finding DbContext classes... +Finding IDesignTimeDbContextFactory implementations... +Finding application service provider in assembly 'DotNetCoreSqlDb'... +Finding Microsoft.Extensions.Hosting service provider... +Using environment 'Development'. +Using application service provider from Microsoft.Extensions.Hosting. +Found DbContext 'MyDatabaseContext'. +Finding DbContext classes in the project... +Using context 'MyDatabaseContext'. +Building bundle... +dotnet publish --runtime linux-x64 --output C:\Users\chu\AppData\Local\Temp\yo5ecgsh.lsb\publish --no-self-contained --disable-build-servers + Determining projects to restore... + Restored E:\DemoProjects\first-app-service\DotNetCoreSqlDb.csproj (in 619 ms). + Restored C:\Users\chu\AppData\Local\Temp\yo5ecgsh.lsb\migrationsbundle.csproj (in 619 ms). + DotNetCoreSqlDb -> E:\DemoProjects\first-app-service\bin\Release\net8.0\linux-x64\DotNetCoreSqlDb.dll + migrationsbundle -> C:\Users\chu\AppData\Local\Temp\yo5ecgsh.lsb\bin\Release\net8.0\linux-x64\migrationsbundle.dll + migrationsbundle -> C:\Users\chu\AppData\Local\Temp\yo5ecgsh.lsb\publish\ + +Done. Migrations Bundle: E:\DemoProjects\first-app-service\migrationsbundle +Don't forget to copy appsettings.json alongside your bundle if you need it to apply migrations. + +--- + +**Cursor** + +从你贴出来的这次命令来看,**`migrationsbundle` 已经打包成功了**,说明本地环境和迁移本身都没问题: + +- 编译成功: + ```text + Build succeeded. 0 Warning(s) 0 Error(s) + ``` +- EF 找到了 `MyDatabaseContext`,环境是 `Development`: + ```text + Found DbContext 'MyDatabaseContext'. + Using environment 'Development'. + ``` +- bundle 成功生成: + ```text + Done. Migrations Bundle: E:\DemoProjects\first-app-service\migrationsbundle + ``` + +也就是说,之前 `azd up` 里的 WARNING(`prepackage hook failed`)是那一轮执行时失败了,但现在你手动跑同一条命令已经能成功了。 + +### 接下来要做什么? + +1. **让 azd 再跑一遍完整打包 + 部署,把现在成功的 prepackage 集成进去:** + + 资源已经在建了,所以之后可以用: + + ```powershell + cd e:\DemoProjects\first-app-service + azd deploy + ``` + + 或你想重来一遍的话再跑一次: + + ```powershell + azd up + ``` + + 这次要留意日志里 **prepackage 部分是否还有 WARNING**,如果没有,说明 bundle 也已经被打进部署包,App Service 启动时就能自动执行迁移。 + +2. **那句 “Don't forget to copy appsettings.json …” 的含义** + + - 这是 EF 默认提醒:如果你的迁移在运行时需要读取配置里的连接串,就要把 `appsettings.json` 放在 bundle 旁边。 + - 对这套模板而言,在 Azure 上真正的连接串来自 **App Service 的配置 / Service Linker 生成的环境变量**,不是本地的 `appsettings.json`,所以不需要把本地开发用的连接串打进 bundle。 + +总结: +现在 `dotnet ef migrations bundle` 已经在本机 OK 了,说明之前的 prepackage WARNING 本质原因已经排除。等 `azd up` 那一轮资源创建跑完后,再执行一次 `azd deploy`,确认日志里没有 prepackage 的 WARNING,就可以到浏览器访问你的 Azure Web App 做验证了。 + +--- + +**User** + +还有错误 azd up +Build started... +Build succeeded. +Building bundle... +Build failed. Use --verbose to see errors. +WARNING: 'prepackage' hook failed with exit code: '1', Path: 'C:\Users\chu\AppData\Local\Temp\azd-prepackage-3571375007.ps1'. : exit code: 1 +Execution will continue since ContinueOnError has been set to true. + +Packaging services (azd package) + + (✓) Done: Packaging service web + - Build Output: C:\Users\chu\AppData\Local\Temp\azd3331050509 + - Package Output: C:\Users\chu\AppData\Local\Temp\dotnet-app-service-sqldb-redis-infra-web-azddeploy-1772590179.zip + + +--- + +**Cursor** + +关键点:**本地手动 `dotnet ef migrations bundle` 可以成功,但 `azd up` 里的 prepackage 失败**。这通常是因为 **运行环境不同**——你手动跑时是 `Development` 环境,而 `azd` 调 prepackage 时默认是 `Production` 环境,导致 `Program.cs` 里走了不同分支。 + +### 1. 发生了什么? + +- 手动命令输出里写得很清楚: + + ```text + Using environment 'Development'. + Found DbContext 'MyDatabaseContext'. + ... + Done. Migrations Bundle: E:\DemoProjects\first-app-service\migrationsbundle + ``` + +- 但 `Program.cs` 是这样判断连接串的: + + ```12:21:e:\DemoProjects\first-app-service\Program.cs + if(builder.Environment.IsDevelopment()) + { + options.UseSqlServer(builder.Configuration.GetConnectionString("MyDbConnection")); + } + else + { + options.UseSqlServer(builder.Configuration.GetConnectionString("AZURE_SQL_CONNECTIONSTRING")); + // 还会配置 Redis + } + ``` + +- 也就是说: + - **Development**:用本地 `MyDbConnection`(存在、没问题) → 你刚才的 `dotnet ef migrations bundle` 成功。 + - **Production / 非 Development**:要求配置 `AZURE_SQL_CONNECTIONSTRING` 和 `AZURE_REDIS_CONNECTIONSTRING`。 + 在你本机跑 `azd up` 时,这两个本地并没有配置,所以 EF 在构建设计时服务提供器时很可能拿到的是 `null` 或无效连接串,导致 **prepackage 这一步失败**,出现: + + > Build failed. Use --verbose to see errors. + > 'prepackage' hook failed with exit code: '1' + +### 2. 两种简单修法(二选一) + +#### 方法 A:在 prepackage 里强制指定环境为 Development(推荐) + +手动在 `azure.yaml` 的 `prepackage` 里,把 `run` 改成带 `--environment Development`,例如: + +```yaml +hooks: + prepackage: + posix: + shell: sh + run: dotnet ef migrations bundle --environment Development --target-runtime linux-x64 --force -o migrationsbundle + interactive: true + continueOnError: true + windows: + shell: pwsh + run: dotnet ef migrations bundle --environment Development --target-runtime linux-x64 --force -o migrationsbundle + interactive: true + continueOnError: true +``` + +这样不管 `azd` 用什么环境变量,EF CLI 都会用 Development 环境,走 `MyDbConnection` 分支,就和你手工命令一模一样,不会再因为本地没有 `AZURE_SQL_CONNECTIONSTRING` 而失败。 + +#### 方法 B:在运行 azd 前,显式设环境变量 + +在你运行 `azd up` 的 PowerShell 里先执行: + +```powershell +cd e:\DemoProjects\first-app-service + +$env:ASPNETCORE_ENVIRONMENT = "Development" + +azd up +``` + +让 prepackage 里的 `dotnet ef` 也在 Development 环境下执行,同样会走本地 `MyDbConnection`,避免访问不存在的 Azure 连接串。 + +--- + +### 3. 现在这个 WARNING 会带来什么影响? + +- `continueOnError: true` 让 `azd up` 继续往下执行,资源能成功创建,包也能打出来。 +- 但如果 prepackage 这一步失败,**部署包里可能没有正确的 `migrationsbundle` 文件**,而你的 `infra/resources.bicep` 又配置了: + + ```bicep + appCommandLine: './migrationsbundle -- --environment Production && dotnet "DotNetCoreSqlDb.dll"' + ``` + + 这会导致 App Service 容器启动时首先尝试运行 `./migrationsbundle`,如果文件缺失,启动可能失败或报错。 + +所以建议你按上面 **方法 A 或 B** 先解决 prepackage 的错误,再执行一次: + +```powershell +azd deploy +``` + +让一个成功构建的 `migrationsbundle` 被打进包里,然后再去浏览器访问 Azure Web App 做验证。 + +--- + +**User** + +不是应该先创建db, 改connectionStrings, 然后再执行 dotnet ef migrations .. 吗? + +--- + +**Cursor** + +你说的那套顺序 **在“本地直接连数据库跑迁移”场景里是对的**,但这套 `azure.yaml + infra + migrationsbundle` 模板里,流程被拆成了两个阶段,要分清: + +--- + +### 1. 现在这套模板实际做了“两个时间点”的事 + +#### 阶段 A:**本地打包迁移(不需要 Azure DB 存在)** + +- `prepackage` 里的命令: + + ```bash + dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + ``` + + 做的事情只是: + + - 编译你的项目 + - 把所有 EF Migrations + 运行迁移所需的代码,打成一个 **可执行文件 `migrationsbundle`** + - 这个过程 **不去连数据库**,只要能创建 `MyDatabaseContext` 就行,所以不要求 Azure SQL 已经创建,也不要求 Azure 的 connection string 可用。 + +- 这个 `migrationsbundle` 会被打进部署包里,上传到 App Service。 + +#### 阶段 B:**在 Azure 上真正“创建/更新数据库”** + +- `infra/resources.bicep` 负责先创建好: + - SQL Server + Database + - VNet、Private Endpoint、Key Vault 等 +- Web App 的启动命令是: + + ```bicep + appCommandLine: './migrationsbundle -- --environment Production && dotnet "DotNetCoreSqlDb.dll"' + ``` + + 也就是: + + 1. 容器启动时,先在 **Azure 环境** 下执行 `./migrationsbundle`: + - 此时 SQL Server / Database 已经由 Bicep 创建好了 + - 连接字符串由 Service Linker + Key Vault 自动配置到 App Service 环境变量里(相当于你说的“改 connectionStrings”这一步) + - `migrationsbundle` 在 Azure 上才真正连上 Azure SQL,执行迁移 + 2. 迁移成功后,再执行 `dotnet "DotNetCoreSqlDb.dll"` 启动你的应用。 + +> 所以:**打 bundle 的时候不需要“先创建 db、改 connectionStrings”**, +> 真正“创建 / 更新数据库”这一步,是在 Azure 上跑 `migrationsbundle` 时完成的。 + +--- + +### 2. 对比你习惯的“本地直连”流程 + +- 传统本地流程(不带 bundle): + 1. 手动建 SQL Server / Database + 2. 写好 connection string(指向这个 DB) + 3. 跑 `dotnet ef database update` 直接连库执行迁移 + +- 这套模板的流程: + 1. 本地打 **迁移包**:`dotnet ef migrations bundle ...`(只打包代码,不连库) + 2. 部署到 Azure + 3. 在 Azure 上,由 **App Service + Service Linker + Key Vault** 提供连接串,启动 `migrationsbundle`,在 Azure SQL 上执行迁移 + +--- + +如果你更习惯“我先自己在 Azure 门户建好 DB,再本地连 Azure SQL 跑一次 `dotnet ef database update`”,也完全可以这么做,那就不用管 `migrationsbundle` 这套;只是这套官方模板选择的是“把迁移动作打包进 bundle,在 App Service 里执行”,更自动化一些。 + + + +--- + +### 2. 如果真的需要指定环境,应该用“环境变量”,而不是 `--environment` 选项 + +EF 在设计时(`dotnet ef ...`)用哪个环境,是看 `ASPNETCORE_ENVIRONMENT` / `DOTNET_ENVIRONMENT` 环境变量,而不是看 CLI 选项。 + +如果你将来想强制用 `Development` 环境,可以这样写(示意): + +- **posix 块:** + + ```yaml + posix: + shell: sh + run: ASPNETCORE_ENVIRONMENT=Development dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + ``` + +- **windows 块(PowerShell):** + + ```yaml + windows: + shell: pwsh + run: | + $env:ASPNETCORE_ENVIRONMENT = "Development" + dotnet ef migrations bundle --target-runtime linux-x64 --force -o migrationsbundle + ``` + +但从你刚才手工执行的输出看,`dotnet ef migrations bundle` 在你本机默认就是用 `Development` 环境并且已经跑成功了,所以**最简单的做法就是先恢复为不带 `--environment` 的原始命令**,然后再跑一次: + +```powershell +cd e:\DemoProjects\first-app-service +azd up +``` + +