Customer API / Tutorial

Run discovery as an Azure scheduled job

Copy, provision, and deploy a one-shot C# or Python discovery client on Azure Container Apps Jobs.

In this article

What you will build

This tutorial provisions Azure resources and deploys a scheduled Container Apps Job. Each execution asks Key Vault to sign a fresh client assertion, requests a fresh Wavac bearer token, calls discovery once, logs only resource and site counts, and exits. It does not cache tokens, retry Wavac requests, implement backoff, or follow redirects. HTTP errors, timeouts, invalid responses, and responses over 1 MiB fail the execution.

Example disclaimer: This tutorial and code are provided as-is, without warranties or guarantees of any kind, including fitness for a particular purpose or that they are secure for your environment. Review the code, dependencies, images, permissions, network controls, logging, monitoring, costs, and deployment policy before production use.

Prepare your tools

You need an Azure subscription and permission to create resources and role assignments. Install Azure CLI and either Bash with OpenSSL or PowerShell 7.3 or later, then sign in and select the intended subscription.

az login
az account set --subscription "<subscription-id-or-name>"
az extension add --name containerapp --upgrade
az login
az account set --subscription "<subscription-id-or-name>"
az extension add --name containerapp --upgrade

Create an empty working directory. Cloning a repository is not required.

mkdir wavac-discovery-job
cd wavac-discovery-job
New-Item -ItemType Directory -Path wavac-discovery-job
Set-Location wavac-discovery-job

Copy the application

Choose a language and create every named file from the corresponding tab. Both versions use the same fail-closed behavior.

Create a dotnet directory, then add these files.

dotnet/Wavac.DiscoveryJob.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Azure.Identity" Version="1.21.0" />
    <PackageReference Include="Azure.Security.KeyVault.Keys" Version="4.10.1" />
  </ItemGroup>

  <ItemGroup>
    <Compile Remove="tests/**/*.cs" />
  </ItemGroup>
</Project>

dotnet/Program.cs

using Azure.Core;
using Azure.Identity;

namespace Wavac.DiscoveryJob;

internal static class Program
{
    public static async Task<int> Main()
    {
        try
        {
            JobConfiguration configuration = JobConfiguration.FromEnvironment();
            TokenCredential credential = new ManagedIdentityCredential(
                ManagedIdentityId.FromUserAssignedClientId(configuration.AzureClientId));
            var signer = new KeyVaultAssertionSigner(configuration.KeyVaultKeyId, credential);

            using var handler = new SocketsHttpHandler
            {
                AllowAutoRedirect = false
            };
            using var httpClient = new HttpClient(handler)
            {
                Timeout = TimeSpan.FromSeconds(30)
            };

            var client = new WavacDiscoveryClient(httpClient);
            DiscoverySummary summary = await client.ExecuteAsync(configuration, signer);
            Console.WriteLine(
                $"Discovery succeeded. Resources: {summary.ResourceCount}; sites: {summary.SiteCount}.");
            return 0;
        }
        catch (JobException exception)
        {
            Console.Error.WriteLine($"Discovery job failed: {exception.Message}");
            return 1;
        }
        catch (Exception exception)
        {
            // Avoid emitting SDK exception details that could contain request metadata.
            Console.Error.WriteLine($"Discovery job failed: {exception.GetType().Name}.");
            return 1;
        }
    }
}

dotnet/JobConfiguration.cs

using System.Text.RegularExpressions;

namespace Wavac.DiscoveryJob;

