Skip to content
Closed
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
90 changes: 90 additions & 0 deletions .github/workflows/android-testcontainers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
name: Android ECH Testcontainers

on:
workflow_dispatch:
pull_request:
types: [opened, labeled, unlabeled, synchronize]
push:
branches:
- main

permissions:
contents: read

env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
android-testcontainers:
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'containers')

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Configure JDK
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 21

# Some act runner images lose their bundled Node path after setup-java updates PATH.
- name: Restore act Node path
if: env.ACT == 'true'
run: |
node_path="$(find /opt/acttoolcache/node -path '*/bin/node' -type f | sort -V | tail -1)"
test -n "$node_path"
dirname "$node_path" >> "$GITHUB_PATH"

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v5
with:
cache-read-only: ${{ env.ACT != 'true' && github.ref != 'refs/heads/main' }}

# act runs this job in a container, where nested KVM is generally unavailable.
# This still exercises the workflow, Gradle launcher, Docker, and Testcontainers locally.
- name: Test ECH services with act
if: env.ACT == 'true'
env:
# A cold Gradle build plus the Go fixture image may be slow on Apple Silicon.
ANDROID_ECH_TEST_SERVICE_TIMEOUT_SECONDS: 1200
run: android-test/run-ech-test.sh --smoke-only

- name: Enable KVM group permissions
if: env.ACT != 'true'
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Run Android ECH test against Testcontainers
if: env.ACT != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: '37.0'
target: google_apis_playstore_ps16k
arch: x86_64
disable-animations: true
emulator-options: >-
-no-window
-gpu swiftshader_indirect
-noaudio
-no-boot-anim
-camera-back none
-memory 2048
script: android-test/run-ech-test.sh

- name: Upload Android test results
if: always() && env.ACT != 'true'
uses: actions/upload-artifact@v7
with:
name: android-ech-testcontainers-results
path: |
android-test/build/outputs/androidTest-results/connected/
android-test/build/reports/androidTests/connected/
80 changes: 80 additions & 0 deletions android-test/run-ech-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash

set -euo pipefail

mode="${1:-instrumentation}"
if [[ "$mode" != "instrumentation" && "$mode" != "--smoke-only" ]]; then
echo "usage: $0 [--smoke-only]" >&2
exit 2
fi

temporary_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
endpoint_file="$temporary_dir/okhttp-android-ech-test.endpoint"
service_log="$temporary_dir/okhttp-android-ech-test.log"
startup_timeout_seconds="${ANDROID_ECH_TEST_SERVICE_TIMEOUT_SECONDS:-1200}"
rm -f "$endpoint_file" "$service_log"

ANDROID_ECH_TEST_ENDPOINT_FILE="$endpoint_file" \
./gradlew :container-tests:runAndroidEchTestService >"$service_log" 2>&1 &
service_pid=$!

cleanup() {
adb reverse --remove tcp:8053 >/dev/null 2>&1 || true
adb reverse --remove tcp:443 >/dev/null 2>&1 || true
adb reverse --remove tcp:8443 >/dev/null 2>&1 || true
rm -f "$endpoint_file"
for _ in {1..200}; do
if ! kill -0 "$service_pid" 2>/dev/null; then
break
fi
sleep 0.1
done
kill "$service_pid" >/dev/null 2>&1 || true
wait "$service_pid" >/dev/null 2>&1 || true
}
trap cleanup EXIT

startup_deadline=$((SECONDS + startup_timeout_seconds))
while ((SECONDS < startup_deadline)); do
if [[ -s "$endpoint_file" ]]; then
break
fi
if ! kill -0 "$service_pid" 2>/dev/null; then
cat "$service_log" >&2
exit 1
fi
sleep 1
done

if [[ ! -s "$endpoint_file" ]]; then
cat "$service_log" >&2
echo "Timed out waiting for the ECH test services" >&2
exit 1
fi

property() {
sed -n "s/^$1=//p" "$endpoint_file"
}

doh_host_port="$(property DOH_HOST_PORT)"
target_host_port="$(property TARGET_HOST_PORT)"
ca_certificate="$(property CA_CERT)"
if [[ ! "$doh_host_port" =~ ^[0-9]+$ || ! "$target_host_port" =~ ^[0-9]+$ || -z "$ca_certificate" ]]; then
cat "$service_log" >&2
echo "Invalid ECH test service metadata" >&2
exit 1
fi

if [[ "$mode" == "--smoke-only" ]]; then
exit 0
fi

adb reverse tcp:8053 "tcp:$doh_host_port"
adb reverse tcp:443 "tcp:$target_host_port"
adb reverse tcp:8443 "tcp:$target_host_port"
./gradlew :android-test:connectedDebugAndroidTest \
-PandroidBuild=true \
-Pandroid.testInstrumentationRunnerArguments.class=okhttp.android.test.EncryptedClientHelloTest \
-Pandroid.testInstrumentationRunnerArguments.ech=true \
-Pandroid.testInstrumentationRunnerArguments.dohPort=8053 \
-Pandroid.testInstrumentationRunnerArguments.caCertificate="$ca_certificate"
84 changes: 84 additions & 0 deletions android-test/run-testcontainers-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env bash

set -euo pipefail

mode="${1:-instrumentation}"
if [[ "$mode" != "instrumentation" && "$mode" != "--smoke-only" ]]; then
echo "usage: $0 [--smoke-only]" >&2
exit 2
fi

temporary_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
endpoint_file="$temporary_dir/okhttp-android-test-service.endpoint"
service_log="$temporary_dir/okhttp-android-test-service.log"
startup_timeout_seconds="${ANDROID_TEST_SERVICE_TIMEOUT_SECONDS:-600}"
rm -f "$endpoint_file" "$service_log"

