Skip to content

feat: gRPC mock server (third mock engine)#51

Merged
HumanBean17 merged 21 commits into
mainfrom
feat/grpc-mock-server
Jul 18, 2026
Merged

feat: gRPC mock server (third mock engine)#51
HumanBean17 merged 21 commits into
mainfrom
feat/grpc-mock-server

Conversation

@HumanBean17

Copy link
Copy Markdown
Owner

Summary

Adds a gRPC mock server as a third mock engine alongside HTTP and Kafka. The SUT's gRPC client can now point at a fake server (mocks.grpc), parallel to mocks.http — closing the false-green surface for runbooks that depend on downstream gRPC services.

  • All four call types (unary, server-streaming, client-streaming, bidi) via a descriptor-driven generic servicer (one grpc.Server, dynamic dispatch through a shared DescriptorPool).
  • Auto-served Health (SERVING) + Reflection — the mock behaves like a real gRPC server (grpc healthcheck + reflection-based clients work out of the box).
  • Reuses the existing stub pipeline (json_subset/jq_bool/resolve_captures/render_typed) and the gRPC client's proto machinery (extracted into a shared kernel agctl/clients/grpc_descriptors.py). Same match → capture → render → status model as HTTP/Kafka stubs.
  • Lazy-import discipline preserved — HTTP/Kafka-only installs import no server-side grpc code; missing grpc extra → ConfigError pointing at pip install 'agctl[grpc]'.

What changed

  • New: agctl/clients/grpc_descriptors.py (shared proto kernel), agctl/mock/grpc_server.py (dispatch core + MockGrpcServer).
  • Extended: config/models.py (mocks.grpc models), mock/{engine,daemon,jq_precompile,capture_validate}.py, commands/{mock_commands,discover_commands}.py, assertions.py (public parse_grpc_status), clients/grpc_client.py (delegates to the kernel — behavior-preserving), data/sample-config.yaml.
  • CLI: mock run/start gain --grpc-listen + --only grpc; mock start/stop/status carry a grpc block; new events grpc.hit/grpc.unmatched/grpc.error (the latter two fatal); new discover category mock-grpc-stubs.
  • Docs: DESIGN.md (WHAT/WHY) + ARCHITECTURE.md (HOW) + the three consumer skills synced.

Config is additive (version stays "3", no migration).

Design & process

  • Spec: docs/superpowers/specs/active/2026-07-17-grpc-mock-server-design.md
  • Plan: docs/superpowers/plans/active/2026-07-17-grpc-mock-server.md
  • Built via subagent-driven development: 13 TDD tasks, a per-task spec+quality review after each, then a 3-scope opus whole-branch review. All findings addressed.

Test status

  • Unit suite: 1407 passed / 20 skipped (skips are env-gated extras).
  • Integration: tests/integration/test_mock_grpc.py 9/9 green — drives a real in-process grpc.Server via agctl grpc call/healthcheck for all four call types + Health + reflection-only resolution + status simulation + summary counters. Self-contained (no Docker); runs whenever the grpc extra is installed.

v1 limitations (documented in DESIGN §10 / ARCHITECTURE §15)

Plaintext only (no TLS on the mock); bidi is request/response pairing (no stateful/server-push); client-stream aggregates at close; no mid-stream abort; descriptors required (reflection is served but cannot bootstrap the mock); grpcio SO_REUSEPORT means two grpc servers can bind the same port silently.

🤖 Generated with Claude Code

HumanBean17 and others added 21 commits July 17, 2026 23:54
Design for `agctl mock grpc` — a gRPC mock engine alongside HTTP/Kafka,
serving all four call types via a descriptor-driven generic servicer, with
auto-served Health + Reflection. Reuses the existing stub pipeline and the
gRPC client's descriptor/JSON↔protobuf machinery (extracted into a shared
proto kernel).

Co-Authored-By: Claude <noreply@anthropic.com>
Task-by-task plan: shared proto kernel, public status helper, config
models, jq/capture walkers, pure dispatch core, MockGrpcServer
(construction + servicer + health/reflection), engine/daemon/commands/
discovery extensions, integration tests, docs sync.

