diff --git a/README.md b/README.md index f54b4472..eae44fa4 100644 --- a/README.md +++ b/README.md @@ -324,7 +324,7 @@ flowchart LR | **Virtual Network** | ARM path (`Microsoft.Network`) | VNet, subnet, NIC, public IP, NSG, private DNS zone (+ virtual network links, record sets), private endpoint (+ private DNS zone groups), and private link service ARM resources; subnet listing is scoped to the parent VNet; NIC private IPs are synthesized for VM/Terraform compatibility | | **Virtual Machines** | ARM path (`Microsoft.Compute`) | VM lifecycle (create/start/stop/deallocate/restart/delete/list), instanceView power state; mocked: no Docker (container backing planned) | | **Azure Cache for Redis** | ARM path (`Microsoft.Cache`) | Cache CRUD, `listKeys`/`regenerateKey`; real `valkey/valkey:8-alpine` containers (data plane, primary key as password) or mocked; non-SSL port | -| **Azure Container Registry** | ARM path (`Microsoft.ContainerRegistry`) | Registry CRUD, `listCredentials`/`regenerateCredential`, `checkNameAvailability`; one shared `registry:2` (Docker Registry V2 push/pull, path-style `loginServer`, anonymous) or mocked | +| **Azure Container Registry** | ARM path (`Microsoft.ContainerRegistry`) | Registry CRUD, `listCredentials`/`regenerateCredential`, `checkNameAvailability`; one shared `registry:2` (Docker Registry V2 push/pull, anonymous on its published port); `az acr login` through the Entra token exchange on `{name}.azurecr.io`; or mocked | | **Azure Container Instances** | ARM path (`Microsoft.ContainerInstance`) | Container group lifecycle (create/update/delete/list by rg + subscription), `start`/`stop`/`restart` with spec-exact LRO shapes, container logs, instanceView, azurerm-safe read-backs (ports/resources always present, canonical enum casing, secrets never echoed); mocked: no Docker (container backing planned) | | **Event Grid** | ARM path (`Microsoft.EventGrid`) + `/{topic}-eventgrid/api/events` | Custom Topics, `listKeys`/`regenerateKey`, webhook `eventSubscriptions` with subject/eventType filters; publish in Event Grid + CloudEvents 1.0 schemas; async webhook delivery with retry; `SubscriptionValidationEvent` handshake; HTTP-only (no sidecar) | | **Azure Monitor / Log Analytics** | ARM path (`Microsoft.OperationalInsights` / `Microsoft.Insights`) + `/dataCollectionRules/{id}/streams/{stream}` + `/v1/workspaces/{id}/query` | Workspaces, Data Collection Endpoints/Rules; Logs Ingestion API; Log Analytics query with a KQL subset (`where`/`project`/`take`/`limit` + timespan); HTTP-only (no sidecar) | diff --git a/compatibility-tests/compat-azcli/run-bats-in-container.sh b/compatibility-tests/compat-azcli/run-bats-in-container.sh index cabf3488..cba5d62c 100755 --- a/compatibility-tests/compat-azcli/run-bats-in-container.sh +++ b/compatibility-tests/compat-azcli/run-bats-in-container.sh @@ -49,9 +49,13 @@ cat /tmp/floci-az.crt >> "$COMBINED_CA" export REQUESTS_CA_BUNDLE="$COMBINED_CA" export SSL_CERT_FILE="$COMBINED_CA" -# *.vault.azure.net data-plane interception: map the test vault DNS to loopback and -# forward 443 to floci-az, mirroring the terraform suite so `az keyvault secret` works. +# *.vault.azure.net and *.azurecr.io data-plane interception: map the test vault and +# registry DNS to loopback and forward 443 to floci-az, mirroring the terraform suite so +# `az keyvault secret` and `az acr login` reach the emulator on the hostnames the clients +# build from the service endpoints. echo "127.0.0.1 floci-test-kv.vault.azure.net" >> /etc/hosts +# Keep this name in sync with ACR_NAME in test/test_helper/common-setup.bash. +echo "127.0.0.1 flocitestacr.azurecr.io" >> /etc/hosts socat TCP-LISTEN:443,bind=127.0.0.1,fork,reuseaddr TCP:"${FLOCI_AZ_HOST}" & sleep 1 @@ -76,7 +80,8 @@ az cloud register -n floci-az \ --endpoint-active-directory-resource-id "${HTTPS_BASE}/" \ --endpoint-active-directory-graph-resource-id "${HTTPS_BASE}/" \ --suffix-storage-endpoint "core.windows.net" \ - --suffix-keyvault-dns ".vault.azure.net" + --suffix-keyvault-dns ".vault.azure.net" \ + --suffix-acr-login-server-endpoint ".azurecr.io" az cloud set -n floci-az diff --git a/compatibility-tests/compat-azcli/test/acr-login.bats b/compatibility-tests/compat-azcli/test/acr-login.bats new file mode 100644 index 00000000..acbc7817 --- /dev/null +++ b/compatibility-tests/compat-azcli/test/acr-login.bats @@ -0,0 +1,141 @@ +#!/usr/bin/env bats +# ACR data plane: `az acr login` performs the Entra token exchange against +# {name}.azurecr.io, and the token it returns pushes and pulls an image through the +# Docker Registry HTTP API V2 on the same host. +# +# `az acr login` without --expose-token shells out to `docker`, which this container does +# not have, so the token flow is exercised with --expose-token (the same challenge and +# /oauth2/exchange calls) and the push/pull is driven over the registry API directly. + +setup_file() { + load 'test_helper/common-setup' + + az group create -n "$RG_NAME" -l "$LOCATION" -o none + az acr create -n "$ACR_NAME" -g "$RG_NAME" -l "$LOCATION" --sku Basic -o none +} + +setup() { + load 'test_helper/common-setup' + + export LOGIN_SERVER="${ACR_NAME}.azurecr.io" + export REPO="compat/app" + export CA_BUNDLE=/tmp/floci-az.crt + + if [ ! -f "$CA_BUNDLE" ]; then + skip "floci-az TLS certificate was not fetched; the registry host cannot be verified" + fi +} + +# base64url payload of a JWT, on stdin, decoded to stdout. +decode_base64url() { + python3 -c 'import base64, sys; s = sys.stdin.read().strip(); sys.stdout.write(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)).decode())' +} + +# curl against the registry host, verifying floci-az's own certificate: the generated cert +# must carry *.azurecr.io for this to succeed. +registry_curl() { + curl -sS --cacert "$CA_BUNDLE" "$@" +} + +# An ACR refresh token from `az acr login`, exchanged for an access token scoped to $REPO. +acr_access_token() { + local refresh_token + refresh_token="$(az acr login -n "$ACR_NAME" --expose-token -o json 2>/dev/null | jq -r '.refreshToken')" + [ -n "$refresh_token" ] && [ "$refresh_token" != "null" ] || return 1 + + registry_curl -X POST "https://${LOGIN_SERVER}/oauth2/token" \ + --data-urlencode "grant_type=refresh_token" \ + --data-urlencode "service=${LOGIN_SERVER}" \ + --data-urlencode "scope=repository:${REPO}:pull,push" \ + --data-urlencode "refresh_token=${refresh_token}" | jq -r '.access_token' +} + +@test "az acr: loginServer is the Azure host name" { + run az_json acr show -n "$ACR_NAME" -g "$RG_NAME" + assert_success + assert_equal "$(echo "$output" | jq -r '.loginServer')" "$LOGIN_SERVER" +} + +@test "acr data plane: GET /v2/ challenges with the bearer realm and service" { + run registry_curl -o /dev/null -D - "https://${LOGIN_SERVER}/v2/" + assert_success + assert_output --partial "401" + assert_output --partial "realm=\"https://${LOGIN_SERVER}/oauth2/token\"" + assert_output --partial "service=\"${LOGIN_SERVER}\"" +} + +@test "az acr login: exchanges an Entra token for an ACR refresh token" { + run az_json acr login -n "$ACR_NAME" --expose-token + assert_success + assert_equal "$(echo "$output" | jq -r '.loginServer')" "$LOGIN_SERVER" + assert_equal "$(echo "$output" | jq -r '.username')" "00000000-0000-0000-0000-000000000000" + # The refresh token is a JWT: clients decode it, so it must have three segments. + # (The azure-cli image ships no awk, so count in bash.) + IFS='.' read -ra segments <<< "$(echo "$output" | jq -r '.refreshToken')" + assert_equal "${#segments[@]}" "3" +} + +@test "acr data plane: the refresh token buys a scoped access token" { + run acr_access_token + assert_success + # The Azure CLI decodes this token and reads its access claim when verifying permissions. + claims="$(echo "$output" | cut -d. -f2 | decode_base64url)" + assert_equal "$(echo "$claims" | jq -r '.access[0].name')" "$REPO" + assert_equal "$(echo "$claims" | jq -r '.access[0].actions | join(",")')" "pull,push" +} + +@test "acr data plane: push and pull an image through the login server" { + token="$(acr_access_token)" + [ -n "$token" ] && [ "$token" != "null" ] || fail "could not obtain an ACR access token" + auth=(-H "Authorization: Bearer ${token}") + + config='{}' + digest="sha256:$(printf '%s' "$config" | sha256sum | cut -d' ' -f1)" + + # Start an upload session; the Location must come back without the internal prefix. + location="$(registry_curl -o /dev/null -D - -X POST "${auth[@]}" \ + "https://${LOGIN_SERVER}/v2/${REPO}/blobs/uploads/" \ + | grep -i '^location:' | sed 's/^[^:]*: *//' | tr -d '\r')" + [ -n "$location" ] || fail "no upload Location header" + case "$location" in + "/v2/${REPO}/blobs/uploads/"*) ;; + *) fail "upload Location leaked the internal repository prefix: $location" ;; + esac + + separator="?" + case "$location" in *"?"*) separator="&" ;; esac + run registry_curl -o /dev/null -w '%{http_code}' -X PUT "${auth[@]}" \ + -H "Content-Type: application/octet-stream" --data-binary "$config" \ + "https://${LOGIN_SERVER}${location}${separator}digest=${digest}" + assert_success + assert_output "201" + + manifest="{\"schemaVersion\":2,\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\",\"config\":{\"mediaType\":\"application/vnd.docker.container.image.v1+json\",\"size\":${#config},\"digest\":\"${digest}\"},\"layers\":[]}" + run registry_curl -o /dev/null -w '%{http_code}' -X PUT "${auth[@]}" \ + -H "Content-Type: application/vnd.docker.distribution.manifest.v2+json" \ + --data-binary "$manifest" "https://${LOGIN_SERVER}/v2/${REPO}/manifests/v1" + assert_success + assert_output "201" + + # Pull it back: manifest, then the config blob it references. + run registry_curl "${auth[@]}" \ + -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ + "https://${LOGIN_SERVER}/v2/${REPO}/manifests/v1" + assert_success + assert_equal "$(echo "$output" | jq -r '.config.digest')" "$digest" + + run registry_curl "${auth[@]}" "https://${LOGIN_SERVER}/v2/${REPO}/blobs/${digest}" + assert_success + assert_output "$config" + + run registry_curl "${auth[@]}" "https://${LOGIN_SERVER}/v2/${REPO}/tags/list" + assert_success + assert_equal "$(echo "$output" | jq -r '.name')" "$REPO" + assert_output --partial "v1" + + # The shared registry stores this as {registry}/{repo}; the catalog must not say so. + run registry_curl "${auth[@]}" "https://${LOGIN_SERVER}/v2/_catalog" + assert_success + assert_output --partial "\"${REPO}\"" + refute_output --partial "${ACR_NAME}/${REPO}" +} diff --git a/compatibility-tests/sdk-test-python/tests/test_acr.py b/compatibility-tests/sdk-test-python/tests/test_acr.py index 854c9db7..8a346a40 100644 --- a/compatibility-tests/sdk-test-python/tests/test_acr.py +++ b/compatibility-tests/sdk-test-python/tests/test_acr.py @@ -1,13 +1,15 @@ """Azure Container Registry compatibility test. Provisions a registry through the ARM management plane, then (when the backing -``registry:2`` sidecar is reachable) pushes a minimal image through the standard -Docker Registry HTTP API V2 and reads it back. - -The data-plane assertions are skipped when the registry never becomes connectable -(e.g. the emulator runs in mocked mode where ``loginServer`` is the cosmetic -``{name}.azurecr.io``). Run floci-az with ``floci-az.services.acr.mocked=false`` -to exercise them. +``registry:2`` sidecar is reachable) pushes a minimal image anonymously through the +container's own published port and reads it back. + +``loginServer`` is ``{name}.azurecr.io``, which needs name resolution and TLS, so +Azure-native clients are covered by the Azure CLI suite. What this asserts is the +convenience path: the shared container stays published and anonymous, the registry +name is the repository prefix there, and ``properties.localPort`` reports the port it +was published on. The data-plane assertions are skipped when the container is not +reachable (mocked mode, or no Docker). """ import hashlib import json @@ -23,6 +25,12 @@ ACR = "sdktestacr" API = "2025-11-01" +# Hosts the shared registry container may answer on. The port comes from the registry +# resource's localPort, so only the host has to be guessed: the container name on the compat +# Docker network, or the loopback address for a local run. ACR_REGISTRY_ENDPOINT overrides the +# whole host:port when neither applies. +REGISTRY_HOSTS = ["floci-az-acr-registry", "localhost"] + ARM_BASE = ( f"{EMULATOR_BASE}/subscriptions/{SUB}/resourceGroups/{RG}" f"/providers/Microsoft.ContainerRegistry/registries/{ACR}" @@ -64,7 +72,7 @@ def provisioned_registry(): def test_arm_response_shape(provisioned_registry): props = provisioned_registry - assert props["loginServer"] + assert props["loginServer"] == f"{ACR}.azurecr.io" assert props["adminUserEnabled"] is True assert props["username"] == ACR assert props["password"] @@ -84,27 +92,51 @@ def test_check_name_availability(): assert "nameAvailable" in taken -def _registry_reachable(host): +def _reachable(endpoint): try: - r = requests.get(f"http://{host}/v2/", timeout=3) - return r.status_code in (200, 401) + return requests.get(f"http://{endpoint}/v2/", timeout=3).status_code in (200, 401) except requests.RequestException: return False -def test_push_and_pull_via_registry_v2(provisioned_registry): - props = provisioned_registry - login = props["loginServer"] - # Shared registry: loginServer is host:port/{registryName}; the V2 API is at the host root and - # the registry name is the repo prefix. In mocked mode loginServer is {name}.azurecr.io (no path). - if "/" not in login: - pytest.skip("registry runs in mocked mode (no data plane)") - host, prefix = login.split("/", 1) - if not _registry_reachable(host): +def _published_registry(props): + """The shared container's published endpoint, or None when it is not running. + + loginServer is the Azure host name, so it does not carry the port. properties.localPort + does, the same way a PostgreSQL or MySQL server reports the port its container published. + """ + override = os.environ.get("ACR_REGISTRY_ENDPOINT") + if override: + return override if _reachable(override) else None + + port = props.get("localPort") + if not port: + return None + for host in REGISTRY_HOSTS: + endpoint = f"{host}:{port}" + if _reachable(endpoint): + return endpoint + return None + + +def test_arm_response_reports_the_published_port(provisioned_registry): + """localPort is how a client finds the anonymous port, now that loginServer cannot say.""" + port = provisioned_registry.get("localPort") + if port is None: + pytest.skip("registry sidecar not running (mocked mode or no Docker)") + assert isinstance(port, int) and port > 0 + + +def test_push_and_pull_anonymously_via_the_published_port(provisioned_registry): + """The published port keeps working without authenticating, over plain HTTP.""" + host = _published_registry(provisioned_registry) + if host is None: pytest.skip("registry sidecar not reachable (mocked mode or no Docker)") base = f"http://{host}/v2" - repo = f"{prefix}/sdk/minimal" + # On the published port the registry name is the repository prefix, the same storage + # {ACR}.azurecr.io/v2/sdk/minimal/... addresses through the emulator. + repo = f"{ACR}/sdk/minimal" # Push a config blob, then a manifest referencing it (a minimal but valid image). config = b"{}" diff --git a/docs/services/acr.md b/docs/services/acr.md index 4afe0e29..29781a01 100644 --- a/docs/services/acr.md +++ b/docs/services/acr.md @@ -1,16 +1,20 @@ # Azure Container Registry Compatible with the `azure-mgmt-containerregistry` SDK, the `az acr` CLI, Terraform's -`azurerm_container_registry`, and any ARM-speaking client for the management plane — plus **any -standard Docker client** (`docker login` / `push` / `pull`, OCI tooling) for the data plane. +`azurerm_container_registry`, and any ARM-speaking client for the management plane, plus **any +standard Docker client** (`docker login` / `push` / `pull`, OCI tooling) for the data plane, +either anonymously on the registry container's published port or with an Entra token on +`{name}.azurecr.io`. ## Features -- **Registry lifecycle** — create, get, update, delete; list by resource group and by subscription -- **Admin credentials** — `listCredentials` / `regenerateCredential` (username + two passwords) -- **Name availability** — `checkNameAvailability` -- **Usages** — `listUsages` (static quota report) -- **Data plane** — a single shared `registry:2` sidecar exposing the **Docker Registry HTTP API V2** +- **Registry lifecycle**: create, get, update, delete; list by resource group and by subscription +- **Admin credentials**: `listCredentials` / `regenerateCredential` (username + two passwords) +- **Name availability**: `checkNameAvailability` +- **Usages**: `listUsages` (static quota report) +- **Entra token exchange**: the bearer challenge on `GET /v2/`, `POST /oauth2/exchange` and + `/oauth2/token`, so `az acr login` and the Azure SDK container-registry clients work +- **Data plane**: a single shared `registry:2` sidecar exposing the **Docker Registry HTTP API V2** (`/v2/…`), so images push and pull with the standard Docker client. All registries are backed by one container and isolated by an internal repository prefix (`{registryName}/{repo}`) @@ -25,35 +29,124 @@ GET .../registries/{name}/listUsages POST /subscriptions/{s}/providers/Microsoft.ContainerRegistry/checkNameAvailability ``` -The **data plane** is served directly by the registry sidecar (not proxied through `4577`). +The data plane has two surfaces onto the same storage. -> **`loginServer` deviation (path style).** Because one shared registry backs every ACR, the registry -> name moves from the host into the path: `loginServer` is `localhost:{port}/{registryName}` natively -> (or `{container}:5000/{registryName}` when floci-az runs in Docker) — **not** `{name}.azurecr.io`. -> Docker handles this transparently: `docker login localhost:{port}` ignores the path, and an image -> ref like `localhost:{port}/{registryName}/app` parses as registry `localhost:{port}` + repo -> `{registryName}/app`. Docker treats `localhost:PORT` as insecure (plain-HTTP) automatically, so no -> daemon config is needed. The data plane is on by default (`mocked: false`). In **mocked** mode -> (no Docker) `loginServer` is the cosmetic `{name}.azurecr.io` for management-plane fidelity. +**The registry container's published port** is the zero-configuration one. It is plain HTTP, +anonymous, and Docker treats `localhost:PORT` as insecure automatically, so nothing has to be +configured: + +``` +localhost:5000/{registryName}/{repo} +``` + +The port comes from the `base-port`-`max-port` range below and is allocated when the shared +container starts, on the first registry you create. It is `5000` unless that port is taken, and +because `loginServer` names the Azure host it cannot carry the port, so the registry resource +reports it as `properties.localPort`: + +```bash +az acr show -n myregistry -g my-rg --query properties.localPort # 5000 +``` + +This is a floci-az convenience field, not part of the real ARM contract, and it is the same field +[PostgreSQL](postgresql.md) and [MySQL](mysql.md) servers use to report the port their container +published. It is absent in `mocked` mode, where no container runs. The emulator also logs the port +at startup, and `docker port floci-az-acr-registry 5000` reports it: + +```text +INFO [io.flo.az.ser.acr.AcrRegistryManager] Started shared ACR registry floci-az-acr-registry on host port 5000 +``` + +**`{name}.azurecr.io`** is what `loginServer` reports and what Azure-native clients use. It is +served by floci-az on 443 when TLS is on (`FLOCI_AZ_TLS_ENABLED=true`), and carries the endpoints +those clients need: + +``` +GET {name}.azurecr.io/v2/ bearer challenge +POST {name}.azurecr.io/oauth2/exchange Entra access token → ACR refresh token +GET|POST {name}.azurecr.io/oauth2/token ACR refresh token → scoped access token +* {name}.azurecr.io/v2/** Docker Registry HTTP API V2 +``` + +The registry name is not part of a repository reference here: `{name}.azurecr.io/app` is the +repository `app`, exactly as in Azure. floci-az adds the `{registryName}/` prefix internally when it +proxies to the shared container, and strips it again from upload `Location` headers, `tags/list` and +`_catalog`. + +> **Name resolution is required.** `{name}.azurecr.io` must resolve to floci-az, the same +> requirement `*.vault.azure.net` already carries for Key Vault. Add a hosts entry (or point your +> resolver at the emulator) and trust the emulator's certificate, which carries `*.azurecr.io`: +> +> ```bash +> echo "127.0.0.1 myregistry.azurecr.io" | sudo tee -a /etc/hosts +> curl -s http://localhost:4577/_floci/tls-cert -o floci-az.crt # then trust it +> ``` +> +> Docker needs the certificate in `/etc/docker/certs.d/myregistry.azurecr.io/ca.crt`. Without name +> resolution, use the published port instead: it needs none of this. +> +> A hosts entry points one name at the emulator, but a wildcard resolver points all of them, so +> floci-az serves `{name}.azurecr.io` only for a registry that was actually created. A name with no +> registry behind it answers `404 NAME_UNKNOWN` at every endpoint, challenge and token included, +> rather than minting tokens for something that does not exist. +> +> TLS is off by default. The challenge realm carries the scheme the request arrived with (honouring +> `X-Forwarded-Proto` behind a terminating proxy), so the token flow is exercisable over plain HTTP, +> but `az acr login` and Docker both address a named registry as `https://` and need TLS on. ## Authentication -The shared backing registry runs **anonymous** (mirroring the AWS ECR design in the sibling emulator): -`docker push`/`pull` work without logging in. The management plane still issues admin credentials -(`listCredentials` / `regenerateCredential`, username = registry name), and `docker login` with them -succeeds — but the credentials are **not enforced** at the data plane. Real ACR enforces admin-user -basic auth and AAD tokens; reproducing per-registry auth on a single shared registry is out of scope. +`az acr login` and the SDK container-registry clients perform Azure's Entra token exchange rather +than a Docker login: `GET /v2/` answers `401` with a `Bearer` challenge naming the realm and +service, an Entra access token is traded for a registry refresh token at `/oauth2/exchange`, and +that refresh token is traded for a scoped access token at `/oauth2/token`. Docker then logs in with +the username `00000000-0000-0000-0000-000000000000` and the access token as the password. floci-az +serves all of it, in both `mocked` modes. + +Intentional deviations, all of them a consequence of the emulator's existing stance that ARM and +Shared Key credentials are accepted without verification: + +- **Tokens are issued, not verified.** Both tokens are real RS256 JWTs signed by the emulator's + Entra signing key, so clients that decode them keep working (the Azure CLI reads the access + token's `access` claim). Nothing checks the Entra token presented at `/oauth2/exchange`, and + nothing checks the refresh token presented at `/oauth2/token`. +- **No authorization is enforced.** The requested scope is recorded in the access token's `access` + claim and then ignored: any token, and any identity, reaches every repository. Only `GET /v2/` + challenges; repository paths are served whether or not a token is presented. Admin credentials + are accepted at the token endpoint so the `password` grant is truthful, but they are not required. +- **The shared backing registry runs anonymous** (mirroring the AWS ECR design in the sibling + emulator), so its published port serves push and pull with no credentials at all. + +Real ACR enforces per-registry auth and repository-scoped permissions; reproducing that on a single +shared registry is out of scope. + +## Example -## Example (non-mocked) +Anonymous, through the published port. `5000` below is the first free port in the configured range, +so substitute the one the startup log reports (`Started shared ACR registry ... on host port`): ```bash -# create (az / terraform / raw ARM) → loginServer like localhost:5000/myregistry docker tag busybox localhost:5000/myregistry/demo/busybox:v1 docker push localhost:5000/myregistry/demo/busybox:v1 curl http://localhost:5000/v2/_catalog # {"repositories":["myregistry/demo/busybox", ...]} docker pull localhost:5000/myregistry/demo/busybox:v1 ``` +The same image, through the Azure login server (hosts entry and trusted certificate in place): + +```bash +az acr create -n myregistry -g my-rg --sku Basic # loginServer: myregistry.azurecr.io +az acr login -n myregistry +docker tag busybox myregistry.azurecr.io/demo/busybox:v1 +docker push myregistry.azurecr.io/demo/busybox:v1 +docker pull myregistry.azurecr.io/demo/busybox:v1 +``` + +`az acr login --expose-token` returns the refresh token without needing a Docker daemon. + +In **mocked** mode (no Docker) the management plane and the whole auth surface still work; +repository operations answer with the registry's `UNSUPPORTED` error, since no container is running. + ## Configuration ```yaml @@ -77,8 +170,9 @@ floci-az: ## Out of scope (future work) -- AAD token auth (`/oauth2/token`) and the `az acr login` token flow — use the admin user + `docker login`. +- Token signature verification, repository-level authorization and scope enforcement. +- Scope maps and tokens as ACR resources, content trust, quarantine, anonymous pull configuration. - `importImage` actual layer copy (accepted as a `202` no-op). -- Geo-replication, webhooks, ACR Tasks, private link, content trust, retention/quarantine policies +- Geo-replication, webhooks, ACR Tasks, private link, retention policies (accepted and echoed as static properties, not enforced). - SKU behavioral differences (Basic/Standard/Premium accepted; no functional difference). diff --git a/docs/services/index.md b/docs/services/index.md index f7fa172c..a1a831d6 100644 --- a/docs/services/index.md +++ b/docs/services/index.md @@ -25,7 +25,7 @@ Floci-AZ provides emulation for several core Azure services. | **Virtual Network** | ARM path (`Microsoft.Network`) | ✅ VNets, subnets, NICs, public IPs, NSGs, private DNS zones (+ virtual network links, record sets), private endpoints (+ private DNS zone groups), private link services; in-process ARM state for Terraform/OpenTofu and VM dependencies | | **Virtual Machines** | ARM path (`Microsoft.Compute`) | ✅ VM lifecycle (create/start/stop/deallocate/restart/delete/list), instanceView; mocked (Docker backing planned) | | **Azure Cache for Redis** | ARM path (`Microsoft.Cache`) | ✅ Cache CRUD, listKeys/regenerateKey; real Redis containers (data plane) or mocked | -| **Azure Container Registry** | ARM path (`Microsoft.ContainerRegistry`) | ✅ Registry CRUD, admin credentials, checkNameAvailability; one shared `registry:2` (Docker Registry V2 push/pull) or mocked | +| **Azure Container Registry** | ARM path (`Microsoft.ContainerRegistry`) | ✅ Registry CRUD, admin credentials, checkNameAvailability; `az acr login` via the Entra token exchange on `{name}.azurecr.io`; one shared `registry:2` (Docker Registry V2 push/pull) or mocked | | **Azure Container Instances** | ARM path (`Microsoft.ContainerInstance`) | ✅ Container group lifecycle (create/update/delete/list), start/stop/restart, container logs, instanceView; mocked (Docker backing planned) | | **Microsoft Entra ID** | `/{tenant}/oauth2/...` + `/.well-known/openid-configuration` | ✅ OpenID Connect provider — RS256-signed tokens, JWKS, discovery; client-credentials, ROPC, and authorization-code+PKCE grants (app registration management still planned) | | **Microsoft Graph** | `/v1.0/...` | ✅ Narrow slice: service principal discovery, group-membership management (`getMemberGroups`, `members/$ref`); full Graph CRUD out of scope | diff --git a/src/main/java/io/floci/az/core/AzureRequest.java b/src/main/java/io/floci/az/core/AzureRequest.java index 8e89bf26..c09993bf 100644 --- a/src/main/java/io/floci/az/core/AzureRequest.java +++ b/src/main/java/io/floci/az/core/AzureRequest.java @@ -18,7 +18,8 @@ public record AzureRequest( boolean secure, // true when the request arrived over HTTPS String host, // host captured before async/blocking dispatch; may be null for direct/internal requests String remoteAddress, // transport peer address; never derived from forwarded headers - String rawPath // original encoded request path, without a leading slash + String rawPath, // original encoded request path, without a leading slash + String rawQuery // original encoded query string, without the leading "?"; null when absent ) { public AzureRequest(String method, String accountName, String serviceType, String resourcePath, @@ -26,7 +27,7 @@ public AzureRequest(String method, String accountName, String serviceType, Strin Map> queryParamsMulti, AuthContext authContext, boolean secure) { this(method, accountName, serviceType, resourcePath, headers, bodyStream, - queryParams, queryParamsMulti, authContext, secure, null, null, resourcePath); + queryParams, queryParamsMulti, authContext, secure, null, null, resourcePath, null); } /** @@ -38,7 +39,7 @@ public AzureRequest(String method, String accountName, String serviceType, Strin HttpHeaders headers, InputStream bodyStream, Map queryParams, AuthContext authContext, boolean secure) { this(method, accountName, serviceType, resourcePath, headers, bodyStream, - queryParams, Map.of(), authContext, secure, null, null, resourcePath); + queryParams, Map.of(), authContext, secure, null, null, resourcePath, null); } /** @@ -49,14 +50,14 @@ public AzureRequest(String method, String accountName, String serviceType, Strin Map> queryParamsMulti, AuthContext authContext, boolean secure, String host) { this(method, accountName, serviceType, resourcePath, headers, bodyStream, - queryParams, queryParamsMulti, authContext, secure, host, null, resourcePath); + queryParams, queryParamsMulti, authContext, secure, host, null, resourcePath, null); } public AzureRequest(String method, String accountName, String serviceType, String resourcePath, HttpHeaders headers, InputStream bodyStream, Map queryParams, AuthContext authContext, boolean secure, String remoteAddress) { this(method, accountName, serviceType, resourcePath, headers, bodyStream, - queryParams, Map.of(), authContext, secure, null, remoteAddress, resourcePath); + queryParams, Map.of(), authContext, secure, null, remoteAddress, resourcePath, null); } /** @@ -66,6 +67,6 @@ public AzureRequest(String method, String accountName, String serviceType, Strin */ public AzureRequest withAuthContext(AuthContext resolved) { return new AzureRequest(method, accountName, serviceType, resourcePath, headers, bodyStream, - queryParams, queryParamsMulti, resolved, secure, host, remoteAddress, rawPath); + queryParams, queryParamsMulti, resolved, secure, host, remoteAddress, rawPath, rawQuery); } } diff --git a/src/main/java/io/floci/az/core/AzureRoutingFilter.java b/src/main/java/io/floci/az/core/AzureRoutingFilter.java index 6d0ed7d7..9c748512 100644 --- a/src/main/java/io/floci/az/core/AzureRoutingFilter.java +++ b/src/main/java/io/floci/az/core/AzureRoutingFilter.java @@ -95,6 +95,7 @@ private record RoutingContext( ContainerRequestContext requestContext, String path, String rawPath, + String rawQuery, HttpHeaders headers, String host, boolean secure, @@ -327,6 +328,9 @@ public Uni filter(ContainerRequestContext requestContext, @Context Htt // Capture context before switching threads String path0 = requestContext.getUriInfo().getPath(); String rawPath0 = serverRequest.path(); + // The query exactly as the client sent it. Proxying handlers forward it verbatim rather + // than re-encoding the decoded parameters, which would not round-trip opaque values. + String rawQuery0 = serverRequest.query(); HttpHeaders headers = httpHeaders; // Capture the request authority/host now (JAX-RS request scope may not propagate to the // blocking thread). Under HTTP/2 the wire protocol uses :authority instead of a Host @@ -345,13 +349,14 @@ public Uni filter(ContainerRequestContext requestContext, @Context Htt return Uni.createFrom().completionStage( vertx.executeBlocking(() -> doFilter( - requestContext, path0, rawPath0, headers, capturedHost, remoteAddress)) + requestContext, path0, rawPath0, rawQuery0, headers, capturedHost, remoteAddress)) .toCompletionStage() ); } private Response doFilter(ContainerRequestContext requestContext, String decodedPath, String rawPath, - HttpHeaders headers, String capturedHost, String remoteAddress) { + String rawQuery, HttpHeaders headers, String capturedHost, + String remoteAddress) { // Never trust a client-supplied account-suffix header: only dispatchByAccountSuffix may set it. // Header names are case-insensitive on the wire, so match keys case-insensitively. for (String header : new ArrayList<>(requestContext.getHeaders().keySet())) { @@ -368,7 +373,7 @@ private Response doFilter(ContainerRequestContext requestContext, String decoded LOGGER.infof("Incoming request: %s %s", requestContext.getMethod(), path); - RoutingContext ctx = new RoutingContext(requestContext, path, encodedPath, headers, + RoutingContext ctx = new RoutingContext(requestContext, path, encodedPath, rawQuery, headers, hostWithoutPort(capturedHost), requestContext.getSecurityContext().isSecure(), remoteAddress); for (Function stage : stages) { @@ -751,7 +756,7 @@ private Outcome dispatchWithoutAuth(RoutingContext ctx, String serviceType, Stri } AzureRequest request = new AzureRequest(ctx.method(), serviceType, serviceType, ctx.path(), ctx.headers(), ctx.requestContext().getEntityStream(), singleValueQueryParams(ctx.requestContext()), - Map.of(), null, ctx.secure(), ctx.host(), ctx.remoteAddress(), ctx.rawPath()); + Map.of(), null, ctx.secure(), ctx.host(), ctx.remoteAddress(), ctx.rawPath(), ctx.rawQuery()); LOGGER.infof("Dispatching %s request to %s: %s %s", label, handler.get().getClass().getSimpleName(), ctx.method(), ctx.path()); return new Handled(handler.get().handle(request)); @@ -768,7 +773,7 @@ private AzureRequest buildRequest(RoutingContext ctx, String account, String ser AzureRequest request = new AzureRequest(ctx.method(), account, serviceType, path, ctx.headers(), ctx.requestContext().getEntityStream(), queryParams, queryParamsMulti, null, ctx.secure(), - ctx.host(), ctx.remoteAddress(), ctx.rawPath()); + ctx.host(), ctx.remoteAddress(), ctx.rawPath(), ctx.rawQuery()); return request.withAuthContext(authPipeline.resolve(request)); } diff --git a/src/main/java/io/floci/az/core/FormBody.java b/src/main/java/io/floci/az/core/FormBody.java new file mode 100644 index 00000000..0bd56d94 --- /dev/null +++ b/src/main/java/io/floci/az/core/FormBody.java @@ -0,0 +1,55 @@ +package io.floci.az.core; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Parses {@code application/x-www-form-urlencoded} request bodies. OAuth-style endpoints + * (Entra's token endpoint, the ACR token exchange) are the only form-encoded surfaces in the + * emulator, and both read the body as a flat map of decoded parameters. + */ +public final class FormBody { + + private FormBody() { + } + + /** + * Decoded form parameters; an unreadable or empty body yields an empty map. + * + *

