Files
clean-architecture-backend-…/infra/redis-sdk/sentinel/compose.yml
T
DongHyeonkaandClaude Opus 5 0cd959a494 feat(httpclient): close the platform review's P0/P1/P2 findings
The review found one defect shape repeated across the platform: surfaces
that were declared, bound, and documented, but that nothing read. An
operator configuring fullUrlRecording, bodyLogging, retry.policy,
validatedDnsPinning, timeout.dns, or any of ten declared metric names got a
guarantee the code never delivered. Every such surface is now in exactly one
of three states -- wired for real, rejected at startup, or registered in a
test-enforced gap list with its reason. No silent no-ops remain.

P0:
- Activate the platform from bootstrap behind app.httpclient.enabled, with a
  single auto-configuration importing the nine child configurations.
- Give the platform a strict, repository-level ENV contract: 74 leaf fields
  derived from the settings record tree, unknown APP_HTTPCLIENT_* rejected.
- Route typed HTTP service clients through the call kernel via
  KernelHttpExchangeAdapter, so they stop bypassing platform policy.
- Pin dynamic-target DNS resolution to the socket for the life of a call,
  closing the resolve-then-connect TOCTOU / rebinding window.
- Actually transmit the idempotency key, and make retry eligibility depend on
  transmission rather than on merely holding one.
- Reject reactive authentication and reactive redirect at startup instead of
  declaring support that does not function.
- Fix the Reactor-only Stable contract row so the lane stops failing.
- Stop advertising HTTP/3 on a transport that negotiates HTTP/1.

P1 covers execution and retry accounting, redirect security (per-hop target
guarding, sensitive-header stripping, 303 body handling), runtime rotation
and transport resource ownership keyed by generation, dynamic-target
hardening (subdomain matching, global-unicast classification, strict CIDR
parsing), protocol intent, pool and timeout wiring, streaming and body
limits, observability parity, and OAuth single-flight refresh on a bounded
pool with a bounded wait.

P2 covers configuration and documentation drift, the Gradle check wiring for
the four hermetic lanes, and the CI gate matrix.

Two test-quality defects surfaced while closing these: the HTTP/2 stream
saturation test ran against cleartext HTTP/1.1 while asserting nothing about
the protocol, and an OAuth contention test slept on a latch that could fire
before the callers it meant to observe. Both now assert what their names
claim.

Verification run: :adapter:outbound:httpclient:check and :app-bootstrap:check
(checkstyle, spotless, spotbugs, and the four hermetic lanes),
verifyCleanArchitectureDependencies, verifyEnvKeys, verifyOneTypePerFile,
verifyDependencyLocks, the documentation and gate-matrix verifiers, and the
performance lane against a real TLS+ALPN HTTP/2 server.

Not executed, and tracked rather than claimed: Docker/Toxiproxy fault
injection, JMH, a real QUIC/HTTP3 server, a real Spring Framework 6.2
distribution (now a delegated-pending gate), live OAuth/TLS/proxy/DNS
integration, and a whole-repository check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:49:31 +09:00

127 lines
5.0 KiB
YAML