Co-Authored-By: Claude <noreply@anthropic.com>
…ors.py

Move the proto-only helpers (descriptor resolution, service/method lookup,
JSON<->protobuf translation) out of GrpcClient into a new shared kernel
module so the upcoming gRPC mock server (Tasks 6/7) reuses one source of truth.

Pure behavior-preserving refactor:
- GrpcClient.resolve_descriptors/find_method/call_type_of/_msg_class/
  _serialize/_deserialize now delegate to grpc_descriptors.* (reflection
  path stays in grpc_client.py — client-only).
- GrpcClient.call_type_of remains accessible as a delegating staticmethod.
- Lazy-import discipline enforced: the kernel never imports grpc /
  grpc_tools / google.protobuf at module top; missing libraries surface as
  ConfigError pointing at pip install 'agctl[grpc]'.

Adds tests/fixtures/mock_grpc/echo.proto (echo.EchoService with all four
call types) and tests/unit/conftest.py with a session-scoped pool fixture
shared by this and later tasks (6/7/12). All 33 existing GrpcClient tests
still pass; full unit suite (1235 tests) passes.

Co-Authored-By: Claude <noreply@anthropic.com>
Factor the duplicated name-or-code gRPC status parsing out of
validate_grpc_assertion_args and evaluate_grpc_assertions into one
public parse_grpc_status(status) -> (code, name) helper. Pure dedupe;
behavior preserved (case-sensitive name lookup, digit-string coercion,
int-code lookup). The helper's own error message drops the CLI --status
prefix so it is reusable beyond the CLI (config model in Task 3, mock
server in Task 5); validate_grpc_assertion_args keeps its CLI-prefixed
message via try/except ... from None. Helper does not import grpc.

Co-Authored-By: Claude <noreply@anthropic.com>
Add typed Pydantic v2 config models for mocks.grpc, parallel to the
existing mocks.http/mocks.kafka models, with offline structural
validation only (response-shape-vs-call-type deferred to Task 6):

- GrpcMatch: body subset + jq predicate (mirrors HttpMatch)
- GrpcResponseMessage: single streaming-response element (message + delay_ms ge=0)
- GrpcResponse: exactly-one-of message/messages (model_validator);
  status validated via parse_grpc_status and stored verbatim for
  render-time (code,name) resolution
- GrpcStub: service/method/match/capture/response/delay_ms
- GrpcMockConfig: listen (parse_listen validator), descriptors,
  reflection, health, concurrency_cap (ge=1), stubs
- MocksConfig.grpc wired (forward ref + model_rebuild, since
  GrpcMockConfig depends on GrpcDescriptorSource from the gRPC section)

Co-Authored-By: Claude <noreply@anthropic.com>
…stubs

Co-Authored-By: Claude <noreply@anthropic.com>
Transport-agnostic dispatch brain for the gRPC mock server. Module-level
functions + GrpcDispatchOutcome dataclass only (no class yet — Task 6/7 add
MockGrpcServer). NO `import grpc` anywhere in the file: the dispatch core
is unit-testable without grpcio installed (verified by a runtime probe and
a source-level test).

Exports:
- build_envelope(service, method, metadata, *, message=None, messages=None)
  Shapes the per-call-type §8.1 envelope: {service, method, metadata
  (lowercased), message} for unary/server_stream/bidi; {..., messages,
  count} for client_stream.
- GrpcDispatchOutcome dataclass: matched, stub_name, messages, status,
  missing_captures. Unmatched outcome clears all other fields.
- dispatch_grpc(stubs, envelope, call_type, *, emit_capture_missing)
  First-match-wins over stubs; match.body (json_subset) AND match.jq
  (jq_bool) AND-ed; omitted match matches unconditionally. client_stream
  skips match.body (subset over aggregated list is ill-defined). On match:
  resolve_captures -> emit_capture_missing per missing -> render_typed per
  response message (server_stream: each messages[*]; others: response.message)
  -> parse_grpc_status for terminal status. Unmatched -> matched=False.