A pair that will not decode is dropped rather than thrown: a malformed percent escape is + * client input, and the endpoints answer a parameter that did not arrive with their own + * documented error. Letting {@link URLDecoder} throw here would escape the handler instead, + * because nothing on the dispatch path catches it, and a 500 would replace that error.

+ */ + public static Map parse(InputStream body) { + Map result = new HashMap<>(); + byte[] bytes; + try { + bytes = body == null ? new byte[0] : body.readAllBytes(); + } catch (IOException e) { + return result; + } + String content = new String(bytes, StandardCharsets.UTF_8); + if (content.isBlank()) { + return result; + } + for (String pair : content.split("&")) { + int eq = pair.indexOf('='); + if (eq < 0) { + continue; + } + try { + String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8); + String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + result.put(key, value); + } catch (IllegalArgumentException e) { + // A malformed escape such as "%zz"; the parameter is treated as absent. + } + } + return result; + } +} diff --git a/src/main/java/io/floci/az/core/RequestUrls.java b/src/main/java/io/floci/az/core/RequestUrls.java index 798fc45f..80987799 100644 --- a/src/main/java/io/floci/az/core/RequestUrls.java +++ b/src/main/java/io/floci/az/core/RequestUrls.java @@ -2,12 +2,33 @@ import io.floci.az.config.EmulatorConfig; +import java.util.Locale; + /** URL helpers for handlers that echo the caller's origin back in generated URLs. */ public final class RequestUrls { private RequestUrls() { } + /** + * The scheme the caller used: {@code X-Forwarded-Proto} when a TLS-terminating reverse proxy + * fronts the emulator (the proxy-to-emulator hop is plaintext, but the client's scheme is what + * generated URLs must carry), and the transport otherwise. Same rule as the Cosmos, PostgreSQL + * and SQL handlers apply to the endpoints they advertise. + */ + public static String resolveScheme(AzureRequest request) { + String forwarded = request.headers() == null + ? null : request.headers().getHeaderString("X-Forwarded-Proto"); + if (forwarded != null && !forwarded.isBlank()) { + // A proxy chain may send a comma-separated list; the first value is the client's. + String scheme = forwarded.split(",")[0].trim().toLowerCase(Locale.ROOT); + if (scheme.equals("http") || scheme.equals("https")) { + return scheme; + } + } + return request.secure() ? "https" : "http"; + } + /** Base URL as seen by the caller — Host header when present, configured base URL otherwise. */ public static String resolveBaseUrl(AzureRequest request, EmulatorConfig config) { String host = request.headers() == null ? null : request.headers().getHeaderString("Host"); diff --git a/src/main/java/io/floci/az/core/tls/TlsConfigSource.java b/src/main/java/io/floci/az/core/tls/TlsConfigSource.java index cc6409e3..b8ef46b6 100644 --- a/src/main/java/io/floci/az/core/tls/TlsConfigSource.java +++ b/src/main/java/io/floci/az/core/tls/TlsConfigSource.java @@ -195,7 +195,8 @@ private static List buildSanList(List customHostnames) { // (not in a container). all.addAll(List.of("localhost", "127.0.0.1", "0.0.0.0", "*.localhost", "localhost.floci-az.io", "*.localhost.floci-az.io", - "*.vault.azure.net", "*.managedhsm.azure.net", "host.docker.internal")); + "*.vault.azure.net", "*.managedhsm.azure.net", "*.azurecr.io", + "host.docker.internal")); all.addAll(customHostnames); return all; } diff --git a/src/main/java/io/floci/az/services/acr/AcrErrors.java b/src/main/java/io/floci/az/services/acr/AcrErrors.java new file mode 100644 index 00000000..42bdf365 --- /dev/null +++ b/src/main/java/io/floci/az/services/acr/AcrErrors.java @@ -0,0 +1,40 @@ +package io.floci.az.services.acr; + +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.util.List; +import java.util.Map; + +/** + * Errors in the Docker Registry HTTP API V2 envelope, + * {@code {"errors":[{"code":"...","message":"..."}]}}. The registry data plane and the ACR token + * endpoints in front of it both answer in this shape; the ARM management plane keeps using + * {@link io.floci.az.core.arm.ArmErrors}. + */ +final class AcrErrors { + + /** The requested operation is not implemented here (OCI distribution-spec {@code UNSUPPORTED}). */ + static final String UNSUPPORTED = "UNSUPPORTED"; + /** The registry data plane is not currently reachable (OCI distribution-spec {@code UNAVAILABLE}). */ + static final String UNAVAILABLE = "UNAVAILABLE"; + /** The named repository or registry is unknown (OCI distribution-spec {@code NAME_UNKNOWN}). */ + static final String NAME_UNKNOWN = "NAME_UNKNOWN"; + /** Authentication is required or the credential presented is unusable ({@code UNAUTHORIZED}). */ + static final String UNAUTHORIZED = "UNAUTHORIZED"; + + private AcrErrors() { + } + + static Response error(int status, String code, String message) { + return Response.status(status) + .entity(Map.of("errors", List.of(Map.of("code", code, "message", message)))) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + /** 400 for a malformed or unsupported token request. */ + static Response badRequest(String message) { + return error(400, UNSUPPORTED, message); + } +} diff --git a/src/main/java/io/floci/az/services/acr/AcrHandler.java b/src/main/java/io/floci/az/services/acr/AcrHandler.java index 03d73985..60596712 100644 --- a/src/main/java/io/floci/az/services/acr/AcrHandler.java +++ b/src/main/java/io/floci/az/services/acr/AcrHandler.java @@ -8,6 +8,7 @@ import io.floci.az.core.AzureRequest; import io.floci.az.core.AzureServiceHandler; import io.floci.az.core.ServiceRoutes; +import io.floci.az.core.RequestUrls; import io.floci.az.core.Resettable; import io.floci.az.core.StoredObject; import io.floci.az.core.storage.StorageBackend; @@ -28,6 +29,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -55,11 +57,23 @@ * * *

Data plane

- *

Each registry is backed by a real {@code registry:2} sidecar exposing the standard Docker - * Registry HTTP API V2. {@code loginServer} returns the actually-reachable host:port of that - * sidecar (localhost natively, the container name when floci-az runs in Docker), so {@code docker - * login/push/pull} work against it directly. In {@code mocked} mode (default) no sidecar is started - * and {@code loginServer} is the cosmetic {@code {name}.azurecr.io} for management-plane fidelity.

+ *
+ *   GET  {name}.azurecr.io/v2/                      challenge
+ *   POST {name}.azurecr.io/oauth2/exchange          Entra access token  -> ACR refresh token
+ *   *    {name}.azurecr.io/oauth2/token             ACR refresh token   -> scoped access token
+ *   *    {name}.azurecr.io/v2/**                    Docker Registry HTTP API V2
+ * 
+ * + *

{@code loginServer} is {@code {name}.azurecr.io}, exactly as Azure reports it, and the host + * suffix routes the data plane here. One shared {@code registry:2} sidecar backs every registry, so + * the registry name becomes an internal repository prefix when {@link AcrRegistryProxy} forwards + * {@code /v2/} requests to it; the container's own published port keeps serving the same storage + * anonymously over plain HTTP.

+ * + *

The auth surface works in either mode. In {@code mocked} mode no sidecar is started, so + * repository operations answer with the registry's {@code UNSUPPORTED} error. A host naming a + * registry that was never created answers {@code NAME_UNKNOWN} at every endpoint: the caller's + * resolver points the whole {@code .azurecr.io} space here, but only created registries exist.

*/ @ApplicationScoped public class AcrHandler implements AzureServiceHandler, Resettable { @@ -75,8 +89,13 @@ public class AcrHandler implements AzureServiceHandler, Resettable { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static final String PROVIDER = "/providers/Microsoft.ContainerRegistry/"; + /** The Azure host suffix the registry data plane is served on. */ + public static final String REGISTRY_HOST_SUFFIX = ".azurecr.io"; + private final EmulatorConfig config; private final AcrRegistryManager registryManager; + private final AcrTokenService tokenService; + private final AcrRegistryProxy registryProxy; private final StorageBackend storage; private final ScheduledExecutorService poller = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "acr-readiness-poller"); @@ -87,9 +106,13 @@ public class AcrHandler implements AzureServiceHandler, Resettable { @Inject public AcrHandler(EmulatorConfig config, AcrRegistryManager registryManager, + AcrTokenService tokenService, + AcrRegistryProxy registryProxy, StorageFactory storageFactory) { this.config = config; this.registryManager = registryManager; + this.tokenService = tokenService; + this.registryProxy = registryProxy; this.storage = storageFactory.create("acr"); } @@ -119,6 +142,7 @@ public boolean enabled(String serviceType) { @Override public ServiceRoutes routes() { return ServiceRoutes.builder() + .host(REGISTRY_HOST_SUFFIX) .provider("Microsoft.ContainerRegistry") .build(); } @@ -133,6 +157,11 @@ public Response handle(AzureRequest req) { LOG.debugf("AcrHandler: %s %s", method, fullPath); + String registryHost = registryFromHost(req.host()); + if (registryHost != null) { + return handleDataPlane(req, registryHost); + } + String tail = extractAcrPath(fullPath); // ── checkNameAvailability (provider root) ────────────────────────── @@ -189,6 +218,98 @@ public Response handle(AzureRequest req) { return notFound("Unknown ACR path: " + tail); } + // ── Data plane ({name}.azurecr.io) ─────────────────────────────────────────── + + /** + * The registry a request is addressed to, taken from the {@code {name}.azurecr.io} host, or + * {@code null} when the request did not arrive on the registry host. The challenge and token + * endpoints exist only there, never on the shared container's published port. + */ + static String registryFromHost(String host) { + if (host == null) { + return null; + } + String lower = host.toLowerCase(Locale.ROOT); + if (!lower.endsWith(REGISTRY_HOST_SUFFIX)) { + return null; + } + String name = lower.substring(0, lower.length() - REGISTRY_HOST_SUFFIX.length()); + return name.isEmpty() ? null : name; + } + + /** {@code {name}.azurecr.io}, the login server Azure itself reports. */ + static String loginServer(String registryName) { + return registryName.toLowerCase(Locale.ROOT) + REGISTRY_HOST_SUFFIX; + } + + private Response handleDataPlane(AzureRequest req, String registryName) { + String path = stripTrailingSlash(req.resourcePath()); + String loginServer = loginServer(registryName); + + // Azure resolves {name}.azurecr.io only for a registry that exists. Here the host is + // whatever the caller's resolver points at the emulator, so an unknown name would + // otherwise be handed tokens and a repository prefix of its own. + if (!registryExists(registryName)) { + return AcrErrors.error(404, AcrErrors.NAME_UNKNOWN, + "the registry '" + loginServer + "' does not exist"); + } + + if (path.equals("oauth2/exchange")) { + return tokenService.handleExchange(req, loginServer); + } + if (path.equals("oauth2/token")) { + return tokenService.handleToken(req, loginServer); + } + if (path.equals("v2") || path.startsWith("v2/")) { + return handleRepository(req, registryName, loginServer, path); + } + return AcrErrors.error(404, AcrErrors.NAME_UNKNOWN, + "unknown registry path '" + path + "' on " + loginServer); + } + + /** + * {@code GET /v2/} without credentials answers the bearer challenge that starts the token + * exchange; everything else is proxied to the shared container. Tokens are issued, not verified, + * so any {@code Authorization} header satisfies this check. + */ + private Response handleRepository(AzureRequest req, String registryName, String loginServer, String path) { + boolean authenticated = req.headers() != null + && req.headers().getHeaderString("Authorization") != null; + if (path.equals("v2") && !authenticated) { + return AcrTokenService.challengeResponse(RequestUrls.resolveScheme(req), loginServer); + } + if (config.services().acr().mocked()) { + return AcrErrors.error(405, AcrErrors.UNSUPPORTED, + "the registry data plane is not available in mocked mode; " + + "set floci-az.services.acr.mocked=false to start the shared registry"); + } + String endpoint = dataPlaneEndpoint(); + if (endpoint == null) { + return AcrErrors.error(502, AcrErrors.UNAVAILABLE, + "the shared registry container is not running"); + } + return registryProxy.proxy(req, registryName, endpoint); + } + + /** The shared container's endpoint, starting it on first use as the management plane does. */ + private String dataPlaneEndpoint() { + try { + registryManager.ensureStarted(); + } catch (Exception e) { + LOG.errorf(e, "Failed to start the shared ACR registry for a data-plane request"); + return null; + } + return registryManager.dataPlaneEndpoint(); + } + + private static String stripTrailingSlash(String path) { + String trimmed = path == null ? "" : path; + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + // ── CRUD operations ────────────────────────────────────────────────────────── private Response handleCreateOrUpdate(String sub, String rg, String registryName, AzureRequest req) { @@ -222,13 +343,12 @@ private Response handleCreateOrUpdate(String sub, String rg, String registryName registry.setAdminUserEnabled(props.path("adminUserEnabled").asBoolean(false)); registry.setTags(parseStringMap(body.path("tags"))); + registry.setLoginServer(loginServer(registryName)); if (config.services().acr().mocked()) { registry.setProvisioningState("Succeeded"); - registry.setLoginServer(registryName.toLowerCase() + ".azurecr.io"); } else if (isNew) { try { registryManager.ensureStarted(); - registry.setLoginServer(registryManager.loginServer(registryName)); registry.setProvisioningState(registryManager.isReady() ? "Succeeded" : "Creating"); } catch (Exception e) { LOG.errorf(e, "Failed to start shared ACR registry for %s", registryName); @@ -377,20 +497,9 @@ private void startReadinessPoller() { registryManager.ensureStarted(); if (registryManager.isReady()) { LOG.infov("ACR registry {0} is now ready", reg.getName()); - reg.setLoginServer(registryManager.loginServer(reg.getName())); reg.setProvisioningState("Succeeded"); putRegistry(reg.storageKey(), reg); } - } else if ("Succeeded".equals(reg.getProvisioningState()) && registryManager.isStarted()) { - // A recovery restart may have moved the shared registry to a new - // host port; converge stored records to the live endpoint. - String liveLoginServer = registryManager.loginServer(reg.getName()); - if (!liveLoginServer.equals(reg.getLoginServer())) { - LOG.infov("ACR registry {0} loginServer refreshed to {1}", - reg.getName(), liveLoginServer); - reg.setLoginServer(liveLoginServer); - putRegistry(reg.storageKey(), reg); - } } }); } catch (Exception e) { @@ -421,6 +530,14 @@ private void putRegistry(String key, Registry registry) { } } + /** + * Whether a registry of this name was created, in any subscription or resource group. The data + * plane knows only the name: {@code {name}.azurecr.io} carries no ARM scope. + */ + private boolean registryExists(String registryName) { + return scanAll().stream().anyMatch(r -> registryName.equalsIgnoreCase(r.getName())); + } + private List scanAll() { List result = new ArrayList<>(); storage.scan(k -> true).forEach(so -> { @@ -455,6 +572,13 @@ private Map toArmResponse(Registry registry) { props.put("zoneRedundancy", "Disabled"); props.put("dataEndpointEnabled", false); props.put("networkRuleBypassOptions", "AzureServices"); + // floci-az convenience, not in the real spec: the host port the shared registry container + // publishes, which serves the same storage anonymously over plain HTTP. loginServer names + // the Azure host, so nothing else reports it. Same field PostgreSQL and MySQL servers carry. + int localPort = registryManager.publishedPort(); + if (localPort > 0) { + props.put("localPort", localPort); + } Map out = new LinkedHashMap<>(); out.put("id", registry.armId()); diff --git a/src/main/java/io/floci/az/services/acr/AcrModels.java b/src/main/java/io/floci/az/services/acr/AcrModels.java index f8d39402..d71df5a2 100644 --- a/src/main/java/io/floci/az/services/acr/AcrModels.java +++ b/src/main/java/io/floci/az/services/acr/AcrModels.java @@ -29,7 +29,7 @@ public static class Registry { private String password; // primary admin password private String password2; // secondary admin password - /** Path-prefixed registry endpoint: {@code localhost:{port}/{name}} (shared registry). */ + /** The registry's Azure login server: {@code {name}.azurecr.io}. */ private String loginServer; private String provisioningState; diff --git a/src/main/java/io/floci/az/services/acr/AcrRegistryManager.java b/src/main/java/io/floci/az/services/acr/AcrRegistryManager.java index 30798a87..0312437e 100644 --- a/src/main/java/io/floci/az/services/acr/AcrRegistryManager.java +++ b/src/main/java/io/floci/az/services/acr/AcrRegistryManager.java @@ -18,12 +18,14 @@ /** * Manages the lifecycle of the single shared {@code registry:2} container that backs every emulated * Azure Container Registry. There is one container per floci-az instance, started lazily on first use - * and reused across all registries — mirroring the AWS ECR design in the sibling emulator. + * and reused across all registries, mirroring the AWS ECR design in the sibling emulator. * *

Registries are isolated within the shared registry by an internal repository prefix - * ({@code {registryName}/{repo}}), so {@code loginServer} carries the registry name as a path segment: - * {@code localhost:{port}/{registryName}}. The backing registry runs anonymous — admin - * credentials are returned by the management plane but not enforced at the data plane.

+ * ({@code {registryName}/{repo}}), which {@link AcrRegistryProxy} applies when it forwards requests + * from {@code {name}.azurecr.io}. The container's own port stays published, so + * {@code localhost:{port}/{registryName}/{repo}} keeps addressing the same storage directly. The + * backing registry runs anonymous: admin credentials are returned by the management plane + * but not enforced at the data plane.

*/ @ApplicationScoped public class AcrRegistryManager { @@ -41,9 +43,9 @@ private String sharedName() { private final EmulatorConfig config; private volatile boolean started; - private volatile int hostPort; private volatile String containerId; private volatile String internalEndpoint; + private volatile int publishedPort; @Inject public AcrRegistryManager(ContainerBuilder containerBuilder, @@ -88,7 +90,7 @@ public synchronized void ensureStarted() { ContainerLifecycleManager.ContainerInfo info = lifecycleManager.createAndStart(spec); this.containerId = info.containerId(); - this.hostPort = chosenPort; + this.publishedPort = chosenPort; ContainerLifecycleManager.EndpointInfo ep = info.getEndpoint(REGISTRY_PORT); if (containerDetector.isRunningInContainer()) { @@ -103,12 +105,22 @@ public synchronized void ensureStarted() { } } - /** The path-prefixed {@code loginServer} for a registry (host[:port]/{registryName}). */ - public String loginServer(String registryName) { - String host = containerDetector.isRunningInContainer() - ? sharedName() + ":" + REGISTRY_PORT - : "localhost:" + hostPort; - return host + "/" + registryName; + /** + * The shared registry's {@code host:port} as reachable from floci-az itself, or {@code null} + * before it has started. This is what the {@code /v2/} proxy forwards to. + */ + public String dataPlaneEndpoint() { + return started ? internalEndpoint : null; + } + + /** + * The host port the shared container publishes {@code registry:5000} on, or {@code 0} before it + * has started. Reported to clients as the registry resource's {@code localPort}, the way + * PostgreSQL and MySQL report theirs: {@code loginServer} names the Azure host, so it is the only + * thing that says which port serves the same storage anonymously. + */ + public int publishedPort() { + return started ? publishedPort : 0; } /** Polls the shared registry's V2 base endpoint to detect readiness. */ @@ -129,10 +141,6 @@ public boolean isReady() { } } - public boolean isStarted() { - return started; - } - /** Stops and removes the shared registry container (emulator shutdown). */ public synchronized void shutdown() { if (started && containerId != null) { diff --git a/src/main/java/io/floci/az/services/acr/AcrRegistryProxy.java b/src/main/java/io/floci/az/services/acr/AcrRegistryProxy.java new file mode 100644 index 00000000..6605741b --- /dev/null +++ b/src/main/java/io/floci/az/services/acr/AcrRegistryProxy.java @@ -0,0 +1,431 @@ +package io.floci.az.services.acr; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.floci.az.core.AzureRequest; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; + +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Proxies the Docker Registry HTTP API V2 from {@code {name}.azurecr.io/v2/…} to the single shared + * {@code registry:2} container, namespacing each repository with the registry name. + * + *

One container backs every emulated registry, so the registry name is an internal repository + * prefix: {@code {name}.azurecr.io/v2/app/manifests/v1} reaches the container as + * {@code /v2/{name}/app/manifests/v1}. The prefix is stripped again from anything the client reads + * back (upload {@code Location} headers, the catalog), so it never leaks into the client's view.

+ */ +@ApplicationScoped +public class AcrRegistryProxy { + + private static final Logger LOG = Logger.getLogger(AcrRegistryProxy.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Headers that belong to one hop and must not be forwarded; also the ones HttpClient rejects. */ + private static final Set HOP_BY_HOP = Set.of( + "connection", "content-length", "expect", "host", "keep-alive", + "proxy-authenticate", "proxy-authorization", "te", "trailer", + "transfer-encoding", "upgrade"); + + /** The shared container is anonymous: the client's registry token is meaningless to it. */ + private static final Set DROPPED_REQUEST_HEADERS = Set.of("authorization"); + + /** Response headers the emulator re-derives rather than copying from the backend. */ + private static final Set REWRITTEN_RESPONSE_HEADERS = Set.of("location"); + + /** Methods that can carry a request body, and so may stream one of undeclared length. */ + private static final Set BODY_METHODS = Set.of("POST", "PUT", "PATCH"); + + /** {@link #pageSize} for a client that asked for no pagination at all. */ + static final int UNPAGINATED = -1; + + /** How long the shared container has to answer one request. */ + private static final Duration BACKEND_TIMEOUT = Duration.ofMinutes(5); + + private static final String V2 = "v2/"; + private static final String CATALOG = "_catalog"; + private static final String TAGS_LIST = "/tags/list"; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + + /** + * The path the shared container sees for a client path under {@code /v2/}. Repository-scoped + * paths gain the registry prefix; the registry-scoped {@code _catalog} does not. + */ + static String backendPath(String registryName, String clientPath) { + String tail = clientPath.startsWith(V2) ? clientPath.substring(V2.length()) : clientPath; + if (tail.isEmpty() || tail.startsWith("_")) { + return V2 + tail; + } + return V2 + registryName + "/" + tail; + } + + /** + * Strips the registry prefix (and any backend authority) from an upload session {@code Location}, + * so the client's next request addresses the repository by the name it used. + */ + static String clientLocation(String registryName, String location) { + if (location == null) { + return null; + } + String path = location; + int schemeEnd = path.indexOf("://"); + if (schemeEnd >= 0) { + int authorityEnd = path.indexOf('/', schemeEnd + 3); + path = authorityEnd < 0 ? "/" : path.substring(authorityEnd); + } + String prefixed = "/" + V2 + registryName + "/"; + if (path.startsWith(prefixed)) { + return "/" + V2 + path.substring(prefixed.length()); + } + return path; + } + + /** True when the response body names repositories and must be translated to the client's view. */ + static boolean rewritesBody(String clientPath) { + return clientPath.endsWith(TAGS_LIST); + } + + /** + * This registry's repositories, in the order the container reported them, with the internal + * prefix removed. + * + *

Reading stops at the first repository outside the prefix. The container walks the + * repository directory tree, so one registry's repositories are a subtree and therefore + * contiguous: the first outsider ends this registry's block, and nothing of ours follows it.

+ */ + static List ownRepositories(String prefix, byte[] body) { + List repositories = new ArrayList<>(); + try { + for (JsonNode repository : MAPPER.readTree(body).path("repositories")) { + String name = repository.asText(""); + if (!name.startsWith(prefix)) { + break; + } + repositories.add(name.substring(prefix.length())); + } + } catch (Exception e) { + LOG.debugv("Could not read the registry catalog: {0}", e.getMessage()); + } + return repositories; + } + + /** + * The page size the client asked for, or {@link #UNPAGINATED} when it asked for none. + * + *

Zero is a request for an empty page, which is not the same as a request for the whole + * catalog, so the two do not collapse. A size that will not parse, or that is negative, is + * treated as absent.

+ */ + static int pageSize(String declared) { + if (declared == null || declared.isBlank()) { + return UNPAGINATED; + } + try { + int declaredSize = Integer.parseInt(declared.trim()); + return declaredSize < 0 ? UNPAGINATED : declaredSize; + } catch (NumberFormatException e) { + return UNPAGINATED; + } + } + + /** The {@code Link} advertising the next page, in the shape the registry itself uses. */ + static String nextLink(String last, int pageSize) { + return "; rel=\"next\""; + } + + /** + * Strips the internal prefix from the {@code name} a repository response reports, so + * {@code /v2/{repo}/tags/list} names the repository the client asked about. + */ + static byte[] unprefixRepositoryName(String registryName, byte[] body) { + try { + JsonNode root = MAPPER.readTree(body); + if (!root.isObject()) { + return body; + } + String name = root.path("name").asText(""); + String prefix = registryName + "/"; + if (!name.startsWith(prefix)) { + return body; + } + ObjectNode object = (ObjectNode) root; + object.put("name", name.substring(prefix.length())); + return MAPPER.writeValueAsBytes(object); + } catch (Exception e) { + LOG.debugv("Could not rewrite the repository name in a registry response: {0}", e.getMessage()); + return body; + } + } + + /** + * Forwards one {@code /v2/} request to the shared container. Bodies stream in both directions, so + * a layer is never held in memory: the request keeps the length the client declared, and the + * response streams back. The exceptions are bodies that name repositories, which are rewritten, + * and {@code HEAD}, which carries the backend's {@code Content-Length} and no body: Docker reads + * that length to size a blob it is about to push. + */ + public Response proxy(AzureRequest request, String registryName, String backendEndpoint) { + String clientPath = trimLeadingSlash(request.rawPath()); + if (clientPath.equals(V2 + CATALOG)) { + return catalog(request, registryName, backendEndpoint); + } + URI target = URI.create("http://" + backendEndpoint + "/" + + backendPath(registryName, clientPath) + queryString(request)); + // Subscribed to at most once: only one of the branches below runs. + HttpRequest.BodyPublisher body = bodyPublisher(request); + try { + if ("HEAD".equals(request.method())) { + return response(send(request, target, body, HttpResponse.BodyHandlers.discarding()), + registryName, null, true); + } + if (rewritesBody(clientPath)) { + HttpResponse backend = + send(request, target, body, HttpResponse.BodyHandlers.ofByteArray()); + return response(backend, registryName, + unprefixRepositoryName(registryName, backend.body()), false); + } + HttpResponse backend = + send(request, target, body, HttpResponse.BodyHandlers.ofInputStream()); + return response(backend, registryName, backend.body(), false); + } catch (Exception e) { + return unavailable(target, e); + } + } + + /** Forwards one request to the shared container, carrying the client's headers over. */ + private HttpResponse send(AzureRequest request, URI target, HttpRequest.BodyPublisher body, + HttpResponse.BodyHandler handler) throws Exception { + HttpRequest.Builder outgoing = HttpRequest.newBuilder(target) + .timeout(BACKEND_TIMEOUT) + .method(request.method(), body); + forwardRequestHeaders(request, outgoing); + return httpClient.send(outgoing.build(), handler); + } + + /** The registry error for a container that could not be reached or did not answer. */ + private static Response unavailable(URI target, Exception e) { + LOG.warnv("ACR data-plane request to {0} failed: {1}", target, e.getMessage()); + return AcrErrors.error(502, AcrErrors.UNAVAILABLE, + "the registry data plane is unavailable: " + e.getMessage()); + } + + /** + * Serves {@code /v2/_catalog} as this registry's own catalog, one backend request per page. + * + *

The container has no filter parameter: the catalog's whole query vocabulary is {@code n} + * and {@code last}. The filter is therefore expressed as a range, which works because a + * registry's repositories are a contiguous subtree of the container's walk. Seeding + * {@code last} with {@code {registry}/} lands exactly on the first of ours, so {@code n} then + * counts ours rather than everyone's and a page comes back full.

+ * + *

One extra repository is requested beyond the page. The container advertises a next page + * whenever the page it returned was full, not when more results actually exist, so its + * {@code Link} cannot say whether this registry has more. That extra entry can: outside the + * prefix it means the block ended here, and the page is the last one.

+ * + *

Verified against {@code registry:2} (Distribution 2.8.3). {@code last} is a position in + * that walk rather than a repository that has to exist, which is what lets the seed name + * nothing. None of this is in the distribution spec, so + * {@code AcrCatalogPaginationDockerTest} pins it.

+ */ + private Response catalog(AzureRequest request, String registryName, String backendEndpoint) { + String prefix = registryName + "/"; + int pageSize = pageSize(firstQueryValue(request, "n")); + URI target = catalogTarget(backendEndpoint, prefix, firstQueryValue(request, "last"), pageSize); + try { + if (pageSize == 0) { + // A page of nothing is what was asked for, so there is nothing to ask the container. + return catalogPage(List.of(), false, pageSize); + } + HttpResponse backend = send(request, target, + HttpRequest.BodyPublishers.noBody(), HttpResponse.BodyHandlers.ofByteArray()); + if (backend.statusCode() != 200) { + return response(backend, registryName, backend.body(), false); + } + + List repositories = ownRepositories(prefix, backend.body()); + boolean more = pageSize > 0 && repositories.size() > pageSize; + return catalogPage(more ? repositories.subList(0, pageSize) : repositories, more, pageSize); + } catch (Exception e) { + return unavailable(target, e); + } + } + + /** The container request behind one catalog page: this registry's range, one repository over. */ + private static URI catalogTarget(String backendEndpoint, String prefix, String clientLast, + int pageSize) { + StringBuilder query = new StringBuilder("?last=") + .append(encode(prefix + (clientLast == null ? "" : clientLast))); + if (pageSize > 0) { + query.append("&n=").append((long) pageSize + 1); + } + return URI.create("http://" + backendEndpoint + "/" + V2 + CATALOG + query); + } + + /** One catalog page, carrying the cursor to the next only when this registry has more. */ + private static Response catalogPage(List repositories, boolean more, int pageSize) + throws JsonProcessingException { + Response.ResponseBuilder page = Response + .ok(MAPPER.writeValueAsBytes(Map.of("repositories", repositories))) + .type(MediaType.APPLICATION_JSON); + if (more) { + page.header("Link", nextLink(repositories.get(repositories.size() - 1), pageSize)); + } + return page.build(); + } + + /** The first value of a query parameter, or {@code null} when the client sent none. */ + private static String firstQueryValue(AzureRequest request, String name) { + Map> parameters = request.queryParamsMulti(); + if (parameters == null) { + return null; + } + List values = parameters.get(name); + return values == null || values.isEmpty() ? null : values.get(0); + } + + /** + * Streams the request body through, never buffering it: a layer is pushed whole and would + * otherwise be held in memory in its entirety. + * + *

A declared {@code Content-Length} is carried over, because that length is what frames a + * blob upload. A body with no declared length is chunked, which the clients here do not do + * but an OCI client streaming a layer of unknown size may, and is forwarded chunked in turn. + * Only methods that can carry a body take that path; a bodyless {@code GET} or {@code DELETE} + * that simply declared no length must not acquire a chunked frame it never had.

+ * + *

The supplier hands out the client's own stream, so it can only be subscribed to once. That + * is safe because {@code HttpClient} retries only idempotent methods by default, and a blob + * upload is {@code POST}/{@code PATCH}/{@code PUT}. Do not enable + * {@code jdk.httpclient.enableAllMethodRetry}: a retry would resubscribe to a stream that has + * already been drained and push a truncated layer.

+ */ + private static HttpRequest.BodyPublisher bodyPublisher(AzureRequest request) { + long length = declaredContentLength(request); + if (length == 0 || request.bodyStream() == null) { + return HttpRequest.BodyPublishers.noBody(); + } + if (length > 0) { + return HttpRequest.BodyPublishers.fromPublisher( + HttpRequest.BodyPublishers.ofInputStream(request::bodyStream), length); + } + if (!BODY_METHODS.contains(request.method())) { + return HttpRequest.BodyPublishers.noBody(); + } + return HttpRequest.BodyPublishers.ofInputStream(request::bodyStream); + } + + /** The client's {@code Content-Length}, or {@code -1} when it declared none. */ + private static long declaredContentLength(AzureRequest request) { + if (request.headers() == null) { + return -1; + } + String declared = request.headers().getHeaderString("Content-Length"); + if (declared == null || declared.isBlank()) { + return -1; + } + try { + return Long.parseLong(declared.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private static void forwardRequestHeaders(AzureRequest request, HttpRequest.Builder outgoing) { + if (request.headers() == null) { + return; + } + request.headers().getRequestHeaders().forEach((name, values) -> { + String lower = name.toLowerCase(Locale.ROOT); + if (HOP_BY_HOP.contains(lower) || DROPPED_REQUEST_HEADERS.contains(lower)) { + return; + } + values.forEach(value -> outgoing.header(name, value)); + }); + } + + private static Response response(HttpResponse backend, String registryName, Object entity, + boolean keepContentLength) { + Response.ResponseBuilder response = Response.status(backend.statusCode()).entity(entity); + backend.headers().map().forEach((name, values) -> { + String lower = name.toLowerCase(Locale.ROOT); + if (REWRITTEN_RESPONSE_HEADERS.contains(lower)) { + return; + } + // Content-Length describes the backend's framing, not ours: a streamed or rewritten body + // is re-framed on the way out, and only HEAD reports the backend's length verbatim. + if ("content-length".equals(lower)) { + if (keepContentLength) { + values.forEach(value -> response.header(name, value)); + } + return; + } + if (HOP_BY_HOP.contains(lower)) { + return; + } + values.forEach(value -> response.header(name, value)); + }); + backend.headers().firstValue("Location") + .map(location -> clientLocation(registryName, location)) + .ifPresent(location -> response.header("Location", location)); + return response.build(); + } + + /** + * The query to forward, including the leading {@code ?}. The client's raw query goes through + * byte for byte: registry:2 mints an opaque {@code _state} for an upload session and expects it + * back exactly as issued, and rebuilding the query from the decoded parameters does not + * round-trip it. Rebuilding is only the fallback for a request that carried no raw query. + */ + private static String queryString(AzureRequest request) { + String raw = request.rawQuery(); + if (raw != null && !raw.isEmpty()) { + return "?" + raw; + } + return queryString(request.queryParamsMulti()); + } + + private static String queryString(Map> parameters) { + if (parameters == null || parameters.isEmpty()) { + return ""; + } + StringBuilder query = new StringBuilder("?"); + parameters.forEach((name, values) -> values.forEach(value -> { + if (query.length() > 1) { + query.append('&'); + } + query.append(encode(name)).append('=').append(encode(value)); + })); + return query.toString(); + } + + private static String encode(String value) { + return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8); + } + + private static String trimLeadingSlash(String path) { + return path == null ? "" : path.replaceFirst("^/+", ""); + } +} diff --git a/src/main/java/io/floci/az/services/acr/AcrTokenService.java b/src/main/java/io/floci/az/services/acr/AcrTokenService.java new file mode 100644 index 00000000..b87d7c49 --- /dev/null +++ b/src/main/java/io/floci/az/services/acr/AcrTokenService.java @@ -0,0 +1,281 @@ +package io.floci.az.services.acr; + +import io.floci.az.config.EmulatorConfig; +import io.floci.az.core.AzureRequest; +import io.floci.az.core.FormBody; +import io.floci.az.services.entra.TokenIssuer; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Azure Container Registry's Entra token exchange: the two {@code /oauth2/} endpoints that + * {@code az acr login} and the Azure SDK container-registry clients use instead of a plain Docker + * login. + * + *
+ *   GET  /v2/                 → 401 + Bearer challenge naming the realm and service
+ *   POST /oauth2/exchange     → Entra access token  → ACR refresh token
+ *   POST /oauth2/token        → ACR refresh token   → scoped ACR access token
+ *   GET  /oauth2/token        → the Docker Registry token endpoint form (service + scope)
+ * 
+ * + *

Both tokens are RS256 JWTs minted by the emulator's {@link TokenIssuer}, so clients that + * decode them keep working: the Azure CLI reads the access token's {@code access} claim when it + * verifies permissions. Tokens are issued, not verified, and the scopes they carry are not + * enforced at the data plane. See {@code docs/services/acr.md}.

+ */ +@ApplicationScoped +public class AcrTokenService { + + /** The username Docker logs in with when the password is an ACR token. */ + public static final String TOKEN_USERNAME = "00000000-0000-0000-0000-000000000000"; + + private static final String ISSUER = "Azure Container Registry"; + /** The grant types the clients send to the exchange endpoint, in the order they are documented. */ + private static final List EXCHANGE_GRANTS = + List.of("access_token", "refresh_token", "access_token_refresh_token"); + + private final EmulatorConfig config; + private final TokenIssuer tokenIssuer; + + @Inject + public AcrTokenService(EmulatorConfig config, TokenIssuer tokenIssuer) { + this.config = config; + this.tokenIssuer = tokenIssuer; + } + + /** + * One entry of an access token's {@code access} claim: what the client asked for, echoed back + * with the bare repository name it used. + */ + public record Access(String type, String name, List actions) { + + Map toClaim() { + Map claim = new LinkedHashMap<>(); + claim.put("type", type); + claim.put("name", name); + claim.put("actions", actions); + return claim; + } + } + + // ── Challenge ──────────────────────────────────────────────────────────────── + + /** + * The {@code WWW-Authenticate} value that starts the flow. The client parses {@code realm} and + * {@code service} out of it and fails with a connectivity error if either is missing. + * + *

The realm carries the scheme the caller actually used. TLS is off by default here, and a + * realm hardcoded to {@code https://} would send the client to a port nothing is listening on. + * {@code service} is always the login server itself, as in Azure.

+ */ + public static String challenge(String scheme, String loginServer) { + return "Bearer realm=\"" + scheme + "://" + loginServer + "/oauth2/token\"" + + ",service=\"" + loginServer + "\""; + } + + /** {@code GET /v2/} answered before authentication: {@code 401} plus the bearer challenge. */ + public static Response challengeResponse(String scheme, String loginServer) { + return Response.status(401) + .header("WWW-Authenticate", challenge(scheme, loginServer)) + .header("Docker-Distribution-Api-Version", "registry/2.0") + .entity(Map.of("errors", List.of(Map.of( + "code", "UNAUTHORIZED", + "message", "authentication required")))) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + // ── Scope parsing ──────────────────────────────────────────────────────────── + + /** + * Parses one scope string into an access entry. The Azure CLI sends + * {@code repository:{name}:{actions}}, {@code artifact-repository:{name}:{actions}} or + * {@code registry:{permission}:*}; Docker sends the same repository form. Actions are comma + * separated. A repository name may itself contain {@code /} but never {@code :}, so the first + * and last separators bound the name. + */ + public static Optional parseScope(String scope) { + if (scope == null || scope.isBlank()) { + return Optional.empty(); + } + int firstColon = scope.indexOf(':'); + int lastColon = scope.lastIndexOf(':'); + if (firstColon <= 0 || lastColon <= firstColon || lastColon == scope.length() - 1) { + return Optional.empty(); + } + String type = scope.substring(0, firstColon); + String name = scope.substring(firstColon + 1, lastColon); + List actions = Arrays.stream(scope.substring(lastColon + 1).split(",")) + .map(String::trim) + .filter(action -> !action.isEmpty()) + .toList(); + if (name.isEmpty() || actions.isEmpty()) { + return Optional.empty(); + } + return Optional.of(new Access(type, name, actions)); + } + + /** Parses every scope in a request: whitespace separates scopes, and the parameter may repeat. */ + public static List parseScopes(List scopes) { + List access = new ArrayList<>(); + if (scopes == null) { + return access; + } + for (String scope : scopes) { + if (scope == null) { + continue; + } + for (String single : scope.split("\\s+")) { + parseScope(single).ifPresent(access::add); + } + } + return access; + } + + // ── Token building ─────────────────────────────────────────────────────────── + + /** + * An ACR refresh token: audience is the login server, tenant is the emulator's. It carries no + * {@code access} claim; it is exchanged for scoped access tokens at the token endpoint. + */ + public String refreshToken(String loginServer) { + return tokenIssuer.issue(spec(loginServer), Map.of("grant_type", "refresh_token")); + } + + /** + * A scoped ACR access token: the {@code access} claim carries one entry per requested scope, + * holding the type, the bare name the client asked for, and its actions. + */ + public String accessToken(String loginServer, List access, String grantType) { + Map claims = new LinkedHashMap<>(); + claims.put("access", access.stream().map(Access::toClaim).toList()); + if (grantType != null) { + claims.put("grant_type", grantType); + } + return tokenIssuer.issue(spec(loginServer), claims); + } + + private TokenIssuer.TokenSpec spec(String loginServer) { + String tenantId = config.services().entra().defaultTenantId(); + String subject = TokenIssuer.deterministicGuid("acr:" + loginServer); + return new TokenIssuer.TokenSpec(tenantId, ISSUER, loginServer, subject, subject, + TOKEN_USERNAME, null, "1.0", null, lifetimeSeconds()); + } + + private long lifetimeSeconds() { + return config.services().entra().tokenLifetimeSeconds(); + } + + // ── Endpoints ──────────────────────────────────────────────────────────────── + + /** + * {@code POST /oauth2/exchange}: trades an Entra access token for an ACR refresh token. The + * Entra token is accepted without verification, so only the request shape is checked. + */ + public Response handleExchange(AzureRequest request, String loginServer) { + if (!"POST".equals(request.method())) { + return AcrErrors.error(405, AcrErrors.UNSUPPORTED, + "the exchange endpoint accepts POST only"); + } + Map form = FormBody.parse(request.bodyStream()); + String grantType = form.getOrDefault("grant_type", ""); + if (!EXCHANGE_GRANTS.contains(grantType)) { + return AcrErrors.badRequest("grant_type '" + grantType + "' is not supported at the " + + "exchange endpoint; expected one of " + String.join(", ", EXCHANGE_GRANTS)); + } + if (isBlank(form.get("service"))) { + return AcrErrors.badRequest("service is required"); + } + return Response.ok(Map.of("refresh_token", refreshToken(loginServer))) + .type(MediaType.APPLICATION_JSON) + .build(); + } + + /** + * {@code /oauth2/token}: {@code POST} with {@code grant_type=refresh_token} for the Entra flow + * or {@code grant_type=password} for admin credentials, and {@code GET} with {@code service} + * and {@code scope} query parameters for Docker's own client. + */ + public Response handleToken(AzureRequest request, String loginServer) { + return switch (request.method()) { + // Docker's own client sends no grant type, only service and scope. + case "GET" -> accessTokenResponse(loginServer, + parseScopes(request.queryParamsMulti().get("scope")), null); + case "POST" -> handleTokenPost(request, loginServer); + default -> AcrErrors.error(405, AcrErrors.UNSUPPORTED, + "the token endpoint accepts GET and POST only"); + }; + } + + private Response handleTokenPost(AzureRequest request, String loginServer) { + Map form = FormBody.parse(request.bodyStream()); + String grantType = form.getOrDefault("grant_type", ""); + switch (grantType) { + case "refresh_token" -> { + String refreshToken = form.get("refresh_token"); + if (isBlank(refreshToken)) { + return AcrErrors.badRequest("refresh_token is required"); + } + // Nothing verifies the token, but a token that is not even a JWT is rejected so + // clients still exercise the re-authentication path they would take against Azure. + if (!looksLikeJwt(refreshToken)) { + return AcrErrors.error(401, AcrErrors.UNAUTHORIZED, "the refresh token is malformed"); + } + } + case "password" -> { + if (isBlank(form.get("password"))) { + return AcrErrors.badRequest("password is required"); + } + } + default -> { + return AcrErrors.badRequest("grant_type '" + grantType + "' is not supported at the " + + "token endpoint; expected refresh_token or password"); + } + } + if (isBlank(form.get("service"))) { + return AcrErrors.badRequest("service is required"); + } + return accessTokenResponse(loginServer, + parseScopes(List.of(form.getOrDefault("scope", ""))), grantType); + } + + private Response accessTokenResponse(String loginServer, List access, String grantType) { + Map body = new LinkedHashMap<>(); + body.put("access_token", accessToken(loginServer, access, grantType)); + body.put("expires_in", lifetimeSeconds()); + body.put("issued_at", DateTimeFormatter.ISO_INSTANT.format( + Instant.now().truncatedTo(ChronoUnit.MILLIS))); + return Response.ok(body).type(MediaType.APPLICATION_JSON).build(); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + /** A compact JWT: three non-empty dot-separated segments. */ + private static boolean looksLikeJwt(String token) { + String[] segments = token.split("\\.", -1); + if (segments.length != 3) { + return false; + } + for (String segment : segments) { + if (segment.isEmpty()) { + return false; + } + } + return true; + } +} diff --git a/src/main/java/io/floci/az/services/entra/EntraServiceHandler.java b/src/main/java/io/floci/az/services/entra/EntraServiceHandler.java index 24c61cca..4a8de6ce 100644 --- a/src/main/java/io/floci/az/services/entra/EntraServiceHandler.java +++ b/src/main/java/io/floci/az/services/entra/EntraServiceHandler.java @@ -4,6 +4,7 @@ import io.floci.az.config.EmulatorConfig; import io.floci.az.core.AzureRequest; import io.floci.az.core.AzureServiceHandler; +import io.floci.az.core.FormBody; import io.floci.az.core.RequestUrls; import io.floci.az.services.entra.EntraModels.AppRegistration; import io.floci.az.services.entra.EntraModels.AuthorizationCode; @@ -14,9 +15,7 @@ import jakarta.ws.rs.core.Response; import org.jboss.logging.Logger; -import java.io.IOException; import java.net.URI; -import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; @@ -25,7 +24,6 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Base64; -import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -361,27 +359,7 @@ private String normalizeScopes(String scope) { } private Map parseForm(AzureRequest request) { - Map result = new HashMap<>(); - byte[] bytes; - try { - bytes = request.bodyStream() == null ? new byte[0] : request.bodyStream().readAllBytes(); - } catch (IOException e) { - return result; - } - String body = new String(bytes, StandardCharsets.UTF_8); - if (body.isBlank()) { - return result; - } - for (String pair : body.split("&")) { - int eq = pair.indexOf('='); - if (eq < 0) { - continue; - } - String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8); - String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); - result.put(key, value); - } - return result; + return FormBody.parse(request.bodyStream()); } /** First path segment is the tenant; defaults to {@code common} for safety. */ diff --git a/src/main/java/io/floci/az/services/entra/TokenIssuer.java b/src/main/java/io/floci/az/services/entra/TokenIssuer.java index 8f50c9a0..f66593ba 100644 --- a/src/main/java/io/floci/az/services/entra/TokenIssuer.java +++ b/src/main/java/io/floci/az/services/entra/TokenIssuer.java @@ -88,7 +88,11 @@ private static void putIfPresent(Map claims, String key, String } } - private String issue(TokenSpec spec, Map extraClaims) { + /** + * Mints a token carrying {@code extraClaims} alongside the standard claim set. ACR's registry + * tokens use this for the {@code access} claim that clients decode to read their permissions. + */ + public String issue(TokenSpec spec, Map extraClaims) { Instant now = Instant.now(); long iat = now.getEpochSecond(); long exp = iat + spec.lifetimeSeconds(); diff --git a/src/test/java/io/floci/az/core/FormBodyTest.java b/src/test/java/io/floci/az/core/FormBodyTest.java new file mode 100644 index 00000000..3dd1c1aa --- /dev/null +++ b/src/test/java/io/floci/az/core/FormBodyTest.java @@ -0,0 +1,75 @@ +package io.floci.az.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Form parsing for the OAuth-style endpoints. The body is client input, so the parser never throws: + * a caller reads a parameter it cannot use as absent and answers with its own error. + */ +@DisplayName("FormBody: form-encoded request bodies") +class FormBodyTest { + + private static Map parse(String body) { + return FormBody.parse(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + } + + @Test + void decodesParameters() { + Map form = parse("grant_type=refresh_token&service=myreg.azurecr.io"); + assertEquals("refresh_token", form.get("grant_type")); + assertEquals("myreg.azurecr.io", form.get("service")); + } + + @Test + void decodesPercentEscapesAndPlusAsSpace() { + Map form = parse("scope=repository%3Ateam%2Fapp%3Apull%2Cpush¬e=a+b"); + assertEquals("repository:team/app:pull,push", form.get("scope")); + assertEquals("a b", form.get("note")); + } + + @Test + void dropsAPairWithAMalformedEscapeInsteadOfThrowing() { + // "%zz" is not a percent escape; URLDecoder throws on it. The pair is dropped so the + // endpoint sees a missing parameter and answers its own 400 rather than a 500. + Map form = parse("grant_type=refresh_token&service=%zz"); + assertFalse(form.containsKey("service")); + assertEquals("refresh_token", form.get("grant_type")); + } + + @Test + void dropsAPairWithATruncatedEscape() { + assertFalse(parse("service=%").containsKey("service")); + assertFalse(parse("service=abc%2").containsKey("service")); + assertFalse(parse("%zz=value").containsKey("%zz")); + } + + @Test + void ignoresPairsWithNoSeparatorAndBlankBodies() { + assertTrue(parse("").isEmpty()); + assertTrue(parse(" ").isEmpty()); + assertTrue(parse("novalue").isEmpty()); + assertEquals(Map.of("a", "1"), parse("novalue&a=1")); + } + + @Test + void treatsAnUnreadableOrAbsentBodyAsEmpty() { + assertTrue(FormBody.parse(null).isEmpty()); + assertTrue(FormBody.parse(new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("stream closed"); + } + }).isEmpty()); + } +} diff --git a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java index 717c112e..0aa8d787 100644 --- a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java +++ b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java @@ -42,7 +42,8 @@ class RoutingTableAssemblyTest { Map.entry(".queue.core.windows.net", "queue"), Map.entry(".table.core.windows.net", "table"), Map.entry(".servicebus.windows.net", "servicebus"), - Map.entry(".azurecontainerapps.io", "containerapps") + Map.entry(".azurecontainerapps.io", "containerapps"), + Map.entry(".azurecr.io", "acr") ); /** A4's ACCOUNT_SUFFIX_ROUTES, verbatim (pre-sort). */ diff --git a/src/test/java/io/floci/az/core/tls/TlsConfigSourceCertificateGenerationTest.java b/src/test/java/io/floci/az/core/tls/TlsConfigSourceCertificateGenerationTest.java index d74072de..2a8f1ee4 100644 --- a/src/test/java/io/floci/az/core/tls/TlsConfigSourceCertificateGenerationTest.java +++ b/src/test/java/io/floci/az/core/tls/TlsConfigSourceCertificateGenerationTest.java @@ -90,8 +90,10 @@ void certificateWithDefaultConfigHasDefaultSans() throws Exception { "SANs should include 'host.docker.internal' so function containers can reach floci-az on the host"); assertTrue(sans.contains("*.managedhsm.azure.net"), "SANs should include '*.managedhsm.azure.net' for Managed-HSM-flavored vault URLs"); - assertEquals(9, sans.size(), - "Default cert should have exactly 9 SANs (localhost, 127.0.0.1, 0.0.0.0, *.localhost, localhost.floci-az.io, *.localhost.floci-az.io, *.vault.azure.net, *.managedhsm.azure.net, host.docker.internal)"); + assertTrue(sans.contains("*.azurecr.io"), + "SANs should include '*.azurecr.io' so registry clients trust {name}.azurecr.io"); + assertEquals(10, sans.size(), + "Default cert should have exactly 10 SANs (localhost, 127.0.0.1, 0.0.0.0, *.localhost, localhost.floci-az.io, *.localhost.floci-az.io, *.vault.azure.net, *.managedhsm.azure.net, *.azurecr.io, host.docker.internal)"); } @Test diff --git a/src/test/java/io/floci/az/services/acr/AcrCatalogPaginationDockerTest.java b/src/test/java/io/floci/az/services/acr/AcrCatalogPaginationDockerTest.java new file mode 100644 index 00000000..1c9e6f1a --- /dev/null +++ b/src/test/java/io/floci/az/services/acr/AcrCatalogPaginationDockerTest.java @@ -0,0 +1,237 @@ +package io.floci.az.services.acr; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.anyOf; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Catalog pagination against a real {@code registry:2}, which is the only thing that can confirm + * the cursor behaviour the proxy is built on. + * + *

The proxy serves one client page from one backend request by seeding {@code last} with + * {@code {registry}/} and asking for one repository beyond the page. That rests on three things + * the distribution spec does not state: the container walks its repositories as a directory tree, + * so one registry's repositories are contiguous; {@code last} is a position in that walk rather + * than a repository that has to exist; and the container advertises a next page whenever the page + * it returned was full rather than when more results remain. If any of those change under a + * {@code registry:2} pull, this test fails rather than the behaviour silently degrading. + * + *

Skipped automatically when Docker is unavailable. + */ +@QuarkusTest +@TestProfile(AcrCatalogPaginationDockerTest.RealModeProfile.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("ACR catalog pagination against a real registry (Docker required)") +class AcrCatalogPaginationDockerTest { + + public static class RealModeProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("floci-az.services.acr.mocked", "false"); + } + } + + /** + * Ours sits between two neighbours in the shared container, which is what makes the cursor + * seed load-bearing: without it the container would spend the first page on {@link #BEFORE} + * and answer with repositories that are all filtered away. + */ + private static final String BEFORE = "catalogpagea"; + private static final String REGISTRY = "catalogpageb"; + private static final String AFTER = "catalogpagec"; + private static final String SUB = "00000000-0000-0000-0000-000000000001"; + private static final String RG = "test-rg-catalog"; + + /** Pushed in an order that does not match the catalog's, so ordering is the registry's own. */ + private static final List OURS = List.of("web", "app", "team/api", "zebra"); + + private boolean pushed; + + /** Pure filesystem check, which is all that is safe before Quarkus is listening. */ + @BeforeAll + void checkDockerAvailable() { + assumeTrue(Files.exists(Paths.get("/var/run/docker.sock")) || System.getenv("DOCKER_HOST") != null, + "Docker socket not available, skipping the real registry catalog tests"); + } + + /** + * Pushes into both registries once, on the first test to need it. This cannot be a + * {@code @BeforeAll}: the HTTP port is not serving yet when that runs. + */ + @BeforeEach + void pushRepositoriesToBothRegistries() { + if (pushed) { + return; + } + createRegistry(BEFORE); + createRegistry(REGISTRY); + createRegistry(AFTER); + // Neighbours on both sides, so a page can be spoiled from either end. Their repositories + // must never appear in our catalog, whatever page they would have fallen on. + push(BEFORE, "app"); + push(BEFORE, "other"); + push(BEFORE, "third"); + for (String repository : OURS) { + push(REGISTRY, repository); + } + push(AFTER, "app"); + push(AFTER, "other"); + pushed = true; + } + + @Test + void theUnpaginatedCatalogIsThisRegistrysOwn() { + assertEquals(List.of("app", "team/api", "web", "zebra"), catalog("")); + } + + @Test + void pagingWalksEveryRepositoryExactlyOnceAndStops() { + List seen = new ArrayList<>(); + String query = "?n=2"; + int pages = 0; + + while (query != null) { + Response page = catalogResponse(query); + seen.addAll(repositories(page)); + String link = page.getHeader("Link"); + if (link != null) { + assertFalse(link.contains(REGISTRY + "/"), + "the next cursor leaked the internal repository prefix: " + link); + } + query = nextQuery(link); + assertTrue(++pages <= 10, "paging did not terminate"); + } + + assertEquals(List.of("app", "team/api", "web", "zebra"), seen); + } + + @Test + void aPageIsFullEvenThoughTheNeighboursRepositoriesShareTheContainer() { + // Three repositories precede ours in the container. Unseeded, the first page would be + // spent on those and come back empty; seeded, the container counts ours and fills it. + assertEquals(List.of("app", "team/api"), catalog("?n=2")); + } + + @Test + void aPageThatExactlyExhaustsTheCatalogCarriesNoNextLink() { + Response page = catalogResponse("?n=4"); + + assertEquals(List.of("app", "team/api", "web", "zebra"), repositories(page)); + assertEquals(null, page.getHeader("Link"), + "the container advertises a next page after a full one, so we must not echo it"); + } + + @Test + void aCursorNamingTheLastRepositoryEndsTheWalk() { + Response page = catalogResponse("?n=2&last=zebra"); + + assertEquals(List.of(), repositories(page)); + assertEquals(null, page.getHeader("Link")); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private static String loginServer(String registry) { + return registry + ".azurecr.io"; + } + + private static void createRegistry(String name) { + given().contentType("application/json") + .body("{\"location\":\"eastus\",\"sku\":{\"name\":\"Basic\"}}") + .when().put("/subscriptions/" + SUB + "/resourceGroups/" + RG + + "/providers/Microsoft.ContainerRegistry/registries/" + name + + "?api-version=2025-11-01") + .then().statusCode(anyOf(is(200), is(201))); + } + + /** Pushes an empty image, which is enough to make the repository exist in the catalog. */ + private static void push(String registry, String repository) { + String host = loginServer(registry); + String config = "{}"; + String digest = "sha256:" + sha256(config); + + String location = given().header("Host", host) + .when().post("/v2/" + repository + "/blobs/uploads/") + .then().statusCode(202) + .extract().header("Location"); + assertNotNull(location, "no upload Location for " + registry + "/" + repository); + assertFalse(location.contains(registry + "/"), + "the upload Location leaked the internal prefix: " + location); + + // The upload session carries an opaque _state that the registry expects back byte for byte, + // so the query must not be re-encoded on the way out. + given().header("Host", host).urlEncodingEnabled(false) + .contentType("application/octet-stream") + .body(config.getBytes(StandardCharsets.UTF_8)) + .when().put(location + (location.contains("?") ? "&" : "?") + "digest=" + digest) + .then().statusCode(201); + + String manifest = "{\"schemaVersion\":2," + + "\"mediaType\":\"application/vnd.docker.distribution.manifest.v2+json\"," + + "\"config\":{\"mediaType\":\"application/vnd.docker.container.image.v1+json\"," + + "\"size\":" + config.length() + ",\"digest\":\"" + digest + "\"},\"layers\":[]}"; + given().header("Host", host) + .contentType("application/vnd.docker.distribution.manifest.v2+json").body(manifest) + .when().put("/v2/" + repository + "/manifests/v1") + .then().statusCode(201); + } + + private static Response catalogResponse(String query) { + return given().header("Host", loginServer(REGISTRY)) + .when().get("/v2/_catalog" + query) + .then().statusCode(200) + .extract().response(); + } + + private static List catalog(String query) { + return repositories(catalogResponse(query)); + } + + private static List repositories(Response response) { + List repositories = response.jsonPath().getList("repositories"); + return repositories == null ? List.of() : repositories; + } + + /** The query of a {@code Link} header's next page, or {@code null} when there is none. */ + private static String nextQuery(String link) { + if (link == null) { + return null; + } + int start = link.indexOf('?'); + int end = link.indexOf('>', start); + return start < 0 || end < 0 ? null : link.substring(start, end); + } + + private static String sha256(String content) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java b/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java index 5e7e9463..457622d4 100644 --- a/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java +++ b/src/test/java/io/floci/az/services/acr/AcrHandlerTest.java @@ -10,11 +10,14 @@ import java.util.Map; import static io.restassured.RestAssured.given; -import static org.hamcrest.CoreMatchers.endsWith; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; /** * Management-plane coverage for Azure Container Registry in mocked mode (no Docker): @@ -70,10 +73,13 @@ void createReturnsRegistryResource() { .body("sku.name", is("Basic")) .body("properties.provisioningState", is("Succeeded")) .body("properties.adminUserEnabled", is(true)) - .body("properties.loginServer", endsWith(".azurecr.io")) + .body("properties.loginServer", is("acrcreate.azurecr.io")) // Fields the azurerm provider dereferences without nil checks — must be present. .body("properties.zoneRedundancy", is("Disabled")) - .body("properties.publicNetworkAccess", is("Enabled")); + .body("properties.publicNetworkAccess", is("Enabled")) + // localPort reports the shared container's published port. Mocked mode starts no + // container, so there is no port to report and the field is left out entirely. + .body("properties", not(hasKey("localPort"))); } @Test @@ -145,6 +151,22 @@ void replicationsReturnsEmptyList() { .body("value", hasSize(0)); } + @Test + void loginServerIsTheAzureHostnameForTheRegistry() { + assertEquals("myregistry.azurecr.io", AcrHandler.loginServer("myregistry")); + // Azure lowercases the login server even when the resource name is mixed case. + assertEquals("myregistry.azurecr.io", AcrHandler.loginServer("MyRegistry")); + } + + @Test + void theRegistryIsResolvedFromTheRequestHost() { + assertEquals("myregistry", AcrHandler.registryFromHost("myregistry.azurecr.io")); + assertEquals("myregistry", AcrHandler.registryFromHost("MyRegistry.AzureCr.Io")); + assertNull(AcrHandler.registryFromHost("localhost")); + assertNull(AcrHandler.registryFromHost(".azurecr.io")); + assertNull(AcrHandler.registryFromHost(null)); + } + @Test void deleteRemovesTheRegistry() { createRegistry("acrdelete"); diff --git a/src/test/java/io/floci/az/services/acr/AcrRegistryProxyHttpTest.java b/src/test/java/io/floci/az/services/acr/AcrRegistryProxyHttpTest.java new file mode 100644 index 00000000..a12c2d29 --- /dev/null +++ b/src/test/java/io/floci/az/services/acr/AcrRegistryProxyHttpTest.java @@ -0,0 +1,412 @@ +package io.floci.az.services.acr; + +import com.sun.net.httpserver.HttpServer; +import io.floci.az.core.AzureRequest; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * The proxy against a stand-in registry: what the shared container actually receives, and what the + * client gets back. {@link AcrRegistryProxyTest} covers the path and body rewriting in isolation. + */ +@DisplayName("AcrRegistryProxy: proxying to the shared registry") +class AcrRegistryProxyHttpTest { + + @Test + void prefixesTheRepositoryAndStripsThePrefixFromTheUploadLocation() throws Exception { + AtomicReference receivedPath = new AtomicReference<>(); + HttpServer registry = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + int port = registry.getAddress().getPort(); + registry.createContext("/", exchange -> { + receivedPath.set(exchange.getRequestURI().getRawPath()); + // registry:2 answers with an absolute URL built from the Host it was called on. + exchange.getResponseHeaders().add("Location", + "http://127.0.0.1:" + port + "/v2/myreg/app/blobs/uploads/abc-123?_state=opaque"); + exchange.sendResponseHeaders(202, -1); + exchange.close(); + }); + registry.start(); + + try { + Response response = proxy(request("POST", "v2/app/blobs/uploads/", null, Map.of()), port); + + assertEquals(202, response.getStatus()); + assertEquals("/v2/myreg/app/blobs/uploads/", receivedPath.get()); + assertEquals("/v2/app/blobs/uploads/abc-123?_state=opaque", + response.getHeaderString("Location")); + } finally { + registry.stop(0); + } + } + + @Test + void streamsTheRequestBodyWithTheLengthTheClientDeclared() throws Exception { + byte[] blob = "a-layer-worth-of-bytes".getBytes(StandardCharsets.UTF_8); + AtomicReference receivedBody = new AtomicReference<>(); + AtomicReference receivedLength = new AtomicReference<>(); + AtomicReference receivedQuery = new AtomicReference<>(); + HttpServer registry = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + registry.createContext("/", exchange -> { + receivedLength.set(exchange.getRequestHeaders().getFirst("Content-Length")); + receivedQuery.set(exchange.getRequestURI().getQuery()); + receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + exchange.sendResponseHeaders(201, -1); + exchange.close(); + }); + registry.start(); + + try { + AzureRequest request = request("PUT", "v2/app/blobs/uploads/abc-123", + new ByteArrayInputStream(blob), + Map.of("Content-Length", String.valueOf(blob.length)), + Map.of("digest", List.of("sha256:abc"))); + + Response response = proxy(request, registry.getAddress().getPort()); + + assertEquals(201, response.getStatus()); + assertEquals(new String(blob, StandardCharsets.UTF_8), receivedBody.get()); + // Streamed, not chunked: a blob upload is framed by its length. + assertEquals(String.valueOf(blob.length), receivedLength.get()); + // Query parameters survive the hop: the registry reads the digest it was sent. + assertEquals("digest=sha256:abc", receivedQuery.get()); + } finally { + registry.stop(0); + } + } + + @Test + void streamsAnUploadThatDeclaredNoLengthInsteadOfBufferingIt() throws Exception { + // A client that streams a layer of unknown size sends it chunked. Forwarding it chunked in + // turn keeps it out of memory; reading it whole to re-declare a length would not. + byte[] blob = "a-layer-of-unknown-size".getBytes(StandardCharsets.UTF_8); + AtomicReference receivedBody = new AtomicReference<>(); + AtomicReference receivedEncoding = new AtomicReference<>(); + AtomicReference receivedLength = new AtomicReference<>(); + HttpServer registry = server(exchange -> { + receivedEncoding.set(exchange.getRequestHeaders().getFirst("Transfer-Encoding")); + receivedLength.set(exchange.getRequestHeaders().getFirst("Content-Length")); + receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + exchange.sendResponseHeaders(202, -1); + exchange.close(); + }); + + try { + AzureRequest request = request("PATCH", "v2/app/blobs/uploads/abc-123", + new ByteArrayInputStream(blob), Map.of()); + + Response response = proxy(request, registry.getAddress().getPort()); + + assertEquals(202, response.getStatus()); + assertEquals(new String(blob, StandardCharsets.UTF_8), receivedBody.get()); + assertEquals("chunked", receivedEncoding.get()); + assertNull(receivedLength.get(), "a chunked body declares no length"); + } finally { + registry.stop(0); + } + } + + @Test + void doesNotChunkABodylessRequestThatDeclaredNoLength() { + // A GET carries nothing to stream. Declaring no length must not turn it into a chunked + // request the client never made. + AtomicReference receivedEncoding = new AtomicReference<>(); + HttpServer registry = server(exchange -> { + receivedEncoding.set(exchange.getRequestHeaders().getFirst("Transfer-Encoding")); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + + try { + AzureRequest request = request("GET", "v2/app/blobs/sha256:abc", + new ByteArrayInputStream(new byte[0]), Map.of()); + + assertEquals(200, proxy(request, registry.getAddress().getPort()).getStatus()); + assertNull(receivedEncoding.get()); + } finally { + registry.stop(0); + } + } + + @Test + void catalogReportsOnlyThisRegistrysRepositories() { + AtomicReference receivedQuery = new AtomicReference<>(); + HttpServer registry = catalogServer(receivedQuery, "myreg/app", "otherreg/app"); + + try { + Response response = proxy(request("GET", "v2/_catalog", null, Map.of()), + registry.getAddress().getPort()); + + assertEquals(200, response.getStatus()); + assertEquals("{\"repositories\":[\"app\"]}", new String((byte[]) response.getEntity())); + // Unpaginated: seeded at the head of our block, with no page size and so no next link. + assertEquals("last=myreg%2F", receivedQuery.get()); + assertNull(response.getHeaderString("Link")); + } finally { + registry.stop(0); + } + } + + @Test + void catalogSeedsTheCursorAtThisRegistrysBlockAndAsksForOneExtra() { + // The seed makes the container count our repositories rather than everyone's, so a page + // comes back full. The extra one is what tells us whether a next page exists. + AtomicReference receivedQuery = new AtomicReference<>(); + HttpServer registry = catalogServer(receivedQuery, "myreg/app", "myreg/web", "myreg/zebra"); + + try { + Response response = proxy(request("GET", "v2/_catalog", null, Map.of(), + Map.of("n", List.of("2"))), registry.getAddress().getPort()); + + assertEquals(200, response.getStatus()); + assertEquals("last=myreg%2F&n=3", receivedQuery.get()); + assertEquals("{\"repositories\":[\"app\",\"web\"]}", new String((byte[]) response.getEntity())); + assertEquals("; rel=\"next\"", response.getHeaderString("Link")); + } finally { + registry.stop(0); + } + } + + @Test + void catalogAnswersAnEmptyPageWithoutAskingTheContainer() { + AtomicReference receivedQuery = new AtomicReference<>(); + HttpServer registry = catalogServer(receivedQuery, "myreg/app", "myreg/web"); + + try { + Response response = proxy(request("GET", "v2/_catalog", null, Map.of(), + Map.of("n", List.of("0"))), registry.getAddress().getPort()); + + assertEquals(200, response.getStatus()); + assertEquals("{\"repositories\":[]}", new String((byte[]) response.getEntity())); + assertNull(response.getHeaderString("Link")); + assertNull(receivedQuery.get(), "a page of nothing needs no container request"); + } finally { + registry.stop(0); + } + } + + @Test + void catalogPrefixesTheCursorTheClientSendsBack() { + AtomicReference receivedQuery = new AtomicReference<>(); + HttpServer registry = catalogServer(receivedQuery, "myreg/zebra", "otherreg/db"); + + try { + Response response = proxy(request("GET", "v2/_catalog", null, Map.of(), + Map.of("n", List.of("2"), "last", List.of("web"))), + registry.getAddress().getPort()); + + assertEquals(200, response.getStatus()); + assertEquals("last=myreg%2Fweb&n=3", receivedQuery.get()); + // The block ends inside this page, so it is the last one and carries no next link. + assertEquals("{\"repositories\":[\"zebra\"]}", new String((byte[]) response.getEntity())); + assertNull(response.getHeaderString("Link")); + } finally { + registry.stop(0); + } + } + + @Test + void catalogEndsThePageWhenTheExtraRepositoryIsOutsideThisRegistry() { + // The container advertises a next page whenever the page it returned was full, so its own + // Link cannot say whether we have more. The extra repository can, and it does not leak. + AtomicReference receivedQuery = new AtomicReference<>(); + HttpServer registry = catalogServer(receivedQuery, "myreg/app", "myreg/web", "otherreg/db"); + + try { + Response response = proxy(request("GET", "v2/_catalog", null, Map.of(), + Map.of("n", List.of("2"))), registry.getAddress().getPort()); + + assertEquals("{\"repositories\":[\"app\",\"web\"]}", new String((byte[]) response.getEntity())); + assertNull(response.getHeaderString("Link")); + } finally { + registry.stop(0); + } + } + + @Test + void catalogNeverForwardsTheBackendsOwnNextLink() { + AtomicReference receivedQuery = new AtomicReference<>(); + HttpServer registry = server(exchange -> { + receivedQuery.set(exchange.getRequestURI().getRawQuery()); + byte[] body = "{\"repositories\":[\"myreg/app\"]}".getBytes(StandardCharsets.UTF_8); + // The backend cursor names the internal repository and must never reach the client. + exchange.getResponseHeaders().add("Link", "; rel=\"next\""); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + + try { + Response response = proxy(request("GET", "v2/_catalog", null, Map.of()), + registry.getAddress().getPort()); + + assertNull(response.getHeaderString("Link")); + } finally { + registry.stop(0); + } + } + + /** A stand-in catalog answering every request with {@code repositories}, recording its query. */ + private static HttpServer catalogServer(AtomicReference receivedQuery, String... repositories) { + String names = String.join(",", List.of(repositories).stream().map(r -> "\"" + r + "\"").toList()); + byte[] body = ("{\"repositories\":[" + names + "]}").getBytes(StandardCharsets.UTF_8); + return server(exchange -> { + receivedQuery.set(exchange.getRequestURI().getRawQuery()); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + } + + @Test + void streamsTheResponseBodyBack() throws Exception { + HttpServer registry = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + registry.createContext("/", exchange -> { + byte[] body = "{}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Docker-Content-Digest", "sha256:abc"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + registry.start(); + + try { + Response response = proxy(request("GET", "v2/app/blobs/sha256:abc", null, Map.of()), + registry.getAddress().getPort()); + + assertEquals(200, response.getStatus()); + assertEquals("sha256:abc", response.getHeaderString("Docker-Content-Digest")); + assertEquals("{}", new String(((InputStream) response.getEntity()).readAllBytes(), + StandardCharsets.UTF_8)); + } finally { + registry.stop(0); + } + } + + @Test + void reportsTheRegistryUnavailableInsteadOfFailingTheRequest() throws Exception { + // A port nothing is listening on: the container died, or never started. + HttpServer closed = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + int port = closed.getAddress().getPort(); + closed.start(); + closed.stop(0); + + Response response = proxy(request("GET", "v2/app/tags/list", null, Map.of()), port); + + assertEquals(502, response.getStatus()); + assertTrue(response.getEntity().toString().contains("UNAVAILABLE"), "expected the registry error shape"); + } + + @Test + void headReportsTheBackendsStatusAndContentLength() { + // Docker sizes a blob it is about to push from this response: the length is the point of + // the request, and HEAD carries no body to re-derive it from. + AtomicReference receivedMethod = new AtomicReference<>(); + HttpServer registry = server(exchange -> { + receivedMethod.set(exchange.getRequestMethod()); + exchange.getResponseHeaders().add("Content-Length", "4096"); + exchange.getResponseHeaders().add("Docker-Content-Digest", "sha256:abc"); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + + try { + Response response = proxy(request("HEAD", "v2/app/blobs/sha256:abc", null, Map.of()), + registry.getAddress().getPort()); + + assertEquals(200, response.getStatus()); + assertEquals("HEAD", receivedMethod.get()); + assertEquals("4096", response.getHeaderString("Content-Length")); + assertEquals("sha256:abc", response.getHeaderString("Docker-Content-Digest")); + } finally { + registry.stop(0); + } + } + + @Test + void forwardsTheClientsQueryStringVerbatim() { + // registry:2 mints an opaque _state for an upload session and expects it back byte for byte. + // Rebuilding the query from decoded parameters would not round-trip it. + String rawQuery = "_state=bXlyZWcvYXBw_9-x.Y%3D%3D&digest=sha256%3Aabc"; + AtomicReference receivedRawQuery = new AtomicReference<>(); + HttpServer registry = server(exchange -> { + receivedRawQuery.set(exchange.getRequestURI().getRawQuery()); + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + + try { + AzureRequest request = request("PUT", "v2/app/blobs/uploads/abc-123", null, Map.of(), + Map.of("_state", List.of("bXlyZWcvYXBw_9-x.Y=="), "digest", List.of("sha256:abc")), + rawQuery); + + Response response = proxy(request, registry.getAddress().getPort()); + + assertEquals(204, response.getStatus()); + assertEquals(rawQuery, receivedRawQuery.get()); + } finally { + registry.stop(0); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + /** A started stand-in registry answering every path with {@code handler}. */ + private static HttpServer server(com.sun.net.httpserver.HttpHandler handler) { + try { + HttpServer registry = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + registry.createContext("/", handler); + registry.start(); + return registry; + } catch (java.io.IOException e) { + throw new IllegalStateException("could not start the stand-in registry", e); + } + } + + private static Response proxy(AzureRequest request, int port) { + return new AcrRegistryProxy().proxy(request, "myreg", "127.0.0.1:" + port); + } + + private static AzureRequest request(String method, String path, InputStream body, + Map headers) { + return request(method, path, body, headers, Map.of(), null); + } + + private static AzureRequest request(String method, String path, InputStream body, + Map headers, + Map> queryParams) { + return request(method, path, body, headers, queryParams, null); + } + + private static AzureRequest request(String method, String path, InputStream body, + Map headers, + Map> queryParams, String rawQuery) { + MultivaluedMap requestHeaders = new MultivaluedHashMap<>(); + headers.forEach(requestHeaders::add); + HttpHeaders httpHeaders = mock(HttpHeaders.class); + when(httpHeaders.getRequestHeaders()).thenReturn(requestHeaders); + headers.forEach((name, value) -> when(httpHeaders.getHeaderString(name)).thenReturn(value)); + + return new AzureRequest(method, "myreg", "acr", path, httpHeaders, body, + Map.of(), queryParams, null, true, "myreg.azurecr.io", "127.0.0.1", path, rawQuery); + } +} diff --git a/src/test/java/io/floci/az/services/acr/AcrRegistryProxyTest.java b/src/test/java/io/floci/az/services/acr/AcrRegistryProxyTest.java new file mode 100644 index 00000000..f6465176 --- /dev/null +++ b/src/test/java/io/floci/az/services/acr/AcrRegistryProxyTest.java @@ -0,0 +1,143 @@ +package io.floci.az.services.acr; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The repository namespacing the proxy applies: one shared container backs every registry, so the + * registry name is prefixed on the way in and stripped from everything the client reads back. + */ +@DisplayName("AcrRegistryProxy: repository prefixing") +class AcrRegistryProxyTest { + + @Test + void prefixesRepositoryScopedPathsWithTheRegistryName() { + assertEquals("v2/myreg/app/manifests/v1", + AcrRegistryProxy.backendPath("myreg", "v2/app/manifests/v1")); + assertEquals("v2/myreg/team/app/blobs/sha256:abc", + AcrRegistryProxy.backendPath("myreg", "v2/team/app/blobs/sha256:abc")); + assertEquals("v2/myreg/app/blobs/uploads/", + AcrRegistryProxy.backendPath("myreg", "v2/app/blobs/uploads/")); + assertEquals("v2/myreg/app/tags/list", + AcrRegistryProxy.backendPath("myreg", "v2/app/tags/list")); + } + + @Test + void leavesRegistryScopedPathsAlone() { + assertEquals("v2/", AcrRegistryProxy.backendPath("myreg", "v2/")); + assertEquals("v2/_catalog", AcrRegistryProxy.backendPath("myreg", "v2/_catalog")); + } + + @Test + void stripsThePrefixFromAnUploadSessionLocation() { + assertEquals("/v2/app/blobs/uploads/abc-123?_state=xyz", + AcrRegistryProxy.clientLocation("myreg", "/v2/myreg/app/blobs/uploads/abc-123?_state=xyz")); + } + + @Test + void rewritesAnAbsoluteLocationToAPathTheClientCanFollow() { + assertEquals("/v2/app/blobs/uploads/abc-123", + AcrRegistryProxy.clientLocation("myreg", + "http://floci-az-acr-registry:5000/v2/myreg/app/blobs/uploads/abc-123")); + } + + @Test + void leavesALocationThatCarriesNoPrefixUntouched() { + assertEquals("/v2/other/manifests/v1", + AcrRegistryProxy.clientLocation("myreg", "/v2/other/manifests/v1")); + assertEquals(null, AcrRegistryProxy.clientLocation("myreg", null)); + } + + @Test + void catalogShowsOnlyThisRegistrysRepositoriesWithoutThePrefix() { + byte[] shared = ("{\"repositories\":[\"myreg/app\",\"myreg/team/api\"]}") + .getBytes(StandardCharsets.UTF_8); + + assertEquals(List.of("app", "team/api"), AcrRegistryProxy.ownRepositories("myreg/", shared)); + } + + @Test + void catalogStopsReadingAtTheFirstRepositoryOutsideThisRegistry() { + // The container walks a directory tree, so a registry's repositories are a contiguous + // subtree. The first outsider ends the block and nothing of ours follows it, which is what + // lets one page be answered by one backend request. + byte[] shared = ("{\"repositories\":[\"myreg/app\",\"myreg/web\",\"myreg-1/app\"," + + "\"myreg0/x\",\"otherreg/db\"]}").getBytes(StandardCharsets.UTF_8); + + assertEquals(List.of("app", "web"), AcrRegistryProxy.ownRepositories("myreg/", shared)); + } + + @Test + void catalogStopsAtTheBlockEdgeRatherThanSkippingPastIt() { + // A real container cannot produce this, because the block is a subtree. Stopping rather + // than skipping is what makes that an invariant: collecting across a gap would count + // repositories the page size did not account for and mint a cursor that skips the gap. + byte[] shared = ("{\"repositories\":[\"myreg/app\",\"otherreg/db\",\"myreg/web\"]}") + .getBytes(StandardCharsets.UTF_8); + + assertEquals(List.of("app"), AcrRegistryProxy.ownRepositories("myreg/", shared)); + } + + @Test + void catalogReadsNothingWhenTheBlockHasNotStarted() { + byte[] shared = "{\"repositories\":[\"otherreg/db\"]}".getBytes(StandardCharsets.UTF_8); + + assertEquals(List.of(), AcrRegistryProxy.ownRepositories("myreg/", shared)); + } + + @Test + void aPageSizeIsReadOnlyWhenTheClientAskedForOne() { + assertEquals(AcrRegistryProxy.UNPAGINATED, AcrRegistryProxy.pageSize(null)); + assertEquals(AcrRegistryProxy.UNPAGINATED, AcrRegistryProxy.pageSize("")); + assertEquals(AcrRegistryProxy.UNPAGINATED, AcrRegistryProxy.pageSize("not-a-number")); + assertEquals(AcrRegistryProxy.UNPAGINATED, AcrRegistryProxy.pageSize("-4")); + assertEquals(10, AcrRegistryProxy.pageSize(" 10 ")); + } + + @Test + void askingForNoRepositoriesIsNotAskingForAllOfThem() { + // n=0 and no n at all used to collapse to the same answer, which handed a client that + // asked for an empty page the entire catalog. + assertEquals(0, AcrRegistryProxy.pageSize("0")); + assertNotEquals(AcrRegistryProxy.pageSize("0"), AcrRegistryProxy.pageSize(null)); + } + + @Test + void theNextLinkCarriesAnUnprefixedCursorInTheRegistrysOwnShape() { + assertEquals("; rel=\"next\"", + AcrRegistryProxy.nextLink("team/api", 2)); + } + + @Test + void tagsListNamesTheRepositoryTheClientAskedAbout() { + byte[] backend = "{\"name\":\"myreg/team/api\",\"tags\":[\"v1\"]}".getBytes(StandardCharsets.UTF_8); + + assertEquals("{\"name\":\"team/api\",\"tags\":[\"v1\"]}", + new String(AcrRegistryProxy.unprefixRepositoryName("myreg", backend), + StandardCharsets.UTF_8)); + } + + @Test + void onlyBodiesThatNameRepositoriesAreRewritten() { + // The catalog is not among them: it is built from the container's answer rather than + // rewritten in place, because it is also paginated. + assertFalse(AcrRegistryProxy.rewritesBody("v2/_catalog")); + assertTrue(AcrRegistryProxy.rewritesBody("v2/app/tags/list")); + assertFalse(AcrRegistryProxy.rewritesBody("v2/app/manifests/v1")); + assertFalse(AcrRegistryProxy.rewritesBody("v2/app/blobs/sha256:abc")); + } + + @Test + void catalogReadsNothingWhenTheBodyIsNotTheExpectedShape() { + assertEquals(List.of(), + AcrRegistryProxy.ownRepositories("myreg/", "".getBytes(StandardCharsets.UTF_8))); + } +} diff --git a/src/test/java/io/floci/az/services/acr/AcrTokenEndpointsTest.java b/src/test/java/io/floci/az/services/acr/AcrTokenEndpointsTest.java new file mode 100644 index 00000000..a08cd98e --- /dev/null +++ b/src/test/java/io/floci/az/services/acr/AcrTokenEndpointsTest.java @@ -0,0 +1,348 @@ +package io.floci.az.services.acr; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.greaterThan; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The ACR Entra token exchange as the clients speak it: the bearer challenge, both grant types on + * each {@code /oauth2/} endpoint, Docker's {@code GET} form, and the error shapes. The protocol is + * taken from Azure CLI 2.89.1 ({@code azure/cli/command_modules/acr/_docker_utils.py}). + * + *

Runs in mocked mode: the auth surface needs no Docker. Repository operations do, so here they + * answer with the registry's {@code UNSUPPORTED} error.

+ */ +@QuarkusTest +@TestProfile(AcrTokenEndpointsTest.MockedProfile.class) +@DisplayName("ACR: Entra token exchange protocol") +public class AcrTokenEndpointsTest { + + public static class MockedProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of("floci-az.services.acr.mocked", "true"); + } + } + + private static final String REGISTRY = "acrproto"; + private static final String LOGIN_SERVER = REGISTRY + ".azurecr.io"; + private static final String FORM = "application/x-www-form-urlencoded"; + /** Stands in for a token from /oauth2/exchange: not verified, but it must be JWT-shaped. */ + private static final String REFRESH_TOKEN = "header.payload.signature"; + private static final String ARM_REGISTRY = + "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/test-rg" + + "/providers/Microsoft.ContainerRegistry/registries/" + REGISTRY; + + /** The data plane serves a registry that exists, so create it the way a client would. */ + @BeforeEach + void createTheRegistry() { + given().when().post("/_admin/reset").then().statusCode(204); + given().contentType("application/json") + .body("{\"location\":\"eastus\",\"sku\":{\"name\":\"Basic\"}," + + "\"properties\":{\"adminUserEnabled\":true}}") + .when().put(ARM_REGISTRY + "?api-version=2025-11-01") + .then().statusCode(201); + } + + // ── Challenge ──────────────────────────────────────────────────────────────── + + @Test + void getV2AnswersTheBearerChallengeThatStartsTheFlow() { + // The test client speaks plain HTTP, so the realm must too: pointing a client at https:// + // when TLS is off sends it to a port nothing is listening on. + given().header("Host", LOGIN_SERVER) + .when().get("/v2/") + .then().statusCode(401) + .header("WWW-Authenticate", is("Bearer realm=\"http://" + LOGIN_SERVER + + "/oauth2/token\",service=\"" + LOGIN_SERVER + "\"")) + .body("errors[0].code", is("UNAUTHORIZED")); + } + + @Test + void theChallengeRealmFollowsAForwardedProto() { + // A TLS-terminating proxy in front of the emulator: the caller reached it over https, even + // though this hop is plaintext. + given().header("Host", LOGIN_SERVER).header("X-Forwarded-Proto", "https") + .when().get("/v2/") + .then().statusCode(401) + .header("WWW-Authenticate", is("Bearer realm=\"https://" + LOGIN_SERVER + + "/oauth2/token\",service=\"" + LOGIN_SERVER + "\"")); + } + + @Test + void theTokenEndpointsExistOnlyOnTheRegistryHost() { + Response offHost = given().contentType(FORM) + .formParam("grant_type", "access_token") + .formParam("service", LOGIN_SERVER) + .formParam("tenant", "00000000-0000-0000-0000-000000000002") + .formParam("access_token", "entra-token") + .when().post("/oauth2/exchange"); + + assertNotEquals(200, offHost.statusCode(), + "the exchange endpoint must not answer on the emulator's own host"); + } + + // ── /oauth2/exchange ───────────────────────────────────────────────────────── + + @Test + void exchangeTradesAnEntraAccessTokenForARefreshToken() { + String refreshToken = exchange("access_token").then().statusCode(200) + .body("refresh_token", notNullValue()) + .extract().path("refresh_token"); + + assertEquals(3, refreshToken.split("\\.").length, "the refresh token must be a JWT"); + assertEquals(LOGIN_SERVER, claims(refreshToken).getString("aud")); + } + + @Test + void exchangeAcceptsEveryGrantTypeTheClientsSend() { + exchange("refresh_token").then().statusCode(200).body("refresh_token", notNullValue()); + exchange("access_token_refresh_token").then().statusCode(200).body("refresh_token", notNullValue()); + } + + @Test + void exchangeRejectsAnUnknownGrantTypeInTheRegistryErrorShape() { + exchange("client_credentials").then().statusCode(400) + .body("errors[0].code", is("UNSUPPORTED")) + .body("errors[0].message", notNullValue()); + } + + @Test + void exchangeRequiresTheServiceParameter() { + given().header("Host", LOGIN_SERVER) + .contentType(FORM) + .formParam("grant_type", "access_token") + .formParam("access_token", "entra-token") + .when().post("/oauth2/exchange") + .then().statusCode(400) + .body("errors[0].code", is("UNSUPPORTED")); + } + + @Test + void aMalformedFormBodyIsAMalformedRequestNotAServerError() { + // "%zz" is not a percent escape. The parameter is treated as absent, so both endpoints + // answer their own 400 in the registry error shape rather than failing to parse. + given().header("Host", LOGIN_SERVER) + .contentType(FORM) + .body("grant_type=access_token&service=%zz") + .when().post("/oauth2/exchange") + .then().statusCode(400) + .body("errors[0].code", is("UNSUPPORTED")); + + given().header("Host", LOGIN_SERVER) + .contentType(FORM) + .body("grant_type=refresh_token&refresh_token=" + REFRESH_TOKEN + "&service=%zz") + .when().post("/oauth2/token") + .then().statusCode(400) + .body("errors[0].code", is("UNSUPPORTED")); + } + + @Test + void exchangeAcceptsPostOnly() { + given().header("Host", LOGIN_SERVER) + .when().get("/oauth2/exchange") + .then().statusCode(405) + .body("errors[0].code", is("UNSUPPORTED")); + } + + // ── /oauth2/token ──────────────────────────────────────────────────────────── + + @Test + void tokenTradesARefreshTokenForAScopedAccessToken() { + String accessToken = given().header("Host", LOGIN_SERVER) + .contentType(FORM) + .formParam("grant_type", "refresh_token") + .formParam("service", LOGIN_SERVER) + .formParam("scope", "repository:app:pull,push") + .formParam("refresh_token", REFRESH_TOKEN) + .when().post("/oauth2/token") + .then().statusCode(200) + .body("expires_in", greaterThan(0)) + .body("issued_at", notNullValue()) + .extract().path("access_token"); + + JsonPath claims = claims(accessToken); + assertEquals(LOGIN_SERVER, claims.getString("aud")); + assertEquals("repository", claims.getString("access[0].type")); + assertEquals("app", claims.getString("access[0].name")); + assertEquals(List.of("pull", "push"), claims.getList("access[0].actions")); + } + + @Test + void tokenAcceptsTheRegistryScopeWithoutARepository() { + String accessToken = tokenForScope("registry:catalog:*"); + + JsonPath claims = claims(accessToken); + assertEquals("registry", claims.getString("access[0].type")); + assertEquals("catalog", claims.getString("access[0].name")); + } + + @Test + void tokenAcceptsAdminCredentialsThroughThePasswordGrant() { + given().header("Host", LOGIN_SERVER) + .contentType(FORM) + .formParam("grant_type", "password") + .formParam("service", LOGIN_SERVER) + .formParam("scope", "repository:app:pull") + .formParam("username", REGISTRY) + .formParam("password", "an-admin-password") + .when().post("/oauth2/token") + .then().statusCode(200) + .body("access_token", notNullValue()); + } + + @Test + void tokenAnswersDockersOwnGetForm() { + String accessToken = given().header("Host", LOGIN_SERVER) + .queryParam("service", LOGIN_SERVER) + .queryParam("scope", "repository:app:pull,push") + .when().get("/oauth2/token") + .then().statusCode(200) + .body("expires_in", greaterThan(0)) + .extract().path("access_token"); + + assertEquals("app", claims(accessToken).getString("access[0].name")); + } + + @Test + void tokenRejectsMalformedRequestsInTheRegistryErrorShape() { + // Unknown grant type. + token("authorization_code", Map.of("service", LOGIN_SERVER)) + .then().statusCode(400).body("errors[0].code", is("UNSUPPORTED")); + // refresh_token grant without a token. + token("refresh_token", Map.of("service", LOGIN_SERVER)) + .then().statusCode(400).body("errors[0].code", is("UNSUPPORTED")); + // password grant without a password. + token("password", Map.of("service", LOGIN_SERVER, "username", REGISTRY)) + .then().statusCode(400).body("errors[0].code", is("UNSUPPORTED")); + // No service. + token("refresh_token", Map.of("refresh_token", REFRESH_TOKEN)) + .then().statusCode(400).body("errors[0].code", is("UNSUPPORTED")); + } + + @Test + void tokenRejectsARefreshTokenThatIsNotEvenAJwt() { + // Nothing verifies the token, but a malformed one still gets the 401 that makes a client + // re-authenticate rather than an access token it cannot use. + token("refresh_token", Map.of("service", LOGIN_SERVER, "refresh_token", "not-a-jwt")) + .then().statusCode(401).body("errors[0].code", is("UNAUTHORIZED")); + } + + // ── Repository operations ──────────────────────────────────────────────────── + + @Test + void repositoryOperationsReportUnsupportedInMockedMode() { + given().header("Host", LOGIN_SERVER).header("Authorization", "Bearer a-token") + .when().get("/v2/app/tags/list") + .then().statusCode(405) + .body("errors[0].code", is("UNSUPPORTED")); + + given().header("Host", LOGIN_SERVER).header("Authorization", "Bearer a-token") + .when().get("/v2/") + .then().statusCode(405) + .body("errors[0].code", is("UNSUPPORTED")); + } + + @Test + void unknownPathsOnTheRegistryHostUseTheRegistryErrorShape() { + given().header("Host", LOGIN_SERVER) + .when().get("/v1/repositories") + .then().statusCode(404) + .body("errors[0].code", is("NAME_UNKNOWN")); + } + + @Test + void aRegistryThatWasNeverCreatedIsNotServed() { + // The hosts entry points every *.azurecr.io name at the emulator, so a name nobody created + // must be refused rather than handed tokens and a repository prefix of its own. + String unknown = "acrneverexisted.azurecr.io"; + + given().header("Host", unknown) + .when().get("/v2/") + .then().statusCode(404) + .body("errors[0].code", is("NAME_UNKNOWN")); + + given().header("Host", unknown).contentType(FORM) + .formParam("grant_type", "access_token") + .formParam("service", unknown) + .when().post("/oauth2/exchange") + .then().statusCode(404) + .body("errors[0].code", is("NAME_UNKNOWN")); + + given().header("Host", unknown).contentType(FORM) + .formParam("grant_type", "refresh_token") + .formParam("service", unknown) + .formParam("refresh_token", REFRESH_TOKEN) + .when().post("/oauth2/token") + .then().statusCode(404) + .body("errors[0].code", is("NAME_UNKNOWN")); + } + + @Test + void deletingTheRegistryTakesItsDataPlaneWithIt() { + given().when().delete(ARM_REGISTRY + "?api-version=2025-11-01") + .then().statusCode(anyOf(is(200), is(202), is(204))); + + given().header("Host", LOGIN_SERVER) + .when().get("/v2/") + .then().statusCode(404) + .body("errors[0].code", is("NAME_UNKNOWN")); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + private static Response exchange(String grantType) { + return given().header("Host", LOGIN_SERVER) + .contentType(FORM) + .formParam("grant_type", grantType) + .formParam("service", LOGIN_SERVER) + .formParam("tenant", "00000000-0000-0000-0000-000000000002") + .formParam("access_token", "an-entra-token") + .when().post("/oauth2/exchange"); + } + + private static Response token(String grantType, Map form) { + var request = given().header("Host", LOGIN_SERVER).contentType(FORM) + .formParam("grant_type", grantType); + form.forEach(request::formParam); + return request.when().post("/oauth2/token"); + } + + private static String tokenForScope(String scope) { + return given().header("Host", LOGIN_SERVER) + .contentType(FORM) + .formParam("grant_type", "refresh_token") + .formParam("service", LOGIN_SERVER) + .formParam("scope", scope) + .formParam("refresh_token", REFRESH_TOKEN) + .when().post("/oauth2/token") + .then().statusCode(200) + .extract().path("access_token"); + } + + /** The token's claims: clients decode these without verifying the signature. */ + private static JsonPath claims(String jwt) { + String[] parts = jwt.split("\\."); + assertTrue(parts.length == 3, "expected a compact JWT, got: " + jwt); + return new JsonPath(new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8)); + } +} diff --git a/src/test/java/io/floci/az/services/acr/AcrTokenServiceTest.java b/src/test/java/io/floci/az/services/acr/AcrTokenServiceTest.java new file mode 100644 index 00000000..94b0d8a2 --- /dev/null +++ b/src/test/java/io/floci/az/services/acr/AcrTokenServiceTest.java @@ -0,0 +1,101 @@ +package io.floci.az.services.acr; + +import io.floci.az.services.acr.AcrTokenService.Access; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Scope parsing and the bearer challenge, the two pieces of the ACR token exchange that are pure + * functions of what the client sent. Scope forms are taken from Azure CLI 2.89.1 + * ({@code azure/cli/command_modules/acr/_docker_utils.py}). + */ +@DisplayName("AcrTokenService: scope parsing and the bearer challenge") +class AcrTokenServiceTest { + + @Test + void parsesTheRepositoryScopeTheAzureCliSends() { + Access access = AcrTokenService.parseScope("repository:app:pull,push").orElseThrow(); + + assertEquals("repository", access.type()); + assertEquals("app", access.name()); + assertEquals(List.of("pull", "push"), access.actions()); + } + + @Test + void keepsSlashesInARepositoryName() { + Access access = AcrTokenService.parseScope("repository:team/app:metadata_read").orElseThrow(); + + assertEquals("team/app", access.name()); + assertEquals(List.of("metadata_read"), access.actions()); + } + + @Test + void parsesTheArtifactRepositoryScope() { + Access access = AcrTokenService.parseScope("artifact-repository:charts:pull").orElseThrow(); + + assertEquals("artifact-repository", access.type()); + assertEquals("charts", access.name()); + } + + @Test + void parsesTheRegistryScopeWhoseActionIsAlwaysAStar() { + Access access = AcrTokenService.parseScope("registry:catalog:*").orElseThrow(); + + assertEquals("registry", access.type()); + assertEquals("catalog", access.name()); + assertEquals(List.of("*"), access.actions()); + } + + @Test + void rejectsScopesThatAreNotTypeNameActions() { + assertEquals(Optional.empty(), AcrTokenService.parseScope(null)); + assertEquals(Optional.empty(), AcrTokenService.parseScope("")); + assertEquals(Optional.empty(), AcrTokenService.parseScope("repository")); + assertEquals(Optional.empty(), AcrTokenService.parseScope("repository:app")); + assertEquals(Optional.empty(), AcrTokenService.parseScope("repository:app:")); + assertEquals(Optional.empty(), AcrTokenService.parseScope(":app:pull")); + } + + @Test + void parsesEveryScopeWhenTheParameterRepeatsOrCarriesSeveral() { + List access = AcrTokenService.parseScopes( + List.of("repository:app:pull repository:other:push", "registry:catalog:*")); + + assertEquals(3, access.size()); + assertEquals("app", access.get(0).name()); + assertEquals("other", access.get(1).name()); + assertEquals("catalog", access.get(2).name()); + } + + @Test + void skipsUnparseableScopesRatherThanFailingTheRequest() { + assertEquals(List.of(), AcrTokenService.parseScopes(List.of(""))); + assertEquals(List.of(), AcrTokenService.parseScopes(null)); + assertEquals(1, AcrTokenService.parseScopes(List.of("nonsense repository:app:pull")).size()); + } + + @Test + void challengeNamesTheRealmAndServiceTheClientParses() { + String challenge = AcrTokenService.challenge("https", "myregistry.azurecr.io"); + + assertEquals("Bearer realm=\"https://myregistry.azurecr.io/oauth2/token\"," + + "service=\"myregistry.azurecr.io\"", challenge); + // The CLI splits on ' ' then ',' and requires both parameters to be quoted. + assertTrue(challenge.startsWith("Bearer ")); + } + + @Test + void challengeRealmCarriesTheSchemeTheCallerUsed() { + // TLS is off by default, and a realm hardcoded to https:// would send the client to a port + // nothing is listening on. The service is the login server either way. + assertEquals("Bearer realm=\"http://myregistry.azurecr.io/oauth2/token\"," + + "service=\"myregistry.azurecr.io\"", + AcrTokenService.challenge("http", "myregistry.azurecr.io")); + } +} diff --git a/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java b/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java index eaeadad3..9a3e86de 100644 --- a/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java +++ b/src/test/java/io/floci/az/services/containerapps/ContainerAppIngressProxyTest.java @@ -38,7 +38,7 @@ void preservesEncodedIngressPath() throws Exception { when(headers.getRequestHeaders()).thenReturn(new MultivaluedHashMap<>()); AzureRequest request = new AzureRequest("GET", "app", "containerapps", "items/a/b c", headers, null, Map.of(), Map.of(), null, false, "app.azurecontainerapps.io", "127.0.0.1", - "items/a%2Fb%20c%3Fvalue%23part").withAuthContext(null); + "items/a%2Fb%20c%3Fvalue%23part", null).withAuthContext(null); assertEquals("app.azurecontainerapps.io", request.host()); assertEquals("127.0.0.1", request.remoteAddress()); var endpoint = new ContainerLifecycleManager.EndpointInfo(