How to: Use Azurite as a service container in Codespaces or Devcontainers

When developing with Azure Storage, it can significantly speed up your development process if you can use a local development environment, rather than constantly connecting directly to storage in Azure itself. This is where Azurite comes in – Azurite is a local storage emulator for Azure Storage, mimicing blob/container, queue and table storage. While there are lots of ways to get it running (e.g. from binary, manually using Docker etc.), I wanted to set it up as a service container in my devcontainer configuration which provides a few benefits:

  • consistency – every time i rebuild my devcontainer i know i’m resetting Azurite back to a known, clean state
  • Isolation – running it in a separate container means i avoid any potential side effects which might arise if it’s running in my main development container
  • Portability – it works consistently on Github codespaces, local devcontainer setups etc.

This guide provides a basic outline for setting up Azurite as a service container in a devcontainer configuration.

Create docker-compose.yml to define the services

Using a docker compose file inside your devcontainer definition lets you define multiple ‘services’ which all work together. In my case, i’ve set up the devcontainer service which is the main development environment based on the Microsoft Python devcontainer image, and azurite which contains azurite. Of course you could add whatever you need – PostgreSQL, Mongo, whatever you need. Here’s my docker-compose file:

services:
  devcontainer:
    image: mcr.microsoft.com/devcontainers/python:1-3.11-bullseye
    platform: linux/amd64
    volumes:
      - ..:/workspace:delegated
    ports:
      - "5000:5000"
    environment:
      - POETRY_VIRTUALENVS_IN_PROJECT=true
    command: /bin/sh -c "while sleep 1000; do :; done"
    network_mode: "host"
  azurite:
    image: mcr.microsoft.com/azure-storage/azurite
    ports:
      - "127.0.0.1:10000:10000"
      - "127.0.0.1:10001:10001"
      - "127.0.0.1:10002:10002"
    command: >
      azurite
      --blobHost 0.0.0.0
      --queueHost 0.0.0.0
      --tableHost 0.0.0.0

Create devcontainer.json

After defining your services, you need to create a devcontainer.json file. This configures the development environment, and its in the dockerComposeFile attribute that you point to your docker-compose file, and defining the service which represents the actual devcontainer. I’ve added a bunch of features which . In my configuration, i’ve also disabled some extensions which are recommended by the base image or other feature extensions by prefixing their names with -. Finally, i included a postCreateCommand which marks the /workspace folder as safe, and installs dependencies using Poetry, although you may want to skip this last step.

{
	"name": "Python 3",
	"dockerComposeFile": ["docker-compose.yml"],
	"workspaceFolder": "/workspace",
	"service": "devcontainer",
	"features": {
		"ghcr.io/devcontainers-contrib/features/poetry:2": {"version": "1.8.3"},
		"ghcr.io/devcontainers/features/github-cli:1": {},
		"ghcr.io/devcontainers/features/node:1": {},
		"ghcr.io/devcontainers/features/azure-cli:1": {},
		"ghcr.io/flexwie/devcontainer-features/pulumi:1": {},
		"ghcr.io/prulloac/devcontainer-features/pre-commit:1": {},
		"ghcr.io/jlaundry/devcontainer-features/azure-functions-core-tools:1": {},
		"ghcr.io/devcontainers/features/rust:1": {}, // for cryptography package
		"ghcr.io/devcontainers/features/docker-outside-of-docker": {}
	},
	"customizations": {
		"vscode": {
			"extensions": [
				"-vadimcn.vscode-lldb",
				"-rust-lang.rust-analyzer",
				"-tamasfe.even-better-toml",
				"-dbaeumer.vscode-eslint"
			]
		}
	},
	"forwardPorts": [10000, 10001, 10002],
  "postCreateCommand": "git config --global --add safe.directory /workspace && poetry self add poetry-plugin-export && poetry config warnings.export false && poetry config virtualenvs.in-project true --local && poetry install --with dev --no-interaction --sync --no-root"
}

Next steps