Signature deviation from brief: stubs is dict[str, GrpcStub] (insertion-
ordered), not list[GrpcStub]. GrpcStub (Task 3) has no .name field — its
identity is the dict key in mocks.grpc.stubs.<name>, exactly like HttpStub.
The dict form carries that name through dispatch without modifying Task 3's
model. Matches MockHTTPServer's stubs: dict[str, HttpStub] precedent.

Reuses json_subset/jq_bool/parse_grpc_status (assertions.py),
resolve_captures (mock/capture.py), render_typed (resolution.py) unchanged.

26 new tests in tests/unit/test_mock_grpc_dispatch.py cover all 10 brief
scenarios (a-j) plus extras (default/int/digit-string status, empty stub
map, omitted match, client_stream body-skip, bidi, dataclass shape,
grpcio-free source probe). 7 tests are jq-gated (per-test
pytest.importorskip("jq")) — they skip cleanly when jq is missing; the
other 19 run without jq. Full unit suite: 1315 passed (was 1289; +26).

Co-Authored-By: Claude <noreply@anthropic.com>
…docstring

Fix 1 (Important): rewrite test_grpc_server_module_does_not_import_grpc to
AST-parse agctl/mock/grpc_server.py and inspect only top-level body nodes
for banned imports (grpc, grpcio, grpc_tools, google, google.protobuf).
The prior substring grep ('import grpc' / 'from grpc' / 'import grpcio')
was a tripwire: a lazy 'import grpc' inside a function body contains the
same substring and would have failed Task 6's first commit even though
the no-top-level-import invariant holds. AST inspection of tree.body only
flags true module-top imports; nested lazy imports (the Task 6/7 contract)
no longer trip the test. Verified: a temporary 'import grpc' placed inside
build_envelope left the test green; change reverted.

Fix 2 (Minor): correct build_envelope docstring — the code checks 'messages'
first, so 'messages' wins (not 'message'). Wording only, no behavior change.

Co-Authored-By: Claude <noreply@anthropic.com>
…d table

Task 6 of the gRPC mock server. MockGrpcServer.__init__ resolves the
descriptor pool ONCE (via the shared kernel, or an injected pool for
tests), validates each stub's service/method + response-shape-vs-call-type
against the derived descriptor, and precomputes stubs_by_method /
method_meta. Construction failures raise ConfigError (exit 2) before any
bind. grpc-runtime wiring (generic servicer, Health, Reflection, lifecycle)
remains Task 7 — _server stays None and no grpc import is added.

stubs_by_method is the dict-of-dicts form (OVERRIDE of the brief's Produces):
dict[tuple[str,str], dict[str, GrpcStub]] — the inner ordered name->stub
dict carries stub names for Task 5's dispatch_grpc(stubs: dict[str,
GrpcStub], ...), which needs them for emit_capture_missing.

Co-Authored-By: Claude <noreply@anthropic.com>
…ifecycle

Wire the grpc runtime into MockGrpcServer (Task 7 of 13). Adds:

- _build_server(): builds grpc.Server with one
  grpc.method_handlers_generic_handler per configured service; each method's
  RpcMethodHandler is wired with the kernel's serialize/deserialize +
  a behavior callable that routes through dispatch_grpc.
- Four behavior handlers (_handle_unary/_handle_server_stream/
  _handle_client_stream/_handle_bidi) covering match/unmatched/non-OK
  status/per-message delay_ms, emitting grpc.hit/grpc.unmatched/grpc.error
  events via the engine's emit_event channel.
- Auto-served Health (config.health) marking every service + overall "" as
  SERVING; auto-served Reflection (config.reflection) via
  reflection.enable_server_reflection, passing the descriptor pool so
  file_containing_symbol responses are populated.
- Lifecycle: start() (ephemeral-port recompute, EADDRINUSE -> ConfigError),
  serve_forever(stop_event) polling every 200ms, idempotent shutdown(2s
  grace), actual_listen() returning the bound host:port.