public sealed record JobConfiguration(
    string AzureClientId,
    Uri KeyVaultKeyId,
    string Subject,
    string KeyThumbprint)
{
    private static readonly Regex ThumbprintPattern = new(
        "^[A-Za-z0-9_-]{43}$",
        RegexOptions.CultureInvariant | RegexOptions.NonBacktracking);

    public static JobConfiguration FromEnvironment() => new(
        Require("AZURE_CLIENT_ID"),
        RequireKeyVaultUri("AZURE_KEY_VAULT_KEY_ID"),
        Require("WAVAC_SUBJECT"),
        RequireThumbprint("WAVAC_KEY_THUMBPRINT"));

    private static string Require(string name)
    {
        string? value = Environment.GetEnvironmentVariable(name);
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new JobException($"Required configuration {name} is missing.");
        }

        return value;
    }

    private static Uri RequireKeyVaultUri(string name)
    {
        string value = Require(name);
        if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? uri))
        {
            throw new JobException($"Required configuration {name} is not a versioned Azure Key Vault key URL.");
        }

        string[] segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
        if (uri.Scheme != Uri.UriSchemeHttps
            || !uri.Host.EndsWith(".vault.azure.net", StringComparison.OrdinalIgnoreCase)
            || segments.Length != 3
            || segments[0] != "keys"
            || segments[1].Length == 0
            || segments[2].Length == 0
            || uri.Query.Length != 0
            || uri.Fragment.Length != 0)
        {
            throw new JobException($"Required configuration {name} is not a versioned Azure Key Vault key URL.");
        }

        return uri;
    }

    private static string RequireThumbprint(string name)
    {
        string value = Require(name);
        if (!ThumbprintPattern.IsMatch(value))
        {
            throw new JobException($"Required configuration {name} is not an RFC 7638 SHA-256 thumbprint.");
        }

        return value;
    }
}

public sealed class JobException(string message) : Exception(message);

dotnet/AssertionSigner.cs

using Azure.Core;
using Azure.Security.KeyVault.Keys.Cryptography;

namespace Wavac.DiscoveryJob;

public interface IAssertionSigner
{
    Task<byte[]> SignAsync(ReadOnlyMemory<byte> signingInput, CancellationToken cancellationToken);
}

public sealed class KeyVaultAssertionSigner(Uri keyId, TokenCredential credential) : IAssertionSigner
{
    private readonly CryptographyClient _client = new(keyId, credential);

    public async Task<byte[]> SignAsync(
        ReadOnlyMemory<byte> signingInput,
        CancellationToken cancellationToken)
    {
        SignResult result = await _client.SignDataAsync(
            SignatureAlgorithm.RS256,
            signingInput.ToArray(),
            cancellationToken);
        return result.Signature;
    }
}

dotnet/WavacDiscoveryClient.cs

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

namespace Wavac.DiscoveryJob;

public sealed record DiscoverySummary(int ResourceCount, int SiteCount);

public sealed class WavacDiscoveryClient(HttpClient httpClient)
{
    public static readonly Uri TokenEndpoint = new("https://api.wavac.io/api/customer/oauth/token");
    public static readonly Uri DiscoveryEndpoint = new("https://api.wavac.io/api/customer/v1/discovery");

    private const int MaximumResponseBytes = 1024 * 1024;
    private const string Scope = "customer.discovery.read";

    public async Task<DiscoverySummary> ExecuteAsync(
        JobConfiguration configuration,
        IAssertionSigner signer,
        CancellationToken cancellationToken = default)
    {
        string assertion = await CreateAssertionAsync(configuration, signer, cancellationToken);
        string accessToken = await RequestAccessTokenAsync(assertion, cancellationToken);
        return await RequestDiscoveryAsync(accessToken, cancellationToken);
    }

    public static async Task<string> CreateAssertionAsync(
        JobConfiguration configuration,
        IAssertionSigner signer,
        CancellationToken cancellationToken = default,
        DateTimeOffset? now = null,
        string? jti = null)
    {
        long issuedAt = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds();
        var header = new Dictionary<string, object>
        {
            ["alg"] = "RS256",
            ["kid"] = configuration.KeyThumbprint,
            ["typ"] = "JWT"
        };
        var claims = new Dictionary<string, object>
        {
            ["iss"] = configuration.Subject,
            ["sub"] = configuration.Subject,
            ["aud"] = TokenEndpoint.AbsoluteUri,
            ["iat"] = issuedAt,
            ["nbf"] = issuedAt,
            ["exp"] = issuedAt + 240,
            ["jti"] = jti ?? Guid.NewGuid().ToString("N")
        };

        string encodedHeader = Base64Url(JsonSerializer.SerializeToUtf8Bytes(header));
        string encodedClaims = Base64Url(JsonSerializer.SerializeToUtf8Bytes(claims));
        string signingInput = $"{encodedHeader}.{encodedClaims}";
        byte[] signature = await signer.SignAsync(
            Encoding.ASCII.GetBytes(signingInput),
            cancellationToken);
        return $"{signingInput}.{Base64Url(signature)}";
    }