# Sentinel lane. Three sentinels because a two-sentinel quorum cannot survive losing one, and a
# failover test that cannot lose a sentinel is not testing failover.
#
# Host networking, not a bridge with published ports. Sentinel does not proxy: it answers
# `SENTINEL get-master-addr-by-name` with the address it monitors, and the client then connects
# there itself. On a bridge that address is the container's internal IP, which the client on the
# host cannot reach, so the lane would resolve a primary it can never talk to — and after a
# promotion it would resolve a different unreachable one. Sharing the host network namespace makes
# the address Sentinel hands out the same address the client can dial, which is the only thing that
# makes the promotion observable from outside.
#
# Ports are fixed rather than parameterised because Sentinel stores them in its own config: the
# monitored address has to match what the client is told, and a lane whose two halves can disagree
# is a lane that fails for reasons that are not the SDK's.
#
# primary 7010 · replica 7011 · sentinels 27010 27011 27012
#
# The ACL file is loaded on both data nodes. The accounts are the deployment's last enforcement
# boundary, so "they exist in standalone" is not evidence that they exist in the topology that will
# actually be run in production.
#
# Both data nodes take their entire configuration from one definition, and that is load-bearing
# rather than tidiness. These two nodes swap roles on every failover, so a setting written only into
# the one that happens to start as primary silently stops applying the moment the lane does the
# thing it exists to do. The lane learned this the hard way: min-replicas-to-write was set on the
# primary only, the first promotion passed, and the second promotion — now writing to the node that
# never had the setting — discarded 2,099 acknowledged writes.
x-data-node: &data-node
image: "redis:${REDIS_VERSION:-7.4}"
network_mode: host
volumes:
- ../acl:/etc/redis/acl:ro
entrypoint:
- /bin/sh
- -c
# REPLICA_OF is deliberately unquoted: it is either empty or a two-word --replicaof argument.
#
# min-replicas-to-write is what stops a superseded primary from acknowledging writes it cannot
# keep. Without it a promotion silently destroys them — measured here at eleven seconds and two
# thousand confirmed-then-discarded writes — because Sentinel does not demote the old primary
# until well after it has promoted the new one. Requiring an in-sync replica turns that window
# into an explicit NOREPLICAS refusal the caller can see and act on. Any deployment where an
# acknowledgement is supposed to mean something has to set these.
- |
exec redis-server \
--port $$NODE_PORT \
$$REPLICA_OF \
--appendonly no \
--save '' \
--min-replicas-to-write 1 \
--min-replicas-max-lag 1 \
--masteruser ca-skeleton-replication \
--masterauth fixture-replication \
--aclfile /etc/redis/acl/all-accounts.acl
healthcheck:
test: ["CMD-SHELL", "[ \"$$(redis-cli -p $$NODE_PORT --user ca-skeleton-application --pass fixture-application --no-auth-warning ping)\" = PONG ]"]
interval: 2s
timeout: 2s
retries: 15
services:
primary:
<<: *data-node
environment:
NODE_PORT: "7010"
REPLICA_OF: ""
replica:
<<: *data-node
environment:
NODE_PORT: "7011"
REPLICA_OF: "--replicaof 127.0.0.1 7010"
depends_on:
primary:
condition: service_healthy
sentinel-1: &sentinel
image: "redis:${REDIS_VERSION:-7.4}"
network_mode: host
# The config is written at start-up rather than mounted because Sentinel rewrites its own file
# when it promotes. A read-only mount would make the first failover fail on a write error, and
# a shared writable mount would have three sentinels rewriting one file.
entrypoint:
- /bin/sh
- -c
- |
cat > /tmp/sentinel.conf <<CONF
port $$SENTINEL_PORT
sentinel monitor skeleton 127.0.0.1 7010 2
sentinel auth-user skeleton ca-skeleton-sentinel
sentinel auth-pass skeleton fixture-sentinel
sentinel down-after-milliseconds skeleton 2000
sentinel failover-timeout skeleton 10000
sentinel parallel-syncs skeleton 1
CONF
exec redis-sentinel /tmp/sentinel.conf
environment:
SENTINEL_PORT: "27010"
healthcheck:
test: ["CMD-SHELL", "[ \"$$(redis-cli -p 27010 ping)\" = PONG ]"]
interval: 2s
timeout: 2s
retries: 15
depends_on:
primary:
condition: service_healthy
sentinel-2:
<<: *sentinel
environment:
SENTINEL_PORT: "27011"
healthcheck:
test: ["CMD-SHELL", "[ \"$$(redis-cli -p 27011 ping)\" = PONG ]"]
interval: 2s
timeout: 2s
retries: 15
sentinel-3:
<<: *sentinel
environment:
SENTINEL_PORT: "27012"
healthcheck:
test: ["CMD-SHELL", "[ \"$$(redis-cli -p 27012 ping)\" = PONG ]"]
interval: 2s
timeout: 2s
retries: 15