All grpc/grpc_health/grpc_reflection imports live inside method bodies so
the module stays grpcio-free at module top (AST invariant preserved). Missing
grpc extra surfaces as ConfigError pointing at 'pip install agctl[grpc]'.

18 new TDD tests (RED -> GREEN) cover all four call types, Health/Reflection
on/off, port-in-use, missing-extra, and lifecycle. 1355/1355 unit tests
pass.

Co-Authored-By: Claude <noreply@anthropic.com>
…ts/summary)

Extend MockEngine to construct/run/shut down a MockGrpcServer as a third
engine alongside HTTP and Kafka:

- __init__ gains run_grpc/grpc_listen/grpc_server_factory (default-safe so
  pre-Task-10 new_mock_engine call sites still work). The default factory
  lazy-imports MockGrpcServer inside the closure body so the engine module
  stays grpcio-free at import time.
- start() Step 2b: resolve listen (--grpc-listen overrides mocks.grpc.listen
  via model_copy), construct via factory, server.start() binds (EADDRINUSE
  already converted to ConfigError by MockGrpcServer). Assigns to
  self._grpc_server only after successful bind so the failure path doesn't
  leave a partially-constructed server.
- run(): daemon thread running serve_forever(self._stop) alongside the
  HTTP/reactor threads.
- shutdown(): guarded server.shutdown() + thread join.
- emit_event: grpc.hit tallies; grpc.unmatched/grpc.error tally AND set
  _runtime_error unconditionally (both are fatal per DESIGN §8.3) so run()
  exits 1 and the summary surfaces the counts.
- _emit_started_line: grpc block {listen, stubs, services, reflection, health}.
- _emit_summary_line: grpc_hits/grpc_unmatched/grpc_errors under the snapshot
  lock alongside the existing counters.

Top-level Config.grpc.descriptors fallback is threaded in Task 10 (the
engine only sees MocksConfig); for now top_level_descriptors=None and
MockGrpcServer surfaces an empty-descriptors ConfigError if mocks.grpc.
descriptors is also None.

Unit-tested via a FakeGrpcServer injected through grpc_server_factory —
the engine test suite stays grpcio-free.

Co-Authored-By: Claude <noreply@anthropic.com>
Task 9: extend the pure daemon functions for gRPC-only and multi-engine

Co-Authored-By: Claude <noreply@anthropic.com>
test_resolve_target_listen_matches_http_listen previously set the pidfile's
legacy listen AND http_listen to the same value as the lookup, so the match
could succeed via the legacy listen field even if the http_listen branch were
deleted. Set legacy listen to a different address (127.0.0.1:19090) so the
match can only succeed through http_listen. Verified via temporary mutation:
removing mock.http_listen from the membership tuple made the test fail;
restoring it made it pass again.

Co-Authored-By: Claude <noreply@anthropic.com>
…ult grpc block

Wire the gRPC mock server into the `mock` CLI per Task 10:
- `_resolve_engines` returns (run_http, run_kafka, run_grpc); new `--only grpc`
  branch guards mocks.grpc.stubs; existing http/kafka branches gain trailing
  False; default resolution picks up mocks.grpc when stubs are configured.
- `new_mock_engine` threads run_grpc / grpc_listen / grpc_server_factory /
  top_level_descriptors to MockEngine (defaults preserve pre-Task-10 callers).
- `mock run` + `mock start` Click: --only choice now [http, kafka, grpc];
  new --grpc-listen option (literal, mirrors --http-listen).
- `mock run` resolves grpc_listen and threads cfg.grpc.descriptors as the
  top-level fallback (Task 8 obligation).
- `_mock_start_core` resolves grpc_listen; keys grpc-only pidfiles under
  mock-grpc-<port>.{pid,log} via engine="grpc" (Task 9 plumbing); daemon
  argv forwards --grpc-listen and --only grpc; already-running pre-check
  branches on http→grpc→kafka.
- Pidfile JSON now persists http_listen and grpc_listen (Task 9 obligation)
  so mock status/stop selection by either listen works end-to-end.