    private async Task<string> RequestAccessTokenAsync(
        string assertion,
        CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(HttpMethod.Post, TokenEndpoint)
        {
            Content = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"] = "client_credentials",
                ["client_assertion_type"] =
                    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
                ["client_assertion"] = assertion,
                ["scope"] = Scope
            })
        };
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        using JsonDocument response = await SendJsonAsync(request, "Token request", cancellationToken);
        JsonElement root = response.RootElement;
        if (!root.TryGetProperty("access_token", out JsonElement tokenElement)
            || tokenElement.ValueKind != JsonValueKind.String
            || string.IsNullOrWhiteSpace(tokenElement.GetString())
            || !root.TryGetProperty("token_type", out JsonElement typeElement)
            || typeElement.ValueKind != JsonValueKind.String
            || !string.Equals(typeElement.GetString(), "Bearer", StringComparison.Ordinal)
            || !root.TryGetProperty("expires_in", out JsonElement expiryElement)
            || !expiryElement.TryGetInt32(out int expiresIn)
            || expiresIn <= 0)
        {
            throw new JobException("Token response did not match the required contract.");
        }

        return tokenElement.GetString()!;
    }

    private async Task<DiscoverySummary> RequestDiscoveryAsync(
        string accessToken,
        CancellationToken cancellationToken)
    {
        using var request = new HttpRequestMessage(HttpMethod.Get, DiscoveryEndpoint);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
        request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

        using JsonDocument response = await SendJsonAsync(request, "Discovery request", cancellationToken);
        JsonElement root = response.RootElement;
        if (!root.TryGetProperty("resources", out JsonElement resources)
            || resources.ValueKind != JsonValueKind.Array
            || !root.TryGetProperty("sites", out JsonElement sites)
            || sites.ValueKind != JsonValueKind.Array)
        {
            throw new JobException("Discovery response did not match the required contract.");
        }

        return new DiscoverySummary(resources.GetArrayLength(), sites.GetArrayLength());
    }

    private async Task<JsonDocument> SendJsonAsync(
        HttpRequestMessage request,
        string operation,
        CancellationToken cancellationToken)
    {
        using HttpResponseMessage response = await httpClient.SendAsync(
            request,
            HttpCompletionOption.ResponseHeadersRead,
            cancellationToken);
        if (!response.IsSuccessStatusCode)
        {
            throw new JobException($"{operation} returned HTTP {(int)response.StatusCode}.");
        }

        string? mediaType = response.Content.Headers.ContentType?.MediaType;
        if (mediaType is null
            || (!mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase)
                && !mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase)))
        {
            throw new JobException($"{operation} did not return JSON.");
        }

        byte[] content = await ReadBoundedAsync(response.Content, operation, cancellationToken);
        try
        {
            return JsonDocument.Parse(content);
        }
        catch (JsonException)
        {
            throw new JobException($"{operation} returned invalid JSON.");
        }
    }

    private static async Task<byte[]> ReadBoundedAsync(
        HttpContent content,
        string operation,
        CancellationToken cancellationToken)
    {
        if (content.Headers.ContentLength > MaximumResponseBytes)
        {
            throw new JobException($"{operation} exceeded the response-size limit.");
        }

        await using Stream source = await content.ReadAsStreamAsync(cancellationToken);
        using var destination = new MemoryStream();
        var buffer = new byte[16 * 1024];
        while (true)
        {
            int read = await source.ReadAsync(buffer, cancellationToken);
            if (read == 0)
            {
                return destination.ToArray();
            }

            if (destination.Length + read > MaximumResponseBytes)
            {
                throw new JobException($"{operation} exceeded the response-size limit.");
            }

            destination.Write(buffer, 0, read);
        }
    }

    private static string Base64Url(ReadOnlySpan<byte> bytes) =>
        Convert.ToBase64String(bytes)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');
}

