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.