ANDROID_TEST_SERVICE_ENDPOINT_FILE="$endpoint_file" \
./gradlew :container-tests:runAndroidTestService >"$service_log" 2>&1 &
service_pid=$!

cleanup() {
adb reverse --remove tcp:8080 >/dev/null 2>&1 || true
# Removing the endpoint file asks the launcher to stop its container and exit cleanly.
rm -f "$endpoint_file"
for _ in {1..100}; do
if ! kill -0 "$service_pid" 2>/dev/null; then
break
fi
sleep 0.1
done
kill "$service_pid" >/dev/null 2>&1 || true
wait "$service_pid" >/dev/null 2>&1 || true
}
trap cleanup EXIT

startup_deadline=$((SECONDS + startup_timeout_seconds))
while ((SECONDS < startup_deadline)); do
if [[ -s "$endpoint_file" ]]; then
break
fi
if ! kill -0 "$service_pid" 2>/dev/null; then
cat "$service_log" >&2
exit 1
fi
sleep 1
done

if [[ ! -s "$endpoint_file" ]]; then
cat "$service_log" >&2
echo "Timed out waiting for the Testcontainers service" >&2
exit 1
fi

endpoint="$(<"$endpoint_file")"
service_reachable=false
for _ in {1..120}; do
if curl --fail --silent "$endpoint/android-test"; then
service_reachable=true
break
fi
sleep 0.25
done
if [[ "$service_reachable" != "true" ]]; then
cat "$service_log" >&2
echo "Testcontainers service is not reachable at $endpoint" >&2
exit 1
fi
echo

if [[ "$mode" == "--smoke-only" ]]; then
exit 0
fi

service_port="${endpoint##*:}"
if [[ ! "$service_port" =~ ^[0-9]+$ ]]; then
echo "Could not read the mapped port from $endpoint" >&2
exit 1
fi

adb reverse tcp:8080 "tcp:$service_port"
./gradlew :android-test:connectedDebugAndroidTest \
-PandroidBuild=true \
-Pandroid.testInstrumentationRunnerArguments.class=okhttp.android.test.TestcontainersHostTest \
-Pandroid.testInstrumentationRunnerArguments.testcontainers=true
91 changes: 91 additions & 0 deletions android-test/src/androidTest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,94 @@ BUILD SUCCESSFUL in 1m 30s
```

n.b. use ANDROID_SERIAL=emulator-5554 or similar if you need to select between devices.

Testcontainers service on the host
----------------------------------

`TestcontainersHostTest` runs on Android while its MockServer service runs in a
Testcontainers-managed Docker container on the host. The CI job uses the API 37.0
`google_apis_playstore_ps16k` system image. With an API 37 emulator running and
Docker available, run:

```
$ android-test/run-testcontainers-test.sh
```

The script starts the host service, discovers its random mapped port, and uses
`adb reverse` to make it available to the test at `127.0.0.1:8080`. It then runs
only `TestcontainersHostTest` and stops the container.

With a Docker engine running, the GitHub workflow can be smoke-tested with `act`
without trying to run a nested emulator:

```
$ act workflow_dispatch -W .github/workflows/android-testcontainers.yml
```

When using Colima, the Docker socket path visible to the Linux daemon differs
from its macOS forwarding path. Start Colima and run `act` with:

```
$ colima start
$ act workflow_dispatch \
-W .github/workflows/android-testcontainers.yml \
--container-architecture linux/arm64 \
--container-daemon-socket unix:///var/run/docker.sock
```

The explicit ARM64 runner avoids emulating an amd64 `act` image on Apple Silicon.
Omit that option on Intel hosts.

For a direct local run with Colima, export the daemon-side socket used by Ryuk:

```
$ export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock
$ android-test/run-testcontainers-test.sh
```

Encrypted Client Hello fixture
------------------------------

`EncryptedClientHelloTest` is the API 37 ECH interoperability test used by the
workflow. Testcontainers starts two instances of a hermetic Go fixture:

* a TLS DoH server that returns an HTTPS (type 65) record containing an
`ech` SvcParam; and
* an HTTPS target configured with the corresponding ECH private key.

The emulator reaches both random host ports through `adb reverse`. The tests
query the DoH server with OkHttp and exercise three API 37 `SSLSocket` paths:

* a current ECH config is accepted and the server observes the private SNI;
* a stale config is rejected, then the server-provided config is used on a
successful ECH retry; and
* a stale config with no server-provided replacement is retried successfully
without ECH.

The test configures `DnsOverHttps` as the client's `Dns` implementation and
makes ordinary OkHttp requests. OkHttp requests the HTTPS DNS record, consumes
its service metadata, configures the API 37 socket, and handles ECH rejection
state. There is no manual DNS message parsing or direct `SSLSocket` use in the
instrumentation test. The retry tests assert the intended OkHttp behavior and
are expected to fail until ECH retry support lands.

Run it with an API 37 emulator and Docker already running:

```
$ export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock # Colima only
$ android-test/run-ech-test.sh
```

To validate the Gradle/Testcontainers side without an emulator, including when
running the workflow with `act`:

```
$ android-test/run-ech-test.sh --smoke-only
$ act workflow_dispatch \
-W .github/workflows/android-testcontainers.yml \
--container-architecture linux/arm64 \
--container-daemon-socket unix:///var/run/docker.sock
```

The test exercises OkHttp's Android ECH connection planning end to end against
the hermetic DoH and HTTPS services.
Loading
Loading