Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
11 changes: 8 additions & 3 deletions compatibility-tests/compat-azcli/run-bats-in-container.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
141 changes: 141 additions & 0 deletions compatibility-tests/compat-azcli/test/acr-login.bats
Original file line number Diff line number Diff line change
@@ -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}"
}
74 changes: 53 additions & 21 deletions compatibility-tests/sdk-test-python/tests/test_acr.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}"
Expand Down Expand Up @@ -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"]
Expand All @@ -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"{}"
Expand Down
Loading