You can connect to the locally emulated storage using a connection string based on a well-known account and key baked in to Azurite:

DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;

You might choose to add more advanced configuration, such as persistent storage, or enabling OAuth (to allow the use of the DefaultAzureCredential).

Finally, you can add additional services, such as PostgreSQL or Mongo, to aid your development process.

How to: Create SAS with multiple permissions in Pulumi

In Pulumi, when calling pulumi_azure_native.storage.list_storage_account_service_sas_output() to generate a SAS, you pass the required permissions to the permissions: Input[str | Permissions | None] parameter. pulumi_azure_native.storage.Permissions is an enum, offering simple selections (R, L etc.):

# Create a shared access signature scoped to the container
app_container_signature = (
  pulumi.Output.all(resource_group.name, storage_account.name, app_container.name)
  .apply(
    lambda args: azure_native.storage.list_storage_account_service_sas_output(
      resource_group_name=args[0],
      account_name=args[1],
      protocols=azure_native.storage.HttpProtocol.HTTPS,
      shared_access_start_time="2022-01-01",
      shared_access_expiry_time="2030-01-01",
      resource=azure_native.storage.SignedResource.C,
      permissions=azure_native.storage.Permissions.R,
      content_type="application/json",
      cache_control="max-age=5",
      content_disposition="inline",
      content_encoding="deflate",
      canonicalized_resource=f"/blob/{args[1]}/{args[2]}",
    )
  )
  .apply(lambda result: pulumi.Output.secret(result.service_sas_token))
)

But you can also pass a string of permissions, any of R, L, D, W, C, A, or P, depending on the actions you want to allow for the SAS. This allows you to specify permissions for reading (R), listing (L), deleting (D), writing (W), creating (C), adding (A), or processing (P) blobs within the container, such as permissions="RWL":

app_container_signature = (
  pulumi.Output.all(resource_group.name, storage_account.name, app_container.name)
  .apply(
    lambda args: azure_native.storage.list_storage_account_service_sas_output(
      resource_group_name=args[0],
      account_name=args[1],
      protocols=azure_native.storage.HttpProtocol.HTTPS,
      shared_access_start_time="2022-01-01",
      shared_access_expiry_time="2030-01-01",
      resource=azure_native.storage.SignedResource.C,
      permissions="RWL",
      content_type="application/json",
      cache_control="max-age=5",
      content_disposition="inline",
      content_encoding="deflate",
      canonicalized_resource=f"/blob/{args[1]}/{args[2]}",
    )
  )
  .apply(lambda result: pulumi.Output.secret(result.service_sas_token))
)

How to: retrieve storage account primary key using Pulumi

Another note for myself. I wanted to use this to give my app access to the entire account. I thought they would be a property of pulumi_azure_native.storage.StorageAccount but they’re not. Instead you need to call pulumi_azure_native.storage.list_storage_account_keys().

import pulumi
import pulumi_azure_native as azure_native
config = pulumi.Config()
# Create a Resource Group
resource_group_name = config.require("resourceGroupName")
location = config.require("location")
resource_group = azure_native.resources.ResourceGroup(
  resource_group_name, resource_group_name=resource_group_name, location=location
)
# Create a Storage Account
storage_account = azure_native.storage.StorageAccount(
  config.require("storageAccountName"),
  resource_group_name=resource_group.name,
  sku=azure_native.storage.SkuArgs(
    name=azure_native.storage.SkuName.STANDARD_LRS,
  ),
  kind=azure_native.storage.Kind.STORAGE_V2,
  location=resource_group.location,
)
# fetch primary key
storage_account_primary_key = (
  pulumi.Output.all(resource_group.name, storage_account.name)
  .apply(lambda args: azure_native.storage.list_storage_account_keys(resource_group_name=args[0], account_name=args[1]))
  .apply(lambda accountKeys: pulumi.Output.secret(accountKeys.keys[0].value))
)

How to: check if a container exists without account level List permission

