We need to containerize this application, so we can push it to the Azure Container Registry before we deploy it to Azure Container Apps:
Open the VS Code Command Palette (Ctrl+Shift+P) and select Docker: Add Docker Files to Workspace...
Use .NET: ASP.NET Core when prompted for the application platform.
Choose the newly-created project, if prompted.
Choose Linux when prompted to choose the operating system.
Set the application port to 8080, which is the default non-privileged port since .NET 8.
You will be asked if you want to add Docker Compose files. Select No.
Dockerfile and .dockerignore files are added to the project workspace.
Open Dockerfile and remove --platform=$BUILDPLATFORM from the FROM instruction.
Dockerfile Build Platform
Azure Container Registry does not set $BUILDPLATFORM presently when building containers. This consequently causes the build to fail. See this issue for details. Therefore, we remove it from the file for the time being. We expect this to be corrected in the future.
From inside the Pages folder, add a new folder named Tasks. Within that folder, add a new folder named Models, then create file as shown below.
Now, in the Tasks folder, we will add 3 Razor pages for CRUD operations which will be responsible for listing tasks, creating a new task, and updating existing tasks.
By looking at the cshtml content notice that the page is expecting a query string named createdBy which will be used to group tasks for application users.
Note
We are following this approach here to keep the workshop simple, but for production applications, authentication should be applied and the user email should be retrieved from the claims identity of the authenticated users.
usingMicrosoft.AspNetCore.Mvc;usingMicrosoft.AspNetCore.Mvc.RazorPages;usingTasksTracker.WebPortal.Frontend.Ui.Pages.Tasks.Models;namespaceTasksTracker.WebPortal.Frontend.Ui.Pages.Tasks{publicclassIndexModel:PageModel{privatereadonlyIHttpClientFactory_httpClientFactory;publicList<TaskModel>?TasksList{get;set;}[BindProperty]publicstring?TasksCreatedBy{get;set;}publicIndexModel(IHttpClientFactoryhttpClientFactory){_httpClientFactory=httpClientFactory;}publicasyncTask<IActionResult>OnGetAsync(){TasksCreatedBy=Request.Cookies["TasksCreatedByCookie"];if(!String.IsNullOrEmpty(TasksCreatedBy)){// direct svc to svc http requestvarhttpClient=_httpClientFactory.CreateClient("BackEndApiExternal");TasksList=awaithttpClient.GetFromJsonAsync<List<TaskModel>>($"api/tasks?createdBy={TasksCreatedBy}");returnPage();}else{returnRedirectToPage("../Index");}}publicasyncTask<IActionResult>OnPostDeleteAsync(Guidid){// direct svc to svc http requestvarhttpClient=_httpClientFactory.CreateClient("BackEndApiExternal");varresult=awaithttpClient.DeleteAsync($"api/tasks/{id}");returnRedirectToPage();}publicasyncTask<IActionResult>OnPostCompleteAsync(Guidid){// direct svc to svc http requestvarhttpClient=_httpClientFactory.CreateClient("BackEndApiExternal");varresult=awaithttpClient.PutAsync($"api/tasks/{id}/markcomplete",null);returnRedirectToPage();}}}
What does this code do?
In the code above we've injected named HttpClientFactory which is responsible to call the Backend API service as HTTP request. The index page supports deleting and marking tasks as completed along with listing tasks for certain users based on the createdBy property stored in a cookie named TasksCreatedByCookie.
More about populating this property later in the workshop.
usingMicrosoft.AspNetCore.Mvc;usingMicrosoft.AspNetCore.Mvc.RazorPages;usingTasksTracker.WebPortal.Frontend.Ui.Pages.Tasks.Models;namespaceTasksTracker.WebPortal.Frontend.Ui.Pages.Tasks{publicclassCreateModel:PageModel{privatereadonlyIHttpClientFactory_httpClientFactory;publicCreateModel(IHttpClientFactoryhttpClientFactory){_httpClientFactory=httpClientFactory;}publicstring?TasksCreatedBy{get;set;}publicIActionResultOnGet(){TasksCreatedBy=Request.Cookies["TasksCreatedByCookie"];return(!String.IsNullOrEmpty(TasksCreatedBy))?Page():RedirectToPage("../Index");}[BindProperty]publicTaskAddModel?TaskAdd{get;set;}publicasyncTask<IActionResult>OnPostAsync(){if(!ModelState.IsValid){returnPage();}if(TaskAdd!=null){varcreatedBy=Request.Cookies["TasksCreatedByCookie"];if(!string.IsNullOrEmpty(createdBy)){TaskAdd.TaskCreatedBy=createdBy;// direct svc to svc http requestvarhttpClient=_httpClientFactory.CreateClient("BackEndApiExternal");varresult=awaithttpClient.PostAsJsonAsync("api/tasks/",TaskAdd);}}returnRedirectToPage("./Index");}}}
What does this code do?
The code is self-explanatory here. We just injected the type HttpClientFactory in order to issue a POST request and create a new task.
usingMicrosoft.AspNetCore.Mvc;usingMicrosoft.AspNetCore.Mvc.RazorPages;usingTasksTracker.WebPortal.Frontend.Ui.Pages.Tasks.Models;namespaceTasksTracker.WebPortal.Frontend.Ui.Pages.Tasks{publicclassEditModel:PageModel{privatereadonlyIHttpClientFactory_httpClientFactory;[BindProperty]publicTaskUpdateModel?TaskUpdate{get;set;}publicstring?TasksCreatedBy{get;set;}publicEditModel(IHttpClientFactoryhttpClientFactory){_httpClientFactory=httpClientFactory;}publicasyncTask<IActionResult>OnGetAsync(Guid?id){TasksCreatedBy=Request.Cookies["TasksCreatedByCookie"];if(String.IsNullOrEmpty(TasksCreatedBy)){returnRedirectToPage("../Index");}if(id==null){returnNotFound();}// direct svc to svc http requestvarhttpClient=_httpClientFactory.CreateClient("BackEndApiExternal");varTask=awaithttpClient.GetFromJsonAsync<TaskModel>($"api/tasks/{id}");if(Task==null){returnNotFound();}TaskUpdate=newTaskUpdateModel(){TaskId=Task.TaskId,TaskName=Task.TaskName,TaskAssignedTo=Task.TaskAssignedTo,TaskDueDate=Task.TaskDueDate,};returnPage();}publicasyncTask<IActionResult>OnPostAsync(){if(!ModelState.IsValid){returnPage();}if(TaskUpdate!=null){// direct svc to svc http requestvarhttpClient=_httpClientFactory.CreateClient("BackEndApiExternal");varresult=awaithttpClient.PutAsJsonAsync($"api/tasks/{TaskUpdate.TaskId}",TaskUpdate);}returnRedirectToPage("./Index");}}}
What does this code do?
The code added is similar to the create operation. The Edit page accepts the TaskId as a Guid, loads the task, and then updates the task by sending an HTTP PUT operation.
Now we will inject an HTTP client factory and define environment variables. To do so, we will register the HttpClientFactory named BackEndApiExternal to make it available for injection in controllers. Open the Program.cs file and update it with highlighted code below. Your file may be flattened rather than indented and not contain some of the below elements. Don't worry. Just place the highlighted lines in the right spot:
varbuilder=WebApplication.CreateBuilder(args);// Add services to the container.builder.Services.AddRazorPages();builder.Services.AddHttpClient("BackEndApiExternal",httpClient=>{varbackendApiBaseUrlExternalHttp=builder.Configuration.GetValue<string>("BackendApiConfig:BaseUrlExternalHttp");if(!string.IsNullOrEmpty(backendApiBaseUrlExternalHttp)){httpClient.BaseAddress=newUri(backendApiBaseUrlExternalHttp);}else{thrownew("BackendApiConfig:BaseUrlExternalHttp is not defined in App Settings.");}});varapp=builder.Build();// Configure the HTTP request pipeline.if(!app.Environment.IsDevelopment()){app.UseExceptionHandler("/Error");// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.app.UseHsts();}app.UseHttpsRedirection();app.UseStaticFiles();app.UseRouting();app.UseAuthorization();app.MapRazorPages();app.Run();
varbuilder=WebApplication.CreateBuilder(args);// Add services to the container.builder.Services.AddRazorPages();builder.Services.AddHttpClient("BackEndApiExternal",httpClient=>{varbackendApiBaseUrlExternalHttp=builder.Configuration.GetValue<string>("BackendApiConfig:BaseUrlExternalHttp");if(!string.IsNullOrEmpty(backendApiBaseUrlExternalHttp)){httpClient.BaseAddress=newUri(backendApiBaseUrlExternalHttp);}else{thrownew("BackendApiConfig:BaseUrlExternalHttp is not defined in App Settings.");}});varapp=builder.Build();// Configure the HTTP request pipeline.if(!app.Environment.IsDevelopment()){app.UseExceptionHandler("/Error");// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.app.UseHsts();}app.UseHttpsRedirection();app.UseRouting();app.UseAuthorization();app.MapStaticAssets();app.MapRazorPages().WithStaticAssets();app.Run();
Next, we will add a new environment variable named BackendApiConfig:BaseUrlExternalHttp into appsettings.json file. This variable will contain the Base URL for the backend API deployed in the previous module to ACA. Later on in the workshop, we will see how we can set the environment variable once we deploy it to ACA. Use the output from this script as the BaseUrlExternalHttp value.
{"Logging":{"LogLevel":{"Default":"Information","Microsoft.AspNetCore":"Warning"}},"AllowedHosts":"*","BackendApiConfig":{"BaseUrlExternalHttp":"url to your backend api goes here. You can find this on the Azure portal overview tab. Look for the Application url property there."}}
Lastly, we will update the web app landing page Index.html and Index.cshtml.cs inside Pages folder to capture the email of the tasks owner user and assign this email to a cookie named TasksCreatedByCookie.
From VS Code Terminal tab, open developer command prompt or PowerShell terminal and navigate to the frontend directory which hosts the .csproj project folder and build the project.
Make sure that the build is successful and that there are no build errors. Usually you should see a Build succeeded message in the terminal upon a successful build.
2. Deploy Razor Pages Web App Frontend Project to ACA¶
Now we will build and push the Web App project docker image to ACR. Use the below command to initiate the image build and push process using ACR. The . at the end of the command represents the docker build context. In our case, we need to be on the parent directory which hosts the .csproject.
Once this step is completed you can verify the results by going to the Azure portal and checking that a new repository named tasksmanager/tasksmanager-frontend-webapp has been created and that a new docker image with a latest tag has been created.
Next, we will create and deploy the Web App to ACA using the following command:
$fqdn=(azcontainerappcreate`--name"$FRONTEND_WEBAPP_NAME"`--resource-group$RESOURCE_GROUP`--environment$ENVIRONMENT`--image"$AZURE_CONTAINER_REGISTRY_NAME.azurecr.io/tasksmanager/$FRONTEND_WEBAPP_NAME"`--registry-server"$AZURE_CONTAINER_REGISTRY_NAME.azurecr.io"`--env-vars"BackendApiConfig__BaseUrlExternalHttp=$BACKEND_API_EXTERNAL_BASE_URL/"`--target-port$TARGET_PORT`--ingress'external'`--min-replicas1`--max-replicas1`--cpu0.25`--memory0.5Gi`--queryproperties.configuration.ingress.fqdn`--outputtsv)$FRONTEND_UI_BASE_URL="https://$fqdn"echo "See the frontend web app at this URL:"echo $FRONTEND_UI_BASE_URL
Tip
Notice how we used the property env-vars to set the value of the environment variable named BackendApiConfig_BaseUrlExternalHttp which we added in the AppSettings.json file. You can set multiple environment variables at the same time by using a space between each variable.
The ingress property is set to external as the Web frontend App will be exposed to the public internet for users.
After you run the command, copy the FQDN (Application URL) of the Azure container app named tasksmanager-frontend-webapp and open it in your browser, and you should be able to browse the frontend web app and manage your tasks.
3. Update Backend Web API Container App Ingress property¶
So far the Frontend App is sending HTTP requests to the publicly exposed Web API. This means that any REST client can invoke the Web API. We need to change the Web API ingress settings and make it accessible only by applications deployed within our Azure Container Environment. Any application outside the Azure Container Environment should not be able to access the Web API.
To change the settings of the Backend API, execute the following command:
$fqdn=(azcontainerappingressenable`--name$BACKEND_API_NAME`--resource-group$RESOURCE_GROUP`--target-port$TARGET_PORT`--type"internal"`--queryfqdn`--outputtsv)$BACKEND_API_INTERNAL_BASE_URL="https://$fqdn"echo "The internal backend API URL:"echo $BACKEND_API_INTERNAL_BASE_URL
Want to know more about the command?
When you do this change, the FQDN (Application URL) will change, and it will be similar to the one shown below. Notice how there is an Internal part of the URL. https://tasksmanager-backend-api.internal.[Environment unique identifier].eastus.azurecontainerapps.io/api/tasks/
If you try to invoke the URL directly it will return 403 - Forbidden as this internal Url can only be accessed successfully from container apps within the container environment. This means that while the API is not accessible, it still provides a clue that something exists at that URL. Ideally, we would want to see a 404 - Not Found. However, recall from module 1 that we did not set internal-only for simplicity's sake of the workshop. In a production scenario, this should be done with completely private networking to not reveal anything.
The FQDN consists of multiple parts. For example, all our Container Apps will be under a specific Environment unique identifier (e.g. agreeablestone-8c14c04c) and the Container App will vary based on the name provided, check the image below for a better explanation.
Now we will need to update the Frontend Web App environment variable to point to the internal backend Web API FQDN. The last thing we need to do here is to update the Frontend WebApp environment variable named BackendApiConfig_BaseUrlExternalHttp with the new value of the internal Backend Web API base URL, to do so we need to update the Web App container app and it will create a new revision implicitly (more about revisions in the upcoming modules). The following command will update the container app with the changes:
Browse the web app again, and you should be able to see the same results and access the backend API endpoints from the Web App. You can obtain the frontend URL from executing this variable.
Execute the Set-Variables.ps1 in the root to update the variables.ps1 file with all current variables. The output of the script will inform you how many variables are written out.