- mock start result adds a `grpc` block ({listen, stubs, services, reflection,
  health}) when run_grpc; HTTP listen/stubs stay as HTTP values.
- MockEngine gains top_level_descriptors param and forwards it to the
  grpc_server_factory call (was hardcoded None).

20 new TDD tests (RED→GREEN); full unit suite 1398 passing.

Co-Authored-By: Claude <noreply@anthropic.com>
Surfaces gRPC mock stubs in agctl discover, mirroring the existing
mock-http-stubs category: a new mock-grpc-stubs entry in _VALID_CATEGORIES,
a grpc_mock_stubs Level-0 count, and Level-1/Level-2/search branches that
read cfg.mocks.grpc.stubs via the _mock_grpc_stubs accessor.

- _mock_grpc_stubs(cfg) / _mock_grpc_listen(cfg) accessors (None-safe)
- _grpc_mock_params(stub): capture keys + {placeholder} scan of response
  message(s), folded into params per Task 11 brief
- _mock_grpc_example: ready-to-use grpcurl hint (gRPC analogue of the curl
  example for mock-http-stubs), with wildcard/IPv6 normalization
- _item_core: match (if set) via model_dump; missing name -> TemplateNotFound
- Fixture gains 2 gRPC mock stubs (echo-ok, echo-status)

Co-Authored-By: Claude <noreply@anthropic.com>
…ealth, reflection)

Task 12 of 13. Spins a real in-process grpcio mock server via MockEngine
(run_grpc=True, default factory lazy-imports the real MockGrpcServer) and
drives all four gRPC call types + Health + Reflection against it through the
`agctl grpc call` / `agctl grpc healthcheck` CLI path via CliRunner.

Self-contained: gated ONLY on the grpc extra (module-level
pytest.importorskip on grpc/grpc_tools/grpc_health/grpc_reflection). No
AGCTL_TEST_LIVE, no Docker — the gRPC mock is in-process server + client.
Verified 9/9 PASS with grpc installed (0.16s); clean skip when grpc is
missing (simulated). Full unit suite (1406) still green.

Scenarios covered:
- unary stub with {msg} capture -> templated response
- status: NOT_FOUND stub -> NOT_FOUND result (ok:true, D6)
- server-stream -> 3 messages in authored order
- client-stream -> one aggregated response templated on {count}
- bidi -> one response per request, in order
- grpc healthcheck --target mock -> SERVING
- reflection-only target (no client descriptors) -> resolves via reflection
- body-match stub + non-matching request -> UNIMPLEMENTED + grpc.unmatched
- engine shutdown summary -> grpc_hits/grpc_unmatched/grpc_errors counters

Co-Authored-By: Claude <noreply@anthropic.com>
Closeout for the gRPC mock feature: ship a commented `mocks:` example in the
packaged sample config (loaded by `agctl config init`) so users can discover
the mock server. The gRPC sub-block shows listen, reflection, health, a
commented descriptors proto/descriptor_set pair, and two stubs covering unary
`response.message` and server-stream `response.messages`, with match/capture
(capture rooted at `.message.`, the gRPC envelope root) and a gRPC status.

The http/kafka sub-blocks were missing from the sample too (the earlier
mock-server feature never updated it), so all three are added together for
coherence, matching DESIGN §2.1 and tests/fixtures/agctl.yaml. The whole block
is commented out → the sample still parses with `mocks: null` and validates
with no environment set.

Mirrored the identical block into README.md's "Complete, copy-paste-ready
config" to satisfy the test_sample_matches_readle_block drift guard
(byte-identical, verified).

Verified: unit 1431 passed / 20 skipped; integration test_mock_grpc 9 passed;
config validate/show on sample green; mock run --help lists --grpc-listen and
--only {http,kafka,grpc}; discover reports grpc_mock_stubs.

Co-Authored-By: Claude <noreply@anthropic.com>
DOCUMENTS-ONLY. DESIGN (WHAT/WHY): mocks.grpc schema (§2.1), mock
run/start --grpc-listen/--only grpc + grpc events + result blocks
(§3.6), config validate scope (§3.7), mock-grpc-stubs discover category
(§3.9), grpc event vocabulary (§4.2), gRPC-mock v1 limitations (§10).