In a storage account, you can create a SAS scoped to a specific container, however, that SAS does not have permission to execute BlobClient.exists() as this requires at least list privileges on the parent object (e.g. the account), and when you try to perform the check, you’ll get this error:

azure.core.exceptions.HttpResponseError: This request is not authorized to perform this operation.

Note that this is an HttpResponseError, not a ClientAuthenticationError (which is actually a more specific error which extends HttpResponseError), so you need to interrogate the specific response code, although note that if the container client does not exist, you might also get a ClientAuthenticationError with this message:

Server failed to authenticate the request. Make sure the value of Authorization header is formed correctly including the signature.\nRequestId:a52b0bdf-401e-0030-755f-fc3a0f000000\nTime:2024-09-01T11:09:19.0123125Z\nErrorCode:AuthenticationFailed\nauthenticationerrordetail:Signature did not match. String to sign used was rwl\n2022-01-01T00:00:00.0000000Z\n2030-01-01T00:00:00.0000000Z\n/blob/<account>/<container>\n\n\nhttps\n2015-04-05\nmax-age=5\ninline\ndeflate\n\napplication/json

As a workaround, you can instead try to read from or write to (depending on the privilege level you want to test for) the container. For example, you might do something like this:

def test_if_container_exists(container_client: ContainerClient) -> bool:
  """check if a container exists first by calling the .exists() method, even if the SAS scope is restricted to the specific container"""
  # first use the .exists() method to check if the container exists
  try:
    container_client.exists()
    return True
  except ClientAuthenticationError:
    # we dont know if this is because the SAS token doesnt have access or the container doesnt exist
    pass
  except HttpResponseError as e:
    if e.status_code == 404:
      return False
    elif e.status_code == 403:
      pass  # could be a SAS token scope restriction
  except ResourceNotFoundError:
    return False
  except Exception as e:
    # if we got any other exception, raise it
    raise
  # if we got ClientAuthenticationError, try to write a small blob to check if the SAS token is valid
  try:
    blob_client = container_client.get_blob_client("test_blob")
    blob_client.upload_blob(b"Test content", overwrite=True)
    return True
  except ClientAuthenticationError or ResourceNotFoundError:
    return False
  except Exception as e:
    # if we got any other exception, raise it
    raise

Fix: “0 functions loaded” deploying Azure Functions from package

This post is mainly a reminder to myself, because i’ve made the same mistake a few times in different projects.

How and why to deploy Azure functions from a package

When using Azure Functions, you can simplify deployment and maintenance of your function by deploying from a package file rather than directly in to the function. This also has the benefit of reducing cold start times, particularly where there are a large number of dependencies.

To deploy from a package (without using the Azure deployment tools), you:

  1. Create a zip file with your code and its dependencies
  2. deploy that zip to Azure
  3. Set the WEBSITE_RUN_FROM_PACKAGE property on the function app settings

The WEBSITE_RUN_FROM_PACKAGE setting can either be a 1 if you’ve deployed your code to the /home/data/SitePackages folder on the function app, or a URL. I prefer to deploy my code as a zip stored in a blob, as this seems cleaner, and is easier to upgrade.

  1. Create a container (app-container)
  2. Upload the file to a blob called something like app-blob-datetime. By appending a timestamp to the blob name, subsequent deployments can be to a new blob (avoiding locking concerns) and then switching over is nearly instant and has almost zero downtime. If desired, it is simple to switch back to a previous version.
  3. Generate a long-lived SAS with read privileges scoped on the container (app-container)
  4. Construct a fully qualified URL to the blob, and set this as the WEBSITE_RUN_FROM_PACKAGE setting.
  5. Restart the function app (typically < 1 second).

Error: 0 functions loaded

When browsing to the functions Overview page, we can get invited to “Create functions in your preferred environment”, implying that no functions exist:

functions overview page, including text "Create functions in your preferred environment"

Navigating to Monitoring > Logs, and selecting Traces shows something interesting (you may need to enable App Insights and restart the function app). First, we see a message 1 functions found (Custom), but immediately after it 0 functions loaded:

