WireMock e2e spike findings: Azure (issue #355, Phase 0)¶
Spike date: 2026-09-20. Scope: Azure only. Question: do CAPZ and the bundled
ASO expose an endpoint-override config surface (AZURE_AUTHORITY_HOST /
custom cloud) pointed at an in-cluster WireMock, and if not, how far do the
HTTPS_PROXY+CA and CoreDNS-rewrite fallbacks get?
Short answer: ASO yes (full config surface, proven to arrival), CAPZ no
(named clouds only, env vars ignored), but the CoreDNS-rewrite fallback
carries CAPZ end to end. HTTPS_PROXY is not viable: WireMock 3.13.2
answers CONNECT with a hollow 200 and cannot tunnel TLS. The Phase 1
mechanism is therefore a combination: ASO endpoint configuration plus a
CoreDNS rewrite for CAPZ's own clients and for MSAL's instance-discovery
anchor.
Environment and pinned versions¶
| Component | Version | Source |
|---|---|---|
| kind | v0.33.0 (node v1.37.0) | repo mise pin |
| clusterctl | v1.14.2 | repo mise pin |
| cert-manager | v1.21.1 | installed by clusterctl init |
| cluster-api (core/bootstrap/control-plane) | v1.14.2 | installed by clusterctl init |
| CAPZ | v1.27.0 | repo pin in mgmt/azure/capi-providers/capz-system/providers.yaml |
| ASO | v2.19.0 (mcr.microsoft.com/k8s/azureserviceoperator:v2.19.0) |
bundled by CAPZ v1.27.0 into capz-system |
| Azure SDK (CAPZ) | azcore v1.23.1, azidentity v1.14.1 | CAPZ v1.27.0 go.mod |
| Azure SDK (ASO) | azcore v1.21.1, azidentity v1.13.1 | ASO v2.19.0 go.mod |
| WireMock | wiremock/wiremock:3.13.2 |
docker hub, same pin as the AWS spike |
Scratch kind cluster wiremock-azure-spike (single control-plane node),
created and deleted for this spike. Dummy GUID credentials throughout
(subscription 00000000-..., tenant 11111111-..., client 22222222-...).
No real Azure subscription, no real credentials, no cloud resources created.
Unlike the AWS provider, clusterctl init --infrastructure azure:v1.27.0
requires NO variables at all: it installs with zero credential input
(workload-identity default, aso-controller-settings born with empty
values). No helm releases were installed in this spike (CAPZ via clusterctl,
WireMock via raw manifests), so the helm boot race from the AWS spike did
not apply.
Setup shape¶
Same harness as the AWS spike (docs/wiremock-e2e-spike-findings-aws.md):
throwaway CA, server certificate signed for the WireMock Service names plus
wildcards, JKS built with the image's own keytool, HTTPS on 8443 behind a
ClusterIP Service on 443, wiremock.wiremock-system.svc. Azure-specific SAN
additions for the CoreDNS-rewrite fallback: login.microsoftonline.com and
management.azure.com. Controller trust: SSL_CERT_FILE=/certs/ca.crt
plus a wiremock-ca ConfigMap in each consuming namespace (capz-system,
default).
Fixtures: an AzureClusterIdentity (type ServicePrincipal, dummy secret) +
Cluster + AzureCluster (spike, location swedencentral) for CAPZ, and
an ASO ResourceGroup CR (wiremock-spike-rg) for ASO. Observation:
WireMock admin API /__admin/requests over kubectl port-forward.
ASO v2.19.0: config surface exists and is honored¶
The surface¶
CAPZ's bundled ASO reads three endpoint settings from the
aso-controller-settings Secret in capz-system (all mounted as
optional: true secretKeyRef env entries on the ASO Deployment):
AZURE_AUTHORITY_HOSTAZURE_RESOURCE_MANAGER_ENDPOINTAZURE_RESOURCE_MANAGER_AUDIENCE
CAPZ's config/aso/settings.yaml (what clusterctl applies) templates all
three from same-named clusterctl variables, so they can be set at
clusterctl init time. The standalone ASO Helm chart exposes the same three
as values (azureAuthorityHost, azureResourceManagerEndpoint,
azureResourceManagerAudience). ASO's config package
(v2/pkg/common/config/config.go) documents all three.
Evidence¶
Patched the secret with dummy SP credentials
(USE_WORKLOAD_IDENTITY_AUTH=false) and all three endpoints set to
https://wiremock.wiremock-system.svc, mounted the throwaway CA, restarted
the Deployment, and created a ResourceGroup CR. Arrivals at WireMock
(redacted samples from /__admin/requests):
{
"method": "POST",
"url": "/11111111-1111-1111-1111-111111111111/oauth2/v2.0/token",
"body": "client_id=22222222-...&grant_type=client_credentials&scope=https%3A%2F%2Fwiremock.wiremock-system.svc%2F.default+openid+offline_access+profile"
}
{
"method": "PUT",
"url": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/wiremock-spike-rg?api-version=2020-06-01",
"headers": {
"host": "wiremock.wiremock-system.svc",
"authorization": "Bearer eyJhbG...lIn0 (the dummy stub token)",
"user-agent": "azsdk-go-generic/v2.19.0 (go1.26.2; linux) aso-controller/v2.19.0 cluster-api-provider-azure/v1.27.0"
},
"response_status": 404
}
All three settings are exercised in that chain: the ARM endpoint in the PUT
URL, the audience in the token scope
(https://wiremock.wiremock-system.svc/.default), and the authority host in
the token path. The 404 is WireMock's unmatched-request page; arrival is
what the spike needed to prove.
Caveat: MSAL instance discovery anchors at the real public cloud¶
With a custom AZURE_AUTHORITY_HOST, token acquisition first failed
against REAL Azure without anything reaching WireMock:
ClientSecretCredential authentication failed.
GET https://login.microsoftonline.com/common/discovery/instance
RESPONSE 400: "error": "invalid_instance",
"error_description": "AADSTS50049: Unknown or invalid instance. Trace ID: 12a52fb9-a4..."
azidentity/MSAL validates a non-public authority via instance discovery
anchored at the hardcoded public endpoint
https://login.microsoftonline.com/common/discovery/instance; real AAD
rejects the in-cluster hostname (AADSTS50049 proves the custom authority
WAS applied, since the canonical host validates fine). ASO exposes no
disable-instance-discovery knob (no DisableInstanceDiscovery anywhere in
the ASO source at v2.19.0). The fix is DNS, not configuration: rewrite
login.microsoftonline.com to WireMock and stub the discovery chain (stub
list below). With the rewrite plus stubs the full auth chain became
hermetic, with the custom authority host still configured.
Caveat: static auth stubs are a Phase 1 prerequisite¶
Like ACK's STS GetCallerIdentity dependency in the AWS spike, no ARM
traffic happens until token acquisition succeeds, so the WireMock stub set
must ship these static mappings from the start:
GET /common/discovery/instancereturningmetadata[].aliasescovering every authority host in play (bothlogin.microsoftonline.comandwiremock.wiremock-system.svcwere listed in this spike).GET /common/.well-known/openid-configuration(ASO's MSAL path) andGET /<tenant>/v2.0/.well-known/openid-configuration(CAPZ's MSAL path; different URL shape, both must exist).POST /<tenant>/oauth2/v2.0/tokenreturning a dummy JWT. Both vendored azidentity versions accept an unsigned JWT-shaped string without complaint.
Bonus: per-cluster credentials inherit the endpoints¶
CAPZ's ASOSecret controller creates a per-cluster ASO credential Secret
(spike-aso-secret, keys: AZURE_SUBSCRIPTION_ID/TENANT_ID/CLIENT_ID/
CLIENT_SECRET, no endpoint keys), and CAPZ's ASOAPI path creates real ASO
CRs for the AzureCluster (a ResourceGroup named spike appeared,
owned by the AzureCluster). Those reconciles flow through the global
endpoint settings automatically (ALLOW_MULTI_ENV_MANAGEMENT defaults to
false, so per-credential endpoint overrides are rejected and the global
ones apply). The whole AzureASOManaged* path krops uses for AKS is
covered by the one Secret patch.
CAPZ v1.27.0: no config surface (three surfaces tried, three negatives)¶
Helm values / deploy-time knobs¶
krops deploys CAPZ via clusterctl, not Helm. The generated
capz-controller-manager Deployment wires exactly one Azure env var
(AZURE_SUBSCRIPTION_ID from capz-manager-bootstrap-credentials) plus
downward-API fields; args are leader election, diagnostics, feature gates,
verbosity. No endpoint flags or env wiring exist.
CR fields¶
AzureCluster.spec.azureEnvironment accepts only the four named clouds
(AzurePublicCloud, AzureChinaCloud, AzureGermanCloud,
AzureUSGovernmentCloud). The CRD schema has no enum (any string passes API
validation, and the field is webhook-immutable after creation), but the
controller's switch in azure/scope/clients.go
(getSettingsFromEnvironment) rejects anything else at reconcile time.
Creating an AzureCluster with azureEnvironment: AzureStackCloud
produced:
failed to create scope: failed to configure azure settings and credentials
for Identity: invalid cloud environment name ""
The empty %q is an upstream error-message bug (the switch's default
branch prints the not-yet-assigned field, not the rejected input), but the
rejection is real: no AzureStackCloud case, no custom endpoint fields
anywhere in the CRD.
Environment variables¶
getSettingsFromEnvironment reads ONLY AZURE_SUBSCRIPTION_ID from the
process environment; endpoints come from hardcoded cloud.Configuration
presets, and azure/scope/identity.go always constructs the credential
with an explicit Cloud block, so azidentity's AZURE_AUTHORITY_HOST
environment fallback never engages either.
Empirical confirmation: patched the capz-controller-manager with
AZURE_AUTHORITY_HOST=https://wiremock.wiremock-system.svc and
AZURE_RESOURCE_MANAGER_ENDPOINT=https://wiremock.wiremock-system.svc
(DNS rewrite OFF) and let the AzureCluster reconcile. CAPZ called REAL
AAD with the dummy tenant:
failed to get availability zones: failed to get zones for location
swedencentral: failed to refresh resource sku cache: could not iterate
resource skus: ClientSecretCredential authentication failed.
GET https://login.microsoftonline.com/11111111-1111-1111-1111-111111111111/v2.0/.well-known/openid-configuration
"error_description": "AADSTS90002: Tenant '11111111-...' not found. ...
Trace ID: 941754d7-... Timestamp: 2026-09-20 15:24:35Z"
A real AAD trace ID proves real-endpoint contact; the WireMock journal stayed empty of CAPZ traffic for the whole window (only ASO's entries from the earlier test). Both env vars are ignored.
CAPZ fallback 1, CoreDNS rewrite: works end to end¶
Corefile additions (exact-name rewrites, real hostnames only):
rewrite name exact login.microsoftonline.com wiremock.wiremock-system.svc.cluster.local.
rewrite name exact management.azure.com wiremock.wiremock-system.svc.cluster.local.
With the WireMock server certificate carrying the real hostnames as SANs
(signed by the throwaway CA, mounted via SSL_CERT_FILE) and the auth
stubs from the ASO section in place, CAPZ's full reconcile arrived at
WireMock:
GET /common/discovery/instance
GET /11111111-1111-1111-1111-111111111111/v2.0/.well-known/openid-configuration
POST /11111111-1111-1111-1111-111111111111/oauth2/v2.0/token
GET /subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Compute/skus (x12)
Redacted ARM sample (/__admin/requests):
{
"method": "GET",
"absoluteUrl": "https://management.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Compute/skus?$filter=location+eq+'swedencentral'&api-version=2021-07-01",
"headers": {
"authorization": "Bearer eyJhbG...lIn0 (the dummy stub token)",
"user-agent": "azsdk-go-armcompute/v5.7.0 (go1.26.7; linux) cluster-api-provider-azure/v1.27.0-dirty"
}
}
The Go runtime in this user-agent (go1.26.7) differs from ASO's
(go1.26.2, see ASO evidence above). That is expected: CAPZ and ASO are
separately compiled binaries built from different upstream repositories. The
azcore/azidentity pins in the version table come from each project's go.mod
and are unaffected.
The absoluteUrl shows the real ARM hostname while TLS terminated at
WireMock, which is exactly the intended rewrite posture. After stubbing the
skus list with {"value": []}, the reconcile walked deeper and its
ASO-managed resources (the ResourceGroup CR named spike) appeared and
were reconciled by ASO through the ASO endpoint configuration, also at
WireMock.
One operational gotcha: a manager that established TLS connections to the real endpoints BEFORE the rewrite keeps reusing them (Go transport keep-alive). It kept placing real AAD calls with fresh trace IDs until the pod was restarted. Restart the controller after applying the rewrite.
CAPZ fallback 2, HTTPS_PROXY + CA: fails at the proxy¶
azcore's default transport honors proxy environment variables
(Proxy: http.ProxyFromEnvironment in azcore v1.23.1
runtime/transport_default_http_client.go), so HTTPS_PROXY is respected
by both CAPZ and ASO in principle. In practice WireMock 3.13.2 cannot
terminate a CONNECT tunnel. From a test pod with
HTTPS_PROXY=http://wiremock.wiremock-system.svc:8080:
> CONNECT management.azure.com:443 HTTP/1.1
< HTTP/1.1 200 OK
* CONNECT tunnel established, response 200
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (OUT), TLS alert, record overflow (534):
* OpenSSL/3.3.2: error:0A0000C6:SSL routines::packet length too long
curl exit 35
The CONNECT gets a hollow 200 (and never even appears in the request
journal), the tunnel carries nothing, and the TLS ClientHello is answered
with non-TLS bytes. A bare 200 is worse than a clean refusal because
clients proceed into the handshake before failing. HTTPS_PROXY is not a
viable interception mechanism with WireMock as the TLS terminator.
Decision gate outcome¶
| Controller | Config-surface override | Fallback result |
|---|---|---|
| ASO v2.19.0 (bundled) | yes: aso-controller-settings Secret (or clusterctl variables / ASO Helm values) with AZURE_AUTHORITY_HOST + AZURE_RESOURCE_MANAGER_ENDPOINT + AZURE_RESOURCE_MANAGER_AUDIENCE |
CoreDNS rewrite additionally required for MSAL's instance-discovery anchor when the authority host is customized |
| CAPZ v1.27.0 | no: named clouds only, endpoint env vars ignored, no CR fields | CoreDNS rewrite of login.microsoftonline.com + management.azure.com works end to end (proven to arrival); HTTPS_PROXY fails (WireMock cannot tunnel CONNECT) |
The Azure Phase 0 gate passes with a combined mechanism: ASO endpoint configuration plus a CoreDNS rewrite covering CAPZ's own SDK clients and the MSAL discovery anchor. No real Azure hostname is contacted once both are in place, and no network-layer interception beyond in-cluster DNS is needed.
Implications for Phase 1¶
- WireMock deployment: identical to the AWS harness (throwaway CA, JKS via
the image's keytool, both password flags, HTTPS 8443 behind ClusterIP
443), with Azure SAN additions
login.microsoftonline.comandmanagement.azure.com. - ASO patch:
aso-controller-settingsSecret with dummy SP credentials,USE_WORKLOAD_IDENTITY_AUTH=false, and the three endpoint settings pointed at WireMock;SSL_CERT_FILE+ CA ConfigMap in capz-system. No per-cluster credential work needed (CAPZ's ASOSecret secrets inherit the global settings). - CAPZ patch: none. CoreDNS exact-name rewrites for
login.microsoftonline.comandmanagement.azure.com, plusSSL_CERT_FILE+ CA ConfigMap on the capz-controller-manager Deployment, plus a manager restart after the rewrite lands (keep-alive). - Static auth stubs required before any controller traffic:
/common/discovery/instance(aliases covering every authority host in play),/common/.well-known/openid-configuration,/<tenant>/v2.0/.well-known/openid-configuration,/<tenant>/oauth2/v2.0/token(a dummy unsigned-JWT token is accepted). Because both vendored azidentity versions accept an unsigned JWT-shaped string (see "Caveat: static auth stubs are a Phase 1 prerequisite" above), this pattern proves endpoint routing only: no token signature or real AAD auth validation is exercised by the harness. - The krops Azure environment's real clusters are
AzureASOManaged*(ASO-API) with workload identity; the virtualized harness substitutes dummy service-principal credentials for the FIC chain, mirroring the issue's planned credential-repoint step. The SP path exercises the same azidentity/azcore client construction for endpoint purposes. - Unmatched-request handling: controllers surface WireMock's unmatched 404
as generic Azure SDK errors (
ERROR CODE UNAVAILABLE) and keep requeuing, which the Phase 5/__admin/requests/unmatchedassertion rides on, same as AWS.
Harness findings beyond the AWS list¶
- WireMock 3.13.2 standalone rejects the 2.x
--http-portflag; the HTTP port flag is--port. Any invalid flag kills the jar with a bareMissingResourceExceptionand no usage text (the error-reporting bundle is missing from the classpath), so flag typos are undiagnosable from the log alone. - The keytool-built keystore is PKCS12 even when named
.jks; it boots fine with the default--keystore-type=JKS(OpenJDK's DualFormatJKS reads both). - Clearing the request journal is
DELETE /__admin/requestsin 3.13.2 (POST /__admin/requests/resetreturns 404). - WireMock answers CONNECT with 200 but never journals the request and never tunnels bytes.
Environment caveats observed during the spike¶
- The loaded-host leader-election flap from the AWS spike hit capi-system
AND capz-system. For capz-controller-manager the real killer was the
liveness probe (
timeoutSeconds: 1,failureThreshold: 3): kubelet SIGTERMs a CPU-starved manager, which then exits 0 after a graceful "leader election lost" shutdown, looking like an election problem. Raising leader-election timeouts (60s/30s/5s) alone did not stabilize it; relaxing the probes (timeoutSeconds: 10,failureThreshold: 12) did. CI runners with a single kind cluster should need neither. clusterctl init --infrastructure azure:v1.27.0needs no credential variables at all, so the "dummy values but variable required" note from the AWS spike has no Azure equivalent.AzureClusterIdentity.spec.clientSecretis an ObjectReference{name, namespace}(no key field; the key is fixed toclientSecret) and the namespace must be set explicitly even for a same-namespace secret, or every reconcile fails with "an empty namespace may not be set when a resource name is provided".
Cleanup¶
kind delete cluster --name wiremock-azure-spike after the runs. No Azure
resources exist to clean (dummy credentials never authenticated anywhere;
the only real Azure calls ever placed were CAPZ's/ASO's unassisted token
probes against the real login.microsoftonline.com, all rejected with
AADSTS errors). No local clusters left running by this spike.