Virtualized e2e harness architecture (WireMock, issue #355)¶
- Status: under review
- Tracking issue: #355
Background¶
Phase 0 (spikes) proved that cloud API traffic can be redirected to an in-cluster WireMock for AWS, Azure, and GCP. Findings live in docs/wiremock-e2e-spike-findings-aws.md, docs/wiremock-e2e-spike-findings-azure.md, and docs/wiremock-e2e-spike-findings-gcp.md.
Phase 1 landed the shared harness library and per-cloud arm structure in virtualized-e2e/. Nothing there is Flux-reconciled, and phases 2–5 have no mise tasks or CI workflow yet.
This proposal records the architectural decisions needed before Phase 2 work can begin: the cross-cloud interception design, the TLS material contract, the recording workflow, and the Phase 4 automation shape, plus five open questions that require a choice before implementation.
Architecture summary¶
Interception mechanisms per cloud¶
Each cloud uses a different interception approach because the controllers have different configuration surfaces.
| Cloud | Controllers | Mechanism | Boot stubs required |
|---|---|---|---|
| AWS | CAPA, ACK S3/RDS/IAM | AWS_ENDPOINT_URL env var (global + per-service) |
STS GetCallerIdentity (ACK dies at startup without it) |
| Azure | CAPZ, ASO | aso-controller-settings Secret for ASO; CoreDNS rewrite name exact of login.microsoftonline.com and management.azure.com for CAPZ (no env surface) |
AAD instance-discovery, openid-configuration, and token endpoint stubs (MSAL validates the authority before any ARM call) |
| GCP | CAPG, KCC | CoreDNS rewrite name exact of *.googleapis.com hostnames + WIF credential repoint of capg-wif-credentials and kcc-wif-credentials |
WIF token-chain stubs: external_account STS exchange (POST /v1/token) and impersonation (:generateAccessToken) |
All three clouds use the same TLS trust mechanism: SSL_CERT_FILE=/certs/ca.crt
pointing at a throwaway CA, mounted via a wiremock-ca ConfigMap. Go's
crypto/x509 honors this env var, covering all controller binaries (all
distroless Go) without SDK involvement. The ConfigMap must exist in each
consuming namespace because Kubernetes volumes cannot cross namespaces.
WireMock deployment¶
WireMock runs in wiremock-system, HTTPS-only on port 8443 behind a
ClusterIP Service on port 443. Stub mappings are mounted as files (not
in-memory; in-memory stubs are wiped by pod restarts). The shared Deployment
template lives in
virtualized-e2e/lib/wiremock/;
the per-cloud arm adds its boot stubs via a ConfigMap.
TLS material contract¶
One throwaway CA is generated per harness run (never committed). Its
ca.crt is distributed as a wiremock-ca ConfigMap to wiremock-system
and each controller namespace. The server certificate is signed by that CA
and built with the WireMock image's own keytool — a LibreSSL-produced PKCS12
fails to load in the WireMock JRE (Phase 0 finding). Both
--keystore-password and --key-manager-password must be set explicitly
(the key-manager default is the literal string password regardless of the
keystore password).
SANs required on the server certificate:
| Arm | SANs |
|---|---|
| All | wiremock.<namespace>.svc, wiremock.<namespace>.svc.cluster.local, *.wiremock.<namespace>.svc, *.wiremock.<namespace>.svc.cluster.local (for S3-style virtual-hosted addressing), localhost (for kubectl port-forward admin access) |
| Azure addition | login.microsoftonline.com, management.azure.com (the rewritten hostnames are kept in SNI/Host, so the cert must cover them) |
| GCP addition | compute.googleapis.com, container.googleapis.com, oauth2.googleapis.com, sts.googleapis.com, iamcredentials.googleapis.com (same reason) |
Phase roadmap¶
| Phase | What | Status |
|---|---|---|
| 0 | Spike: prove interception per cloud | Done |
| 1 | Shared harness library + per-cloud arm structure | Done |
| 2 | Live recordings (requires a real cloud run per arm) | Not started |
| 3 | Scenario/state-machine content (lib/scenario-schema.json shape) |
Not started |
| 4 | mise run <cloud>-e2e-virtualized tasks + CI workflow |
Not started |
| 5 | Full assertion scripts (shared helpers in lib/assertions.py) |
Helpers only |
Environment setup flow¶
This is the ordered sequence a Phase 4 mise task runs for one cloud arm. The steps are the same across clouds; the cloud-specific differences are in the patch files and boot stubs applied at each step.
1. kind create cluster --name wiremock-<cloud>-e2e
(single control-plane node; one cluster per arm, per run)
2. Install mise-pinned host tools (clusterctl, helm, kubectl)
via jdx/mise-action in CI; already present in the toolbox container locally
3. Generate TLS material (once per cluster, never committed):
a. Throwaway root CA (2-day validity) with the WireMock image's own keytool
via `docker run --rm wiremock/wiremock:<pin> keytool -genkeypair ...`
(Option A from D1; same JRE that will load the keystore)
b. Server certificate signed by the CA; SANs per-arm as documented in
the TLS material contract above
c. JKS keystore packed with both --keystore-password and --key-manager-password
set to the same value (the key-manager default of "password" is a Phase 0 trap)
4. Distribute TLS material:
a. Create namespace wiremock-system
b. kubectl create secret generic wiremock-keystore -n wiremock-system
--from-file=wiremock.jks --from-literal=keystore-password=... --from-literal=key-manager-password=...
c. kubectl create configmap wiremock-ca -n wiremock-system --from-file=ca.crt
d. kubectl create configmap wiremock-ca in EACH controller namespace
(capa-system + ack-system for AWS; capz-system for Azure; capg-system + cnrm-system for GCP)
Namespace must exist before the ConfigMap; create it if absent
5. Render and apply WireMock + boot stubs:
a. envsubst < lib/wiremock/namespace.yaml.tmpl | kubectl apply -f -
b. envsubst < lib/wiremock/deployment.yaml.tmpl | kubectl apply -f -
c. envsubst < lib/wiremock/service.yaml.tmpl | kubectl apply -f -
d. kubectl apply -f <cloud>/wiremock/stubs-configmap.yaml
e. kubectl wait --for=condition=Available deployment/wiremock -n wiremock-system
6. Install cloud controllers:
AWS: clusterctl init --infrastructure aws:<pin> (dummy AWS_B64ENCODED_CREDENTIALS)
helm install ack-s3-controller ... ; helm install ack-rds-controller ...
helm install ack-iam-controller ...
Azure: clusterctl init --infrastructure azure:<pin> (no credential variables needed)
GCP: clusterctl init --infrastructure gcp:<pin>
kubectl apply -f mgmt/gcp/infrastructure/kcc-operator/ (KCC bundle)
7. Cloud-specific pre-patch steps (BEFORE patching Deployments):
Azure: kubectl patch secret/aso-controller-settings -n capz-system ...
(endpoint keys + dummy SP credentials; must precede the Deployment patch
because secretKeyRef env vars are read at pod start)
kubectl apply -f <cloud>/wiremock/patches/coredns.yaml
kubectl rollout restart deployment/coredns -n kube-system
kubectl rollout status deployment/coredns -n kube-system
GCP: kubectl patch secret/capg-wif-credentials ...
kubectl patch secret/kcc-wif-credentials ...
kubectl apply -f <cloud>/wiremock/patches/coredns-rewrites.yaml
kubectl rollout restart deployment/coredns -n kube-system
kubectl rollout status deployment/coredns -n kube-system
(rewrite must be in place BEFORE controllers start; a running manager
keeps its keep-alive TLS connection to the real host until pod restart)
8. Patch controller Deployments (endpoint overrides + CA trust):
kubectl patch deployment/<name> -n <ns> --type strategic \
--patch-file <cloud>/wiremock/patches/<controller>.yaml
per controller, in parallel where the namespaces are independent
AWS patch order: capa-controller-manager, then ack-{s3,rds,iam}-controller
Azure patch order: azureserviceoperator-controller-manager, capz-controller-manager
GCP patch order: capg-controller-manager, cnrm-controller-manager
NOTE: patch BEFORE waiting on availability — an unpatched ACK controller
crash-loops on real-STS auth (AWS Phase 0 trap); Azure CAPZ keeps
existing connections to real AAD until its pod restarts
9. Wait for all controller Deployments to be Available:
lib/assertions.py deployment-available <ns> <name> per controller
(uses kubectl wait --for=condition=Available with a generous timeout;
the ACK boot race means the controller becomes Available only after
the patch triggers its rollout, not immediately after helm install)
10. Apply trigger CRs (Phase 2+, requires stubs to be present):
AWS: kubectl apply -f <test-cluster-cr> (AWSCluster + Cluster, eu-north-1)
kubectl apply -f <test-bucket-cr> (ACK Bucket)
Azure: kubectl apply -f <test-azurecluster-cr>
GCP: kubectl apply -f <test-gcpcluster-cr>
11. Wait for reconcile activity:
poll /__admin/requests (kubectl port-forward + curl --cacert ca.crt)
until at least one request per expected controller has arrived,
or timeout (60s) -- arrival proves the full chain: env override,
cluster DNS (for virtual-hosted S3 / CoreDNS rewrites), CA trust, WireMock
12. Run Phase 5 assertions:
lib/assertions.py unmatched (fails if /__admin/requests/unmatched is non-empty
after allowing boot stubs; this is the quality gate — see below)
13. Collect debug artifacts (always, including on failure):
kubectl logs deployment/<controller> -n <ns> per controller
curl --cacert ca.crt https://localhost:<port>/__admin/requests
kubectl describe deployment/<controller> -n <ns>
upload as GitHub Actions artifact with retention-days: 3
14. kind delete cluster --name wiremock-<cloud>-e2e
Quality gate¶
The quality gate is the zero-unmatched-requests assertion (step 12 above).
lib/assertions.py unmatched calls GET /__admin/requests/unmatched on
the WireMock admin API. A non-empty response means a controller called a
cloud API endpoint for which no stub mapping exists. This is a hard failure
because it means either:
- the stub set is incomplete (a reconcile path is unrecorded), or
- controller behavior changed after the recordings were taken (schema drift).
The --allow flag lets the task exclude known-unmatched paths that are
intentionally out of scope for the current arm (for example, gRPC calls from
CAPG in the REST-only Phase 2 scope for GCP, per D4).
What the gate does NOT catch: a stub matched but returned the wrong response body. That requires Phase 3 stateful scenarios and is a separate assertion layer on top of the unmatched check.
CI placement¶
The WireMock e2e run takes an estimated 15–25 minutes per arm (kind spin-up ~3 min, controller install ~5 min, reconcile activity ~5 min, assertions ~1 min, teardown ~1 min), or roughly 20–30 minutes with all three arms in parallel. That is too long to block PR merges.
| Trigger | Workflow | Job timeout | Gates merges |
|---|---|---|---|
pull_request + push: main (path filter virtualized-e2e/**) |
virtualized-e2e.yml |
30 min per arm | No — informational on PR; required on push to main |
schedule: nightly |
virtualized-e2e.yml |
30 min per arm | No |
workflow_dispatch |
virtualized-e2e.yml |
30 min per arm | No |
The path filter (paths: [virtualized-e2e/**]) means the workflow only runs
when a PR touches the harness files. It does NOT run on unrelated changes
(Renovate bumps, management cluster manifests, etc.), so it does not add
latency to the normal PR cycle.
On push: main (post-merge, not pre-merge), a failure opens or comments on
a tracking issue titled virtualized-e2e: scheduled workflow is failing,
following the same report-status pattern as
air-gapped.yml.
Cancelled runs are ignored; a recovery closes the issue.
Phase 1 (structure, no recordings) adds no new workflow. mise run validate
builds the virtualized-e2e/ overlays on the host; validate.yml does not
build them yet. The virtualized-e2e.yml workflow is a Phase 4 deliverable.
D6: per-arm job structure¶
| Option | How | Trade-offs |
|---|---|---|
A. Three parallel jobs in one workflow (aws-e2e, azure-e2e, gcp-e2e) |
strategy: matrix or explicit job names; all run in parallel; one report-status job at the end |
Each arm uses one runner; total wall time = max(per-arm time); one workflow file to maintain |
B. Three separate workflows (virtualized-e2e-aws.yml, etc.) |
Independent schedules and path filters per cloud | Fine-grained control; easier to disable one arm; more workflow files |
| C. Sequential jobs in one workflow | One runner, arms run one after the other | Simplest; total wall time = sum; unnecessary serialization |
Recommendation: A — consistent with the repo's existing single-file
nightly pattern. Note: on.<event>.paths is workflow-scoped in GitHub Actions,
not job-scoped; per-arm triggering within one file requires dorny/paths-filter
or explicit if: conditions on each job.
Open decisions¶
D1: TLS certificate generation tooling (Phase 4)¶
Every arm README states "Phase 4 generates the throwaway CA and JKS keystore," but the Phase 0 spike used manual openssl and keytool steps that are not yet scripted.
| Option | How | Trade-offs |
|---|---|---|
A. Shell script using docker run with the WireMock image |
docker run --rm wiremock/wiremock:<pin> keytool ... generates the JKS inside the same JRE that will load it; no host-side Java dependency |
Requires Docker on the mise host and during CI; the image pin must match the deployment pin |
B. step CLI (smallstep) |
mise-pinned step generates the CA and signs the cert; a separate keytool invocation (from a JDK) converts to JKS |
Two tool dependencies; step is already familiar from similar repos but not in this mise config |
C. cfssl + openssl + keytool |
Toolchain already verified to work in Phase 0 macOS run; cfssl is lightweight |
LibreSSL (macOS default openssl) produced a PKCS12 that failed to load in the WireMock JRE; any CI runner must use a non-LibreSSL openssl or skip the PKCS12 path entirely |
| D. Run generation inside the toolbox container | Run the Phase 4 mise task inside the toolbox; requires adding Java (keytool) to the image, which currently has none | Keeps all tooling in one image; toolbox image is large already |
Recommendation: A — the WireMock image's own keytool was the Phase 0 finding for reliability, and Docker is already a hard dependency for kind.
D2: Phase 4 automation design¶
The apply order differs non-trivially per cloud (see each arm README). This decision shapes whether Phase 4 is a single parameterized script, three separate scripts, or mise tasks.
| Option | How | Trade-offs |
|---|---|---|
A. One mise task per arm (aws-e2e-virtualized, azure-e2e-virtualized, gcp-e2e-virtualized) |
Each task is a small shell script in mise.toml; shared steps (CA gen, WireMock apply) are a helper task they call |
Explicit per-cloud; task names match the existing bootstrap/teardown naming style; easy to run one arm without the others |
B. Single parameterized script scripts/e2e-virtualized.sh <cloud> |
One entry point, cloud-specific steps in case branches |
Consistent with scripts/toolbox-run.sh; harder to read the per-cloud apply order |
C. Extend bootstrap.sh / bootstrap-rs |
The harness setup is part of the bootstrap lifecycle | Couples test infrastructure to the production bootstrap; hard to run independently |
Recommendation: A — matches the repo's existing task-per-environment pattern and keeps each cloud's apply order readable.
The tasks must run inside the toolbox container (same --entrypoint mise
pattern as kubeconfigs, sops-*, etc., with MISE_AUTO_INSTALL=0) so the
tool versions are pinned. The one exception: mise run validate runs on the
host and already builds the kustomize overlays in virtualized-e2e/; that
stays a host task.
D3: GCP incomplete rewrite list¶
The current CoreDNS rewrite set covers CAPG's resource APIs and the auth
endpoints but not the KCC services the repo's GCP resources actually use:
iam.googleapis.com (IAMServiceAccount, IAMPolicyMember,
WorkloadIdentityPool), serviceusage.googleapis.com,
storage.googleapis.com, sqladmin.googleapis.com, and
servicenetworking.googleapis.com. Under the current WIF repoint, KCC
resources authenticate against WireMock but then dial the real hostnames
with a dummy token — so they are neither intercepted nor assertable.
| Option | How | Trade-offs |
|---|---|---|
| A. Extend the rewrite list and SAN set per KCC service in scope | Add each missing host to coredns-rewrites.yaml and to the server certificate SANs; add boot stubs per service |
Full coverage; the SAN set and recording scope grow with the resource set; must be decided before Phase 2 recordings are collected |
| B. Scope the GCP arm to CAPG + KCC auth only | Rename the arm's stated scope; document that KCC resource APIs are explicitly out of scope | Simpler Phase 2; leaves the KCC resource path untested; may need to revisit when more GCP resources are added |
| C. Two GCP arms: one for CAPG, one for KCC resources | gcp/capg-wiremock/ and gcp/kcc-wiremock/ |
Clean separation; doubles the setup overhead |
This decision must be made before Phase 2 recordings start, because recordings collected under Option B cannot be extended to Option A without re-running against a live environment.
D4: GCP gRPC gap¶
WireMock 3.13.2 standalone cannot produce application/grpc responses. The
GKE gRPC path (GCPManagedControlPlane reconcile calls the Container API via
gRPC) returns a 404, surfaced by the client as rpc Unimplemented. Arrival
assertions work; stateful gRPC replay does not.
| Option | How | Trade-offs |
|---|---|---|
A. Add wiremock-grpc-extension |
Mount the extension JAR into the WireMock Deployment; evaluate gRPC stub and replay capabilities | Adds a dependency and a pinned JAR; the extension is community-maintained and its replay fidelity is not yet evaluated |
| B. Scope the GCP arm to REST-only replay | Formally document that gRPC calls from CAPG are arrival-assertable but not replay-testable; Phase 5 assertions skip gRPC call counts | Simpler; already the de-facto Phase 0 outcome; limits coverage of the GKE control-plane path |
| C. Replace the gRPC path with a REST-based GKE client in CAPG | Out of scope for krops | Not feasible; controlled by the upstream CAPG project |
Option B is the pragmatic Phase 2 starting point; Option A can be evaluated in parallel as a separate spike (the extension is not pinned to a Phase).
D5: Azure credential repoint¶
The Azure arm substitutes dummy service-principal credentials
(USE_WORKLOAD_IDENTITY_AUTH=false) for the workload-identity FIC chain that
the real environment uses. This is the Phase 1 shortcut: the production
bootstrap uses arc-federate (issue #236) to give the kind cluster an Arc OIDC
issuer and register FICs for CAPZ and ASO workload identity, but the harness
has not yet wired that path.
| Option | How | Trade-offs |
|---|---|---|
| A. Stay with SP credentials for the harness | Document that the harness uses SP auth, not WIF; the SP path exercises the same azidentity/azcore client construction for endpoint purposes | Cannot assert the WIF token exchange path; diverges from the production credential model |
B. Reuse the Arc-federated OIDC issuer from arc-federate |
The arc-federate task already registers an Arc OIDC issuer for the kind cluster and creates FICs for CAPZ/ASO; evaluate whether the harness can ride the same issuer to assert the WIF token-exchange path |
Aligns with the production credential model; requires verifying that CAPZ/ASO accept tokens from the Arc issuer without a live AAD call, which WireMock would need to stub |
| C. Fabricate a standalone OIDC issuer for kind | Stand up a local OIDC server in the kind cluster; point Azure AD (or a WireMock stub of AAD) at it | Complex; AAD federation requires a real registered app even for stubs; only viable if the Azure arm can stub the federation endpoints end-to-end |
D. Repoint aso-credentials similarly to the GCP WIF repoint |
If ASO's MSAL client honors an external_account-style credential override, repoint the Secret to WireMock's token stub |
Requires verifying whether ASO/CAPZ accept a token without validating the issuer against a live AAD; unevaluated |
This is a Phase 2 prerequisite only if the harness aims to assert the WIF token-exchange path. If assertion coverage stops at ARM/AAD API arrival (same posture as GCP for now), Option A is sufficient.
Slices¶
The slices follow the phase order. Each is independently mergeable.
| # | Slice | Depends on | Done when |
|---|---|---|---|
| 1 | This document accepted; AGENTS.md and docs/ updated with a docs/wiremock-e2e.md architecture page |
none | Docs only; mise run validate passes |
| 2 | TLS cert generation script (D1 resolved) | 1 | Script generates a valid CA + server cert + JKS; mise run validate builds the arms; a smoke test loads the JKS into the WireMock image |
| 3 | Phase 4 mise tasks skeleton: aws-e2e-virtualized, azure-e2e-virtualized, gcp-e2e-virtualized (D2 resolved); tasks run CA gen + WireMock apply + controller patches |
2 | Each task reaches "WireMock available" on a local kind cluster with no CR triggers yet |
| 4 | Phase 2 recordings: AWS arm (live run required) | 3 | AWS: CAPA + ACK S3/RDS/IAM recording stubs committed; sanitize_recording.py --check passes; mise run aws-e2e-virtualized reaches "zero unmatched requests" |
| 5 | Phase 2 recordings: Azure arm (live run required; D5 resolved) | 3 | Azure: CAPZ + ASO recording stubs committed; same bar as slice 4 |
| 6 | Phase 2 recordings: GCP arm (live run required; D3 resolved) | 3 | GCP: CAPG + KCC (agreed scope) recording stubs committed; same bar |
| 7 | Phase 5 assertion scripts per arm: Deployment available + unmatched-request check | 4–6 | mise run <cloud>-e2e-virtualized exits non-zero on unmatched requests; CI job added |
| 8 | Phase 3 scenario content (stateful multi-step reconcile flows) | 7 | At least one stateful scenario per cloud passes end-to-end in CI |
Slices 4–6 require live cloud access (real AWS/Azure/GCP accounts) and are render-only in CI until sandbox accounts are available per environment.
Risks¶
- The gRPC limitation (D4) means CAPG's GKE control-plane path is arrival-assertable at best; the replay gap is a permanent ceiling unless Option A (wiremock-grpc-extension) is evaluated.
- Recording freshness: WireMock recordings are snapshots of the API at the time of capture. Controller upgrades may change request shapes, requiring re-recording. A CI job that replays stale recordings and passes provides false confidence unless there is a way to detect schema drift.
- The Phase 0 spike ran on a macOS host; CI runs on Linux. The JKS generation path (D1) must be verified on the CI runner architecture and OS, not just developer laptops. The LibreSSL vs OpenSSL distinction is the known failure mode.
- Stub completeness: boot stubs cover the minimum needed to bring a controller
up. Phase 2 must discover all API calls a full reconcile makes; any gap
surfaces as a requeue loop, not a test failure. The
/__admin/requests/unmatchedcheck is the safety net, but it does not distinguish "never happened" from "happened but matched".