ARCHITECTURE (HOW): module map incl. mock/grpc_server.py +
clients/grpc_descriptors.py (§3), grpc stub request-lifecycle trace
(§4), grpc events + fail-loudly (§6/§7), shared proto kernel + new
MockGrpcServer subsection (§8), packaging/tests/deltas/limitations
(§11/§12/§14/§15).

Consumer skills (skills/agctl, skills/agctl-config + reference/mocks):
--only grpc/--grpc-listen, mock-grpc-stubs discover, grpc fatal events
in the post-mock grep, mocks.grpc authoring guide.

Co-Authored-By: Claude <noreply@anthropic.com>
1. GrpcResponse docstring: correct the inverted message/messages↔call-type
   mapping (message = unary/client_stream/bidi, messages = server_stream).
2. _grpc_mock_params docstring: same correction (was backwards).
3. mock run --help smoke test: pin --grpc-listen alongside the other flags.
4. MockGrpcServer.start(): explicitly stop(grace=0) the built grpc.Server on
   bind failure before resetting _server, instead of relying on GC.
5. MockGrpcServer._check_response_shape: reject server_stream stubs with an
   empty response.messages list (fail-loud on surprising authoring); new unit
   test covers the empty-list case.

All 1407 unit tests + 9 gRPC integration tests pass; docstrings now match the
runtime mapping.

Co-Authored-By: Claude <noreply@anthropic.com>
Final-gate fresh-eyes review of PR #51 found 5 issues the prior waves missed:

1. (Important) grpc_descriptors.deserialize: MessageToDict lost snake_case
   field names (user_id -> userId) and default scalars (n=0 dropped),
   silently breaking match.body and capture.from. Pass
   preserving_proto_field_name=True + always_print_fields_with_no_presence=True.

2. (Minor) parse_grpc_status: Unicode digit-strings like "²" satisfy
   isdigit() but int("²") raises ValueError, escaping as an uncaught
   traceback on the CLI path. Guard the coercion with isascii() so Unicode
   digits fall through to the documented ConfigError.

3. (Important) MockEngine.run: drain the gRPC server (shutdown -> grace)
   BEFORE the exit-code snapshot, mirroring the kafka join-before-snapshot
   pattern. Without it, an in-flight grpc.error during shutdown set counters
   the summary reflected but the exit code did not -> false-green exit 0.

4. (Important) grpc_server handlers: implement stub.response.metadata
   (documented but previously a silent no-op) across all 4 call types via
   send_initial_metadata (once-per-RPC, before first response) +
   set_trailing_metadata (before abort/return/stream-end). Unmatched path
   sends nothing.

5. (Minor) mock start: a grpc-only daemon's startup-error detail.listen
   pointed at the HTTP default (0.0.0.0:18080); now resolves to the gRPC
   address when run_grpc and not run_http.

Tests: +14 (echo.proto gains UserService/UserRequest for snake_case; Fix 1
covers match.body + capture.from + zero-int; Fix 4 adds TestResponseMetadata
across unary/non-OK/unmatched/server_stream/bidi via real grpcio).
Full unit suite 1421 passed; gRPC integration 9 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
The new class-based `mock start` gRPC tests invoke the managed-daemon
command, which is POSIX-only (`_require_posix_daemon` rejects win32).
The pre-existing `mock start` tests carry a per-function
`@pytest.mark.skipif(os.name == "nt", ...)`, but TestMockStartGrpc is
the first class-based mock-start suite and was missing the gate — so on
Windows CI all 6 tests hit the platform ConfigError (one masquerading
as KeyError:'listen' because the gate's exit-2 detail lacks `listen`).

Add the same class-level skipif the module-level suites already use.

Co-Authored-By: Claude <noreply@anthropic.com>
@HumanBean17
HumanBean17 merged commit f9b605f into main Jul 18, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant