Azure Static Web App: Cloud Resume Challenge

Azure Static Web App: Cloud Resume Challenge
Azure Static Web Apps is great for hosting static web content at cloud scale - Photo by Surface / Unsplash

Azure Static Web Apps is great for hosting fast and high scale static web pages that can be used for landing pages, informational pages, posters, and general content or websites that do not require complex dynamic activity.

The following is my slightly overdue implementation of the popular 'Cloud Resume Challenge' (Thank you Gwyneth Pena-Siguenza for championing this challenge in the Cloud Community!! 😎). My live page for my implementation is here from Azure's Global CDN https://batsiraicdn.azureedge.net (I did not buy a custom domain but may do so in the near future).For simplicity, I will focus on the implementation in this blog (Code and Architecture/Structure) more so than operational activities (Devops and CI/CD). I plan to keep my page live for as long as possible.

My implementation is as follows:

Creating the Web App Content

The content of the Web App is a single HTML page with one CSS file and one Javascript file. The files are a heavy modification of the template found here

These are the files for my page:

Other utility images used on HTML page:

Creating an Azure Cosmos DB to track Visitor Count (Data Storage)

One of the key features part of the challenge is to have a visitor count that increments when a new visitor comes to view the Web App. To store the visitor counts, I used Azure Cosmos DB with the serverless capacity mode to keep costs extremely low. The CosmosDB instance has one Table called VisitCounter where an extra int column called Counter has been added to store the visit counter as part of each new record:

Cosmos DB Instance with VisitCounter Table

Creating an Azure Function to Write new visit count to Azure Cosmos DB (API), and bring back the new value

To keep track of a visitor count on the web page, there is a need to add a record to a data store in Azure when a visitor hits the Static Web App(to be written to the Visit Counter Table in the Cosmos DB instance). When a visitor arrives on our web page from a browser, an HTTP GET request is automatically available to us just from this action. We can then send this request from Javascript to an HTTP Triggered Azure Function that will write to the Azure Cosmos DB with a new visitor data insertion record. The Function then returns the new count value back to the browser:

//Note that your Anonymously called Azure Function will need
//to have your static website's endpoint as an allowed
//origin under the CORS settings in the Function App in the portal
[FunctionName("CosmosReadWriter")]
public static async Task<IActionResult> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req,
ILogger log)
{
    string tableName = "VisitCounter";
    
    //get Cosmos Db credentials stored in Key Vault.
    //If using KeyVault, you will want to set a Managed Identity
    //for your Azure Function App. If not, you can temporarily
    //hard code your cosmosDB credentials
    string accountName = await GetKeyVaultSecret("cosmosAccountName");
    string accountKey = await GetKeyVaultSecret("cosmosDBKey");
    string tableUri = await GetKeyVaultSecret("cosmosTableUri");

    var tableClient = new TableClient(new Uri(tableUri), tableName,
            		  new TableSharedKeyCredential(accountName, accountKey));
    
    //try to get the latest visitor count record in CosmosDB
	var queryResultsFilter = 
		tableClient.Query<TableEntity>().OrderByDescending(x =>x.Timestamp)?.Take(1);
    
    //Write value of 1 if CosmosDB is empty.
    //Increment latest visitor count by 1, write new value to DB
    if (!queryResultsFilter.Any())
    {   
        //insert first record as 1 if DB is completely empty
        var tableEntity = 
        	new TableEntity(DateTime.UtcNow.ToString("yyyy-MMM-dd HH:mm:ss"),
            Guid.NewGuid().ToString())
        {
            { "Counter", 1 }
        };

    	tableClient.AddEntity(tableEntity);
        return new OkObjectResult(newvalue);
    }
    else
    {   //add 1 to whatever is the latest record, then return new value
        var result = queryResultsFilter?.FirstOrDefault();
        var current = result.GetInt32("Counter");
        var newvalue = current + 1;
        var tableEntity = 
        	new TableEntity(DateTime.UtcNow.ToString("yyyy-MMM-dd HH:mm:ss"),
            Guid.NewGuid().ToString())
        {
        	{ "Counter",newvalue }
        };

        tableClient.AddEntity(tableEntity);
        //return newest value as incremented visitor count back to browser
        return new OkObjectResult(newvalue);
    }
}

Cosmos DB Writer and Reader

This utility function will retrieve secrets from KeyVault.

public static async Task<string> GetKeyVaultSecret(string kvSecret)
{
    var credential = new DefaultAzureCredential();

    var secretClient = new SecretClient(new Uri("{KEY_VAULT_URI}"),
    				   credential);

    KeyVaultSecret secret = await secretClient.GetSecretAsync(kvSecret);
    return secret.Value;
}

Key Vault Access code from Function

Packages for Azure Function:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <AzureFunctionsVersion>v4</AzureFunctionsVersion>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Azure.Data.Tables" Version="12.8.0" />
    <PackageReference Include="Azure.Identity" Version="1.9.0" />
    <PackageReference Include="Azure.Security.KeyVault.Secrets" Version="4.5.0" />
    <PackageReference Include="Microsoft.NET.Sdk.Functions" Version="4.1.1" />
  </ItemGroup>
  <ItemGroup>
    <None Update="host.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
    <None Update="local.settings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      <CopyToPublishDirectory>Never</CopyToPublishDirectory>
    </None>
  </ItemGroup>
</Project>

Nuget Packages for Azure Function code

Access CosmosDB connection credentials from Key Vault with Managed Identity

I stored my CosmosDB credentials (AccountName, CosmosDB Key and Table Uri) in KeyVault as Secrets. Because of my Azure Function's need to access these secrets from my KeyVault, note that after publishing the Azure Function, I enabled a System-Assigned Managed Identity on the Azure Function App under Function App>Identity and then gave my Function App the Get Permissions only within the Key Vault's Access Policies Settings under KeyVault>Access Policies>Create.

Static Web App Setup from Blob Storage

I relied on using a very new capability in Azure Blob Storage that allows a Blob Container(folder) to host my Static Web App rather than creating a standalone Azure Static Web App Resource in Azure. It is a matter of creating an Azure Storage Account or using an existing Storage Account, then Enable Static Web App Support under Storage Account>Static Website in the Portal. Then upload your all your web app files (html , js, css and image files(if any)) into a new dedicated blob storage container that gets automatically created called $web:

Static Files uploaded to $web folder

After uploading to the storage container, by default, the web app can be viewed at https://{yourstorageaccount}.{yourstoragezone}.web.core.windows.net/ . This allows viewers to view the app directly from this origin URL. However it is also possible to create a CDN resource in Azure to scale the web app across the world which I did. Using a CDN means your content can be pushed or replicated to 'the Edge' closer to where your users are so that they don't always need to request your page from your page's origin location.

I created an Azure CDN profile through going through the wizard in Storage Account>Front Door and CDN, where the Origin hostname is "Static Website" (2nd dropdown option):

The CDN might take a few minutes to finish proliferating after creation so accessing the CDN endpoint might not show the app immediately.

The Azure CDN endpoint name can also be further customized with a Custom Domain (which I did not extend to do in this case - requires yearly maintenance cost from Domain Registrar)

CORS Access

Because my application calls out to an Azure Function App from the web page's Javascript, there is a need to protect the Function App by specifying which Origins are allowed to get a response back from the Azure Function. I added this protection by adding the CDN's endpoint as an allowed Origin for CORS on the Azure Function App under Function App>CORS>Tick Enable Access-Control-Allow-Credentials:

Adding the CDN Endpoint as an allowed Origin in addition to the storage origin endpoint

Doing this allows the visit counter to work properly when a user accesses the site from the CDN's global network as follows:

The Azure Static Web App running from the default CDN Endpoint

Results!!

The page is live and can be seen here (Default CDN Endpoint), where the visit counter should update after every hit - https://batsiraicdn.azureedge.net/

Conclusions

It also important to note that the Static Webapp's design can be further improved by letting the client send it's requests to an Azure APIM resource that would sit in front of the Azure Function. That way, we could introduce targeted rate limiting policies to prevent our Function possibly facing a DDoS attack introducing heavy traffic since it is called anonymously. I chose not to incur the additional cost of adding an APIM since it can become costly over the long term for this project. If I did experience traffic resembling an attack, I would turn off the Azure Function App or disable Static Web Apps on the Storage Account, the goal here is not to try and scale infinitely and accommodate such traffic.