dotnet/Dockerfile

FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY Wavac.DiscoveryJob.csproj packages.lock.json ./
RUN dotnet restore --locked-mode
COPY *.cs ./
RUN dotnet publish --configuration Release --no-restore --output /app

FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine
WORKDIR /app
COPY --from=build /app ./
USER $APP_UID
ENTRYPOINT ["dotnet", "Wavac.DiscoveryJob.dll"]

dotnet/.dockerignore

bin/
obj/
tests/

Generate the lockfiles used by the container build.

cd dotnet
dotnet restore --use-lock-file
cd ..

Create a python directory, then add these files.

python/app.py

from __future__ import annotations

import base64
import hashlib
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Protocol

TOKEN_ENDPOINT = "https://api.wavac.io/api/customer/oauth/token"
DISCOVERY_ENDPOINT = "https://api.wavac.io/api/customer/v1/discovery"
SCOPE = "customer.discovery.read"
MAXIMUM_RESPONSE_BYTES = 1024 * 1024
HTTP_TIMEOUT_SECONDS = 30
THUMBPRINT_PATTERN = re.compile(r"^[A-Za-z0-9_-]{43}$", re.ASCII)


class JobError(Exception):
    """A sanitized failure that is safe to emit to the job log."""


@dataclass(frozen=True)
class JobConfiguration:
    azure_client_id: str
    key_vault_key_id: str
    subject: str
    key_thumbprint: str

    @classmethod
    def from_environment(cls) -> JobConfiguration:
        configuration = cls(
            azure_client_id=_required("AZURE_CLIENT_ID"),
            key_vault_key_id=_required("AZURE_KEY_VAULT_KEY_ID"),
            subject=_required("WAVAC_SUBJECT"),
            key_thumbprint=_required("WAVAC_KEY_THUMBPRINT"),
        )
        parsed_key_id = urllib.parse.urlsplit(configuration.key_vault_key_id)
        key_path = [part for part in parsed_key_id.path.split("/") if part]
        if (
            parsed_key_id.scheme != "https"
            or not (parsed_key_id.hostname or "").endswith(".vault.azure.net")
            or len(key_path) != 3
            or key_path[0] != "keys"
            or bool(parsed_key_id.query)
            or bool(parsed_key_id.fragment)
        ):
            raise JobError(
                "Required configuration AZURE_KEY_VAULT_KEY_ID is not a "
                "versioned Azure Key Vault key URL."
            )
        if THUMBPRINT_PATTERN.fullmatch(configuration.key_thumbprint) is None:
            raise JobError(
                "Required configuration WAVAC_KEY_THUMBPRINT is not an "
                "RFC 7638 SHA-256 thumbprint."
            )
        return configuration


@dataclass(frozen=True)
class DiscoverySummary:
    resource_count: int
    site_count: int


class AssertionSigner(Protocol):
    def sign(self, signing_input: bytes) -> bytes: ...


class KeyVaultAssertionSigner:
    def __init__(self, key_id: str, azure_client_id: str) -> None:
        from azure.identity import ManagedIdentityCredential
        from azure.keyvault.keys.crypto import CryptographyClient

        self._credential = ManagedIdentityCredential(client_id=azure_client_id)
        self._client = CryptographyClient(key_id, self._credential)

    def sign(self, signing_input: bytes) -> bytes:
        from azure.keyvault.keys.crypto import SignatureAlgorithm

        digest = hashlib.sha256(signing_input).digest()
        return self._client.sign(SignatureAlgorithm.rs256, digest).signature

    def close(self) -> None:
        self._credential.close()


class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
    def redirect_request(
        self,
        req: urllib.request.Request,
        fp: Any,
        code: int,
        msg: str,
        headers: Any,
        newurl: str,
    ) -> None:
        return None