two log entries, 1 functions found (Custom) immediately after it 0 functions loaded

Fix: Ensure all packages are available

In order for the custom package to load, you need to ensure that all of the Python packages that it needs are available. To do this, you must install the packages in to the folder and then zip it. You can do this with Pip, setting the specific output folder e.g.

pip install --disable-pip-version-check --target="build/.python_packages/lib/site-packages" -r app/requirements.txt

Then, when zip the file to include the .python_packages folder. Here’s my build script, which also ensures that we don’t include any files specified in .funcignore:

#!/bin/bash

# Remove and recreate the 'build' folder
rm -rf build
mkdir -p build

# export requirements.txt
poetry export --only main --without-hashes -f requirements.txt --output app/requirements.txt

# Copy the contents of the 'app' folder to 'build', including hidden files and folders
shopt -s dotglob
cp -r app/* build/

# Apply .funcignore to the contents of 'build'
if [ -f "app/.funcignore" ]; then
  while IFS= read -r pattern; do
    find build -name "$pattern" -exec rm -rf {} +
  done < app/.funcignore
fi

# https://github.com/Azure/azure-functions-host/issues/9720#issuecomment-2129618480
pip install --disable-pip-version-check --target="build/.python_packages/lib/site-packages" -r app/requirements.txt

Fix: Pulumi error Cannot modify this site because another operation is in progress creating Azure Function on Dynamic Consumption plan

Another post which is more to remind me than anything else. When creating an Azure Function in Pulumi, you may get the following error:

Cannot modify this site because another operation is in progress

After a bit of digging, i found this issue on the Pulumi repo, which points to a page on the Azure Functions wiki where they say:

If you run in a dedicated mode, you need to turn on the Always On setting for your Function App to run properly … When running in a Consumption Plan or Premium Plan you should not enable Always On. 

The Always On setting for Python is actually part of the constructor of pulumi_azure_native.web.SiteConfigArgs:

(parameter) always_on: Input[bool] | None
always_on: true if Always On is enabled; otherwise, false.

Setting this to true solved the problem.

Fix: WordPress error “The response is not a valid JSON response when” uploading images

When uploading images to WordPress, you may get this error. There are plenty of blogs online offering solutions, but they only apply to self-hosted instances – mine is hosted on just-what-i-find.onyx-sites.io/.

The error is a little pop up with the text The response is not a valid JSON response at the bottom of the screen when you try and upload an image:

popup of The response is not a valid JSON

Looking in the developer tools console on the browser shows one of two error messages:

Failed to load resource: the server responded with a status of 403 () for URL /wp-json/wp/v2/media

or

POST https://atomic-temporary-181991729.wpcomstaging.com/wp-json/wp/v2/media?_locale=user 403 (Forbidden)

I have Cloudflare in front of my blog, with the OWASP filter set enabled. By examining the Security Events log (in Cloudflare at Security > Events), and adding a filter for the path /wp-json/wp/v2/media:

screenshot showing that the `path` filter is mapped to the URI

i was able to see that WAF was triggering on a specific rule, 949110: Inbound Anomaly Score Exceeded. There are lots of posts on the Cloudflare forum about this. One answer from the Cloudflare team points out that the OWASP ruleset is not managed by Cloudflare – they simply integrate it in their WAF, so they have no way to tweak it. They do, however, point out you can bypass it. So I created a custom rule to match (http.request.uri.path eq "/wp-json/wp/v2/media"):

screenshot of the configuration settings as described above

I then selected to “Skip specific rules from a Managed Ruleset”, and disable rule 949110: Inbound Anomaly Score Exceeded for this specific URI:

screenshot showing the rule is being skipped

I apply the ruleset before the OWASP one in the priority list:

screen shot of managed rules in order: cloudflare managed ruleset, skip 949110 for wp media upload, cloudflare owasp core ruleset, cloudflare leaked credentials check. all are enabled.

And now, no more errors. Of course, this will reduce the security protection of your WordPress instance – at least for this URI. See the Cloudflare documentation for more details.