This is an automated email from the ASF dual-hosted git repository.

krishvishal pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new 2d7fddba5 feat(gateways): Kafka gateway Phase 1 quick-start and Docker 
Compose E2E (#4372)
2d7fddba5 is described below

commit 2d7fddba5552640518aa7f1372f69af9c77e436a
Author: ryerraguntla <[email protected]>
AuthorDate: Sat Oct 3 01:45:07 2026 -0400

    feat(gateways): Kafka gateway Phase 1 quick-start and Docker Compose E2E 
(#4372)
---
 .config/nextest.toml                           |   3 +
 .pr4372-comments.json                          |   0
 gateways/kafka/Dockerfile                      |  64 +++++
 gateways/kafka/Dockerfile.dockerignore         |  48 ++++
 gateways/kafka/README.md                       |  98 +++++++
 gateways/kafka/docker-compose.yml              | 141 ++++++++++
 gateways/kafka/docs/MANUAL_TESTING.md          |  35 ++-
 gateways/kafka/docs/TEST_SUITE.md              |  17 +-
 gateways/kafka/tests/common/gateway_process.rs | 102 ++++++++
 gateways/kafka/tests/common/iggy_server.rs     |  86 ++++---
 gateways/kafka/tests/phase1_e2e_tests.rs       | 341 +++++++++++++++++++++++++
 licenserc.toml                                 |   1 +
 12 files changed, 898 insertions(+), 38 deletions(-)

diff --git a/.config/nextest.toml b/.config/nextest.toml
index d9d69fbca..1cedeed01 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -63,6 +63,9 @@ filter = '''
     + binary_id(iggy-gateway-kafka::fetch_real_bridge_tests)
     + binary_id(iggy-gateway-kafka::list_offsets_real_bridge_tests)
     + binary_id(iggy-gateway-kafka::produce_real_bridge_tests)
+    + binary_id(iggy-gateway-kafka::create_topics_real_bridge_tests)
+    + binary_id(iggy-gateway-kafka::metadata_real_bridge_tests)
+    + binary_id(iggy-gateway-kafka::phase1_e2e_tests)
 '''
 test-group = "kafka_bridge"
 
diff --git a/.pr4372-comments.json b/.pr4372-comments.json
new file mode 100644
index 000000000..e69de29bb
diff --git a/gateways/kafka/Dockerfile b/gateways/kafka/Dockerfile
new file mode 100644
index 000000000..d5d95eb68
--- /dev/null
+++ b/gateways/kafka/Dockerfile
@@ -0,0 +1,64 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Build context is the repository root (a Cargo workspace member cannot build 
standalone) -
+# `docker-compose.yml` in this directory sets `context: ../..` accordingly.
+
+ARG RUST_VERSION=1.98
+FROM rust:${RUST_VERSION}-slim-trixie AS builder
+
+WORKDIR /build
+# build-essential: the zstd/lz4/snap codecs kafka-protocol pulls in compile a 
small amount of
+# vendored C. No libssl-dev - core/sdk's TLS is rustls, not openssl.
+RUN apt-get update && apt-get install -y \
+    build-essential \
+    pkg-config \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY rust-toolchain.toml rust-toolchain.toml
+# Resolves/installs the toolchain now, in a layer keyed only on 
rust-toolchain.toml - matching
+# RUST_VERSION above means this is a no-op resolution, not a download, but 
isolates the cost
+# from the full COPY . . below, which invalidates on any source change in the 
workspace.
+# Same ordering core/server/Dockerfile uses for the same reason.
+RUN cargo --version
+
+COPY . .
+# Cache mounts persist across builds but not into the image layer, so the 
binary is copied out
+# to a plain path before the mount unmounts. --locked matches CI (scripts 
never resolve a
+# dependency version CI didn't already lock).
+RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
+    --mount=type=cache,target=/build/target,sharing=locked \
+    cargo build --release --locked --package iggy-gateway-kafka --bin 
iggy-gateway-kafka && \
+    cp target/release/iggy-gateway-kafka /iggy-gateway-kafka
+
+FROM debian:trixie-slim
+# netcat-openbsd: nothing in the gateway's own dependency graph needs it - 
it's here only so
+# docker-compose.yml's healthcheck has a TCP-probe primitive to call.
+RUN apt-get update && apt-get install -y \
+    ca-certificates \
+    netcat-openbsd \
+    && rm -rf /var/lib/apt/lists/*
+COPY --from=builder /iggy-gateway-kafka .
+
+ENV IGGY_KAFKA_BIND_ADDR=0.0.0.0:9093
+# No baked IGGY_KAFKA_ADVERTISED_HOST default: wildcard bind means the gateway 
cannot derive
+# its own advertised host (server.rs's BrokerAdvertise::from_server_config 
refuses to start
+# without one), and that refusal is deliberate - it forces whoever runs this 
image to say what
+# host a client can actually reach it on, rather than silently advertising 
"localhost" to a
+# client on a different machine. docker-compose.yml sets it explicitly for 
this quick start.
+
+CMD ["/iggy-gateway-kafka"]
diff --git a/gateways/kafka/Dockerfile.dockerignore 
b/gateways/kafka/Dockerfile.dockerignore
new file mode 100644
index 000000000..6386a3b5b
--- /dev/null
+++ b/gateways/kafka/Dockerfile.dockerignore
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# This image only. Root .dockerignore must keep .git so the server image
+# can stamp vergen git metadata. This file replaces the root ignore file
+# for gateways/kafka/Dockerfile.
+.git
+.config
+.dockerignore
+.github
+.gitignore
+.idea
+.vscode
+.zed
+/assets
+/core/certs
+/helm
+/licenses
+/local_data
+/performance_results
+# Selectively exclude scripts/ subdirs not needed in container builds.
+# scripts/ci/third-party-licenses.sh + render-node-licenses.mjs are
+# required by the convenience-binary Dockerfiles to render LICENSE-binary
+# at build time.
+/scripts/dashboard
+/scripts/performance
+/scripts/*.sh
+/scripts/ci/*
+!/scripts/ci/third-party-licenses.sh
+!/scripts/ci/render-node-licenses.mjs
+**/target
+/target
+/web/node_modules
+/web/dist
diff --git a/gateways/kafka/README.md b/gateways/kafka/README.md
index f6a74231e..e8841b8d6 100644
--- a/gateways/kafka/README.md
+++ b/gateways/kafka/README.md
@@ -45,6 +45,104 @@ Default bind: `127.0.0.1:9093`. Environment variables:
 | `IGGY_KAFKA_PRE_AUTH_TIMEOUT_SECS` | `15` | Seconds an unauthenticated 
connection may sit between frames. Waiting for an authentication slot and the 
verification itself each get this budget, the verification's starting once it 
holds a slot. Separate from the 10-minute idle timeout that applies once 
authenticated |
 | `IGGY_KAFKA_MAX_CONCURRENT_AUTHENTICATIONS` | `4` | Credential verifications 
the gateway runs at once, across all connections. Each costs a password hash on 
an Iggy shard thread. This bounds the gateway's side only: a check that times 
out frees its slot while its hash keeps running inside Iggy. Size it below the 
Iggy node's shard count |
 
+## Quick start (Docker Compose)
+
+The fastest way to see a Kafka client talk to Iggy: two containers, no local 
Rust toolchain to
+build them. (Reading the record back below uses `docker compose exec`, not a 
local build either.)
+
+```text
+                    Kafka wire (TCP 9093)              Iggy wire (TCP 8090)
+  kcat / librdkafka  ─────────────────────▶  gateway  ─────────────────────▶  
iggy-server
+  (your Kafka client)                    iggy-gateway-kafka                  
(real broker)
+```
+
+```bash
+cd gateways/kafka
+docker compose up --build
+```
+
+`iggy-server` pulls the prebuilt `apache/iggy:edge` image (refreshed on a 
master push only when `server` or `iggy-cli` is affected, or `web` or 
`core/server/Dockerfile` changes),
+so only `iggy-gateway-kafka` compiles from source. Its Dockerfile mounts a 
cargo registry/target
+cache, so a first run costs one real compile and every run after reuses it, 
even across a source
+change elsewhere in the workspace. Testing a local `iggy-server` change: 
comment out `image:` and
+uncomment `build:` in `docker-compose.yml` first. That build mounts cargo 
registry, git, and
+target caches, so the first run is still a full release build of several 
minutes and later runs
+reuse those mounts.
+
+This starts `iggy-server` (the real Iggy broker) and `iggy-gateway-kafka` 
(bridge enabled,
+pointed at that server) on a shared Docker network, with the gateway waiting 
for the server's
+health check before it starts. From another terminal:
+
+```bash
+# Broker discovery works with just kcat - it has no create-topic mode of its 
own, though.
+kcat -b 127.0.0.1:9093 -L
+```
+
+A full create-topic-then-produce flow needs a client that can send 
`CreateTopics` (there is no
+auto-create on Produce). With Python's `kafka-python` (illustrative - not 
exercised by an
+automated test, so adjust if the library's API has moved since this was 
written):
+
+```python
+from kafka.admin import KafkaAdminClient, NewTopic
+from kafka import KafkaProducer
+
+admin = KafkaAdminClient(bootstrap_servers="127.0.0.1:9093")
+admin.create_topics([NewTopic(name="orders", num_partitions=1, 
replication_factor=1)])
+
+producer = KafkaProducer(bootstrap_servers="127.0.0.1:9093")
+producer.send("orders", b"hello from kafka-python").get(timeout=5)
+```
+
+Read the record back with a real Kafka consumer - Fetch is wired to Iggy with 
the bridge on (see
+the stub warning above), so `kcat` reads it the same way it would against a 
real broker:
+
+```bash
+kcat -b 127.0.0.1:9093 -C -t orders -o beginning -e
+```
+
+`-e` exits once `kcat` catches up rather than waiting for more. No consumer 
group needed for a
+one-shot read; see [docs/CONSUMER_GROUPS.md](docs/CONSUMER_GROUPS.md) for 
group-based consuming.
+Polling Iggy directly still works too, if you want to see the record from the 
other side:
+
+```bash
+docker compose exec iggy-server /usr/local/bin/iggy --username iggy \
+  --password iggy-gateway-quickstart message poll kafka orders 0 --offset 0 
--message-count 1
+```
+
+**Ports**: gateway `9093` (Kafka wire, the one a client dials), Iggy `8090` 
(TCP, `iggy` CLI/SDK),
+`3000`/`8080`/`8092` (Iggy HTTP/QUIC/WebSocket, unused by the gateway itself 
but published for
+direct Iggy inspection).
+
+**Config**: the compose file sets fixed dev-only root credentials 
(`IGGY_ROOT_PASSWORD`) so the
+gateway's `IGGY_KAFKA_IGGY_PASSWORD` is known ahead of time - never reuse them 
outside a local
+stack. See `gateways/kafka/docker-compose.yml` and the environment variable 
tables below for
+every other knob.
+
+**Limitations** (see the stub warning above for the full list): OffsetCommit 
and OffsetFetch are
+not implemented yet, so a consumer group can form and get assigned partitions 
but can't commit or
+resume from a saved offset - use `assign()` with an explicit start offset, as 
the Fetch example
+above does with `-o beginning`. Single gateway, single Iggy node, SASL off by 
default. This is a
+development quick start, not a production deployment shape.
+
+This quick start's topics use Iggy's default `Durability::Replicated` - a 
Produce ack is a
+quorum commit, not an fsync. A `docker kill` or host power loss can lose acked 
records despite
+the named Docker volume; the volume protects a clean `docker compose 
down`/`up`, not a crash.
+Neither this quick start nor `phase1_e2e_tests.rs` exercises crash durability 
- the automated
+test and the manual read-back above both only prove a produced record is 
readable from the
+still-live process that wrote it. `docs/MANUAL_TESTING.md`'s Category I covers 
a *restart* (a
+graceful `docker compose restart`, which flushes on the way down) as a smoke 
test for "does the
+volume actually persist across a container restart" - it is not a crash test 
and does not
+contradict this limitation.
+
+**Success criteria** (tracked for 
[#3539](https://github.com/apache/iggy/issues/3539)): a new
+contributor should reach a successful produce (and an Iggy-side read-back 
confirming it landed)
+within 15 minutes of `git clone`, and every Phase 1 API key (CreateTopics, 
Metadata, Produce,
+ListOffsets) has an automated test exercising the real compiled binaries end 
to end -
+`tests/phase1_e2e_tests.rs` is that test, spawning real `iggy-server` and 
`iggy-gateway-kafka`
+processes (not `KafkaGateway::run` in-process, as every other suite in this 
crate does) and
+driving CreateTopics → Metadata → Produce → ListOffsets over real TCP, then 
reading the produced
+records back through the Iggy SDK.
+
 ## Test
 
 ```bash
diff --git a/gateways/kafka/docker-compose.yml 
b/gateways/kafka/docker-compose.yml
new file mode 100644
index 000000000..af1c11a03
--- /dev/null
+++ b/gateways/kafka/docker-compose.yml
@@ -0,0 +1,141 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Quick-start: a real iggy-server plus the Kafka gateway bridged to it, for a 
Kafka client to
+# talk to end to end. `docker compose up` from this directory. See README.md's 
"Quick start".
+
+services:
+  iggy-server:
+    # Prebuilt. :edge refreshes on a master push only when `server` or 
`iggy-cli` is
+    # affected, or `web` or `core/server/Dockerfile` changes - avoids a full 
from-scratch workspace
+    # release build on first `up --build`. Testing a local server change: 
comment this out
+    # and uncomment `build:` below instead - `core/server/Dockerfile`'s 
`runtime` target is
+    # the same Dockerfile and target `apache/iggy:edge` itself is published 
from
+    # (.github/config/publish.yml), so the healthcheck/volume/listener-address 
settings below
+    # stay correct either way.
+    image: apache/iggy:edge
+    # :edge is a rolling tag - without this, compose reuses whatever was 
pulled last time it
+    # happened to not be cached locally, and a stale server can reject a newer 
gateway's login
+    # once the wire protocol moves. Always check for a newer edge instead of 
trusting the cache.
+    pull_policy: always
+    # build:
+    #   context: ../..
+    #   dockerfile: core/server/Dockerfile
+    #   target: runtime
+    restart: unless-stopped
+    cap_add:
+      - SYS_NICE
+    security_opt:
+      - seccomp:unconfined
+    ulimits:
+      memlock:
+        soft: -1
+        hard: -1
+    environment:
+      - IGGY_NODE_ADVERTISED_ADDRESS=localhost
+      # Fixed, non-default root credentials: this quick-start's gateway needs 
a password it
+      # knows ahead of time, and iggy-server otherwise generates a random one 
per start.
+      # Dev-only - never reuse these outside a local compose stack.
+      - IGGY_ROOT_USERNAME=iggy
+      - IGGY_ROOT_PASSWORD=iggy-gateway-quickstart
+      # config.toml's own defaults are all 127.0.0.1 - fine for a single 
binary on the host,
+      # not reachable from a sibling container. The gateway dials 
iggy-server:8090 over this
+      # compose network, so TCP at minimum has to bind the wildcard address; 
the other three
+      # are overridden too since this file also publishes them to the host 
(see bdd's own
+      # docker-compose.server.yml for the same pattern against the same image).
+      - IGGY_TCP_ADDRESS=0.0.0.0:8090
+      - IGGY_HTTP_ADDRESS=0.0.0.0:3000
+      - IGGY_QUIC_ADDRESS=0.0.0.0:8080
+      - IGGY_WEBSOCKET_ADDRESS=0.0.0.0:8092
+    networks:
+      - iggy-kafka-gateway
+    ports:
+      - 127.0.0.1:3000:3000
+      - 127.0.0.1:8080:8080
+      - 127.0.0.1:8090:8090
+      - 127.0.0.1:8092:8092
+    volumes:
+      # apache/iggy:edge (core/server/Dockerfile's 
`runtime`/`runtime-prebuilt` targets) sets
+      # WORKDIR /app; config.toml's own `path = "local_data"` is relative to 
that, not to `/`.
+      - iggy-server:/app/local_data
+    # This gates the gateway's start on the server actually answering a 
command, not just
+    # accepting a TCP connect (a bare connect succeeds before the 
request-processing loop is
+    # wired up; `ping` is unauthenticated - see 
core/cli/src/commands/binary_system/ping.rs's
+    # `login_required() -> false` - so this still doesn't prove root-user 
login is ready, only
+    # that the server itself is). Root is seeded during metadata recovery, 
before the TCP
+    # listeners bind (core/server/src/boot/mod.rs), so there is no "server up, 
root not ready"
+    # window for the gateway to race against - RECONNECTION_RETRIES in 
bridge/iggy_bridge/mod.rs
+    # (3 retries after the first attempt, 4 attempts total) exists for 
transient connect
+    # failures, not for waiting out root provisioning.
+    healthcheck:
+      test: ["CMD", "/usr/local/bin/iggy", "ping"]
+      # start_interval covers startup (forks a process + fresh TCP connect 
every 2s only until
+      # healthy); interval is the steady-state cadence for the rest of the 
container's life, so
+      # it stays slow enough not to matter.
+      start_interval: 2s
+      interval: 30s
+      timeout: 3s
+      start_period: 10s
+      retries: 30
+
+  iggy-gateway-kafka:
+    build:
+      context: ../..
+      dockerfile: gateways/kafka/Dockerfile
+    container_name: iggy-gateway-kafka
+    restart: unless-stopped
+    depends_on:
+      iggy-server:
+        condition: service_healthy
+    environment:
+      - IGGY_KAFKA_BIND_ADDR=0.0.0.0:9093
+      # Binding the wildcard address above means the gateway cannot derive its 
own advertised
+      # host (server.rs::BrokerAdvertise::from_server_config refuses to start 
without this) -
+      # same requirement root docker-compose.yml's 
IGGY_NODE_ADVERTISED_ADDRESS already carries
+      # for iggy-server, for the same reason: the value a Kafka client's 
Metadata response
+      # points it back at has to be reachable from outside the container.
+      #
+      # Literal 127.0.0.1, not "localhost": the port below is published on 
127.0.0.1 only, and
+      # a client resolving "localhost" to ::1 first (common default on many 
systems) would get
+      # a Metadata response pointing it at an address nothing is actually 
listening on.
+      - IGGY_KAFKA_ADVERTISED_HOST=127.0.0.1
+      - IGGY_KAFKA_BRIDGE_ENABLED=true
+      - IGGY_KAFKA_IGGY_ADDR=iggy-server:8090
+      - IGGY_KAFKA_IGGY_USERNAME=iggy
+      - IGGY_KAFKA_IGGY_PASSWORD=iggy-gateway-quickstart
+    networks:
+      - iggy-kafka-gateway
+    ports:
+      - 127.0.0.1:9093:9093
+    # `depends_on: service_healthy` only gates the initial `up`, never a later 
restart - without
+    # its own healthcheck this container crash-loops invisibly (no state 
`docker compose ps` can
+    # show) if restarted while iggy-server happens to be down. The listener 
binds only after the
+    # bridge connects (src/main.rs), so "port answers" already implies "bridge 
is up".
+    healthcheck:
+      test: ["CMD", "nc", "-z", "127.0.0.1", "9093"]
+      interval: 10s
+      timeout: 3s
+      start_period: 15s
+      retries: 5
+
+volumes:
+  iggy-server:
+    driver: local
+
+networks:
+  iggy-kafka-gateway:
+    name: iggy-kafka-gateway
diff --git a/gateways/kafka/docs/MANUAL_TESTING.md 
b/gateways/kafka/docs/MANUAL_TESTING.md
index ea4c1f668..3df52b832 100644
--- a/gateways/kafka/docs/MANUAL_TESTING.md
+++ b/gateways/kafka/docs/MANUAL_TESTING.md
@@ -297,9 +297,11 @@ for CreateTopics, and so on).
 | F5 | Oversized frame | Send 4-byte length > 8 MiB | Connection 
rejected/closed, no OOM |
 | F6 | Graceful shutdown | Ctrl+C on gateway | Log "shutdown requested", 
in-flight requests drain |
 
-### Category G — Real Kafka client (kcat)
+### Category G — Real Kafka client (kcat, Java console consumer)
 
-Requires `kcat` installed. Gateway does **not** implement SASL or full broker 
semantics — expect limited success.
+Requires `kcat` installed. SASL is off by default 
(`IGGY_KAFKA_SASL_ENABLED=false` - see Category S
+for the SASL-on flow) and the gateway does not implement full broker semantics 
— expect limited
+success. This category predates the bridge landing: G2/G3's "fails at 
metadata" framing assumes an unbridged gateway, and no longer holds with 
`IGGY_KAFKA_BRIDGE_ENABLED=true` against a topic that was actually created 
first. See Category I for the bridged, docker-compose flow.
 
 | ID | Test | Command | Expected (foundation) |
 | ---- | ------ | --------- | --------------------- |
@@ -325,6 +327,32 @@ Record kcat version and exact error strings in your test 
log. G1 passing is the
 | H5 | Transactional Produce | Send key 0 v3 with a non-null 
`transactional_id` and `acks=1` | `ec=35` per partition, **not** `ec=6`; 
connection stays open. With `acks=0`: no response, and the gateway closes the 
connection |
 | H6 | Transaction API keys | `send --host 127.0.0.1:9093 --api-key 24` (also 
25, 26, 28) | Connection closes, no response bytes - they are never advertised |
 
+### Category I — Docker Compose quick start 
([#3539](https://github.com/apache/iggy/issues/3539))
+
+Exercises the real compiled binaries the way an operator actually runs them - 
not `cargo run`
+against a locally-built binary, and not `KafkaGateway::run` in-process the way 
every automated
+suite except `tests/phase1_e2e_tests.rs` does.
+
+| ID | Test | Steps | Expected |
+| ---- | ------ | ------- | ---------- |
+| I1 | Stack comes up | `cd gateways/kafka && docker compose up --build` | 
Both containers start; gateway does not exit (it would if the bridge connect 
failed before the server's health check passed) |
+| I2 | Broker discovery | `kcat -b 127.0.0.1:9093 -L` | Broker listed, 
reachable |
+| I3 | Create + produce | Run the `kafka-python` snippet from README.md's 
Quick start | `create_topics` and `send(...).get()` both return without raising 
|
+| I4 | Read-back via Kafka Fetch | `kcat -b 127.0.0.1:9093 -C -t orders -o 
beginning -e` | The produced record's payload is printed - validates the actual 
Kafka Fetch path, not just Iggy's own side |
+| I5 | Volume persistence across a restart | `docker compose restart 
iggy-server`, wait for its health check to go healthy again, re-run I4's poll 
command | The same record is still readable after the container restarts 
against the same named volume |
+| I6 | Teardown | `docker compose down -v` | Both containers stop; volume 
removed |
+
+I5 is a graceful restart (`docker compose restart` sends SIGTERM, which 
flushes on the way down) -
+it checks that the named volume actually persists data across a container 
restart, not that a
+produced record survives an unclean crash. It has no automated equivalent yet.
+`tests/phase1_e2e_tests.rs` and every step above I5 only prove 
read-your-own-write on a live
+process (see README.md's Limitations, which also covers why this quick start's 
default topic
+durability is not crash-safe). Treat an I5 failure as a real regression, not a 
flake.
+
+Record wall-clock time from `git clone` to I3 passing - README.md's Quick 
start states a 15-minute
+target for a new contributor 
([#3539](https://github.com/apache/iggy/issues/3539) acceptance
+criteria).
+
 ---
 
 ## 4. Validation reference
@@ -414,8 +442,9 @@ kcat version (if used): ___________
 [ ] D1–D10 Flexible vs legacy encoding
 [ ] E1–E4  Metadata stub semantics
 [ ] F1–F6  TCP / connection behavior
-[ ] G1–G8  Real clients (record errors for G2/G3)
+[ ] G1–G8  kcat / Java client (record errors for G2/G3)
 [ ] H1–H6  Adversarial input
+[ ] I1–I6  Docker Compose quick start (record wall-clock time to I3; I5 is 
restart durability)
 
 Automated regression:
 [ ] cargo test -p iggy-gateway-kafka — all passed (see `TEST_SUITE.md` for why 
this checklist
diff --git a/gateways/kafka/docs/TEST_SUITE.md 
b/gateways/kafka/docs/TEST_SUITE.md
index 4153ec8c2..e2a006958 100644
--- a/gateways/kafka/docs/TEST_SUITE.md
+++ b/gateways/kafka/docs/TEST_SUITE.md
@@ -36,6 +36,18 @@ in the same target directory; neither harness invokes `cargo 
build` itself). Ski
 fails with a clear "binary not found" message naming the build command to run, 
not a hang or a
 silent skip.
 
+### `iggy-gateway-kafka` binary (required for `phase1_e2e_tests`)
+
+`phase1_e2e_tests` spawns the compiled gateway itself, bridged to a spawned 
`iggy-server`, so
+`iggy-server` still needs the manual build step above. `iggy-gateway-kafka` 
does not: it is this
+package's own `[[bin]]`, so `CARGO_BIN_EXE_iggy-gateway-kafka` resolves at 
compile time and Cargo
+builds it automatically as part of `cargo test -p iggy-gateway-kafka`.
+
+```bash
+cargo build --package server --bin iggy-server
+cargo test -p iggy-gateway-kafka
+```
+
 ---
 
 ## Test files
@@ -71,9 +83,10 @@ file under `tests/` anymore.
 | 
[`list_offsets_real_bridge_tests.rs`](../tests/list_offsets_real_bridge_tests.rs)
 | ListOffsets (key 2) through the whole handler against a real, spawned 
`iggy-server`: LATEST, EARLIEST, codes 3 and 43 | No (needs the `iggy-server` 
binary - see Prerequisites) |
 | [`fetch_real_bridge_tests.rs`](../tests/fetch_real_bridge_tests.rs) | Fetch 
(key 1) through the whole handler against a real, spawned `iggy-server` — 
records go in through the Iggy SDK or the Produce handler and come back through 
`kafka_protocol`'s client decoder, at every version, with codes 0, 1, 3, 6, -1 
and 70, paged polls, and the wait | No (needs the `iggy-server` binary - see 
Prerequisites) |
 | [`produce_real_bridge_tests.rs`](../tests/produce_real_bridge_tests.rs) | 
Produce (key 0) through the whole handler against a real, spawned `iggy-server` 
— records go in as Kafka wire bytes and come back through the Iggy SDK, plus 
one error code per partition | No (needs the `iggy-server` binary - see 
Prerequisites) |
+| [`phase1_e2e_tests.rs`](../tests/phase1_e2e_tests.rs) | 
[#3539](https://github.com/apache/iggy/issues/3539) acceptance: CreateTopics → 
Metadata → Produce → ListOffsets over real TCP against the real compiled 
`iggy-gateway-kafka` binary bridged to a real spawned `iggy-server` - the only 
suite that spawns the gateway as a process rather than calling 
`KafkaGateway::run` in-process | No (needs both binaries - see Prerequisites) |
 
-`tests/common/` holds shared helpers (`codec.rs`, `fixtures.rs`, `scope.rs`, 
`server.rs`,
-`iggy_server.rs`, `tcp.rs`, `wire.rs`), compiled per test binary via 
`#[path]`, not a test binary itself. `codec.rs`
+`tests/common/` holds shared helpers (`codec.rs`, `fixtures.rs`, 
`gateway_process.rs`, `scope.rs`,
+`server.rs`, `iggy_server.rs`, `tcp.rs`, `wire.rs`), compiled per test binary 
via `#[path]`, not a test binary itself. `codec.rs`
 is test-only primitive encode/decode scaffolding for hand-building 
legacy/adversarial wire shapes
 `kafka_protocol`'s spec-correct encoder cannot produce - it is not the 
gateway's production codec.
 
diff --git a/gateways/kafka/tests/common/gateway_process.rs 
b/gateways/kafka/tests/common/gateway_process.rs
new file mode 100644
index 000000000..b7e3f70e6
--- /dev/null
+++ b/gateways/kafka/tests/common/gateway_process.rs
@@ -0,0 +1,102 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Real `iggy-gateway-kafka` process harness, mirroring `iggy_server.rs`'s 
`TestServer` for the
+//! gateway binary itself. Every existing "e2e" suite in this crate drives 
`KafkaGateway::run`
+//! in-process (`common/server.rs`), never the compiled binary an operator 
actually runs - this
+//! type closes that gap for #3539's docker-compose-shaped end-to-end test.
+
+#![allow(dead_code)]
+
+use std::path::PathBuf;
+use std::process::{Child, Command};
+
+use crate::iggy_server::{PortGuard, TestServer, graceful_kill, 
wait_for_listener};
+use std::env;
+
+/// Locates the already-built `iggy-gateway-kafka` binary.
+///
+/// Unlike `iggy_server.rs`'s `iggy_server_binary()`, this does not need an 
ancestor walk from
+/// `env::current_exe()`: that workaround exists because `iggy-server` lives 
in a *different*
+/// package, where `CARGO_BIN_EXE_*` never resolves. `iggy-gateway-kafka` is 
this package's own
+/// `[[bin]]` (`Cargo.toml`), so `CARGO_BIN_EXE_iggy-gateway-kafka` is set at 
compile time and
+/// Cargo builds the binary itself before running this test - same pattern 
already used in
+/// `gateways/kafka/tools/kafka-tool/tests/generate_cli_tests.rs`.
+fn iggy_gateway_binary() -> PathBuf {
+    PathBuf::from(env!("CARGO_BIN_EXE_iggy-gateway-kafka"))
+}
+
+pub struct TestGateway {
+    child: Child,
+    pub address: String,
+    _port_guard: PortGuard,
+}
+
+impl TestGateway {
+    /// Spawns `iggy-gateway-kafka` with the bridge enabled against `server`, 
then blocks until
+    /// its listener is ready or the startup budget is exhausted.
+    pub async fn spawn(server: &TestServer) -> Self {
+        let port_guard = PortGuard::acquire();
+        let address = format!("127.0.0.1:{}", port_guard.port);
+        let config = server.test_config();
+
+        let mut command = Command::new(iggy_gateway_binary());
+        // env_clear(): without it the child inherits this test process's full 
environment, and
+        // the gateway's own reject_unknown_kafka_env_vars (main.rs) 
hard-fails startup on any
+        // unrecognized inherited IGGY_KAFKA_* var - or, worse, a *recognized* 
one (say
+        // IGGY_KAFKA_ADVERTISED_HOST, which this crate's own 
README/docker-compose.yml teach a
+        // contributor to export) silently changes the spawned gateway's 
behavior out from under
+        // this harness's assertions.
+        command.env_clear();
+        if let Some(llvm_profile_file) = env::var_os("LLVM_PROFILE_FILE") {
+            command.env("LLVM_PROFILE_FILE", llvm_profile_file);
+        }
+        command
+            .env("IGGY_KAFKA_BIND_ADDR", &address)
+            .env("IGGY_KAFKA_BRIDGE_ENABLED", "true")
+            .env("IGGY_KAFKA_IGGY_ADDR", &config.address)
+            .env("IGGY_KAFKA_IGGY_USERNAME", &config.username)
+            .env(
+                "IGGY_KAFKA_IGGY_PASSWORD",
+                secrecy::ExposeSecret::expose_secret(&config.password),
+            );
+        let child = command.spawn().expect("spawn iggy-gateway-kafka");
+
+        let mut gateway = Self {
+            child,
+            address,
+            _port_guard: port_guard,
+        };
+        gateway.wait_ready().await;
+        gateway
+    }
+
+    /// Bare TCP connect poll, same rationale as `TestServer::wait_ready`: a 
full Kafka client
+    /// handshake would pay retry overhead on every failed attempt. A connect 
slightly ahead of
+    /// the gateway's own accept loop can't happen here - `main.rs` connects 
the bridge before
+    /// binding the listener, so a successful connect already implies the 
bridge is up too.
+    async fn wait_ready(&mut self) {
+        wait_for_listener(&mut self.child, &self.address, 
"iggy-gateway-kafka").await;
+    }
+}
+
+impl Drop for TestGateway {
+    fn drop(&mut self) {
+        graceful_kill(&mut self.child);
+        let _ = self.child.wait();
+    }
+}
diff --git a/gateways/kafka/tests/common/iggy_server.rs 
b/gateways/kafka/tests/common/iggy_server.rs
index b8bef7c73..51cc9a746 100644
--- a/gateways/kafka/tests/common/iggy_server.rs
+++ b/gateways/kafka/tests/common/iggy_server.rs
@@ -23,6 +23,7 @@
 #![allow(dead_code)]
 
 use std::collections::HashMap;
+use std::env;
 use std::fs::{File, OpenOptions};
 use std::path::PathBuf;
 use std::process::{Child, Command};
@@ -146,9 +147,18 @@ fn 
given_a_ceiling_at_the_u16_maximum_when_choosing_a_band_should_refuse() {
 }
 
 /// Exclusive claim on one port, released (and the port freed for reuse) when 
dropped - including
-/// on an unclean process exit, since the OS drops the `flock` with the file 
descriptor. Unlike
-/// bind-then-drop, this process itself never binds the port before 
`iggy-server` does; see
-/// `port_band`'s doc comment for the limits of that guarantee against *other* 
processes.
+/// on an unclean process exit, since the OS drops the `flock` with the file 
descriptor.
+///
+/// `acquire` also bind-probes the port (bind, then drop the listener with no 
accepted
+/// connections, so nothing lingers in `TIME_WAIT`) before handing it out. The 
flock alone only
+/// excludes another `PortGuard`-based process; it does nothing against a 
process from a *prior*
+/// test run that's still holding the port (killed hard enough to skip `Drop`, 
so its own flock
+/// died with it but its listener didn't). A bind-probe catches that case at 
acquisition time
+/// instead of leaving callers to detect a stale listener after the fact - 
detecting it after
+/// the fact can't work on the first readiness-poll iteration, before the real 
child has even
+/// attempted its own `bind()` yet, so a successful connect this early is 
ambiguous between
+/// "ours, racing ahead of its own listen" and "a stale orphan's". See 
`port_band`'s doc comment
+/// for the limits of this guarantee against a `bind(0)` from an unrelated 
process.
 pub struct PortGuard {
     pub port: u16,
     _lock: File,
@@ -171,7 +181,10 @@ impl PortGuard {
             else {
                 continue;
             };
-            if file.try_lock().is_ok() {
+            // A stale listener (not another PortGuard user, the flock already 
excludes those)
+            // can still hold this port - skip it on a failed bind, leave the 
lock file for the
+            // next acquire to try again, keep scanning.
+            if file.try_lock().is_ok() && 
std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
                 return Self { port, _lock: file };
             }
         }
@@ -250,6 +263,13 @@ impl TestServer {
         let address = format!("127.0.0.1:{}", port_guard.port);
 
         let mut command = Command::new(iggy_server_binary());
+        // env_clear(): without it the child inherits this test process's full 
environment, and
+        // a stray recognized IGGY_* var could silently change the spawned 
server's behavior out
+        // from under this harness's assertions (same reasoning as 
gateway_process.rs's spawn).
+        command.env_clear();
+        if let Some(llvm_profile_file) = env::var_os("LLVM_PROFILE_FILE") {
+            command.env("LLVM_PROFILE_FILE", llvm_profile_file);
+        }
         command
             .env("IGGY_PATH", data_dir.display().to_string())
             .env("IGGY_TCP_ADDRESS", &address)
@@ -293,40 +313,15 @@ impl TestServer {
     }
 
     /// Polls with a bare TCP connect, not a full `IggyBridge::connect`: the 
latter carries
-    /// `RECONNECTION_RETRIES` (3 dials at ~1s apart) on every failed attempt, 
so a poll loop built
+    /// `RECONNECTION_RETRIES` (3 retries after the first attempt, 4 dials at 
~1s apart total) on
+    /// every failed attempt, so a poll loop built
     /// on it pays several real seconds per iteration instead of running at 
its own 100ms cadence,
     /// and a *successful* poll iteration would authenticate a client and then 
drop it without
     /// `close()`, leaking a session server-side. A TCP accept slightly ahead 
of the app being
     /// ready to authenticate is fine - every caller's own subsequent 
`IggyBridge::connect` already
     /// retries a few times, which covers the last few hundred ms of that gap.
-    ///
-    /// Checks `try_wait()` every iteration: a server that fails at boot (bad 
config, a port
-    /// stolen between `PortGuard::acquire` and its own bind) exits almost 
immediately, and without
-    /// this check that reads as "still starting" for the full 30s budget - 
the eventual failure
-    /// names the wrong cause ("did not become ready") instead of the real one 
(exited early, with
-    /// whatever it printed before dying).
     async fn wait_ready(&mut self) {
-        let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
-        loop {
-            if let Some(status) = self.child.try_wait().expect("poll child 
status") {
-                panic!(
-                    "iggy-server at {} exited during startup with {status}",
-                    self.address
-                );
-            }
-            if tokio::net::TcpStream::connect(self.address.as_str())
-                .await
-                .is_ok()
-            {
-                return;
-            }
-            assert!(
-                tokio::time::Instant::now() < deadline,
-                "iggy-server at {} did not become ready within the startup 
budget",
-                self.address
-            );
-            tokio::time::sleep(Duration::from_millis(100)).await;
-        }
+        wait_for_listener(&mut self.child, &self.address, "iggy-server").await;
     }
 
     pub fn test_config(&self) -> IggyBridgeConfig {
@@ -352,7 +347,32 @@ impl TestServer {
 const SIGTERM_TIMEOUT: Duration = Duration::from_secs(5);
 const SIGKILL_POLL_INTERVAL: Duration = Duration::from_millis(50);
 
-fn graceful_kill(child: &mut Child) {
+/// Shared by `TestServer::wait_ready` and `TestGateway::wait_ready` 
(`gateway_process.rs`) -
+/// identical poll loop, only the process label in the panic/assert messages 
differs.
+///
+/// Checks `try_wait()` every iteration: a process that fails at boot (bad 
config, a port stolen
+/// between `PortGuard::acquire` and its own bind) exits almost immediately, 
and without this
+/// check that reads as "still starting" for the full 30s budget - the 
eventual failure names the
+/// wrong cause ("did not become ready") instead of the real one (exited 
early, with whatever it
+/// printed before dying).
+pub async fn wait_for_listener(child: &mut Child, address: &str, label: &str) {
+    let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
+    loop {
+        if let Some(status) = child.try_wait().expect("poll child status") {
+            panic!("{label} at {address} exited during startup with {status}");
+        }
+        if tokio::net::TcpStream::connect(address).await.is_ok() {
+            return;
+        }
+        assert!(
+            tokio::time::Instant::now() < deadline,
+            "{label} at {address} did not become ready within the startup 
budget"
+        );
+        tokio::time::sleep(Duration::from_millis(100)).await;
+    }
+}
+
+pub fn graceful_kill(child: &mut Child) {
     // `wait_ready` calls `try_wait()` too (and panics on an early exit, 
unwinding into this via
     // `Drop`) - if it already reaped the child, `child.id()` is a PID the OS 
is free to hand to an
     // unrelated process by the time we get here, and a raw `libc::kill` 
(unlike `std::Child::kill`,
diff --git a/gateways/kafka/tests/phase1_e2e_tests.rs 
b/gateways/kafka/tests/phase1_e2e_tests.rs
new file mode 100644
index 000000000..a03aa5f64
--- /dev/null
+++ b/gateways/kafka/tests/phase1_e2e_tests.rs
@@ -0,0 +1,341 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! #3539 acceptance test: the whole Phase 1 flow (`CreateTopics` -> 
`Metadata` -> `Produce` ->
+//! `ListOffsets`) driven over real TCP against the real compiled 
`iggy-gateway-kafka` binary,
+//! itself bridged to a real compiled `iggy-server` binary - the same two 
processes
+//! `docker-compose.yml` wires together, spawned directly as host binaries 
rather than through
+//! Docker Compose itself. Not `KafkaGateway::run` spawned in-process the way 
every other "e2e"
+//! suite in this crate does (`common/server.rs`). A regression only the 
actual binaries'
+//! startup, env parsing, or process wiring can produce (as opposed to a 
handler-level bug the
+//! in-process suites already cover) fails here and nowhere else.
+//!
+//! This does **not** exercise `docker-compose.yml`, the `Dockerfile`s, image 
builds, container
+//! networking, or the health-check gate - those are covered by 
`docs/MANUAL_TESTING.md`'s
+//! Category I (manual) only, not by any automated suite. A broken compose 
file, a missing
+//! runtime library in the image, or a wrong port/network mapping would not be 
caught here.
+//!
+//! The final read-back step goes through the Iggy SDK directly rather than a 
real Kafka Fetch,
+//! the same substitute `produce_real_bridge_tests.rs` uses - Fetch is 
implemented
+//! (`src/protocol/handlers/fetch.rs`, exercised by 
`fetch_real_bridge_tests.rs`), but wiring a
+//! hand-built Fetch request/response codec into this specific acceptance test 
is left as a
+//! follow-up rather than bundled into this PR's scope.
+
+use std::net::SocketAddr;
+
+use bytes::{Bytes, BytesMut};
+use iggy::prelude::{Consumer, Identifier, MessageClient, PollingStrategy};
+use kafka_protocol::indexmap::IndexMap;
+use kafka_protocol::records::{
+    Compression, NO_PARTITION_LEADER_EPOCH, NO_PRODUCER_EPOCH, NO_PRODUCER_ID, 
NO_SEQUENCE, Record,
+    RecordBatchEncoder, RecordEncodeOptions, TimestampType,
+};
+
+use iggy_gateway_kafka::protocol::api::{
+    API_KEY_CREATE_TOPICS, API_KEY_LIST_OFFSETS, API_KEY_METADATA, 
API_KEY_PRODUCE, ERROR_NONE,
+};
+
+#[path = "common/codec.rs"]
+mod codec;
+#[path = "common/gateway_process.rs"]
+mod gateway_process;
+#[path = "common/iggy_server.rs"]
+mod iggy_server;
+#[path = "common/tcp.rs"]
+mod tcp;
+#[path = "common/wire.rs"]
+mod wire;
+
+use codec::{Decoder, Encoder};
+use gateway_process::TestGateway;
+use iggy_server::{TestServer, raw_client};
+use tcp::round_trip;
+
+const TOPIC: &str = "phase1-e2e-topic";
+const STREAM: &str = "kafka";
+const CREATE_TOPICS_VERSION: i16 = 5;
+const METADATA_VERSION: i16 = 9;
+const PRODUCE_VERSION: i16 = 7;
+const LIST_OFFSETS_VERSION: i16 = 6;
+const CREATE_TIME: i64 = 1_700_000_000_123;
+
+fn build_create_topics_request(topic: &str, num_partitions: i32) -> Bytes {
+    let mut enc = Encoder::with_capacity(128);
+    enc.write_varint(2); // one topic (compact array N+1)
+    enc.write_compact_nullable_string(Some(topic));
+    enc.write_i32(num_partitions);
+    enc.write_i16(1); // replication_factor
+    enc.write_varint(1); // no explicit assignments
+    enc.write_varint(1); // no configs
+    enc.write_empty_tagged_fields(); // topic tagged fields
+    enc.write_i32(5_000); // timeout_ms
+    enc.write_bool(false); // validate_only
+    enc.write_empty_tagged_fields(); // request tagged fields
+    enc.freeze()
+}
+
+/// `(error_code, error_message, num_partitions)` from the first (only) topic 
result.
+fn decode_create_topics_response(body: Bytes) -> (i16, Option<String>, i32) {
+    let mut d = Decoder::new(body);
+    let _throttle_time_ms = d.read_i32().expect("throttle_time_ms");
+    let _topics_plus_one = d.read_varint().expect("topics array count");
+    let _name = d.read_compact_nullable_string().expect("topic name");
+    let error_code = d.read_i16().expect("error_code");
+    let error_message = 
d.read_compact_nullable_string().expect("error_message");
+    let num_partitions = d.read_i32().expect("num_partitions");
+    (error_code, error_message, num_partitions)
+}
+
+/// `(topic error_code, partition error_code, leader_id of partition 0, 
node_ids in the brokers
+/// array, controller_id, first broker's host, first broker's port)` for the 
single requested
+/// topic.
+#[allow(clippy::type_complexity)]
+fn decode_metadata_response(body: Bytes) -> (i16, i16, i32, Vec<i32>, i32, 
Option<String>, i32) {
+    let mut d = Decoder::new(body);
+    let _throttle_time_ms = d.read_i32().expect("throttle_time_ms");
+
+    let brokers_plus_one = d.read_varint().expect("brokers array count");
+    assert!(brokers_plus_one >= 2, "at least one broker in the list");
+    let mut broker_host = None;
+    let mut broker_port = 0;
+    let mut node_ids = Vec::new();
+    for i in 1..brokers_plus_one {
+        let node_id = d.read_i32().expect("node_id");
+        let host = d.read_compact_nullable_string().expect("host");
+        let port = d.read_i32().expect("port");
+        d.read_compact_nullable_string().expect("rack");
+        d.read_tagged_fields().expect("broker tagged fields");
+        node_ids.push(node_id);
+        if i == 1 {
+            broker_host = host;
+            broker_port = port;
+        }
+    }
+    d.read_compact_nullable_string().expect("cluster_id");
+    let controller_id = d.read_i32().expect("controller_id");
+
+    let topics_plus_one = d.read_varint().expect("topics array count");
+    assert_eq!(topics_plus_one, 2, "exactly one topic was requested");
+    let error_code = d.read_i16().expect("topic error_code");
+    d.read_compact_nullable_string().expect("topic name");
+    d.read_bool().expect("is_internal");
+
+    let partitions_plus_one = d.read_varint().expect("partitions array count");
+    assert_eq!(partitions_plus_one, 2, "one partition was created");
+    let partition_error_code = d.read_i16().expect("partition error_code");
+    d.read_i32().expect("partition_index");
+    let leader_id = d.read_i32().expect("leader_id");
+    (
+        error_code,
+        partition_error_code,
+        leader_id,
+        node_ids,
+        controller_id,
+        broker_host,
+        broker_port,
+    )
+}
+
+fn assert_metadata_response(body: Bytes, gateway_addr: SocketAddr) {
+    let (
+        error_code,
+        partition_error_code,
+        leader_id,
+        node_ids,
+        controller_id,
+        broker_host,
+        broker_port,
+    ) = decode_metadata_response(body);
+    assert_eq!(error_code, ERROR_NONE, "Metadata must report the new topic");
+    assert_eq!(
+        partition_error_code, ERROR_NONE,
+        "the single partition must report no error"
+    );
+    assert_eq!(leader_id, 1, "this gateway is the sole broker");
+    assert!(
+        node_ids.contains(&leader_id),
+        "leader_id {leader_id} must name a broker actually present in the 
brokers array \
+         {node_ids:?} - a real client cannot route to a leader it was never 
told about"
+    );
+    assert_eq!(
+        controller_id, 1,
+        "single-broker cluster: the controller is the same broker"
+    );
+    assert_eq!(
+        broker_host.as_deref(),
+        Some(gateway_addr.ip().to_string().as_str()),
+        "Metadata must advertise the address a client can actually reach this 
gateway on"
+    );
+    assert_eq!(
+        broker_port,
+        i32::from(gateway_addr.port()),
+        "Metadata must advertise the port this gateway is actually listening 
on"
+    );
+}
+
+fn record(offset: i64, value: &[u8]) -> Record {
+    Record {
+        transactional: false,
+        control: false,
+        delete_horizon: false,
+        partition_leader_epoch: NO_PARTITION_LEADER_EPOCH,
+        producer_id: NO_PRODUCER_ID,
+        producer_epoch: NO_PRODUCER_EPOCH,
+        timestamp_type: TimestampType::Creation,
+        offset,
+        sequence: NO_SEQUENCE + i32::try_from(offset).expect("small offset"),
+        timestamp: CREATE_TIME,
+        key: None,
+        value: Some(Bytes::copy_from_slice(value)),
+        headers: IndexMap::new(),
+    }
+}
+
+fn build_produce_request(topic: &str, records: &[Record]) -> Bytes {
+    let mut batch_buf = BytesMut::new();
+    let options = RecordEncodeOptions {
+        version: 2,
+        compression: Compression::None,
+    };
+    RecordBatchEncoder::encode(&mut batch_buf, records.iter(), &options)
+        .expect("encode record batch");
+
+    let mut enc = Encoder::with_capacity(1024);
+    enc.write_nullable_string(None)
+        .expect("null transactional_id");
+    enc.write_i16(1); // acks: leader
+    enc.write_i32(5_000); // timeout_ms
+    enc.write_i32(1); // one topic
+    enc.write_nullable_string(Some(topic))
+        .expect("topic name fits");
+    enc.write_i32(1); // one partition
+    enc.write_i32(0); // partition_index
+    enc.write_nullable_bytes(Some(&batch_buf))
+        .expect("record batch fits");
+    enc.freeze()
+}
+
+/// `(error_code, base_offset)` from the first (only) partition result.
+fn decode_produce_response(body: Bytes) -> (i16, i64) {
+    let mut d = Decoder::new(body);
+    let _topics = d.read_i32().expect("topics array count");
+    let _name = d.read_nullable_string().expect("topic name");
+    let _partitions = d.read_i32().expect("partitions array count");
+    let _index = d.read_i32().expect("partition_index");
+    let error_code = d.read_i16().expect("error_code");
+    let base_offset = d.read_i64().expect("base_offset");
+    let _log_append_time = d.read_i64().expect("log_append_time");
+    let _log_start_offset = d.read_i64().expect("log_start_offset");
+    (error_code, base_offset)
+}
+
+/// `(error_code, offset)` for the requested partition's latest-offset result.
+fn decode_list_offsets_response(body: Bytes) -> (i16, i64) {
+    let mut d = Decoder::new(body);
+    let _throttle_time_ms = d.read_i32().expect("throttle_time_ms");
+    let _topics_plus_one = d.read_varint().expect("topics array count");
+    let _name = d.read_compact_nullable_string().expect("topic name");
+    let _partitions_plus_one = d.read_varint().expect("partitions array 
count");
+    let _partition_index = d.read_i32().expect("partition_index");
+    let error_code = d.read_i16().expect("error_code");
+    let _timestamp = d.read_i64().expect("timestamp");
+    let offset = d.read_i64().expect("offset");
+    (error_code, offset)
+}
+
+#[tokio::test]
+async fn 
phase1_produce_flow_through_real_gateway_process_and_real_iggy_server() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let gateway = TestGateway::spawn(&server).await;
+    let addr: SocketAddr = gateway.address.parse().expect("gateway address");
+
+    let (corr, body) = round_trip(
+        addr,
+        API_KEY_CREATE_TOPICS,
+        CREATE_TOPICS_VERSION,
+        1,
+        &build_create_topics_request(TOPIC, 1),
+    )
+    .await;
+    assert_eq!(corr, 1, "correlation id must echo the request");
+    let (error_code, error_message, num_partitions) = 
decode_create_topics_response(body);
+    assert_eq!(error_code, ERROR_NONE, "CreateTopics must succeed");
+    assert_eq!(
+        error_message, None,
+        "no error_message on a successful result"
+    );
+    assert_eq!(num_partitions, 1);
+
+    let (corr, body) = round_trip(
+        addr,
+        API_KEY_METADATA,
+        METADATA_VERSION,
+        2,
+        &wire::build_metadata_flexible_request(&[TOPIC]),
+    )
+    .await;
+    assert_eq!(corr, 2, "correlation id must echo the request");
+    assert_metadata_response(body, addr);
+
+    let records = [record(0, b"phase1-hello"), record(1, b"phase1-world")];
+    let (corr, body) = round_trip(
+        addr,
+        API_KEY_PRODUCE,
+        PRODUCE_VERSION,
+        3,
+        &build_produce_request(TOPIC, &records),
+    )
+    .await;
+    assert_eq!(corr, 3, "correlation id must echo the request");
+    let (error_code, base_offset) = decode_produce_response(body);
+    assert_eq!(error_code, ERROR_NONE, "Produce must succeed");
+    assert_eq!(base_offset, 0, "first batch into an empty partition");
+
+    let (corr, body) = round_trip(
+        addr,
+        API_KEY_LIST_OFFSETS,
+        LIST_OFFSETS_VERSION,
+        4,
+        &wire::build_list_offsets_request(LIST_OFFSETS_VERSION, TOPIC, 0),
+    )
+    .await;
+    assert_eq!(corr, 4, "correlation id must echo the request");
+    let (error_code, offset) = decode_list_offsets_response(body);
+    assert_eq!(error_code, ERROR_NONE, "ListOffsets must succeed");
+    assert_eq!(offset, 2, "high watermark after two produced records");
+
+    // Read the produced records back through the Iggy SDK directly rather 
than a real Kafka
+    // Fetch - see this file's module doc for why (Fetch itself is 
implemented).
+    let raw = raw_client(&server).await;
+    let polled = raw
+        .poll_messages(
+            &Identifier::named(STREAM).expect("stream name"),
+            &Identifier::named(TOPIC).expect("topic name"),
+            Some(0),
+            
&Consumer::new(Identifier::named("phase1-e2e-reader").expect("consumer name")),
+            &PollingStrategy::offset(0),
+            2,
+            false,
+        )
+        .await
+        .expect("poll produced messages")
+        .messages;
+    assert_eq!(polled.len(), 2, "both produced records must be readable");
+    assert_eq!(polled[0].payload.as_ref(), b"phase1-hello");
+    assert_eq!(polled[1].payload.as_ref(), b"phase1-world");
+}
diff --git a/licenserc.toml b/licenserc.toml
index 4957b49f8..0025dec84 100644
--- a/licenserc.toml
+++ b/licenserc.toml
@@ -47,6 +47,7 @@ excludes = [
     "**/.gitignore",
     "**/.gitattributes",
     "**/.dockerignore",
+    "**/Dockerfile.dockerignore",
     "**/.helmignore",
     "**/.prettierignore",
     "**/.editorconfig",

Reply via email to