class JsonTransport:
    def __init__(self) -> None:
        self._opener = urllib.request.build_opener(NoRedirectHandler())

    def send(self, request: urllib.request.Request, operation: str) -> dict[str, Any]:
        try:
            with self._opener.open(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
                status = response.status
                content_type = response.headers.get_content_type()
                body = response.read(MAXIMUM_RESPONSE_BYTES + 1)
        except urllib.error.HTTPError as error:
            status = error.code
            error.close()
            raise JobError(f"{operation} returned HTTP {status}.") from None
        except urllib.error.URLError:
            raise JobError(f"{operation} could not reach its HTTPS endpoint.") from None

        if not 200 <= status < 300:
            raise JobError(f"{operation} returned HTTP {status}.")
        if content_type != "application/json" and not content_type.endswith("+json"):
            raise JobError(f"{operation} did not return JSON.")
        if len(body) > MAXIMUM_RESPONSE_BYTES:
            raise JobError(f"{operation} exceeded the response-size limit.")

        try:
            value = json.loads(body)
        except (UnicodeDecodeError, json.JSONDecodeError):
            raise JobError(f"{operation} returned invalid JSON.") from None
        if not isinstance(value, dict):
            raise JobError(f"{operation} did not return a JSON object.")
        return value


class WavacDiscoveryClient:
    def __init__(self, transport: JsonTransport) -> None:
        self._transport = transport

    def execute(
        self,
        configuration: JobConfiguration,
        signer: AssertionSigner,
    ) -> DiscoverySummary:
        assertion = create_assertion(configuration, signer)
        token = self._request_access_token(assertion)
        return self._request_discovery(token)

    def _request_access_token(self, assertion: str) -> str:
        body = urllib.parse.urlencode(
            {
                "grant_type": "client_credentials",
                "client_assertion_type": (
                    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                ),
                "client_assertion": assertion,
                "scope": SCOPE,
            }
        ).encode("ascii")
        request = urllib.request.Request(
            TOKEN_ENDPOINT,
            data=body,
            method="POST",
            headers={
                "Accept": "application/json",
                "Content-Type": "application/x-www-form-urlencoded",
                "User-Agent": "wavac-azure-discovery-job-python/1.0",
            },
        )
        response = self._transport.send(request, "Token request")
        token = response.get("access_token")
        if (
            not isinstance(token, str)
            or not token
            or response.get("token_type") != "Bearer"
            or not isinstance(response.get("expires_in"), int)
            or response["expires_in"] <= 0
        ):
            raise JobError("Token response did not match the required contract.")
        return token

    def _request_discovery(self, access_token: str) -> DiscoverySummary:
        request = urllib.request.Request(
            DISCOVERY_ENDPOINT,
            method="GET",
            headers={
                "Accept": "application/json",
                "Authorization": f"Bearer {access_token}",
                "User-Agent": "wavac-azure-discovery-job-python/1.0",
            },
        )
        response = self._transport.send(request, "Discovery request")
        resources = response.get("resources")
        sites = response.get("sites")
        if not isinstance(resources, list) or not isinstance(sites, list):
            raise JobError("Discovery response did not match the required contract.")
        return DiscoverySummary(len(resources), len(sites))


def create_assertion(
    configuration: JobConfiguration,
    signer: AssertionSigner,
    *,
    now: int | None = None,
    jti: str | None = None,
) -> str:
    issued_at = now if now is not None else int(datetime.now(timezone.utc).timestamp())
    header = {
        "alg": "RS256",
        "kid": configuration.key_thumbprint,
        "typ": "JWT",
    }
    claims = {
        "iss": configuration.subject,
        "sub": configuration.subject,
        "aud": TOKEN_ENDPOINT,
        "iat": issued_at,
        "nbf": issued_at,
        "exp": issued_at + 240,
        "jti": jti or uuid.uuid4().hex,
    }
    encoded_header = _base64url(_json_bytes(header))
    encoded_claims = _base64url(_json_bytes(claims))
    signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii")
    signature = signer.sign(signing_input)
    return f"{signing_input.decode('ascii')}.{_base64url(signature)}"


def _required(name: str) -> str:
    value = os.environ.get(name, "")
    if not value.strip():
        raise JobError(f"Required configuration {name} is missing.")
    return value


def _json_bytes(value: dict[str, Any]) -> bytes:
    return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8")


def _base64url(value: bytes) -> str:
    return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")


def main() -> int:
    signer: KeyVaultAssertionSigner | None = None
    try:
        configuration = JobConfiguration.from_environment()
        signer = KeyVaultAssertionSigner(
            configuration.key_vault_key_id,
            configuration.azure_client_id,
        )
        summary = WavacDiscoveryClient(JsonTransport()).execute(configuration, signer)
        print(
            "Discovery succeeded. "
            f"Resources: {summary.resource_count}; sites: {summary.site_count}."
        )
        return 0
    except JobError as error:
        print(f"Discovery job failed: {error}", file=sys.stderr)
        return 1
    except Exception as error:
        # Avoid emitting SDK exception details that could contain request metadata.
        print(f"Discovery job failed: {type(error).__name__}.", file=sys.stderr)
        return 1
    finally:
        if signer is not None:
            signer.close()


if __name__ == "__main__":
    raise SystemExit(main())

python/requirements.txt

azure-identity==1.25.3
azure-keyvault-keys==4.11.1

python/Dockerfile

FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    VIRTUAL_ENV=/opt/venv

RUN python -m venv "$VIRTUAL_ENV"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"

RUN groupadd --system app && useradd --system --gid app --create-home app
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir --requirement requirements.txt
COPY app.py ./
USER app
ENTRYPOINT ["python", "/app/app.py"]

python/.dockerignore

__pycache__/
*.pyc
tests/

Provision Azure

The script creates a resource group, registry with administrator credentials disabled, RBAC-enabled Key Vault with purge protection, RSA key, managed identity, and Container Apps environment. These resources can incur charges. Change the location if required by your organization.

set -euo pipefail
LOCATION="eastus"; SUFFIX="$(openssl rand -hex 3)"
RESOURCE_GROUP="wavac-discovery-$SUFFIX"; ACR_NAME="wavacdiscovery$SUFFIX"
KEY_VAULT_NAME="wavac-discovery-$SUFFIX"; KEY_NAME="wavac-signing"
IDENTITY_NAME="wavac-discovery-job"; CONTAINERAPPS_ENVIRONMENT="wavac-discovery"
az group create -n "$RESOURCE_GROUP" -l "$LOCATION" -o none
az acr create -n "$ACR_NAME" -g "$RESOURCE_GROUP" -l "$LOCATION" --sku Basic --admin-enabled false -o none
az keyvault create -n "$KEY_VAULT_NAME" -g "$RESOURCE_GROUP" -l "$LOCATION" --enable-rbac-authorization true --enable-purge-protection true -o none
az identity create -n "$IDENTITY_NAME" -g "$RESOURCE_GROUP" -l "$LOCATION" -o none
az containerapp env create -n "$CONTAINERAPPS_ENVIRONMENT" -g "$RESOURCE_GROUP" -l "$LOCATION" -o none
ME="$(az ad signed-in-user show --query id -o tsv)"; KV_ID="$(az keyvault show -n "$KEY_VAULT_NAME" --query id -o tsv)"
az role assignment create --assignee-object-id "$ME" --assignee-principal-type User --role "Key Vault Crypto Officer" --scope "$KV_ID" -o none
az keyvault key create --vault-name "$KEY_VAULT_NAME" -n "$KEY_NAME" --kty RSA --size 2048 --ops sign verify --protection software -o none
az keyvault key show --vault-name "$KEY_VAULT_NAME" -n "$KEY_NAME" --query key -o json > wavac-public-jwk.json
printf '%s\n' "$RESOURCE_GROUP" "$ACR_NAME" "$KEY_VAULT_NAME"
$ErrorActionPreference="Stop"; $PSNativeCommandUseErrorActionPreference=$true
$Location="eastus"; $Suffix=[Guid]::NewGuid().ToString("N").Substring(0,6)
$ResourceGroup="wavac-discovery-$Suffix"; $AcrName="wavacdiscovery$Suffix"
$KeyVaultName="wavac-discovery-$Suffix"; $KeyName="wavac-signing"
$IdentityName="wavac-discovery-job"; $ContainerAppsEnvironment="wavac-discovery"
az group create -n $ResourceGroup -l $Location -o none
az acr create -n $AcrName -g $ResourceGroup -l $Location --sku Basic --admin-enabled false -o none
az keyvault create -n $KeyVaultName -g $ResourceGroup -l $Location --enable-rbac-authorization true --enable-purge-protection true -o none
az identity create -n $IdentityName -g $ResourceGroup -l $Location -o none
az containerapp env create -n $ContainerAppsEnvironment -g $ResourceGroup -l $Location -o none
$Me=az ad signed-in-user show --query id -o tsv; $KvId=az keyvault show -n $KeyVaultName --query id -o tsv
az role assignment create --assignee-object-id $Me --assignee-principal-type User --role "Key Vault Crypto Officer" --scope $KvId -o none
az keyvault key create --vault-name $KeyVaultName -n $KeyName --kty RSA --size 2048 --ops sign verify --protection software -o none
az keyvault key show --vault-name $KeyVaultName -n $KeyName --query key -o json | Set-Content -Encoding utf8 wavac-public-jwk.json
$ResourceGroup; $AcrName; $KeyVaultName

Role assignments can take several minutes to propagate. If key creation returns an authorization error, stop, wait, and rerun only the key creation and public-JWK commands with the values already created.

Enroll the key

Follow enroll a signing key using wavac-public-jwk.json and request customer.discovery.read. Keep the returned subject and RFC 7638 thumbprint. The file contains public material; the private key remains non-exportable in Key Vault.

Build and deploy

Set the values printed by provisioning and returned by enrollment. Run the selected tab from wavac-discovery-job. The job identity receives only registry pull and key-signing roles. The schedule runs hourly in UTC, and both application and replica retry behavior are disabled.

set -euo pipefail
LANGUAGE="dotnet" # Change to python when applicable.
WAVAC_SUBJECT="<subject>"; WAVAC_KEY_THUMBPRINT="<thumbprint>"
ACR_ID="$(az acr show -n "$ACR_NAME" -g "$RESOURCE_GROUP" --query id -o tsv)"; LOGIN="$(az acr show -n "$ACR_NAME" -g "$RESOURCE_GROUP" --query loginServer -o tsv)"
KV_ID="$(az keyvault show -n "$KEY_VAULT_NAME" -g "$RESOURCE_GROUP" --query id -o tsv)"; KEY_ID="$(az keyvault key show --vault-name "$KEY_VAULT_NAME" -n "$KEY_NAME" --query key.kid -o tsv)"
IDENTITY_ID="$(az identity show -n "$IDENTITY_NAME" -g "$RESOURCE_GROUP" --query id -o tsv)"; CLIENT_ID="$(az identity show -n "$IDENTITY_NAME" -g "$RESOURCE_GROUP" --query clientId -o tsv)"; PRINCIPAL_ID="$(az identity show -n "$IDENTITY_NAME" -g "$RESOURCE_GROUP" --query principalId -o tsv)"
az role assignment create --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal --role AcrPull --scope "$ACR_ID" -o none
az role assignment create --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal --role "Key Vault Crypto User" --scope "$KV_ID/keys/$KEY_NAME" -o none
TAG="$(date -u +%Y%m%d%H%M%S)"; IMAGE_NAME="wavac/discovery-$LANGUAGE:$TAG"
az acr build -r "$ACR_NAME" -g "$RESOURCE_GROUP" -t "$IMAGE_NAME" "$LANGUAGE"
az containerapp job create -n "wavac-discovery-$LANGUAGE" -g "$RESOURCE_GROUP" --environment "$CONTAINERAPPS_ENVIRONMENT" --trigger-type Schedule --cron-expression "0 * * * *" --replica-timeout 120 --replica-retry-limit 0 --replica-completion-count 1 --parallelism 1 --cpu 0.25 --memory 0.5Gi --container-name discovery --image "$LOGIN/$IMAGE_NAME" --mi-user-assigned "$IDENTITY_ID" --registry-server "$LOGIN" --registry-identity "$IDENTITY_ID" --env-vars "AZURE_CLIENT_ID=$CLIENT_ID" "AZURE_KEY_VAULT_KEY_ID=$KEY_ID" "WAVAC_SUBJECT=$WAVAC_SUBJECT" "WAVAC_KEY_THUMBPRINT=$WAVAC_KEY_THUMBPRINT"
$ErrorActionPreference="Stop"; $PSNativeCommandUseErrorActionPreference=$true
$Language="dotnet" # Change to python when applicable.
$WavacSubject="<subject>"; $WavacKeyThumbprint="<thumbprint>"
$AcrId=az acr show -n $AcrName -g $ResourceGroup --query id -o tsv; $Login=az acr show -n $AcrName -g $ResourceGroup --query loginServer -o tsv
$KvId=az keyvault show -n $KeyVaultName -g $ResourceGroup --query id -o tsv; $KeyId=az keyvault key show --vault-name $KeyVaultName -n $KeyName --query key.kid -o tsv
$IdentityId=az identity show -n $IdentityName -g $ResourceGroup --query id -o tsv; $ClientId=az identity show -n $IdentityName -g $ResourceGroup --query clientId -o tsv; $PrincipalId=az identity show -n $IdentityName -g $ResourceGroup --query principalId -o tsv
az role assignment create --assignee-object-id $PrincipalId --assignee-principal-type ServicePrincipal --role AcrPull --scope $AcrId -o none
az role assignment create --assignee-object-id $PrincipalId --assignee-principal-type ServicePrincipal --role "Key Vault Crypto User" --scope "$KvId/keys/$KeyName" -o none
$Tag=(Get-Date).ToUniversalTime().ToString("yyyyMMddHHmmss"); $ImageName="wavac/discovery-$($Language):$Tag"
az acr build -r $AcrName -g $ResourceGroup -t $ImageName $Language
$Args=@("containerapp","job","create","-n","wavac-discovery-$Language","-g",$ResourceGroup,"--environment",$ContainerAppsEnvironment,"--trigger-type","Schedule","--cron-expression","0 * * * *","--replica-timeout","120","--replica-retry-limit","0","--replica-completion-count","1","--parallelism","1","--cpu","0.25","--memory","0.5Gi","--container-name","discovery","--image","$Login/$ImageName","--mi-user-assigned",$IdentityId,"--registry-server",$Login,"--registry-identity",$IdentityId,"--env-vars","AZURE_CLIENT_ID=$ClientId","AZURE_KEY_VAULT_KEY_ID=$KeyId","WAVAC_SUBJECT=$WavacSubject","WAVAC_KEY_THUMBPRINT=$WavacKeyThumbprint"); az @Args

Verify and clean up

Start one execution and inspect its result. A successful log contains Discovery succeeded and bounded resource/site counts. Any HTTP or contract failure exits nonzero without logging credentials or response bodies.

JOB_NAME="wavac-discovery-$LANGUAGE"
az containerapp job start -n "$JOB_NAME" -g "$RESOURCE_GROUP"
az containerapp job execution list -n "$JOB_NAME" -g "$RESOURCE_GROUP" -o table
az containerapp job logs show -n "$JOB_NAME" -g "$RESOURCE_GROUP" --container discovery --tail 20 --format text
$JobName="wavac-discovery-$Language"
az containerapp job start -n $JobName -g $ResourceGroup
az containerapp job execution list -n $JobName -g $ResourceGroup -o table
az containerapp job logs show -n $JobName -g $ResourceGroup --container discovery --tail 20 --format text

Delete the tutorial resource group when finished. Do not run cleanup against a shared group. Purge protection retains the deleted vault for its configured retention period.

az group delete -n "$RESOURCE_GROUP" --yes
az group delete -n $ResourceGroup --yes

Continue the integration

Read the discovery reference and integration guide before adding aggregate-data or downtime requests. Azure details follow Microsoft guidance for Container Apps Jobs, managed identities, managed-identity image pulls, and Key Vault RBAC.