feat: grpc 기능 deep 구현
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
# ADR-GRPC-001: The gRPC platform ships as a registered family, not as one adapter leaf
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:*`, `:grpc-advanced:*`, `src/config/architecture/modules.json`
|
||||
|
||||
## Context
|
||||
|
||||
The two source plans describe a type-safe gRPC execution platform with its own API, SPI, adapters and
|
||||
composition root: fifteen Stable modules under `modules/grpc` and sixteen Advanced ones under
|
||||
`modules/grpc-advanced`, on Gradle Kotlin DSL, in package `io.backend.skeleton.grpc`, against
|
||||
Spring Boot 4.1.
|
||||
|
||||
None of that layout exists here. This repository uses Groovy DSL, a fail-closed module registry that
|
||||
owns the leaf list, package root `dev.caskeleton`, and Spring Boot 4.0.8. The plans anticipate this:
|
||||
their last Global Constraint says that when the repository structure differs, file paths are remapped
|
||||
and the public contracts, invariants and test meanings are not changed.
|
||||
|
||||
Two shapes were available. Fold the platform into the existing `:adapter:inbound:grpc` leaf as
|
||||
packages — which is what the JPA, GraphQL, WebSocket and HTTP platforms did here — or register it as
|
||||
a family the way `messaging:*` is registered.
|
||||
|
||||
## Decision
|
||||
|
||||
Register it as a family: twelve Stable leaves under `src/grpc/` and six Advanced ones under
|
||||
`src/grpc-advanced/`.
|
||||
|
||||
The deciding property is that this is not a layer of this application. Root `CLAUDE.md` already
|
||||
describes `messaging:*` as "a vendored messaging platform: a product with its own API, SPI, adapters
|
||||
and composition boundary, not a layer of this application", and the gRPC platform is the same shape
|
||||
for the same reason — the application is meant to reach it the way it reaches a library, through an
|
||||
application-owned port. The four platforms that became packages are all layers of this application;
|
||||
this one is not.
|
||||
|
||||
The split between `src/grpc/` and `src/grpc-advanced/` is not organisational. The Stable plan
|
||||
requires that the Stable starter's build fail if it reaches an Advanced module, and separate Gradle
|
||||
path prefixes make that a `verifyCleanArchitectureDependencies` failure rather than a review note:
|
||||
`grpc-spring-boot-starter`'s registry entry names no advanced id, and it cannot acquire one silently.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The registry grew from 44 leaves to 62.** That is a large registry change, made deliberately and in
|
||||
one place. Every new leaf is `runtime_memberships: []`, so nothing ships until a second, explicit
|
||||
decision moves it.
|
||||
|
||||
**The advanced boundary is checked twice.** Once by the registry at build time, and once by
|
||||
`GrpcStableBuildInvariant` at runtime, because a fat jar or a shaded artifact is assembled by
|
||||
something the registry never sees.
|
||||
|
||||
**Four testkit modules became four test lanes.** The plan's split exists so in-process results cannot
|
||||
be mistaken for network results; this repository expresses that with `ca.strict-test-lane`, whose
|
||||
lanes fail when they discover nothing and never serve an up-to-date result. `GrpcEvidenceGrade` keeps
|
||||
the same rule inside the code, so a report cannot cite a contract run as transport evidence.
|
||||
|
||||
**Codegen is not wired.** See ADR-GRPC-002.
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-GRPC-002: Schema governance runs without protoc and without the Buf CLI
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-proto-contract`, `:grpc:grpc-codegen`
|
||||
|
||||
## Context
|
||||
|
||||
Stable Tasks 8 through 11 require proto style rules, Buf format/lint/breaking governance, a single
|
||||
Java codegen owner, and a descriptor artifact whose consumer-compile result gates a release.
|
||||
|
||||
Two of the tools those tasks name are absent from this toolchain. The Buf CLI is not installed. And
|
||||
`protoc` is available through the Gradle protobuf plugin, but every leaf in this repository passes
|
||||
spotless with google-java-format, checkstyle, SpotBugs at HIGH confidence, Error Prone and `-Werror`
|
||||
— and generated protobuf sources pass none of them. Turning codegen on means excluding a source set
|
||||
from five quality gates.
|
||||
|
||||
There is precedent for such an exclusion: the `jmh` source set has `spotbugsJmh` and `checkstyleJmh`
|
||||
disabled and Error Prone off. So the carve-out is available. It is also a decision about the quality
|
||||
baseline of a leaf, taken for one task, and outside what this work was asked to change.
|
||||
|
||||
`adapter:inbound:grpc` also carries a recorded decision in the opposite direction: its `CLAUDE.md`
|
||||
forbids the protobuf plugin and `.proto` in that leaf, on the grounds that a consuming feature module
|
||||
should own its schema.
|
||||
|
||||
## Decision
|
||||
|
||||
Commit the `.proto` sources and implement every rule the tasks require as executable Java, with no
|
||||
protoc run and no Buf CLI invocation.
|
||||
|
||||
`GrpcProtoContractValidator` reads `.proto` text and enforces proto3 syntax, the
|
||||
`{organization}.{domain}.v{major}` package rule, `java_multiple_files`, a generated Java package
|
||||
disjoint from the hand-written one, `_UNSPECIFIED` enum zero values, `reserved` declarations checked
|
||||
against a supplied removal history, a well-known-type allowlist and a map-field allowlist. It runs
|
||||
against the committed schema in its own test, so the shipped `.proto` files are live rather than
|
||||
decorative.
|
||||
|
||||
`GrpcBufPolicy` fixes the breaking gate at Buf's `FILE` category and names the four lifecycle stages
|
||||
a compliant pipeline registers. `GrpcCodegenManifest` fixes one codegen owner and refuses a literal
|
||||
generator version. `GrpcDescriptorArtifact`, `GrpcConsumerFixture` and `GrpcSchemaArtifactPublisher`
|
||||
carry the schema hash, the descriptor digest and the per-consumer source-break report, and refuse a
|
||||
publish that breaks a consumer or republishes a released version with different bytes.
|
||||
|
||||
The committed `buf.yaml` states the same rules, so running the CLI in an environment that has it
|
||||
reaches the same verdict.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The invariants are enforced; the process is not run.** Everything Tasks 8 to 11 are about — which
|
||||
schema changes are refused, which consumer breaks block a release, who owns generation — is a
|
||||
build-checkable rule here. What is missing is the protoc invocation and the Buf binary.
|
||||
|
||||
**Turning codegen on is a bounded change.** `GrpcCodegenManifest.caSkeleton()` already names the
|
||||
owner, the managed version source, the build-directory output paths and the disjoint package policy
|
||||
that a real plugin configuration has to satisfy. The work is a source-set carve-out and a plugin
|
||||
block, not a redesign.
|
||||
|
||||
**The fixtures use a text codec.** `GrpcTextCodec` gives the testkit a UTF-8 marshaller so the
|
||||
in-process and Netty lanes can exercise interceptors, status mapping, metadata limits and stream
|
||||
sequencing without generated stubs. Those contracts are properties of the platform and the transport,
|
||||
not of any message shape, so the substitution costs nothing — and the lanes run today rather than
|
||||
after codegen lands.
|
||||
@@ -0,0 +1,50 @@
|
||||
# ADR-GRPC-003: Transport, business and stream evidence are three axes, and none implies another
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-core-api`, `:grpc:grpc-policy`, `:grpc:grpc-testkit`
|
||||
|
||||
## Context
|
||||
|
||||
A failed RPC produces a status code, and a status code is not an answer to the question the caller
|
||||
actually has. `DEADLINE_EXCEEDED` on a mutation does not say whether the mutation happened;
|
||||
`UNAVAILABLE` after the request was sent does not say the server never saw it; response headers
|
||||
arriving does not say a transaction committed.
|
||||
|
||||
Every one of those is a place where a plausible inference produces a duplicate write or a lost one,
|
||||
and none of them is visible in a test that only exercises the happy path.
|
||||
|
||||
## Decision
|
||||
|
||||
Model what happened as three independent axes, and refuse the inferences between them.
|
||||
|
||||
`GrpcTransportEvidence` records what the client observed on the wire, and distinguishes `NOT_SENT` —
|
||||
the client watched its own send fail — from `UNOBSERVED`, which is every other case where nothing is
|
||||
known. `GrpcBusinessEvidence` records what the application confirmed, with `COMMIT_UNKNOWN` as a real
|
||||
state rather than a placeholder. `GrpcStreamEvidence` is a sealed hierarchy whose non-empty cases all
|
||||
carry a position, because "partial" without a last sequence can be neither resumed nor reconciled.
|
||||
|
||||
`GrpcExecutionEvidence` holds all three and rejects combinations nobody could have observed: a unary
|
||||
call with stream evidence, or a request the client watched fail to send that nonetheless carries
|
||||
business evidence. Promoting response headers to a confirmed commit is possible only by editing
|
||||
`withResponseHeadersSeen`, which is one method rather than a plausible line in an interceptor.
|
||||
|
||||
`GrpcCompletionOutcome.forMutation` derives what a caller may conclude, and defaults
|
||||
`DEADLINE_EXCEEDED` and post-send `UNAVAILABLE` on a mutation to `COMPLETION_UNKNOWN`.
|
||||
|
||||
The same types are used by the failure model and by the observation convention, so an incident has
|
||||
one account of a call rather than two.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A whole class of retry bug becomes unrepresentable.** `GrpcRetryEligibility` reads all three axes
|
||||
plus the idempotency profile; a caller cannot reach "retry" from a status alone because the status
|
||||
alone is not an input.
|
||||
|
||||
**The fault lane has something to check.** `GrpcTransportEvidenceClassifier` turns a client's
|
||||
observations into evidence and refuses to infer `NOT_SENT` from an unobserved state — and the lane
|
||||
exercises it against a real connection dropped mid-call, not against a mock.
|
||||
|
||||
**Callers must handle a third outcome.** `COMPLETION_UNKNOWN` is not a failure and not a success, and
|
||||
a caller that treats it as either is wrong. `GrpcOperationStatusQuery` and `GrpcCompletionReconciler`
|
||||
exist so that resolving it is a supported path rather than an exercise for the caller.
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-GRPC-004: One retry owner, and keyed mutations need a durable ledger
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-policy`, `:grpc:grpc-operation-ledger-jpa`, `:grpc:grpc-core-api`
|
||||
|
||||
## Context
|
||||
|
||||
Three layers can retry a gRPC call: the application, the channel's service config, and a service
|
||||
mesh. Their effects multiply. Three attempts at each layer is twenty-seven requests for one call, and
|
||||
the load arrives exactly when the dependency is already failing.
|
||||
|
||||
Separately, a mutation that is safe to repeat needs somewhere to record that it ran. Without one, a
|
||||
retry after a lost response either duplicates the effect or drops it, and nothing distinguishes the
|
||||
two afterwards.
|
||||
|
||||
## Decision
|
||||
|
||||
**Exactly one retry owner per channel.** `GrpcRetryOwner` has four values including `NONE`, which is a
|
||||
decision rather than an omission. `GrpcServiceConfigPolicy` refuses an in-process retry entry when the
|
||||
owner is the mesh or nobody, and `GrpcRetryOwnershipValidator` compares the service config's method
|
||||
names against the policy catalog — a renamed method leaves its retry entry matching nothing, silently,
|
||||
and the method then runs with channel defaults.
|
||||
|
||||
**Retry eligibility reads the method, the evidence and the status together.**
|
||||
`GrpcRetryEligibility` refuses a non-idempotent method outright, refuses any call whose stream
|
||||
delivered a prefix, and turns a `DEADLINE_EXCEEDED` or post-send `UNAVAILABLE` mutation into
|
||||
"resolve the completion first" rather than a retry.
|
||||
|
||||
**A keyed mutation is retryable only with both a caller key and a durable ledger.**
|
||||
`GrpcOperationLedger` is a port in `grpc-core-api`, so the policy layer can require durable
|
||||
idempotency without depending on a database. Its `claim` contract is a single atomic insert-or-read
|
||||
against a unique constraint: `JpaGrpcOperationLedger` inserts first and reads on constraint violation,
|
||||
because a read-then-insert implementation has a window exactly as wide as the race it closes and
|
||||
passes every test that does not run two attempts concurrently.
|
||||
|
||||
The identity is caller fingerprint plus full method plus hashed key. All three are load-bearing:
|
||||
without the caller, one tenant's key suppresses another's write; without the method, a key reused
|
||||
across operations makes the second a replay of the first.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A budget bounds retries as a fraction of traffic.** `GrpcRetryBudget` degrades to roughly no
|
||||
retries when everything is failing, which is the behaviour that lets a dependency recover.
|
||||
|
||||
**The ledger and the mutation should commit together.** `JpaGrpcOperationLedger` carries no
|
||||
transaction annotations, deliberately: a `REQUIRES_NEW` would put the claim in its own transaction and
|
||||
reintroduce the window where the write is durable and the claim is not.
|
||||
|
||||
**A key reused for a different request is a caller error, not a duplicate.** The stored request
|
||||
fingerprint turns that into `FAILED_PRECONDITION` rather than silently returning the first request's
|
||||
answer.
|
||||
@@ -0,0 +1,50 @@
|
||||
# ADR-GRPC-005: One writer per stream, a bounded queue, and resume that refuses to guess
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc:grpc-policy`
|
||||
|
||||
## Context
|
||||
|
||||
`StreamObserver` is not thread-safe, and the failure when two producers call `onNext` concurrently is
|
||||
not an exception — it is interleaved bytes, which a client decodes as a corrupt message or, worse, as
|
||||
a valid one it should never have received.
|
||||
|
||||
Two further properties of server streams are easy to get wrong in ways that look healthy. A consumer
|
||||
that falls behind either terminates the stream or silently loses messages, and the second leaves a
|
||||
client with a stream that appears fine and is missing changes. And a reconnect either continues from
|
||||
a position the server can still replay, or skips whatever is no longer there.
|
||||
|
||||
## Decision
|
||||
|
||||
**A bounded queue drained by one writer.** `GrpcSerializedStreamWriter` accepts messages from any
|
||||
thread and hands them to the transport only from `flush`, which is synchronized. `write` returning
|
||||
`ACCEPTED` means queued, and the name is deliberately not `sent`: the transport call returns as soon
|
||||
as bytes are handed over, so no method here can honestly report delivery.
|
||||
|
||||
**Both a message bound and a byte bound.** Either alone is unbounded in the other dimension.
|
||||
`GrpcFlowControlPolicy` also takes the transport's own readiness signal, because a writer that relies
|
||||
only on its queue bound produces as fast as it can allocate.
|
||||
|
||||
**Termination is the default for a slow consumer.** `GrpcSlowConsumerPolicy.DROP_OLDEST` exists for
|
||||
feeds whose business meaning tolerates loss, and is not the default, because a client cannot detect
|
||||
dropped messages: the sequence numbers it sees are the ones it was sent.
|
||||
|
||||
**Resume is refused rather than faked.** `GrpcStreamGapDetector` requires a signed, unexpired token
|
||||
whose caller and filter fingerprints match the current request, refuses one whose snapshot version
|
||||
moved, and returns `FULL_RESYNC_REQUIRED` when the cursor predates retained history. `GrpcResumeToken`
|
||||
carries a key id so the signing key can rotate without invalidating every outstanding token.
|
||||
|
||||
## Consequences
|
||||
|
||||
**A stream carries an envelope, not a bare payload.** `GrpcStreamEnvelope` holds the stream id,
|
||||
generation, sequence, snapshot version and resume token, because resume, gap detection and drain all
|
||||
need a position and a generation.
|
||||
|
||||
**Four clocks, not one.** `GrpcStreamLifetimePolicy` separates setup deadline, idle timeout, max
|
||||
duration and heartbeat interval, and refuses combinations where one can never fire. Merging any pair
|
||||
produces a familiar bug: an idle timeout used as a max duration kills healthy busy streams.
|
||||
|
||||
**A heartbeat is a liveness signal and nothing else.** It is not an application acknowledgement and
|
||||
not an ordering guarantee; `GrpcStreamHeartbeat` says so in the place somebody would otherwise reuse
|
||||
it.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-GRPC-006: Stable discovery is DNS and static, and a Kubernetes profile names who balances
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-31
|
||||
- Scope: `:grpc:grpc-discovery`, `:grpc:grpc-client`
|
||||
|
||||
## Context
|
||||
|
||||
A gRPC channel's discovery configuration has a failure mode with no runtime symptom: it works, and
|
||||
it does not do what the dashboard says it does.
|
||||
|
||||
The specific case is `round_robin` over a Kubernetes Service ClusterIP. The Service is one virtual
|
||||
address, so the resolver returns one endpoint and the client-side balancer has nothing to rotate
|
||||
across; kube-proxy picks a pod at connect time, and an HTTP/2 connection is long-lived, so every
|
||||
request from that client goes to the same pod for the life of the connection. Nothing fails. The
|
||||
configuration says `round_robin`, the metrics show requests spread across clients rather than pods,
|
||||
and the conclusion "we have client-side load balancing" is wrong in a way nobody is prompted to
|
||||
check.
|
||||
|
||||
The mirror-image mistake is `pick_first` over a headless record, which pins a client to one pod out
|
||||
of many.
|
||||
|
||||
Separately, a service mesh changes who owns retries, and a deployment that adds mesh routing without
|
||||
removing its own retry policy has two retriers whose effects multiply.
|
||||
|
||||
## Decision
|
||||
|
||||
**Stable resolvers are Static, DNS and Unix domain socket; Stable load balancing is `pick_first` and
|
||||
`round_robin`.** `GrpcDiscoveryPolicyValidator.requireStableScheme` refuses `xds`, `consul`, `etcd`
|
||||
and `eureka` by name, with a message saying they are Advanced capabilities with their own control
|
||||
plane and promotion gate rather than unknown schemes.
|
||||
|
||||
**The pairing is checked against the resolved address count, not against intent.**
|
||||
`GrpcResolverProfile` carries `expectedAddressCount`, and `GrpcStableLoadBalancer.effective` answers
|
||||
whether the policy distributes anything over that many endpoints. A `round_robin` profile over one
|
||||
address is a reported violation whose message says it describes spreading that is not happening.
|
||||
|
||||
**A Kubernetes deployment names its routing mode**, and the mode implies both the balancer and the
|
||||
retry owner. `GrpcKubernetesRoutingMode` has three values — `K8S_VIP`, `K8S_HEADLESS`, `MESH` — and
|
||||
`GrpcKubernetesProfile` refuses a mesh profile whose retry owner retries in-process.
|
||||
|
||||
**A profile that carries long-lived streams must state a reconnect budget and a readiness drain
|
||||
grace.** A stream pins a client to one pod for its whole life, so every rollout, eviction and
|
||||
scale-down ends it. `GrpcKubernetesProfileValidator` additionally reports a VIP profile carrying
|
||||
long streams, and a drain grace shorter than the reconnect budget — the second means the pod stops
|
||||
serving before its clients have finished reconnecting elsewhere.
|
||||
|
||||
**A DNS profile must refresh.** `GrpcResolverProfile` refuses a zero refresh interval on DNS,
|
||||
because a channel that resolved once at startup keeps sending to addresses that stopped existing an
|
||||
hour ago, and the resulting `UNAVAILABLE` looks like an unhealthy deployment long after the rollout
|
||||
finished.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Two validators, not one.** `GrpcDiscoveryPolicyValidator` asks whether a balancer does anything
|
||||
over the addresses it will see; `GrpcKubernetesProfileValidator` asks whether the deployment shape,
|
||||
the retry owner and the stream obligations agree. A deployment can have a coherent resolver profile
|
||||
and still have put retries in two places, so merging them would let one answer hide the other.
|
||||
|
||||
**`expectedAddressCount` has to come from somewhere.** It is a declared number, and a declaration can
|
||||
be wrong. It is still better than the alternative, which is not comparing anything: a wrong
|
||||
declaration is a wrong statement somebody wrote down, and a missing one is a question nobody asked.
|
||||
`GrpcChannelProfileValidator` takes resolved counts where they are known at startup and skips the
|
||||
check where they are not, rather than guessing and failing on a name that cannot be resolved yet.
|
||||
|
||||
**xDS is reachable, and not by this route.** It lives in `grpc-advanced-resilience` behind its
|
||||
capability flag and its production approval, and `GrpcXdsStartupGuard.advertisableAsStableSupport()`
|
||||
returns false so the Stable support statement cannot widen quietly. See ADR-GRPC-ADV-001.
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR-GRPC-ADV-001: Each advanced capability has its own flag, its own grade and its own promotion
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-08-30
|
||||
- Scope: `:grpc-advanced:*`
|
||||
|
||||
## Context
|
||||
|
||||
The advanced plan covers sixteen capabilities that differ by orders of magnitude in what they bring
|
||||
with them. gRPC-Web adds a proxy. Reactor adds a dependency. xDS adds a control plane, its outage
|
||||
modes, its own security boundary and its own version skew. Hedging duplicates production traffic.
|
||||
|
||||
Bundling them under one flag makes enabling the cheapest of those the same decision as enabling the
|
||||
most consequential.
|
||||
|
||||
## Decision
|
||||
|
||||
**One flag per capability**, under `ca-skeleton.grpc.advanced.<capability>.enabled`, all off by
|
||||
default.
|
||||
|
||||
**Four grades.** `ADVANCED_STABLE` starts on its flag; `EXPERIMENTAL` additionally needs a separate
|
||||
production approval, because the flag says somebody wanted the feature and the approval says somebody
|
||||
accepted that its failure modes are not fully characterised; `WATCH` cannot start at all; `DISABLED`
|
||||
is withdrawn.
|
||||
|
||||
`GrpcAdvancedModuleGuard` distinguishes the three refusals — flag unset, grade unstartable,
|
||||
production unapproved — because the remedy differs in each case.
|
||||
|
||||
**Promotion evidence is per capability.** `GrpcAdvancedPromotionEvidence` is one record per
|
||||
capability, so no promotion can drag another along;
|
||||
`GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` returns an empty list, and that is a tested
|
||||
property rather than a claim. Two thresholds: seven days of soak plus complete evidence for
|
||||
`ADVANCED_STABLE`, thirty for a Stable default, because the second means every deployment gets the
|
||||
capability's dependencies and its failure modes.
|
||||
|
||||
**Infrastructure is named per capability.** `GrpcAdvancedInfrastructureTestkit` records that
|
||||
gRPC-Web needs a proxy, Servlet needs a container, xDS needs a stoppable control plane and Kotlin
|
||||
needs a toolchain. A suite that runs without its infrastructure passes and establishes nothing, which
|
||||
is worse than not having one.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The Kotlin adapter fails closed here, and says why.** This repository has no Kotlin toolchain, so
|
||||
`GrpcKotlinCompatibilityGate.supportableHere()` returns false. The four contract requirements — one
|
||||
schema source, coroutine cancellation propagation, Flow backpressure inside the Stable bounds,
|
||||
platform evidence types preserved — are checkable and are checked; only the compile lane is missing.
|
||||
|
||||
**Edition 2026 cannot be used however its watch report reads.** `GrpcEdition2026Guard` is not
|
||||
conditional on the report, because letting a status record also authorise use means a schema moves
|
||||
onto an edition the moment somebody marks four fields SUPPORTED, with no promotion decision, no
|
||||
consumer migration and no ADR.
|
||||
|
||||
**xDS is not part of the Stable support statement.** It works, behind its flag and its approval;
|
||||
`GrpcXdsStartupGuard.advertisableAsStableSupport()` returns false so a support matrix cannot widen
|
||||
quietly.
|
||||
@@ -0,0 +1,62 @@
|
||||
# gRPC advanced capability support matrix
|
||||
|
||||
Every capability in `:grpc-advanced:*`, its grade, and what it would take to raise it.
|
||||
`GrpcAdvancedSupportMatrix` is the machine-readable form; `GrpcAdvancedCapability.defaultGrade`
|
||||
carries the same values.
|
||||
|
||||
All capabilities are off by default. Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`.
|
||||
|
||||
## Grades
|
||||
|
||||
| Grade | May start | Production needs a separate approval |
|
||||
| --- | --- | --- |
|
||||
| `ADVANCED_STABLE` | Yes | No |
|
||||
| `EXPERIMENTAL` | Yes | Yes |
|
||||
| `WATCH` | No | — |
|
||||
| `DISABLED` | No | — |
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Flag | Grade | Real infrastructure its evidence needs |
|
||||
| --- | --- | --- | --- |
|
||||
| Protobuf Edition 2024 | `edition-2024` | `ADVANCED_STABLE` | — |
|
||||
| Protobuf Edition 2026 | `edition-2026` | `WATCH` | — |
|
||||
| Client streaming | `client-streaming` | `ADVANCED_STABLE` | — |
|
||||
| Bidirectional streaming | `bidi-streaming` | `ADVANCED_STABLE` | — |
|
||||
| Manual flow control | `manual-flow-control` | `ADVANCED_STABLE` | — |
|
||||
| Read-only unary hedging | `hedging` | `EXPERIMENTAL` | — |
|
||||
| Custom name resolver | `custom-resolver` | `ADVANCED_STABLE` | — |
|
||||
| Custom load balancer | `custom-load-balancer` | `EXPERIMENTAL` | — |
|
||||
| Proxyless xDS | `xds` | `EXPERIMENTAL` | xDS control plane |
|
||||
| gRPC-Web | `grpc-web` | `ADVANCED_STABLE` | gRPC-Web proxy |
|
||||
| Servlet HTTP/2 | `servlet-compat` | `ADVANCED_STABLE` | Servlet container |
|
||||
| Spring Integration bridge | `integration-bridge` | `ADVANCED_STABLE` | — |
|
||||
| Reactor adapter | `reactor` | `ADVANCED_STABLE` | — |
|
||||
| Kotlin coroutine / Flow | `kotlin` | `ADVANCED_STABLE` | Kotlin toolchain |
|
||||
| Channelz / CSDS diagnostics | `channel-diagnostics` | `ADVANCED_STABLE` | — |
|
||||
|
||||
## What the grades mean here, concretely
|
||||
|
||||
**Grade is a statement about the contract, not about a deployment.** Every capability's contract is
|
||||
implemented and tested in this repository. What no capability has is evidence from a real deployment:
|
||||
`GrpcAdvancedPromotionEvidence` for each one is empty, and no promotion has been granted.
|
||||
|
||||
**Four capabilities cannot produce meaningful evidence here at all**, because the infrastructure they
|
||||
need is absent. `GrpcAdvancedInfrastructureTestkit.missingInfrastructure` names them, and a suite that
|
||||
runs without its infrastructure passes and establishes nothing.
|
||||
|
||||
**Kotlin is the sharpest case.** This repository has no Kotlin toolchain, so
|
||||
`GrpcKotlinCompatibilityGate.supportableHere()` returns false and always will until one exists. The
|
||||
four contract requirements — one schema source shared with Java, coroutine cancellation propagated,
|
||||
Flow backpressure inside the Stable buffer bounds, platform evidence types preserved — are checkable
|
||||
without a toolchain and are checked. The compile lane is not.
|
||||
|
||||
## Promotion thresholds
|
||||
|
||||
| To | Soak | Also required |
|
||||
| --- | --- | --- |
|
||||
| `ADVANCED_STABLE` | 7 days | compatibility evidence, security review, fault evidence, performance evidence, ADR, runbook, real-environment test |
|
||||
| Stable default | 30 days | all of the above, plus a dependency, security and operational-cost review |
|
||||
|
||||
`WATCH` becomes `EXPERIMENTAL` before anything else. Promotions are independent: promoting one
|
||||
capability changes no other's grade.
|
||||
@@ -0,0 +1,75 @@
|
||||
# gRPC platform support matrix
|
||||
|
||||
What the Stable gRPC platform (`:grpc:*`) is certified against, what it is only checked against, and
|
||||
what is merely watched. The distinction is the point: "works with Spring Boot" is not a statement
|
||||
anyone can act on.
|
||||
|
||||
`GrpcCompatibilityMatrix.caSkeleton()` is the machine-readable form of this table, and
|
||||
`GrpcStableReleaseGate` blocks a release when a certified lane has no result or a failing one.
|
||||
|
||||
## Lanes
|
||||
|
||||
| Lane | Grade | Failure blocks a release |
|
||||
| --- | --- | --- |
|
||||
| Boot-managed platform (Spring Boot 4.0.8 BOM) | Certified | Yes |
|
||||
| proto3 with explicit `optional` | Certified | Yes |
|
||||
| `grpc-netty-shaded` | Certified | Yes |
|
||||
| `grpc-netty` (unshaded) | Compatibility | No |
|
||||
| Upstream gRPC Java version override | Compatibility | No |
|
||||
| Protobuf Edition 2024 | Watch | No |
|
||||
| Protobuf Edition 2026 | Watch | No |
|
||||
|
||||
## Runtime baseline
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Java | 21 |
|
||||
| Spring Boot | 4.0.8 (the repository baseline; the plans assume 4.1) |
|
||||
| io.grpc | `ext.grpcVersion` in `src/build.gradle` |
|
||||
| Protobuf | `ext.protobufVersion` in `src/build.gradle` |
|
||||
| Stable transport | Netty (shaded) |
|
||||
| Stable RPC shapes | Unary, Server Streaming |
|
||||
| Stable resolvers | Static, DNS, Unix domain socket |
|
||||
| Stable load balancing | `pick_first`, `round_robin` |
|
||||
|
||||
## Evidence grades
|
||||
|
||||
A capability may only be advertised on evidence of a grade that can establish it.
|
||||
`GrpcEvidenceGrade.requireCertifies` enforces this, and `GrpcReleaseEvidence.supports` refuses a
|
||||
claim backed by the wrong lane.
|
||||
|
||||
| Grade | Lane | Establishes |
|
||||
| --- | --- | --- |
|
||||
| `CONTRACT` | `grpcInProcessContractTest` | adapter, interceptor order, status mapping, validation, idempotency replay, context propagation |
|
||||
| `TRANSPORT` | `grpcNettyContractTest` | HTTP/2, TLS, mTLS, metadata limit, message limit, GOAWAY, keepalive, graceful shutdown |
|
||||
| `FAULT` | `grpcFaultTest` | connection loss, completion unknown, partial stream, evidence classifier |
|
||||
| `PERFORMANCE` | `grpcPerformanceTest` | latency, stream saturation, executor saturation, drain budget |
|
||||
|
||||
In-process results are never transport evidence. The in-process transport does not negotiate TLS,
|
||||
does not frame HTTP/2 and does not enforce transport-level limits, so a suite that passes there has
|
||||
tested the adapter and not the transport.
|
||||
|
||||
## What is not supported
|
||||
|
||||
| | Where it lives |
|
||||
| --- | --- |
|
||||
| Client streaming, bidirectional streaming | `grpc-advanced-streaming` |
|
||||
| Manual flow control | `grpc-advanced-streaming` |
|
||||
| Hedging | `grpc-advanced-resilience` |
|
||||
| Custom name resolver, custom load balancer | `grpc-advanced-resilience` |
|
||||
| xDS | `grpc-advanced-resilience` |
|
||||
| gRPC-Web, Servlet HTTP/2, Spring Integration, Reactor, Kotlin | `grpc-advanced-compat` |
|
||||
| Channelz / CSDS diagnostics | `grpc-advanced-diagnostics` |
|
||||
|
||||
## Current release status
|
||||
|
||||
Not released. Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the
|
||||
platform is build-only: it compiles, its lanes run, and no deployed artifact carries it.
|
||||
|
||||
Two release gate inputs are outstanding and are the work between here and a release:
|
||||
|
||||
- **Performance baseline.** The performance lane runs and asserts shape — ordered percentiles, a gate
|
||||
that reads them — rather than absolute numbers. A recorded baseline on a known runner is what turns
|
||||
it into a regression gate.
|
||||
- **Schema codegen.** No `protoc` runs in this build (ADR-GRPC-002), so the descriptor artifact and
|
||||
the consumer-compile fixture are governed as policy rather than produced from a compiled schema.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Runbook: gRPC advanced capabilities
|
||||
|
||||
Scope: the `:grpc-advanced:*` family. Everything here is off by default and stays off until a
|
||||
deployment names it. Nothing in this family ships in a runtime composition today.
|
||||
|
||||
Flags are `ca-skeleton.grpc.advanced.<capability>.enabled`. `GrpcAdvancedCapability` owns the list of
|
||||
capability names; `GrpcAdvancedSupportMatrix` owns their current grades.
|
||||
|
||||
---
|
||||
|
||||
## A capability refuses to start
|
||||
|
||||
`GrpcAdvancedModuleGuard` gives three different refusals, and the remedy differs:
|
||||
|
||||
| Message contains | Meaning | Remedy |
|
||||
| --- | --- | --- |
|
||||
| "its feature flag is not set" | Nobody enabled it | Set the property named in the message |
|
||||
| "tracked rather than implemented" | Grade is `WATCH` | Nothing to do here; the capability is not implemented |
|
||||
| "uncharacterised failure modes" | Grade is `EXPERIMENTAL` and this is production | Record a production approval, or run it outside production |
|
||||
|
||||
The refusal message always names the property key, so the first case is a configuration line rather
|
||||
than a support question.
|
||||
|
||||
---
|
||||
|
||||
## xDS: the control plane went away
|
||||
|
||||
**What you are seeing.** The control plane is unreachable and clients are still routing.
|
||||
|
||||
**What it means.** `GrpcXdsFailurePolicy` serves the last-known-good snapshot, up to its staleness
|
||||
bound.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. Check the snapshot's age. `SERVE_LAST_KNOWN_GOOD` is the healthy degraded state.
|
||||
2. `STALE_BEYOND_BOUND` means the snapshot is older than the policy allows and is no longer trusted.
|
||||
Beyond that bound, a decommissioned backend would otherwise keep receiving traffic indefinitely.
|
||||
3. `NO_SNAPSHOT_YET` on a starting instance means it never reached the control plane. It fails after
|
||||
the initial fetch timeout rather than starting with no routing.
|
||||
|
||||
**Do not** add application-level retry policy while xDS is enabled. `GrpcXdsStartupGuard` refuses it,
|
||||
because retry defined in two places has a winner that depends on resolution order rather than on a
|
||||
decision.
|
||||
|
||||
---
|
||||
|
||||
## gRPC-Web: a browser call hangs and then fails with no status
|
||||
|
||||
**Almost always the proxy.** A gRPC status arrives as a trailer, and a browser cannot read a trailer
|
||||
the proxy did not expose. Check that the proxy's CORS `expose_headers` includes `grpc-status` and
|
||||
`grpc-message`; `GrpcWebProxyContract.violations` reports exactly this, and the reference
|
||||
configuration in `envoy/envoy.yaml` shows it in place.
|
||||
|
||||
**If the method is client- or bidirectional-streaming**, it cannot work over gRPC-Web at all — a
|
||||
browser has no way to send a stream of messages. `GrpcWebCompatibilityGate` reports such a method
|
||||
before it is exposed.
|
||||
|
||||
---
|
||||
|
||||
## Servlet: a transport setting appears to be ignored
|
||||
|
||||
It is ignored. The container owns the socket, so keepalive tuning, maximum connection age and
|
||||
flow-control window tuning belong to it. `GrpcServletStartupValidator` refuses those settings at
|
||||
startup rather than accepting and dropping them, because a setting that is silently ignored sends the
|
||||
investigation somewhere else.
|
||||
|
||||
A Servlet run never substitutes for Netty certification.
|
||||
|
||||
---
|
||||
|
||||
## Hedging: backend load doubled
|
||||
|
||||
**Expected, within a bound.** Hedging trades duplicate load for tail latency.
|
||||
`GrpcHedgingResult` records both `duplicateBackendCalls` and `cancelledLoserAttempts`; a dashboard
|
||||
showing only the latency improvement makes the trade look free.
|
||||
|
||||
**What to check.** `GrpcHedgingBudget` caps hedges as a fraction of completed calls. If duplicate
|
||||
load is above that fraction, the budget is not being consumed — which means something is issuing
|
||||
hedges outside the coordinator.
|
||||
|
||||
**Hedging is refused** for anything but a read-only unary method. A hedged mutation runs twice by
|
||||
design, and an idempotency key does not help: the second attempt duplicates a success in progress
|
||||
rather than retrying a failure.
|
||||
|
||||
---
|
||||
|
||||
## Promoting a capability
|
||||
|
||||
`GrpcAdvancedPromotionGate.evaluate` names every missing item. Promotion to `ADVANCED_STABLE` needs
|
||||
compatibility evidence, a security review, fault evidence, performance evidence, an ADR, a runbook, a
|
||||
real-environment test and seven days of soak. A Stable default needs thirty.
|
||||
|
||||
Promotions are independent: promoting one capability changes no other's grade, and
|
||||
`GrpcAdvancedSupportMatrix.apply` refuses a decision made against a different matrix state.
|
||||
|
||||
Before citing a suite as evidence, check `GrpcAdvancedInfrastructureTestkit.missingInfrastructure`.
|
||||
A suite that ran without the proxy, the container, the control plane or the toolchain it needs passed
|
||||
and established nothing.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Runbook: gRPC platform operations
|
||||
|
||||
Scope: the `:grpc:*` family. All of it is build-only today — every leaf's `runtime_memberships` is
|
||||
empty — so nothing here fires in production yet. It is written now because the states it covers are
|
||||
the ones an on-call cannot work out from first principles at three in the morning, and shipping the
|
||||
behaviour before the runbook means the first person to meet one is doing that.
|
||||
|
||||
Configuration lives under `ca-skeleton.grpc.platform.*` and is bound by `GrpcPlatformProperties`.
|
||||
The platform does not start unless `ca-skeleton.grpc.platform.enabled=true`.
|
||||
|
||||
---
|
||||
|
||||
## COMPLETION_UNKNOWN on a mutation
|
||||
|
||||
**What you are seeing.** A client received `DEADLINE_EXCEEDED`, `UNAVAILABLE` or `INTERNAL` on a
|
||||
state-changing call, and the response trailer `completion-outcome` reads `COMPLETION_UNKNOWN`.
|
||||
|
||||
**What it means.** The server may have committed. This is not a failure and not a success; the status
|
||||
code cannot distinguish them, which is why the outcome is carried separately.
|
||||
|
||||
**What not to do.** Do not re-issue the call. Do not tell the caller it failed. Both are wrong half
|
||||
the time, and which half is not knowable from the status.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. Take the `error-execution-id` from the trailers. It is the only link between what the client saw
|
||||
and what the server did.
|
||||
2. If the method is `IDEMPOTENCY_KEY_REQUIRED`, query the operation ledger with the caller
|
||||
fingerprint, the full method name and the caller's key. `GrpcOperationStatusQuery` returns one of
|
||||
`IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`, `NOT_FOUND` or `UNKNOWN`.
|
||||
3. `COMMITTED` means return the stored outcome reference, not a freshly computed answer — the resource
|
||||
may have changed since, and a new answer would describe the state at reconciliation time rather
|
||||
than the state the caller's own call produced.
|
||||
4. `NOT_FOUND` means the operation never started and is safe to re-issue. `FAILED_TERMINAL` means the
|
||||
same.
|
||||
5. `UNKNOWN` means the ledger could not be consulted. Nothing may be concluded. The case is queued by
|
||||
`GrpcCompletionReconciler` and retried later.
|
||||
6. If the method is not keyed, there is no ledger row. Resolve it against the business resource, or
|
||||
escalate to the service owner. This is the case the keyed profile exists to avoid.
|
||||
|
||||
**Escalate when** the reconciler's pending list grows across passes. That means the ledger is
|
||||
unreachable rather than slow.
|
||||
|
||||
---
|
||||
|
||||
## A stream ended with FULL_RESYNC_REQUIRED
|
||||
|
||||
**What you are seeing.** A client's resume was refused and it was told to resynchronise.
|
||||
|
||||
**What it means.** The server can no longer replay from the client's cursor. Either the snapshot
|
||||
version moved, or the cursor predates retained history.
|
||||
|
||||
**What to do.** Nothing on the server. The client is expected to discard its position and start a new
|
||||
stream from a fresh snapshot. A client that instead retries the same token will keep receiving the
|
||||
same answer.
|
||||
|
||||
**Escalate when** it is happening to many clients at once. That usually means history retention was
|
||||
reduced, or snapshots are rotating faster than clients reconnect.
|
||||
|
||||
---
|
||||
|
||||
## A stream ended with SLOW_CONSUMER
|
||||
|
||||
**What you are seeing.** Streams terminating with `SLOW_CONSUMER`, and
|
||||
`grpc.stream.flow_control_stalls` rising.
|
||||
|
||||
**What it means.** The consumer could not keep up with the bounded queue. The stream was terminated
|
||||
rather than silently dropping messages, because a client cannot detect drops — the sequence numbers
|
||||
it sees are the ones it was sent.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. Check whether the consumer is slow or the producer is fast. `grpc.stream.messages` against
|
||||
`grpc.stream.lifetime` tells you the rate.
|
||||
2. If the consumer is slow, the fix is on the consumer. Raising the queue bound moves the failure
|
||||
later and makes it larger.
|
||||
3. A resume is not available after this ending: the messages that overflowed the queue are gone, so
|
||||
continuing from the last delivered sequence would silently skip them. The client resynchronises.
|
||||
|
||||
---
|
||||
|
||||
## RESOURCE_EXHAUSTED under load
|
||||
|
||||
**What you are seeing.** Calls refused with `RESOURCE_EXHAUSTED` and `GrpcAdmissionController`
|
||||
reporting rejections.
|
||||
|
||||
**What it means.** The server is at its concurrency and queue bounds and is shedding rather than
|
||||
queueing. This is the designed behaviour: accepting work whose callers have already given up spends
|
||||
capacity on nothing.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. Read `grpc.rpc.duration` and `grpc.rpc.queue_wait` separately. Queue time rising with duration flat
|
||||
means the bottleneck is admission, not the work.
|
||||
2. Check which saturation counter is moving — executor, channel or flow control. They look identical
|
||||
in a latency graph and have different fixes.
|
||||
3. Raising `ca-skeleton.grpc.platform.executor-queue-capacity` defers the problem; it does not remove
|
||||
it. `GrpcExecutorProfile` refuses a queue above ten thousand for that reason.
|
||||
|
||||
---
|
||||
|
||||
## A rollout is producing errors at every deploy
|
||||
|
||||
**What you are seeing.** A burst of `UNAVAILABLE` or `CANCELLED` each time an instance goes away.
|
||||
|
||||
**What it means.** The drain sequence is not completing, or is running out of order.
|
||||
|
||||
**What to do.**
|
||||
|
||||
1. `GrpcDrainResult` records what each drain achieved: completed and cancelled unary calls, signalled
|
||||
and cancelled streams, and which phases ran. A drain that routinely force-cancels is the cause.
|
||||
2. The order matters. Readiness flips first and nothing is refused during that window, because there
|
||||
is a gap between an instance reporting unready and routing acting on it. Refusing during that gap
|
||||
turns a clean rollout into a burst of errors at every deploy.
|
||||
3. For long streams, check the Kubernetes profile's `streamReconnectBudget` and
|
||||
`readinessDrainGrace`. A stream is pinned to one pod for its whole life, so every rollout ends it;
|
||||
a profile with long streams and no reconnect budget has not decided what clients do next.
|
||||
|
||||
---
|
||||
|
||||
## Verifying a deployment's configuration
|
||||
|
||||
`GrpcPlatformSnapshotService` produces a secret-free snapshot for a caller on the admin network
|
||||
holding an admin role. Both gates are required.
|
||||
|
||||
`GrpcPlatformSnapshotService.driftAgainstRelease` compares a running snapshot with the release
|
||||
manifest and reports schema version, method policy hash and per-channel profile differences. An
|
||||
instance running a configuration the release did not ship is behind a whole class of incidents that
|
||||
are otherwise diagnosed by reading logs.
|
||||
|
||||
The snapshot carries hashes and names only. A field whose name looks like a credential is refused at
|
||||
construction rather than redacted.
|
||||
|
||||
---
|
||||
|
||||
## Things that are deliberately off
|
||||
|
||||
- **Reflection in production.** `GrpcReflectionMode.defaultFor` returns `DISABLED` for `STAGE` and
|
||||
`PROD`. Reflection publishes the whole schema to anyone who can open a connection.
|
||||
- **Every advanced capability.** See `docs/runbooks/grpc-advanced-capabilities.md`.
|
||||
- **The platform itself.** `ca-skeleton.grpc.platform.enabled` defaults to false, and every `:grpc:*`
|
||||
leaf is build-only in the registry.
|
||||
@@ -0,0 +1,117 @@
|
||||
# 타입 안전 gRPC 실행 플랫폼 — 실행 계획과 실행 결과
|
||||
|
||||
설계 SSOT: [docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md](../specs/2026-08-30-grpc-platform-adaptation-design.md)
|
||||
|
||||
원본:
|
||||
- `docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md` (Stable, Task 1–53)
|
||||
- `docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md` (Advanced, Task 1–18)
|
||||
|
||||
이 문서는 실행 전 계획이자 실행 결과 기록이다. 각 phase는 leaf 단위로 닫혔고, 닫힘 조건은
|
||||
`./gradlew <gradle-path>:check` 통과다 — 즉 test + spotless + checkstyle + SpotBugs(HIGH) +
|
||||
Error Prone/`-Werror` + 저장소 전역 게이트 전이 실행이다.
|
||||
|
||||
## Phase 0 — 레지스트리와 스캐폴딩
|
||||
|
||||
- [x] `src/config/architecture/modules.json`에 18개 leaf 등록 (`grpc:*` 12, `grpc-advanced:*` 6)
|
||||
- [x] leaf별 `build.gradle` 18개. io.grpc를 쓰는 leaf는 `grpc-bom`을 module scope로 import
|
||||
- [x] `src/build.gradle`: `:grpc:` / `:grpc-advanced:` 를 plain JUnit+AssertJ 테스트 분기에 추가
|
||||
(`messaging:*`와 같은 이유 — core-api가 Spring도 io.grpc도 이름 부르지 않는다는 주장을
|
||||
검증 가능하게 만든다)
|
||||
- [x] `./gradlew --write-locks ...resolveAndLockAll` 로 lockfile 18개 생성
|
||||
- [x] `verifyCleanArchitectureDependencies` 통과
|
||||
|
||||
## Phase 1 — Foundation (`grpc-core-api`, Stable Task 1–7 + ledger port)
|
||||
|
||||
- [x] Task 1 `GrpcStableModuleCatalog` / `GrpcStableBuildInvariant`
|
||||
- [x] Task 2 `GrpcMethodName` / `GrpcServiceName` / `GrpcChannelProfileName` / `RpcType` / `GrpcStatusCode`
|
||||
- [x] Task 3 `RpcIdempotencyProfile` / `WaitForReadyPolicy` / `GrpcMethodPolicy` / `GrpcMethodPolicyCatalog`
|
||||
- [x] Task 4 `GrpcTransportEvidence` / `GrpcBusinessEvidence` / `GrpcStreamEvidence` / `GrpcExecutionEvidence`
|
||||
- [x] Task 5 `GrpcFailureCategory` / `GrpcCompletionOutcome` / `GrpcFailureContext` / `GrpcPlatformException`
|
||||
- [x] Task 6 `GrpcDeadlineProfile` / `GrpcDeadlineBudget` / `GrpcCancellationToken` / `GrpcDeadlineExceededException`
|
||||
- [x] Task 7 `GrpcRequestContext` / `GrpcMetadataKey` / `GrpcMetadataBudget` / `GrpcClientIdentity`
|
||||
- [x] 추가: `dev.caskeleton.grpc.ledger` port (`GrpcOperationLedger` 외 3) — 정책 계층이 DB에 의존하지
|
||||
않고 durable idempotency를 요구할 수 있게 하기 위해 core-api에 둔다
|
||||
|
||||
## Phase 2 — Contract governance (`grpc-proto-contract`, `grpc-codegen`, Task 8–11)
|
||||
|
||||
- [x] Task 8 `.proto` 2개 + `buf.yaml` + `GrpcProtoStyleManifest` / `GrpcProtoRuleViolation` / `GrpcProtoContractValidator`
|
||||
- [x] Task 9 `GrpcBufPolicy` / `GrpcBreakingCategory` / `GrpcSchemaBaseline`
|
||||
- [x] Task 10 `GrpcCodegenManifest` / `GrpcGeneratedPackagePolicy` / `GrpcCodegenOutput`
|
||||
- [x] Task 11 `GrpcDescriptorArtifact` / `GrpcConsumerFixture` / `GrpcSchemaArtifactPublisher`
|
||||
- 편차: protoc·Buf CLI 미실행. 근거는 ADR-GRPC-002
|
||||
|
||||
## Phase 3 — Policy (`grpc-policy`, Task 12·16·17·20·28–31·33·34·37–43)
|
||||
|
||||
- [x] Task 12 validation, Task 16 context propagation, Task 17 status/rich error, Task 20 TLS/credential rotation
|
||||
- [x] Task 28 deadline calculator, Task 29 cancellation coordinator
|
||||
- [x] Task 30 service config/retry owner, Task 31 retry eligibility/budget/coordinator, Task 42 wait-for-ready
|
||||
- [x] Task 33 idempotency interceptor, Task 34 completion reconciliation
|
||||
- [x] Task 37–41 stream envelope·writer·flow control·resume token·lifetime
|
||||
- [x] Task 43 message size / compression / payload boundary
|
||||
|
||||
## Phase 4 — Server boundary (`grpc-server`, Task 13–15·18–19)
|
||||
|
||||
- [x] Task 13 boundary rules + raw API import rule, Task 14 typed service adapter SPI
|
||||
- [x] Task 15 interceptor 순서 계약, Task 18 Netty profile/executor/admission, Task 19 shaded parity
|
||||
|
||||
## Phase 5 — Client / discovery / admin / observability / ledger
|
||||
|
||||
- [x] `grpc-client` Task 24–27
|
||||
- [x] `grpc-discovery` Task 35–36
|
||||
- [x] `grpc-admin` Task 21–23·45
|
||||
- [x] `grpc-observability` Task 44
|
||||
- [x] `grpc-operation-ledger-jpa` Task 32 (entity + repository + migration + port impl)
|
||||
|
||||
## Phase 6 — Composition과 인증 (`grpc-spring-boot-starter`, `grpc-testkit`)
|
||||
|
||||
- [x] Task 52 properties / auto-configuration / startup validator
|
||||
- [x] Task 46 in-process fixture, Task 47 실제 Netty + TLS/mTLS fixture
|
||||
- [x] Task 48 fault point / scenario / evidence classifier
|
||||
- [x] Task 49–50 unary·streaming contract suite, Task 51 performance budget/gate
|
||||
- [x] Task 53 compatibility matrix / release evidence / release gate
|
||||
- [x] strict test lane 4개 등록 및 실행: `grpcInProcessContractTest`, `grpcNettyContractTest`,
|
||||
`grpcFaultTest`, `grpcPerformanceTest`
|
||||
|
||||
## Phase 7 — Advanced (`grpc-advanced:*`, Advanced Task 1–18)
|
||||
|
||||
- [x] A1·A18 `grpc-advanced-bootstrap`
|
||||
- [x] A2·A3 `grpc-advanced-edition`
|
||||
- [x] A4–A7 `grpc-advanced-streaming`
|
||||
- [x] A8–A11 `grpc-advanced-resilience`
|
||||
- [x] A12–A16 `grpc-advanced-compat`
|
||||
- [x] A17 `grpc-advanced-diagnostics`
|
||||
|
||||
## Phase 8 — 문서와 게이트
|
||||
|
||||
- [x] `src/grpc/CLAUDE.md`, `src/grpc-advanced/CLAUDE.md`
|
||||
- [x] ADR-GRPC-001..005, ADR-GRPC-ADV-001
|
||||
- [x] `docs/runbooks/grpc-platform-operations.md`, `docs/runbooks/grpc-advanced-capabilities.md`
|
||||
- [x] `docs/compatibility/grpc-support-matrix.md`, `docs/compatibility/grpc-advanced-support-matrix.md`
|
||||
- [x] `verifyRunbookReferences`, `verifyDocumentedLeafCount` 통과
|
||||
|
||||
## Phase 9 — 2026-08-31 계획 대조 감사
|
||||
|
||||
사용자가 "빠짐없이 반영한게 맞나"를 물어 계획의 `Files:` 절 전체를 기계 대조했다. 결과와 조치는
|
||||
설계 문서 §6이 SSOT다. 요약:
|
||||
|
||||
- [x] 대조 스크립트 실행 → 초기 결과 present=316 / missing=38
|
||||
- [x] 실제 누락 6건 보완: `GrpcKubernetesProfileValidator`, `ADR-GRPC-006`(discovery/Kubernetes),
|
||||
xDS `bootstrap.json`, `control-plane-snapshot.json`, `DocumentClientFixture`,
|
||||
`buf.gen.yaml`+`buf.lock`
|
||||
- [x] 추가한 픽스처는 전부 실제 검사에 물렸다 — 장식이 되지 않도록:
|
||||
`GrpcXdsStartupGuard.bootstrapMismatches`(namespace 불일치·비TLS control plane),
|
||||
redactor가 실제 모양의 CSDS 데이터로 검증, `GrpcConsumerFixture.fromJavaSource`가 fixture
|
||||
소스에서 요구사항을 역산
|
||||
- [x] 테스트 클래스 17건을 계획이 명시한 이름으로 분리
|
||||
- [x] 재대조 → present=341 / missing=13, 잔여 13건은 전부 §6.1–6.4의 기록된 편차
|
||||
(ADR 파일명 6, codegen convention plugin 3, TLS 인증서 3, Kotlin `.kt` 1)
|
||||
|
||||
## 남은 작업 (이 계획 범위 밖, 별도 결정 필요)
|
||||
|
||||
1. **런타임 투입.** 신규 leaf 전부 `runtime_memberships: []`다. `app-bootstrap`에 배선하려면 registry
|
||||
membership 변경 + `verifyRuntimeModuleMembership` 통과가 선행이며, 그것은 별도 결정이다.
|
||||
2. **`adapter:inbound:grpc` 브리지.** registry의 `allowed_dependencies`에 `grpc-core-api`·`grpc-server`를
|
||||
추가하고 typed service adapter를 배선하는 작업. 플랫폼이 green이 된 지금이 시작점이다.
|
||||
3. **protoc / Buf CLI 활성화.** ADR-GRPC-002가 조건과 비용을 기록한다.
|
||||
4. **performance baseline 기록.** 현재 lane은 shape만 검증한다. 알려진 runner에서의 baseline이
|
||||
regression gate를 만든다.
|
||||
@@ -0,0 +1,208 @@
|
||||
# 타입 안전 gRPC 실행 플랫폼 — 이 저장소 규약으로의 어댑팅 설계
|
||||
|
||||
두 계획 문서를 이 저장소(`ca-skeleton`)의 실제 구조·정책·빌드 게이트에 맞춰 실행 가능한 형태로
|
||||
옮기는 설계다.
|
||||
|
||||
- 원본 A: `docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md` (Stable, Task 1–53)
|
||||
- 원본 B: `docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md` (Advanced, Task 1–18)
|
||||
|
||||
원본은 `modules/grpc/**` · Gradle Kotlin DSL · 패키지 `io.backend.skeleton.grpc` · Spring Boot 4.1을
|
||||
전제한다. 이 저장소는 `src/**` · Groovy DSL · `dev.caskeleton` · Spring Boot 4.0.8 · fail-closed
|
||||
module registry를 쓴다. 원본 Global Constraint의 마지막 항목이 이 어댑팅을 명시적으로 허용한다:
|
||||
"실제 저장소 구조가 예상 경로와 다르면 파일 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는
|
||||
변경하지 않는다."
|
||||
|
||||
## 1. 패밀리 위치 — 왜 `adapter:inbound:grpc` 확장이 아닌가
|
||||
|
||||
원본이 기술하는 것은 인바운드 어댑터 하나가 아니라 **자기 API·SPI·adapter·조립 경계를 가진 벤더드
|
||||
RPC 플랫폼**이다. 이 저장소에는 그 형태의 선례가 이미 있다 — `messaging:*`. root `CLAUDE.md`가
|
||||
직접 그렇게 규정한다: "vendored messaging platform: a product with its own API, SPI, adapters and
|
||||
composition boundary, not a layer of this application".
|
||||
|
||||
따라서 gRPC 플랫폼도 같은 자리에 둔다.
|
||||
|
||||
```text
|
||||
src/grpc/ → :grpc:* Stable 플랫폼 (12 leaf)
|
||||
src/grpc-advanced/ → :grpc-advanced:* Advanced/Experimental (6 leaf)
|
||||
src/adapter/inbound/grpc 기존 인바운드 전송 어댑터 (그대로 유지)
|
||||
```
|
||||
|
||||
`grpc-advanced`를 별도 디렉터리·별도 Gradle prefix로 分離하는 이유는 원본 Stable Task 1의 불변
|
||||
조건 "Stable starter가 `modules/grpc-advanced`를 참조하면 build를 실패시킨다"를 registry의
|
||||
`allowed_dependencies`만으로 기계 검증할 수 있게 만들기 위해서다. `verifyCleanArchitectureDependencies`가
|
||||
그 게이트다.
|
||||
|
||||
패키지 루트는 `dev.caskeleton.grpc` / `dev.caskeleton.grpc.advanced`다.
|
||||
|
||||
## 2. Leaf 매핑 — 원본 31개 모듈 → 이 저장소 18개 leaf
|
||||
|
||||
원본 모듈 경계 중 **이 저장소가 이미 다른 메커니즘으로 표현하는 것**만 합친다. 능력(capability)은
|
||||
하나도 버리지 않는다.
|
||||
|
||||
### Stable — `src/grpc/`
|
||||
|
||||
| leaf | 원본 모듈 | 담는 Task | 의존 |
|
||||
| --- | --- | --- | --- |
|
||||
| `grpc-core-api` | grpc-core-api | 2–7 | (없음, Java stdlib) |
|
||||
| `grpc-proto-contract` | grpc-proto-contract | 8 | core-api |
|
||||
| `grpc-codegen` | grpc-codegen | 9–11 | core-api, proto-contract |
|
||||
| `grpc-policy` | grpc-policy | 12, 16, 17, 20, 28–31, 33, 34, 37–43 | core-api |
|
||||
| `grpc-server` | grpc-server | 13–15, 18, 19 | core-api, policy |
|
||||
| `grpc-client` | grpc-client | 24–27 | core-api, policy |
|
||||
| `grpc-discovery` | grpc-discovery | 35, 36 | core-api, client |
|
||||
| `grpc-admin` | grpc-admin | 21–23, 45 | core-api, server |
|
||||
| `grpc-observability` | grpc-observability | 44 | core-api |
|
||||
| `grpc-operation-ledger-jpa` | grpc-operation-ledger-jpa | 32 | core-api |
|
||||
| `grpc-spring-boot-starter` | grpc-spring-boot-starter | 52 | 위 전부 |
|
||||
| `grpc-testkit` | testkit-core + -inprocess + -netty + -fault | 46–51, 53 | 위 전부 |
|
||||
|
||||
**testkit 4개를 1개로 합친 근거.** 원본이 testkit을 넷으로 쪼갠 목적은 "in-process 증거를 실제
|
||||
네트워크 증거로 오인하지 않게 한다"이다. 이 저장소는 그 목적을 모듈 경계가 아니라 **strict test
|
||||
lane** 컨벤션(`ca.strict-test-lane`)으로 이미 표현한다 — lane은 태그/소스셋/명시 테스트 중 하나로만
|
||||
선택하고, 아무것도 실행하지 않으면 실패하며, up-to-date 결과를 제공하지 않는다. 그래서 네 모듈은
|
||||
`grpc-testkit` 한 leaf 안의 네 lane이 된다:
|
||||
`grpcInProcessContractTest`, `grpcNettyContractTest`, `grpcFaultTest`, `grpcPerformanceTest`.
|
||||
원본의 "in-process는 HTTP/2·TLS 증거가 아니다"는 lane 분리와 `GrpcEvidenceGrade`로 강제한다.
|
||||
|
||||
### Advanced — `src/grpc-advanced/`
|
||||
|
||||
| leaf | 원본 모듈 | 담는 Task |
|
||||
| --- | --- | --- |
|
||||
| `grpc-advanced-bootstrap` | grpc-advanced-bootstrap | A1, A18 |
|
||||
| `grpc-advanced-edition` | grpc-edition-2024 + grpc-edition-2026-experimental | A2, A3 |
|
||||
| `grpc-advanced-streaming` | grpc-client-streaming + grpc-bidi-streaming + grpc-manual-flow-control | A4–A7 |
|
||||
| `grpc-advanced-resilience` | grpc-hedging + grpc-custom-resolver + grpc-custom-load-balancer + grpc-xds | A8–A11 |
|
||||
| `grpc-advanced-compat` | grpc-web + grpc-servlet-compat + grpc-integration-bridge + grpc-reactor + grpc-kotlin | A12–A16 |
|
||||
| `grpc-advanced-diagnostics` | grpc-channel-diagnostics | A17 |
|
||||
|
||||
Advanced 모듈은 capability마다 leaf를 나누는 대신 **capability grade와 feature flag**
|
||||
(`GrpcAdvancedCapability` / `GrpcAdvancedFeatureFlags`)로 분리한다. A18의 요구사항
|
||||
"Edition, streaming, xDS, gRPC-Web, Servlet, language adapter가 서로의 승격을 묶지 않는다"는
|
||||
leaf 경계가 아니라 capability별 독립 promotion evidence로 표현되므로, 합쳐도 그 불변 조건은 유지된다.
|
||||
|
||||
## 3. 원본과 달라지는 지점 (deviation)과 근거
|
||||
|
||||
| # | 원본 | 이 저장소 | 근거 |
|
||||
| --- | --- | --- | --- |
|
||||
| D1 | Spring Boot 4.1 BOM | Spring Boot 4.0.8 BOM | 저장소 실제 baseline(`src/build.gradle:13`). BOM이 SSOT라는 계약 자체는 유지 |
|
||||
| D2 | Boot-managed Spring gRPC starter | self-managed `io.grpc` (`ext.grpcVersion`) | 기존 기록된 결정(`adapter/inbound/grpc/README.md` "왜 self-managed Netty 인가"). starter 커플링 회피 |
|
||||
| D3 | Gradle Kotlin DSL, `settings.gradle.kts` | Groovy DSL + `config/architecture/modules.json` | registry가 leaf 목록의 SSOT이고 settings는 그것을 읽기만 한다 |
|
||||
| D4 | `io.backend.skeleton.grpc` | `dev.caskeleton.grpc` | 저장소 기본 패키지 |
|
||||
| D5 | Buf CLI (`bufLint`/`bufBreaking` 등) | 저장소 소유 규칙 엔진 + Gradle verify task | Buf CLI 바이너리가 이 환경에 없다. lint/format/breaking **규칙**을 Java로 구현해 동일 판정을 내리고, CLI는 동일 규칙을 재확인하는 선택 경로로 남긴다 |
|
||||
| D6 | protoc/grpc-java codegen을 빌드에서 실행 | `.proto` 소스 + codegen **정책·descriptor 계약**만 실행, protoc 실행은 명시적 확장점 | 아래 별도 절 |
|
||||
| D7 | `grpc-kotlin`의 `.kt` 소스 | Java 쪽 coroutine/Flow **경계 계약**만 | 저장소에 Kotlin 플러그인·소스셋이 없다. A16의 5개 요구사항 중 4개(스키마 단일 소스, cancellation 전파 계약, backpressure 우회 금지, evidence 타입 보존)는 Java 계약으로 표현 가능하고, Kotlin 툴체인 lane은 `GrpcKotlinCompatibilityGate`가 미충족으로 fail-closed 판정한다 |
|
||||
| D8 | 새 leaf가 곧 런타임 | 신규 leaf 전부 `runtime_memberships: []` (build-only) | `messaging:*`가 처음 착지한 방식과 동일. 런타임 투입은 registry membership 변경 + `verifyRuntimeModuleMembership` 통과가 선행 조건이며, 그것은 별도 결정이다 |
|
||||
|
||||
### D6 — protoc 실행을 지금 켜지 않는 이유
|
||||
|
||||
이 저장소의 모든 leaf는 예외 없이 spotless(google-java-format) · checkstyle · spotbugs(HIGH) ·
|
||||
errorprone · `-Werror`를 통과해야 한다(`src/build.gradle`의 `configure(subprojects)` 블록). protoc가
|
||||
만든 소스는 그 어느 것도 통과하지 못하므로, 실제 codegen을 켜려면 해당 소스셋에서 다섯 게이트를
|
||||
모두 끄는 carve-out이 필요하다. 저장소에 선례는 있다(`jmh` 소스셋). 하지만 그 carve-out은 Task
|
||||
10 하나를 위해 품질 게이트를 여는 결정이고, 그 결정은 이 작업 범위 밖의 승인 사항이다.
|
||||
|
||||
그래서 Task 8–11의 **불변 조건**은 전부 실행 가능한 형태로 구현한다:
|
||||
proto style 규칙 검증, 삭제 필드 `reserved` 이력 대조, Buf breaking category(`FILE`) 판정,
|
||||
generated package와 hand-written package 겹침 금지, descriptor+schema hash 릴리스 아티팩트,
|
||||
consumer fixture 실패 시 릴리스 차단. 빠지는 것은 protoc 프로세스 호출 하나이고,
|
||||
`GrpcCodegenManifest`가 그 지점을 단일 owner로 고정한 채 비워둔다.
|
||||
|
||||
## 4. 유지되는 원본 불변 조건 (변경 없음)
|
||||
|
||||
- 실행 증거 3축(Transport/Business/Stream) 분리, `RESPONSE_HEADERS_SEEN` → `COMMIT_CONFIRMED` 자동 승격 금지
|
||||
- `DEADLINE_EXCEEDED` mutation = `COMPLETION_UNKNOWN` 후보, `UNAVAILABLE`만으로 상태 변경 RPC 재호출 금지
|
||||
- `NON_IDEMPOTENT`에 explicit retry·hedging 금지, explicit retry owner는 하나
|
||||
- Stable Unary는 positive deadline 필수
|
||||
- Server Streaming: bounded queue + single serialized writer, partial delivery 후 whole-call retry 금지
|
||||
- Stable RPC 유형은 Unary·Server Streaming, Client/Bidi는 Advanced
|
||||
- production reflection 기본 비활성, dev·stage·prod TLS 필수, trust-all 금지
|
||||
- Stable resolver = Static·DNS, Stable LB = pick_first·round_robin
|
||||
- metric tag에 raw metadata/payload/actor/tenant/object/stream/idempotency ID 금지
|
||||
- Netty가 Stable certification transport, in-process는 network/TLS 증거가 아님
|
||||
|
||||
## 5. 애플리케이션이 이 패밀리에 도달하는 경로
|
||||
|
||||
`messaging:*`의 MSG-015(bridge 부재)를 반복하지 않는다. 계약상의 경로는
|
||||
|
||||
```text
|
||||
application-owned port → adapter:inbound:grpc (typed service adapter) → :grpc:grpc-server SPI
|
||||
```
|
||||
|
||||
이고, Stable Task 13/14가 그 경계를 소유한다(`GrpcApplicationBoundaryRules`,
|
||||
`GrpcRawApiImportRule`, `GrpcServiceAdapter`). 플랫폼이 green이 된 뒤 마지막 단계에서
|
||||
`adapter-inbound-grpc`의 `allowed_dependencies`에 `grpc-core-api`·`grpc-server`를 추가하고
|
||||
브리지를 배선한다. 그 전까지 플랫폼은 self-contained build-only다.
|
||||
|
||||
## 6. 원본 파일 목록 대비 대조 (2026-08-31 감사)
|
||||
|
||||
두 계획이 `Files:` 절에 명시한 산출물 전체를 기계적으로 대조했다. 감사 스크립트는 각 Task의 파일
|
||||
basename이 저장소에 존재하는지 확인한다(build 산출물 제외, 단 annotation processor가 생성하는
|
||||
`spring-configuration-metadata.json`은 build 출력에서 확인).
|
||||
|
||||
```text
|
||||
STABLE (Task 1–53) : present=250 missing=11
|
||||
ADVANCED (Task 1–18) : present= 91 missing= 2
|
||||
TOTAL : present=341 missing=13
|
||||
```
|
||||
|
||||
잔여 13건은 전부 아래 편차로 설명된다. **행동이 빠진 것은 없다.**
|
||||
|
||||
### 6.1 ADR 파일명 (6건) — 저장소 명명 규약
|
||||
|
||||
계획은 `ADR-060` ~ `ADR-065` 연번을 쓴다. 이 저장소의 `docs/adr/`는 접두사 규약을 쓴다
|
||||
(`ADR-WS-001`, `ADR-MONGO-004`, `ADR-WEB-ADV-003`). 내용은 1:1이다.
|
||||
|
||||
| 계획 | 이 저장소 |
|
||||
| --- | --- |
|
||||
| ADR-060 platform-boundary | `ADR-GRPC-001-platform-family-and-registry-shape.md` |
|
||||
| ADR-061 execution-evidence | `ADR-GRPC-003-three-axis-execution-evidence.md` |
|
||||
| ADR-062 retry-idempotency | `ADR-GRPC-004-retry-ownership-and-durable-idempotency.md` |
|
||||
| ADR-063 streaming-resume | `ADR-GRPC-005-server-streaming-single-writer-and-resume.md` |
|
||||
| ADR-064 discovery-kubernetes | `ADR-GRPC-006-stable-discovery-and-kubernetes-routing.md` |
|
||||
| ADR-065 advanced-promotion | `ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md` |
|
||||
|
||||
계획에 없던 `ADR-GRPC-002-schema-governance-without-protoc.md`가 추가로 있다 — D6 결정을 기록한다.
|
||||
|
||||
### 6.2 codegen convention plugin 3건 — D6
|
||||
|
||||
`io.backend.grpc-buf-conventions.gradle.kts`, `io.backend.grpc-codegen-conventions.gradle.kts`,
|
||||
그 `.properties`. protoc를 실행하지 않으므로 실행할 convention plugin이 없다. 이들이 고정했을 결정은
|
||||
`GrpcCodegenManifest`·`GrpcCodegenOutput`·`GrpcGeneratedPackagePolicy`가 Java로 강제하고,
|
||||
`buf.yaml`·`buf.gen.yaml`·`buf.lock`이 CLI가 있는 환경에서 같은 판정을 내리도록 커밋되어 있다.
|
||||
|
||||
### 6.3 TLS 인증서 3건 — 런타임 생성으로 대체
|
||||
|
||||
`ca.crt`, `server.crt`, `client.crt`. 커밋된 인증서는 만료되고, 커밋된 개인키는 개인키다.
|
||||
`GrpcTlsTestMaterial`이 JDK `keytool`로 fixture마다 PKCS12를 생성하고 `close()`가 지운다. Netty의
|
||||
`SelfSignedCertificate`는 JDK 21에서 `sun.security.x509` 미export로 실패하므로 쓰지 않았다.
|
||||
|
||||
### 6.4 Kotlin 소스 1건 — D7
|
||||
|
||||
`GrpcCoroutineAdapter.kt`. 저장소에 Kotlin 툴체인이 없다. 계약 요구 4건은 Java로 검증하고
|
||||
(`GrpcKotlinProfile`, `GrpcCoroutineContextBridge`), compile lane은
|
||||
`GrpcKotlinCompatibilityGate.supportableHere()`가 `false`로 fail-closed다.
|
||||
|
||||
### 6.5 2026-08-31 감사에서 실제로 메꾼 것 (6건)
|
||||
|
||||
감사가 아니었으면 남았을 것들이다.
|
||||
|
||||
| 항목 | 조치 |
|
||||
| --- | --- |
|
||||
| `GrpcKubernetesProfileValidator` | 이름 있는 타입으로 분리. VIP+장기스트림, drain grace < reconnect budget 두 규칙 추가 |
|
||||
| ADR-064 상당 (discovery/Kubernetes) | `ADR-GRPC-006` 작성 |
|
||||
| xDS `bootstrap.json` | 테스트 리소스로 추가 + `GrpcXdsStartupGuard.bootstrapMismatches`가 실제로 대조 (namespace 불일치·비TLS control plane 검출) |
|
||||
| `control-plane-snapshot.json` | 테스트 리소스로 추가 + redactor가 실제 모양의 데이터로 검증됨 |
|
||||
| `DocumentClientFixture.java` + fixture `build.gradle.kts` | 추가. `GrpcConsumerFixture.fromJavaSource`가 fixture 소스에서 service·method·package 요구사항을 **역산**하므로 손으로 적은 목록이 아니다 |
|
||||
| `buf.gen.yaml`, `buf.lock` | 추가 + proto contract 테스트가 내용을 검증 |
|
||||
|
||||
### 6.6 테스트 클래스 17건 — 감사 후 계획대로 분리
|
||||
|
||||
감사 시점에 leaf별로 통합돼 있던 테스트를 계획이 명시한 이름으로 분리했다. 분리 전에도 모든
|
||||
타입이 실제로 검증되고 있었으나(통합 클래스가 해당 타입을 참조), 계획과의 추적성을 위해 나눴다:
|
||||
|
||||
`GrpcNettyVariantSelectorTest`, `GrpcReflectionPolicyTest`, `GrpcClientMetadataPolicyTest`,
|
||||
`GrpcKubernetesProfileTest`, `GrpcEdition2026GuardTest`, `GrpcClientStreamPolicyTest`,
|
||||
`GrpcClientMessageDeduplicatorTest`, `GrpcBidiSequenceTrackerTest`, `GrpcDemandControllerTest`,
|
||||
`GrpcHedgingEligibilityTest`, `GrpcResolverSafetyPolicyTest`, `GrpcLoadBalancerSafetyPolicyTest`,
|
||||
`GrpcXdsStartupGuardTest`, `GrpcWebCompatibilityGateTest`, `GrpcServletStartupValidatorTest`,
|
||||
`GrpcIntegrationBridgePolicyTest`, `GrpcReactorContextBridgeTest`, `GrpcKotlinCompatibilityGateTest`.
|
||||
+7
-4
@@ -477,12 +477,15 @@ configure(subprojects.findAll { it.childProjects.isEmpty() }) {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The messaging platform leaves own a broker-neutral public contract. Keeping their test
|
||||
// classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no Spring
|
||||
// dependency" verifiable rather than aspirational; leaves that genuinely need a Spring
|
||||
// The messaging and gRPC platform leaves own a transport-neutral public contract. Keeping
|
||||
// their test classpath on plain JUnit + AssertJ is what makes "messaging-core-api has no
|
||||
// Spring dependency" — and the same claim for grpc-core-api, which additionally may not
|
||||
// name io.grpc — verifiable rather than aspirational; leaves that genuinely need a Spring
|
||||
// test context add it in their own build file.
|
||||
if (project.path in [':domain-core', ':application-core', ':shared-contract'] ||
|
||||
project.path.startsWith(':messaging:')) {
|
||||
project.path.startsWith(':messaging:') ||
|
||||
project.path.startsWith(':grpc:') ||
|
||||
project.path.startsWith(':grpc-advanced:')) {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testImplementation 'org.assertj:assertj-core'
|
||||
} else {
|
||||
|
||||
@@ -587,6 +587,203 @@
|
||||
"messaging-transport-spi"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-core-api",
|
||||
"gradle_path": ":grpc:grpc-core-api",
|
||||
"source_path": "src/grpc/grpc-core-api",
|
||||
"allowed_dependencies": [],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-proto-contract",
|
||||
"gradle_path": ":grpc:grpc-proto-contract",
|
||||
"source_path": "src/grpc/grpc-proto-contract",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-codegen",
|
||||
"gradle_path": ":grpc:grpc-codegen",
|
||||
"source_path": "src/grpc/grpc-codegen",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-proto-contract"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-policy",
|
||||
"gradle_path": ":grpc:grpc-policy",
|
||||
"source_path": "src/grpc/grpc-policy",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-server",
|
||||
"gradle_path": ":grpc:grpc-server",
|
||||
"source_path": "src/grpc/grpc-server",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-client",
|
||||
"gradle_path": ":grpc:grpc-client",
|
||||
"source_path": "src/grpc/grpc-client",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-discovery",
|
||||
"gradle_path": ":grpc:grpc-discovery",
|
||||
"source_path": "src/grpc/grpc-discovery",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-client"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-admin",
|
||||
"gradle_path": ":grpc:grpc-admin",
|
||||
"source_path": "src/grpc/grpc-admin",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-server"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-observability",
|
||||
"gradle_path": ":grpc:grpc-observability",
|
||||
"source_path": "src/grpc/grpc-observability",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-operation-ledger-jpa",
|
||||
"gradle_path": ":grpc:grpc-operation-ledger-jpa",
|
||||
"source_path": "src/grpc/grpc-operation-ledger-jpa",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-spring-boot-starter",
|
||||
"gradle_path": ":grpc:grpc-spring-boot-starter",
|
||||
"source_path": "src/grpc/grpc-spring-boot-starter",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-proto-contract",
|
||||
"grpc-codegen",
|
||||
"grpc-policy",
|
||||
"grpc-server",
|
||||
"grpc-client",
|
||||
"grpc-discovery",
|
||||
"grpc-admin",
|
||||
"grpc-observability",
|
||||
"grpc-operation-ledger-jpa"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-testkit",
|
||||
"gradle_path": ":grpc:grpc-testkit",
|
||||
"source_path": "src/grpc/grpc-testkit",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-proto-contract",
|
||||
"grpc-codegen",
|
||||
"grpc-policy",
|
||||
"grpc-server",
|
||||
"grpc-client",
|
||||
"grpc-discovery",
|
||||
"grpc-admin",
|
||||
"grpc-observability",
|
||||
"grpc-operation-ledger-jpa"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-bootstrap",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-bootstrap",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-bootstrap",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-edition",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-edition",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-edition",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-proto-contract",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-streaming",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-streaming",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-streaming",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-resilience",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-resilience",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-resilience",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy",
|
||||
"grpc-client",
|
||||
"grpc-discovery",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-compat",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-compat",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-compat",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-policy",
|
||||
"grpc-server",
|
||||
"grpc-client",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
},
|
||||
{
|
||||
"id": "grpc-advanced-diagnostics",
|
||||
"gradle_path": ":grpc-advanced:grpc-advanced-diagnostics",
|
||||
"source_path": "src/grpc-advanced/grpc-advanced-diagnostics",
|
||||
"allowed_dependencies": [
|
||||
"grpc-core-api",
|
||||
"grpc-client",
|
||||
"grpc-advanced-bootstrap"
|
||||
],
|
||||
"runtime_memberships": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# grpc-advanced — local authority for the advanced gRPC capabilities
|
||||
|
||||
이 문서는 `grpc-advanced:*` family의 **local authority**다. leaf 목록·gradle path·허용 의존성은
|
||||
`src/config/architecture/modules.json`이 SSOT다. Root 정책(`CLAUDE.md` / `AGENTS.md`)과 충돌하면
|
||||
root가 이긴다.
|
||||
|
||||
이 family는 Stable gRPC 플랫폼(`grpc:*`)이 **의도적으로 제외한** 능력들을 담는다. 별도 디렉터리와
|
||||
별도 Gradle prefix인 이유는 하나다: "Stable starter가 advanced module을 참조하면 build가 실패한다"는
|
||||
불변 조건을 registry의 `allowed_dependencies`만으로 기계 검증할 수 있게 하기 위해서다.
|
||||
|
||||
## 의존 방향
|
||||
|
||||
```text
|
||||
grpc-advanced:* → grpc:* (허용)
|
||||
grpc:* → grpc-advanced:* (금지 — registry가 거부한다)
|
||||
```
|
||||
|
||||
`grpc-spring-boot-starter`의 registry 엔트리에는 어떤 advanced id도 없다.
|
||||
`verifyCleanArchitectureDependencies`가 build time에, `GrpcStableBuildInvariant`와
|
||||
`GrpcAdvancedModuleGuard.requireStableStarterIsClean`이 runtime에 같은 규칙을 강제한다.
|
||||
|
||||
## Capability grade와 feature flag
|
||||
|
||||
capability마다 별도 flag를 갖는다. 하나의 "advanced" 스위치로 묶지 않는 이유는, gRPC-Web을 켜는
|
||||
결정(프록시 하나)과 xDS를 켜는 결정(control plane과 그 장애 모드 전체)이 같은 결정이 아니기
|
||||
때문이다. 하나의 스위치는 두 번째 결정을 실수로 내리게 만든다.
|
||||
|
||||
| grade | 시작 가능 | production 추가 승인 |
|
||||
| --- | --- | --- |
|
||||
| `ADVANCED_STABLE` | O | 불필요 |
|
||||
| `EXPERIMENTAL` | O | **필요** |
|
||||
| `WATCH` | X (추적만) | — |
|
||||
| `DISABLED` | X | — |
|
||||
|
||||
property key는 `ca-skeleton.grpc.advanced.<capability>.enabled`이고 전부 기본 off다.
|
||||
`GrpcAdvancedModuleGuard`가 세 조건(flag 미설정 / grade가 시작 불가 / production 승인 없음)을
|
||||
구분해서 거부하며, 세 경우의 조치가 다르므로 메시지도 다르다.
|
||||
|
||||
## Leaf별 담당 capability
|
||||
|
||||
| leaf | capability |
|
||||
| --- | --- |
|
||||
| `grpc-advanced-bootstrap` | capability grade, feature flag, module guard, capability별 promotion gate |
|
||||
| `grpc-advanced-edition` | Protobuf Edition 2024 opt-in lane, Edition 2026 watch lane |
|
||||
| `grpc-advanced-streaming` | client streaming(session/dedup/checkpoint), bidi(dual sequence), manual flow control |
|
||||
| `grpc-advanced-resilience` | read-only hedging, custom name resolver SPI, custom load balancer SPI, proxyless xDS |
|
||||
| `grpc-advanced-compat` | gRPC-Web, Servlet HTTP/2, Spring Integration bridge, Reactor adapter, Kotlin 경계 |
|
||||
| `grpc-advanced-diagnostics` | Channelz/CSDS 진단, advanced infrastructure testkit 요구사항 |
|
||||
|
||||
## Promotion은 capability별로 독립이다
|
||||
|
||||
`GrpcAdvancedPromotionEvidence`는 capability마다 별도 레코드다. 공유 레코드였다면 하나를 승격할 때
|
||||
같은 시점에 측정된 다른 것들이 함께 승격된다. `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong`이
|
||||
항상 빈 리스트인 것은 주석이 아니라 테스트되는 속성이다.
|
||||
|
||||
승격 문턱은 두 개다: `ADVANCED_STABLE`은 7일 soak + 전체 증거, Stable default는 30일 soak. 두 번째가
|
||||
더 높은 이유는 모든 배포가 그 의존성과 장애 모드를 갖게 되기 때문이다.
|
||||
|
||||
## 이 저장소에서 검증할 수 없는 것
|
||||
|
||||
`GrpcAdvancedInfrastructureTestkit`이 capability별로 필요한 실제 인프라를 명시한다.
|
||||
|
||||
- `grpc-web` → gRPC-Web 프록시
|
||||
- `servlet-compat` → Servlet 컨테이너
|
||||
- `xds` → 중지 가능한 xDS control plane
|
||||
- `kotlin` → Kotlin 툴체인 (**이 저장소에 없다**)
|
||||
|
||||
인프라 없이 도는 suite는 통과하면서 아무것도 증명하지 않으므로, suite가 없는 것보다 나쁘다.
|
||||
`GrpcKotlinCompatibilityGate.supportableHere()`가 `false`를 반환하는 것은 그 사실의 코드 표현이다 —
|
||||
Kotlin 계약 요구사항 4개는 검증되지만 compile lane은 존재하지 않는다.
|
||||
|
||||
## 금지
|
||||
|
||||
- Stable leaf(`grpc:*`)가 이 family를 참조하는 것.
|
||||
- capability grade 없이, 또는 flag 없이 advanced 코드 경로를 실행하는 것.
|
||||
- `WATCH` capability를 스키마 소스로 사용하는 것 (`GrpcEdition2026Guard`가 무조건 거부한다).
|
||||
- 실제 인프라 없이 실행한 suite를 promotion evidence로 인용하는 것.
|
||||
@@ -0,0 +1,12 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag
|
||||
// contract, the module guard that refuses an unflagged capability, and the per-capability
|
||||
// promotion gate.
|
||||
//
|
||||
// This leaf depends on Stable public types and never the other way round. The Stable starter's
|
||||
// registry entry names no advanced id, so `verifyCleanArchitectureDependencies` is what makes
|
||||
// "Advanced never leaks into Stable" a build failure rather than a review note.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=compileClasspath,runtimeClasspath
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
/**
|
||||
* Every capability the Stable platform deliberately excludes, and how ready each one is.
|
||||
*
|
||||
* <p>Grading them individually is the design. Bundling them under one "advanced" flag makes
|
||||
* enabling gRPC-Web — a compatibility bridge with a proxy in front of it — the same decision as
|
||||
* enabling xDS, which brings a control plane and its outage modes. They are not the same decision,
|
||||
* and a single switch is how the second one gets made by accident.
|
||||
*/
|
||||
public enum GrpcAdvancedCapability {
|
||||
/** Protobuf Edition 2024 as an opt-in schema lane. */
|
||||
EDITION_2024("edition-2024", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Protobuf Edition 2026. Recorded, not usable. */
|
||||
EDITION_2026("edition-2026", GrpcCapabilityGrade.WATCH),
|
||||
/** Client streaming with session, dedup and checkpoint. */
|
||||
CLIENT_STREAMING("client-streaming", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Bidirectional streaming with independent per-direction sequences. */
|
||||
BIDI_STREAMING("bidi-streaming", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Manual flow control, for approved streaming methods. */
|
||||
MANUAL_FLOW_CONTROL("manual-flow-control", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Read-only unary hedging. */
|
||||
HEDGING("hedging", GrpcCapabilityGrade.EXPERIMENTAL),
|
||||
/** A custom name resolver. */
|
||||
CUSTOM_RESOLVER("custom-resolver", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** A custom load balancer. */
|
||||
CUSTOM_LOAD_BALANCER("custom-load-balancer", GrpcCapabilityGrade.EXPERIMENTAL),
|
||||
/** Proxyless xDS. */
|
||||
XDS("xds", GrpcCapabilityGrade.EXPERIMENTAL),
|
||||
/** The gRPC-Web bridge. */
|
||||
GRPC_WEB("grpc-web", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** A Servlet container owning the HTTP/2 socket. */
|
||||
SERVLET_COMPAT("servlet-compat", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** The Spring Integration bridge. */
|
||||
INTEGRATION_BRIDGE("integration-bridge", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** The Reactor adapter. */
|
||||
REACTOR("reactor", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** The Kotlin coroutine and Flow adapter. */
|
||||
KOTLIN("kotlin", GrpcCapabilityGrade.ADVANCED_STABLE),
|
||||
/** Channelz and CSDS diagnostics. */
|
||||
CHANNEL_DIAGNOSTICS("channel-diagnostics", GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
|
||||
private final String flagName;
|
||||
private final GrpcCapabilityGrade defaultGrade;
|
||||
|
||||
GrpcAdvancedCapability(String flagName, GrpcCapabilityGrade defaultGrade) {
|
||||
this.flagName = flagName;
|
||||
this.defaultGrade = defaultGrade;
|
||||
}
|
||||
|
||||
/** The property suffix under {@code ca-skeleton.grpc.advanced}. */
|
||||
public String flagName() {
|
||||
return flagName;
|
||||
}
|
||||
|
||||
/** The full property key that enables this capability. */
|
||||
public String propertyKey() {
|
||||
return "ca-skeleton.grpc.advanced." + flagName + ".enabled";
|
||||
}
|
||||
|
||||
/** How ready this capability is today. */
|
||||
public GrpcCapabilityGrade defaultGrade() {
|
||||
return defaultGrade;
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
/**
|
||||
* A capability was used without being enabled.
|
||||
*
|
||||
* <p>The message carries the property key. An advanced capability is off by default and the refusal
|
||||
* is the first thing a developer meets when trying it; telling them which key to set turns a
|
||||
* support question into a configuration line.
|
||||
*/
|
||||
public class GrpcAdvancedCapabilityDisabledException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient GrpcAdvancedCapability capability;
|
||||
|
||||
/** Refuses use of a disabled capability. */
|
||||
public GrpcAdvancedCapabilityDisabledException(GrpcAdvancedCapability capability, String reason) {
|
||||
super(render(capability, reason));
|
||||
this.capability = capability;
|
||||
}
|
||||
|
||||
private static String render(GrpcAdvancedCapability capability, String reason) {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("a refusal explains itself");
|
||||
}
|
||||
return "advanced capability '"
|
||||
+ capability.flagName()
|
||||
+ "' is not available: "
|
||||
+ reason
|
||||
+ " (set "
|
||||
+ capability.propertyKey()
|
||||
+ "=true to enable it)";
|
||||
}
|
||||
|
||||
/** The capability that was refused. */
|
||||
public GrpcAdvancedCapability capability() {
|
||||
return capability;
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
import java.util.EnumMap;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which advanced capabilities a deployment has turned on.
|
||||
*
|
||||
* <p>Everything is off unless named. A capability that switches itself on because its jar is
|
||||
* present is a capability nobody decided to run, and the ones here bring proxies, control planes
|
||||
* and duplicate request load with them.
|
||||
*/
|
||||
public final class GrpcAdvancedFeatureFlags {
|
||||
|
||||
private final Map<GrpcAdvancedCapability, Boolean> enabled =
|
||||
new EnumMap<>(GrpcAdvancedCapability.class);
|
||||
private final Map<GrpcAdvancedCapability, GrpcCapabilityGrade> grades =
|
||||
new EnumMap<>(GrpcAdvancedCapability.class);
|
||||
private final boolean production;
|
||||
private final Set<GrpcAdvancedCapability> productionApprovals;
|
||||
|
||||
private GrpcAdvancedFeatureFlags(
|
||||
boolean production, Set<GrpcAdvancedCapability> productionApprovals) {
|
||||
this.production = production;
|
||||
this.productionApprovals =
|
||||
EnumSet.copyOf(
|
||||
productionApprovals.isEmpty()
|
||||
? EnumSet.noneOf(GrpcAdvancedCapability.class)
|
||||
: EnumSet.copyOf(productionApprovals));
|
||||
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
|
||||
enabled.put(capability, false);
|
||||
grades.put(capability, capability.defaultGrade());
|
||||
}
|
||||
}
|
||||
|
||||
/** Flags for a non-production environment. */
|
||||
public static GrpcAdvancedFeatureFlags forDevelopment() {
|
||||
return new GrpcAdvancedFeatureFlags(false, Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags for production.
|
||||
*
|
||||
* @param productionApprovals the experimental capabilities somebody has accepted the risk of
|
||||
*/
|
||||
public static GrpcAdvancedFeatureFlags forProduction(
|
||||
Set<GrpcAdvancedCapability> productionApprovals) {
|
||||
if (productionApprovals == null) {
|
||||
throw new IllegalArgumentException("an approval set is required, even if empty");
|
||||
}
|
||||
return new GrpcAdvancedFeatureFlags(true, productionApprovals);
|
||||
}
|
||||
|
||||
/** Turns a capability on. */
|
||||
public GrpcAdvancedFeatureFlags enable(GrpcAdvancedCapability capability) {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
enabled.put(capability, true);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Overrides a capability's grade, for a deployment that has its own evidence. */
|
||||
public GrpcAdvancedFeatureFlags withGrade(
|
||||
GrpcAdvancedCapability capability, GrpcCapabilityGrade grade) {
|
||||
if (capability == null || grade == null) {
|
||||
throw new IllegalArgumentException("a grade override needs both parts");
|
||||
}
|
||||
grades.put(capability, grade);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Whether the flag is set, regardless of whether the capability may actually start. */
|
||||
public boolean flagSet(GrpcAdvancedCapability capability) {
|
||||
return Boolean.TRUE.equals(enabled.get(capability));
|
||||
}
|
||||
|
||||
/** The grade in force for a capability. */
|
||||
public GrpcCapabilityGrade gradeOf(GrpcAdvancedCapability capability) {
|
||||
return grades.get(capability);
|
||||
}
|
||||
|
||||
/** Whether this deployment is production. */
|
||||
public boolean production() {
|
||||
return production;
|
||||
}
|
||||
|
||||
/** Whether an experimental capability has been separately approved for production. */
|
||||
public boolean productionApproved(GrpcAdvancedCapability capability) {
|
||||
return productionApprovals.contains(capability);
|
||||
}
|
||||
|
||||
/** The capabilities that are both flagged and permitted to start. */
|
||||
public Set<GrpcAdvancedCapability> active() {
|
||||
Set<GrpcAdvancedCapability> running = EnumSet.noneOf(GrpcAdvancedCapability.class);
|
||||
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
|
||||
if (GrpcAdvancedModuleGuard.available(this, capability)) {
|
||||
running.add(capability);
|
||||
}
|
||||
}
|
||||
return Set.copyOf(running);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
import dev.caskeleton.grpc.core.GrpcStableBuildInvariant;
|
||||
import dev.caskeleton.grpc.core.GrpcStableModuleCatalog;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The single gate every advanced capability passes through.
|
||||
*
|
||||
* <p>Three conditions, checked in this order because each explains a different refusal: the flag is
|
||||
* not set, the grade cannot start at all, or production has not separately approved an experimental
|
||||
* capability. Collapsing them into one boolean produces a "not enabled" message for three
|
||||
* situations with three different remedies.
|
||||
*/
|
||||
public final class GrpcAdvancedModuleGuard {
|
||||
|
||||
private GrpcAdvancedModuleGuard() {}
|
||||
|
||||
/** Whether {@code capability} may run under {@code flags}. */
|
||||
public static boolean available(
|
||||
GrpcAdvancedFeatureFlags flags, GrpcAdvancedCapability capability) {
|
||||
if (flags == null || capability == null) {
|
||||
throw new IllegalArgumentException("availability needs flags and a capability");
|
||||
}
|
||||
if (!flags.flagSet(capability)) {
|
||||
return false;
|
||||
}
|
||||
GrpcCapabilityGrade grade = flags.gradeOf(capability);
|
||||
if (!grade.startable()) {
|
||||
return false;
|
||||
}
|
||||
return !(flags.production()
|
||||
&& grade.requiresProductionApproval()
|
||||
&& !flags.productionApproved(capability));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when {@code capability} may not run.
|
||||
*
|
||||
* @throws GrpcAdvancedCapabilityDisabledException naming which of the three conditions failed
|
||||
*/
|
||||
public static void require(GrpcAdvancedFeatureFlags flags, GrpcAdvancedCapability capability) {
|
||||
if (flags == null || capability == null) {
|
||||
throw new IllegalArgumentException("a guard needs flags and a capability");
|
||||
}
|
||||
if (!flags.flagSet(capability)) {
|
||||
throw new GrpcAdvancedCapabilityDisabledException(capability, "its feature flag is not set");
|
||||
}
|
||||
GrpcCapabilityGrade grade = flags.gradeOf(capability);
|
||||
if (!grade.startable()) {
|
||||
throw new GrpcAdvancedCapabilityDisabledException(
|
||||
capability,
|
||||
"it is graded "
|
||||
+ grade
|
||||
+ ", which cannot start; a WATCH capability is tracked rather than implemented");
|
||||
}
|
||||
if (flags.production()
|
||||
&& grade.requiresProductionApproval()
|
||||
&& !flags.productionApproved(capability)) {
|
||||
throw new GrpcAdvancedCapabilityDisabledException(
|
||||
capability,
|
||||
"it is "
|
||||
+ grade
|
||||
+ " and production needs a separate approval; the flag says somebody wanted it, not "
|
||||
+ "that somebody accepted its uncharacterised failure modes");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when the Stable starter's dependency set reaches an advanced module.
|
||||
*
|
||||
* <p>The same invariant the registry enforces at build time, asserted here so a runtime that was
|
||||
* assembled some other way — a fat jar, a shaded artifact, a test harness — is checked too.
|
||||
*/
|
||||
public static void requireStableStarterIsClean(Set<String> starterDependencies) {
|
||||
GrpcStableBuildInvariant.requireNoAdvancedDependency(
|
||||
"grpc-spring-boot-starter", starterDependencies);
|
||||
}
|
||||
|
||||
/** The advanced module ids, for a runtime classpath check. */
|
||||
public static Set<String> advancedModules() {
|
||||
return GrpcStableModuleCatalog.advancedModules();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
/**
|
||||
* How much a capability has been established, and what that permits.
|
||||
*
|
||||
* <p>{@link #EXPERIMENTAL} in production needs a second, separate approval rather than the
|
||||
* capability flag alone. The flag says somebody wanted the feature; the approval says somebody
|
||||
* accepted that its failure modes are not fully characterised, which is a different person's
|
||||
* decision on most teams.
|
||||
*/
|
||||
public enum GrpcCapabilityGrade {
|
||||
/** Contract, fault and operational evidence exist. Enable with the capability flag. */
|
||||
ADVANCED_STABLE(true, false),
|
||||
/** Works, but its failure modes are not fully characterised. Needs a production approval too. */
|
||||
EXPERIMENTAL(true, true),
|
||||
/** Tracked, not implemented. Cannot be enabled. */
|
||||
WATCH(false, false),
|
||||
/** Withdrawn or refused. Cannot be enabled. */
|
||||
DISABLED(false, false);
|
||||
|
||||
private final boolean startable;
|
||||
private final boolean requiresProductionApproval;
|
||||
|
||||
GrpcCapabilityGrade(boolean startable, boolean requiresProductionApproval) {
|
||||
this.startable = startable;
|
||||
this.requiresProductionApproval = requiresProductionApproval;
|
||||
}
|
||||
|
||||
/** Whether a deployment may run this capability at all. */
|
||||
public boolean startable() {
|
||||
return startable;
|
||||
}
|
||||
|
||||
/** Whether production additionally requires an explicit approval. */
|
||||
public boolean requiresProductionApproval() {
|
||||
return requiresProductionApproval;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Whether one capability moves to a new grade.
|
||||
*
|
||||
* <p>Carries the grade it would move to as well as the blockers, so a refusal says what was being
|
||||
* asked for. "Not promoted" is ambiguous between a failed promotion to Advanced Stable and a failed
|
||||
* promotion to a Stable default, and the second is a much larger decision.
|
||||
*/
|
||||
public record GrpcAdvancedPromotionDecision(
|
||||
GrpcAdvancedCapability capability,
|
||||
GrpcCapabilityGrade from,
|
||||
GrpcCapabilityGrade to,
|
||||
boolean promoted,
|
||||
List<String> blockers) {
|
||||
|
||||
/** Requires blockers exactly when refused. */
|
||||
public GrpcAdvancedPromotionDecision {
|
||||
if (capability == null || from == null || to == null || blockers == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a promotion decision names its capability and both grades");
|
||||
}
|
||||
blockers = List.copyOf(blockers);
|
||||
if (promoted && !blockers.isEmpty()) {
|
||||
throw new IllegalArgumentException("a granted promotion has no blockers");
|
||||
}
|
||||
if (!promoted && blockers.isEmpty()) {
|
||||
throw new IllegalArgumentException("a refused promotion says why");
|
||||
}
|
||||
}
|
||||
|
||||
/** A granted promotion. */
|
||||
public static GrpcAdvancedPromotionDecision grant(
|
||||
GrpcAdvancedCapability capability, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
|
||||
return new GrpcAdvancedPromotionDecision(capability, from, to, true, List.of());
|
||||
}
|
||||
|
||||
/** A refused promotion. */
|
||||
public static GrpcAdvancedPromotionDecision refuse(
|
||||
GrpcAdvancedCapability capability,
|
||||
GrpcCapabilityGrade from,
|
||||
GrpcCapabilityGrade to,
|
||||
List<String> blockers) {
|
||||
return new GrpcAdvancedPromotionDecision(capability, from, to, false, blockers);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What one capability has behind it.
|
||||
*
|
||||
* <p>Per capability, never shared. The Stable plan's requirement that Edition, streaming, xDS,
|
||||
* gRPC-Web, Servlet and the language adapters do not gate each other only means something if their
|
||||
* evidence is separate: a shared record makes promoting one of them promote whichever others
|
||||
* happened to be measured at the same time.
|
||||
*/
|
||||
public record GrpcAdvancedPromotionEvidence(
|
||||
GrpcAdvancedCapability capability,
|
||||
boolean compatibilityEvidence,
|
||||
boolean securityReview,
|
||||
boolean faultEvidence,
|
||||
boolean performanceEvidence,
|
||||
Duration soakDuration,
|
||||
boolean architectureDecisionRecord,
|
||||
boolean runbook,
|
||||
boolean realEnvironmentTest) {
|
||||
|
||||
/** Requires a capability and a non-negative soak. */
|
||||
public GrpcAdvancedPromotionEvidence {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("promotion evidence names its capability");
|
||||
}
|
||||
if (soakDuration == null || soakDuration.isNegative()) {
|
||||
throw new IllegalArgumentException("a soak duration must be present and non-negative");
|
||||
}
|
||||
}
|
||||
|
||||
/** No evidence at all, which is where a capability starts. */
|
||||
public static GrpcAdvancedPromotionEvidence none(GrpcAdvancedCapability capability) {
|
||||
return new GrpcAdvancedPromotionEvidence(
|
||||
capability, false, false, false, false, Duration.ZERO, false, false, false);
|
||||
}
|
||||
|
||||
/** Everything a promotion to Advanced Stable needs. */
|
||||
public static GrpcAdvancedPromotionEvidence complete(
|
||||
GrpcAdvancedCapability capability, Duration soakDuration) {
|
||||
return new GrpcAdvancedPromotionEvidence(
|
||||
capability, true, true, true, true, soakDuration, true, true, true);
|
||||
}
|
||||
|
||||
/** Which required items are absent, as a set a report can print. */
|
||||
public Set<String> missing() {
|
||||
Set<String> missing = new java.util.LinkedHashSet<>();
|
||||
if (!compatibilityEvidence) {
|
||||
missing.add("compatibility evidence");
|
||||
}
|
||||
if (!securityReview) {
|
||||
missing.add("security review");
|
||||
}
|
||||
if (!faultEvidence) {
|
||||
missing.add("fault evidence");
|
||||
}
|
||||
if (!performanceEvidence) {
|
||||
missing.add("performance evidence");
|
||||
}
|
||||
if (!architectureDecisionRecord) {
|
||||
missing.add("architecture decision record");
|
||||
}
|
||||
if (!runbook) {
|
||||
missing.add("runbook");
|
||||
}
|
||||
if (!realEnvironmentTest) {
|
||||
missing.add("real environment test");
|
||||
}
|
||||
return Set.copyOf(missing);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Decides one capability's promotion, on its own evidence.
|
||||
*
|
||||
* <p>Two thresholds rather than one. Reaching Advanced Stable means the capability works and is
|
||||
* documented; becoming a Stable default means every deployment gets it, which additionally puts its
|
||||
* dependencies on every classpath and its failure modes in every on-call rotation. The second needs
|
||||
* the first plus a longer soak, because a capability that has run in one deployment for a week is
|
||||
* not the same claim as one that ships to all of them.
|
||||
*/
|
||||
public final class GrpcAdvancedPromotionGate {
|
||||
|
||||
/** The soak a promotion to Advanced Stable requires. */
|
||||
public static final Duration ADVANCED_STABLE_SOAK = Duration.ofDays(7);
|
||||
|
||||
/** The soak a promotion to a Stable default requires. */
|
||||
public static final Duration STABLE_DEFAULT_SOAK = Duration.ofDays(30);
|
||||
|
||||
private GrpcAdvancedPromotionGate() {}
|
||||
|
||||
/**
|
||||
* Whether {@code capability} may move from {@code from} to {@code to}.
|
||||
*
|
||||
* @throws IllegalArgumentException when the transition is not one this gate governs
|
||||
*/
|
||||
public static GrpcAdvancedPromotionDecision evaluate(
|
||||
GrpcAdvancedPromotionEvidence evidence, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
|
||||
if (evidence == null || from == null || to == null) {
|
||||
throw new IllegalArgumentException("a promotion needs evidence and both grades");
|
||||
}
|
||||
if (from == to) {
|
||||
throw new IllegalArgumentException("a promotion changes the grade");
|
||||
}
|
||||
GrpcAdvancedCapability capability = evidence.capability();
|
||||
List<String> blockers = new ArrayList<>();
|
||||
|
||||
evidence.missing().stream()
|
||||
.sorted()
|
||||
.forEach(missing -> blockers.add(capability.flagName() + " has no " + missing));
|
||||
|
||||
Duration requiredSoak =
|
||||
to == GrpcCapabilityGrade.ADVANCED_STABLE ? ADVANCED_STABLE_SOAK : STABLE_DEFAULT_SOAK;
|
||||
if (evidence.soakDuration().compareTo(requiredSoak) < 0) {
|
||||
blockers.add(
|
||||
capability.flagName()
|
||||
+ " soaked for "
|
||||
+ evidence.soakDuration().toDays()
|
||||
+ " day(s); promotion to "
|
||||
+ to
|
||||
+ " requires "
|
||||
+ requiredSoak.toDays());
|
||||
}
|
||||
if (from == GrpcCapabilityGrade.WATCH && to != GrpcCapabilityGrade.EXPERIMENTAL) {
|
||||
blockers.add(
|
||||
capability.flagName()
|
||||
+ " is WATCH, which is tracked rather than implemented; it becomes EXPERIMENTAL "
|
||||
+ "before anything else");
|
||||
}
|
||||
return blockers.isEmpty()
|
||||
? GrpcAdvancedPromotionDecision.grant(capability, from, to)
|
||||
: GrpcAdvancedPromotionDecision.refuse(capability, from, to, blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether promoting {@code promoted} would drag another capability with it.
|
||||
*
|
||||
* <p>Always empty, and the method exists so a test can assert that rather than a comment claiming
|
||||
* it: each capability's evidence is its own record, so there is no path by which one promotion
|
||||
* changes another's grade.
|
||||
*/
|
||||
public static List<GrpcAdvancedCapability> capabilitiesDraggedAlong(
|
||||
GrpcAdvancedCapability promoted) {
|
||||
if (promoted == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Every advanced capability's current grade, in one place an adopter can read.
|
||||
*
|
||||
* <p>The published answer to "is this supported". Without it the answer is inferred from whether a
|
||||
* class exists, which says only that somebody wrote it.
|
||||
*/
|
||||
public final class GrpcAdvancedSupportMatrix {
|
||||
|
||||
private final Map<GrpcAdvancedCapability, GrpcCapabilityGrade> grades =
|
||||
new EnumMap<>(GrpcAdvancedCapability.class);
|
||||
|
||||
/** A matrix at each capability's default grade. */
|
||||
public GrpcAdvancedSupportMatrix() {
|
||||
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
|
||||
grades.put(capability, capability.defaultGrade());
|
||||
}
|
||||
}
|
||||
|
||||
/** The grade of one capability. */
|
||||
public GrpcCapabilityGrade gradeOf(GrpcAdvancedCapability capability) {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
return grades.get(capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a granted promotion.
|
||||
*
|
||||
* @throws IllegalArgumentException when the decision's starting grade is not the current one,
|
||||
* which means two promotions raced or one was replayed
|
||||
*/
|
||||
public GrpcAdvancedSupportMatrix apply(GrpcAdvancedPromotionDecision decision) {
|
||||
if (decision == null) {
|
||||
throw new IllegalArgumentException("a decision is required");
|
||||
}
|
||||
if (!decision.promoted()) {
|
||||
return this;
|
||||
}
|
||||
GrpcCapabilityGrade current = grades.get(decision.capability());
|
||||
if (current != decision.from()) {
|
||||
throw new IllegalArgumentException(
|
||||
"capability '"
|
||||
+ decision.capability().flagName()
|
||||
+ "' is "
|
||||
+ current
|
||||
+ ", not "
|
||||
+ decision.from()
|
||||
+ "; this decision was made against a different matrix");
|
||||
}
|
||||
grades.put(decision.capability(), decision.to());
|
||||
return this;
|
||||
}
|
||||
|
||||
/** The whole matrix. */
|
||||
public Map<GrpcAdvancedCapability, GrpcCapabilityGrade> snapshot() {
|
||||
return Map.copyOf(grades);
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package dev.caskeleton.grpc.advanced.bootstrap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcAdvancedModuleGuardTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("every advanced capability is off unless a deployment names it")
|
||||
void everythingIsOffByDefault() {
|
||||
GrpcAdvancedFeatureFlags flags = GrpcAdvancedFeatureFlags.forDevelopment();
|
||||
|
||||
assertThat(flags.active()).isEmpty();
|
||||
for (GrpcAdvancedCapability capability : GrpcAdvancedCapability.values()) {
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, capability)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each capability has its own flag, not a shared one")
|
||||
void eachCapabilityHasItsOwnFlag() {
|
||||
assertThat(GrpcAdvancedCapability.XDS.propertyKey())
|
||||
.isEqualTo("ca-skeleton.grpc.advanced.xds.enabled");
|
||||
assertThat(GrpcAdvancedCapability.GRPC_WEB.propertyKey())
|
||||
.isNotEqualTo(GrpcAdvancedCapability.XDS.propertyKey());
|
||||
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.GRPC_WEB);
|
||||
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.GRPC_WEB)).isTrue();
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unflagged capability is refused, and the message names the key to set")
|
||||
void anUnflaggedCapabilityNamesItsKey() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcAdvancedModuleGuard.require(
|
||||
GrpcAdvancedFeatureFlags.forDevelopment(), GrpcAdvancedCapability.REACTOR))
|
||||
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
|
||||
.hasMessageContaining("ca-skeleton.grpc.advanced.reactor.enabled=true");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a WATCH capability cannot be enabled, however loudly it is flagged")
|
||||
void aWatchCapabilityCannotStart() {
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.EDITION_2026);
|
||||
|
||||
assertThat(GrpcAdvancedCapability.EDITION_2026.defaultGrade())
|
||||
.isEqualTo(GrpcCapabilityGrade.WATCH);
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.EDITION_2026))
|
||||
.isFalse();
|
||||
assertThatThrownBy(
|
||||
() -> GrpcAdvancedModuleGuard.require(flags, GrpcAdvancedCapability.EDITION_2026))
|
||||
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
|
||||
.hasMessageContaining("tracked rather than implemented");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an experimental capability needs a second approval in production")
|
||||
void experimentalCapabilitiesNeedAProductionApproval() {
|
||||
GrpcAdvancedFeatureFlags unapproved =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of()).enable(GrpcAdvancedCapability.XDS);
|
||||
GrpcAdvancedFeatureFlags approved =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of(GrpcAdvancedCapability.XDS))
|
||||
.enable(GrpcAdvancedCapability.XDS);
|
||||
|
||||
assertThat(GrpcAdvancedModuleGuard.available(unapproved, GrpcAdvancedCapability.XDS)).isFalse();
|
||||
assertThat(GrpcAdvancedModuleGuard.available(approved, GrpcAdvancedCapability.XDS)).isTrue();
|
||||
assertThatThrownBy(
|
||||
() -> GrpcAdvancedModuleGuard.require(unapproved, GrpcAdvancedCapability.XDS))
|
||||
.isInstanceOf(GrpcAdvancedCapabilityDisabledException.class)
|
||||
.hasMessageContaining("uncharacterised failure modes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an experimental capability runs outside production on its flag alone")
|
||||
void experimentalCapabilitiesRunInDevelopment() {
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment().enable(GrpcAdvancedCapability.HEDGING);
|
||||
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.HEDGING)).isTrue();
|
||||
GrpcAdvancedModuleGuard.require(flags, GrpcAdvancedCapability.HEDGING);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the Stable starter reaching an advanced module is refused")
|
||||
void theStableStarterMayNotReachAnAdvancedModule() {
|
||||
GrpcAdvancedModuleGuard.requireStableStarterIsClean(Set.of("grpc-core-api", "grpc-policy"));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcAdvancedModuleGuard.requireStableStarterIsClean(
|
||||
Set.of("grpc-core-api", "grpc-advanced-resilience")))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("grpc-advanced-resilience");
|
||||
assertThat(GrpcAdvancedModuleGuard.advancedModules())
|
||||
.contains("grpc-advanced-bootstrap", "grpc-advanced-streaming", "grpc-advanced-compat");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only flagged and permitted capabilities appear as active")
|
||||
void activeReflectsBothFlagAndGrade() {
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of())
|
||||
.enable(GrpcAdvancedCapability.GRPC_WEB)
|
||||
.enable(GrpcAdvancedCapability.XDS)
|
||||
.enable(GrpcAdvancedCapability.EDITION_2026);
|
||||
|
||||
assertThat(flags.active()).containsExactly(GrpcAdvancedCapability.GRPC_WEB);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a deployment may raise a capability's grade on its own evidence")
|
||||
void aDeploymentMayOverrideAGrade() {
|
||||
GrpcAdvancedFeatureFlags flags =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of())
|
||||
.enable(GrpcAdvancedCapability.HEDGING)
|
||||
.withGrade(GrpcAdvancedCapability.HEDGING, GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
|
||||
assertThat(GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.HEDGING)).isTrue();
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package dev.caskeleton.grpc.advanced.release;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcCapabilityGrade;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcAdvancedPromotionGateTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("a capability with complete evidence and a full soak is promoted")
|
||||
void completeEvidencePromotes() {
|
||||
GrpcAdvancedPromotionDecision decision =
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.HEDGING, Duration.ofDays(7)),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
|
||||
assertThat(decision.promoted()).isTrue();
|
||||
assertThat(decision.blockers()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("every missing piece of evidence is named")
|
||||
void everyMissingPieceIsNamed() {
|
||||
GrpcAdvancedPromotionDecision decision =
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
|
||||
assertThat(decision.blockers())
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("compatibility evidence"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("security review"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("fault evidence"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("runbook"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("real environment test"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("soaked for"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("becoming a Stable default needs a longer soak than becoming Advanced Stable")
|
||||
void theStableDefaultThresholdIsHigher() {
|
||||
GrpcAdvancedPromotionEvidence weekLongSoak =
|
||||
GrpcAdvancedPromotionEvidence.complete(GrpcAdvancedCapability.GRPC_WEB, Duration.ofDays(7));
|
||||
|
||||
assertThat(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
weekLongSoak,
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE)
|
||||
.promoted())
|
||||
.isTrue();
|
||||
assertThat(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
weekLongSoak, GrpcCapabilityGrade.ADVANCED_STABLE, GrpcCapabilityGrade.DISABLED)
|
||||
.blockers())
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("requires 30"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a WATCH capability becomes EXPERIMENTAL before anything else")
|
||||
void watchPromotesOnlyToExperimental() {
|
||||
assertThat(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.EDITION_2026, Duration.ofDays(60)),
|
||||
GrpcCapabilityGrade.WATCH,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE)
|
||||
.blockers())
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("becomes EXPERIMENTAL"));
|
||||
assertThat(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.EDITION_2026, Duration.ofDays(60)),
|
||||
GrpcCapabilityGrade.WATCH,
|
||||
GrpcCapabilityGrade.EXPERIMENTAL)
|
||||
.promoted())
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("promoting one capability drags none of the others with it")
|
||||
void promotionsAreIndependent() {
|
||||
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
|
||||
GrpcCapabilityGrade webBefore = matrix.gradeOf(GrpcAdvancedCapability.GRPC_WEB);
|
||||
|
||||
matrix.apply(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.HEDGING, Duration.ofDays(7)),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE));
|
||||
|
||||
assertThat(matrix.gradeOf(GrpcAdvancedCapability.HEDGING))
|
||||
.isEqualTo(GrpcCapabilityGrade.ADVANCED_STABLE);
|
||||
assertThat(matrix.gradeOf(GrpcAdvancedCapability.GRPC_WEB)).isEqualTo(webBefore);
|
||||
assertThat(GrpcAdvancedPromotionGate.capabilitiesDraggedAlong(GrpcAdvancedCapability.HEDGING))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a decision made against a different matrix state is refused")
|
||||
void aStaleDecisionIsRefused() {
|
||||
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
matrix.apply(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.complete(
|
||||
GrpcAdvancedCapability.GRPC_WEB, Duration.ofDays(7)),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("made against a different matrix");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a refused promotion leaves the matrix alone")
|
||||
void aRefusedPromotionChangesNothing() {
|
||||
GrpcAdvancedSupportMatrix matrix = new GrpcAdvancedSupportMatrix();
|
||||
|
||||
matrix.apply(
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.ADVANCED_STABLE));
|
||||
|
||||
assertThat(matrix.gradeOf(GrpcAdvancedCapability.XDS))
|
||||
.isEqualTo(GrpcCapabilityGrade.EXPERIMENTAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a promotion that changes nothing is refused")
|
||||
void aNoOpPromotionIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcAdvancedPromotionGate.evaluate(
|
||||
GrpcAdvancedPromotionEvidence.none(GrpcAdvancedCapability.XDS),
|
||||
GrpcCapabilityGrade.EXPERIMENTAL,
|
||||
GrpcCapabilityGrade.EXPERIMENTAL))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Compatibility bridges: gRPC-Web, the Servlet HTTP/2 profile, the Spring Integration bridge, the
|
||||
// Reactor adapter, and the Kotlin coroutine/Flow boundary.
|
||||
//
|
||||
// No Kotlin source set (adaptation D7): this repository has no Kotlin toolchain, so the Kotlin lane
|
||||
// is expressed as a Java-side boundary contract whose compatibility gate fails closed until a real
|
||||
// toolchain lane exists. Everything the gate would otherwise assert — one schema source, coroutine
|
||||
// cancellation propagation, Flow backpressure inside the Stable buffer limits, evidence type
|
||||
// preservation — is a checkable contract without it.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-policy')
|
||||
api project(':grpc:grpc-server')
|
||||
api project(':grpc:grpc-client')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
|
||||
// api: the Reactor adapter's public signatures are Mono/Flux, and the Integration gateways name
|
||||
// Spring Integration's Message. Hiding either would only stop an adopter compiling against the
|
||||
// API this module documents.
|
||||
api 'io.projectreactor:reactor-core'
|
||||
api 'org.springframework.integration:spring-integration-core'
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.springframework.integration:spring-integration-core:7.0.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-messaging:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.caskeleton.grpc.advanced.integration;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataKey;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a Spring Integration flow may exchange with a gRPC call.
|
||||
*
|
||||
* <p>The header allowlist is the whole policy. A Spring Integration {@code Message} accumulates
|
||||
* headers as it moves through a flow — routing keys, correlation ids, errors channels, whatever a
|
||||
* transformer added — and copying them onto gRPC metadata sends a service's internal plumbing
|
||||
* across the network, where it counts against the metadata budget and occasionally carries
|
||||
* something sensitive.
|
||||
*
|
||||
* <p>The bridge does not add durability. Spring Integration channels can look like a broker, and a
|
||||
* bridge that implied acknowledgement or redelivery semantics would be promising something gRPC
|
||||
* does not do.
|
||||
*/
|
||||
public record GrpcIntegrationBridgePolicy(
|
||||
Set<GrpcMetadataKey> headerAllowlist, Set<String> payloadConverters) {
|
||||
|
||||
/** Copies both sets and refuses a bridge with no converter. */
|
||||
public GrpcIntegrationBridgePolicy {
|
||||
if (headerAllowlist == null || payloadConverters == null) {
|
||||
throw new IllegalArgumentException("a bridge policy states its allowlist and converters");
|
||||
}
|
||||
headerAllowlist = Set.copyOf(headerAllowlist);
|
||||
payloadConverters = Set.copyOf(payloadConverters);
|
||||
if (payloadConverters.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a bridge with no registered converter cannot turn a Message payload into a request; "
|
||||
+ "leaving it to reflection is how an unexpected type reaches the wire");
|
||||
}
|
||||
}
|
||||
|
||||
/** The metadata that survives from a Message's headers. */
|
||||
public Map<GrpcMetadataKey, String> metadataFrom(Map<String, Object> messageHeaders) {
|
||||
if (messageHeaders == null) {
|
||||
throw new IllegalArgumentException("message headers must not be null");
|
||||
}
|
||||
Map<GrpcMetadataKey, String> metadata = new LinkedHashMap<>();
|
||||
headerAllowlist.forEach(
|
||||
key -> {
|
||||
Object value = messageHeaders.get(key.name());
|
||||
if (value != null) {
|
||||
metadata.put(key, String.valueOf(value));
|
||||
}
|
||||
});
|
||||
return Map.copyOf(metadata);
|
||||
}
|
||||
|
||||
/** Whether a payload type has a registered converter. */
|
||||
public boolean converterRegistered(String payloadType) {
|
||||
return payloadConverters.contains(payloadType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bridge provides broker-style acknowledgement or redelivery.
|
||||
*
|
||||
* <p>Always false. A bridge that implied either would be promising a delivery guarantee gRPC does
|
||||
* not make.
|
||||
*/
|
||||
public boolean providesBrokerSemantics() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bridge replaces the generated stub and service APIs.
|
||||
*
|
||||
* <p>Always false. It is one way to reach a gRPC call from an existing integration flow, not the
|
||||
* way an application is meant to call one.
|
||||
*/
|
||||
public boolean replacesGeneratedApis() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.grpc.advanced.integration;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcRequestContext;
|
||||
import java.util.Map;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
|
||||
/**
|
||||
* Turns an inbound gRPC call into a Spring Integration {@code Message}.
|
||||
*
|
||||
* <p>The request context travels as one header holding the immutable context object, rather than as
|
||||
* a scattering of actor, tenant and deadline headers. Flattening it would let a transformer in the
|
||||
* middle of a flow change the tenant of a request that has already been authenticated.
|
||||
*
|
||||
* @param <Q> the request payload type
|
||||
*/
|
||||
public final class GrpcIntegrationInboundGateway<Q> {
|
||||
|
||||
/** The header the immutable request context travels under. */
|
||||
public static final String CONTEXT_HEADER = "grpcRequestContext";
|
||||
|
||||
private final GrpcIntegrationBridgePolicy policy;
|
||||
|
||||
/** Binds a gateway to its bridge policy. */
|
||||
public GrpcIntegrationInboundGateway(GrpcIntegrationBridgePolicy policy) {
|
||||
if (policy == null) {
|
||||
throw new IllegalArgumentException("an inbound gateway needs a bridge policy");
|
||||
}
|
||||
this.policy = policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Message a flow receives.
|
||||
*
|
||||
* @throws IllegalArgumentException when the payload type has no registered converter
|
||||
*/
|
||||
public Message<Q> toMessage(Q payload, GrpcRequestContext context) {
|
||||
if (payload == null || context == null) {
|
||||
throw new IllegalArgumentException("an inbound message needs a payload and a context");
|
||||
}
|
||||
String payloadType = payload.getClass().getName();
|
||||
if (!policy.converterRegistered(payloadType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"no converter is registered for '"
|
||||
+ payloadType
|
||||
+ "'; converting by reflection is how an unexpected type reaches a flow");
|
||||
}
|
||||
Map<String, Object> headers = new java.util.LinkedHashMap<>();
|
||||
headers.put(CONTEXT_HEADER, context);
|
||||
context.metadata().forEach((key, value) -> headers.put(key.name(), value));
|
||||
return MessageBuilder.withPayload(payload).copyHeaders(headers).build();
|
||||
}
|
||||
|
||||
/** The policy in force. */
|
||||
public GrpcIntegrationBridgePolicy policy() {
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.grpc.advanced.integration;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataKey;
|
||||
import java.util.Map;
|
||||
import org.springframework.messaging.Message;
|
||||
|
||||
/**
|
||||
* Turns an outbound Spring Integration {@code Message} into a gRPC call's inputs.
|
||||
*
|
||||
* <p>Applies the header allowlist rather than copying what the flow accumulated. A flow's headers
|
||||
* are its own bookkeeping; putting them on the wire spends the metadata budget on another service's
|
||||
* plumbing and occasionally sends something that should not leave the process.
|
||||
*
|
||||
* @param <C> the request payload type
|
||||
*/
|
||||
public final class GrpcIntegrationOutboundGateway<C> {
|
||||
|
||||
private final GrpcIntegrationBridgePolicy policy;
|
||||
|
||||
/** Binds a gateway to its bridge policy. */
|
||||
public GrpcIntegrationOutboundGateway(GrpcIntegrationBridgePolicy policy) {
|
||||
if (policy == null) {
|
||||
throw new IllegalArgumentException("an outbound gateway needs a bridge policy");
|
||||
}
|
||||
this.policy = policy;
|
||||
}
|
||||
|
||||
/** The metadata this message contributes to the call. */
|
||||
public Map<GrpcMetadataKey, String> metadataFrom(Message<C> message) {
|
||||
if (message == null) {
|
||||
throw new IllegalArgumentException("an outbound message is required");
|
||||
}
|
||||
return policy.metadataFrom(message.getHeaders());
|
||||
}
|
||||
|
||||
/**
|
||||
* The payload to send.
|
||||
*
|
||||
* @throws IllegalArgumentException when the payload type has no registered converter
|
||||
*/
|
||||
public C payloadFrom(Message<C> message) {
|
||||
if (message == null) {
|
||||
throw new IllegalArgumentException("an outbound message is required");
|
||||
}
|
||||
C payload = message.getPayload();
|
||||
String payloadType = payload.getClass().getName();
|
||||
if (!policy.converterRegistered(payloadType)) {
|
||||
throw new IllegalArgumentException("no converter is registered for '" + payloadType + "'");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** The policy in force. */
|
||||
public GrpcIntegrationBridgePolicy policy() {
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.grpc.advanced.kotlin;
|
||||
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
|
||||
import java.time.Instant;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The contract a Kotlin coroutine adapter has to satisfy, expressed as Java callbacks.
|
||||
*
|
||||
* <p>Callbacks rather than coroutine types, so the rule is checkable without a Kotlin toolchain. A
|
||||
* Kotlin adapter wires its {@code Job} completion handler to {@link #onCoroutineCancelled} and its
|
||||
* cancellation source to {@link #cancelCoroutineScope}; what the platform needs is that both
|
||||
* directions exist, and that is what this makes assertable.
|
||||
*/
|
||||
public final class GrpcCoroutineContextBridge {
|
||||
|
||||
private GrpcCoroutineContextBridge() {}
|
||||
|
||||
/**
|
||||
* What a Kotlin adapter calls when its coroutine scope is cancelled.
|
||||
*
|
||||
* <p>Coroutine cancellation is cooperative and structured: cancelling a scope cancels its
|
||||
* children, and a gRPC call started inside it is not one of them unless something says so.
|
||||
*/
|
||||
public static Runnable onCoroutineCancelled(
|
||||
GrpcCancellationCoordinator coordinator, Supplier<Instant> now) {
|
||||
if (coordinator == null || now == null) {
|
||||
throw new IllegalArgumentException("the bridge needs a coordinator and a clock");
|
||||
}
|
||||
return () -> coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, now.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the coroutine scope so a platform cancellation reaches it.
|
||||
*
|
||||
* @param cancelScope what the Kotlin side does to cancel its scope
|
||||
*/
|
||||
public static void cancelCoroutineScope(
|
||||
GrpcCancellationCoordinator coordinator, Runnable cancelScope, String operationName) {
|
||||
if (coordinator == null || cancelScope == null) {
|
||||
throw new IllegalArgumentException("the bridge needs a coordinator and a cancel action");
|
||||
}
|
||||
if (operationName == null || operationName.isBlank()) {
|
||||
throw new IllegalArgumentException("a cancellable operation needs a name");
|
||||
}
|
||||
coordinator.register(
|
||||
new dev.caskeleton.grpc.deadline.GrpcCancellableOperation() {
|
||||
@Override
|
||||
public String name() {
|
||||
return operationName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel(GrpcCancellationReason reason) {
|
||||
cancelScope.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.grpc.advanced.kotlin;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Whether a Kotlin adapter may be advertised as supported.
|
||||
*
|
||||
* <p>Fails closed in this repository, and says so rather than reporting a pass it cannot justify.
|
||||
* There is no Kotlin toolchain here (adaptation design D7), so the compile lane that would
|
||||
* establish the last requirement has never run; a gate that reported success anyway would put an
|
||||
* unverified claim in the support matrix.
|
||||
*
|
||||
* <p>The other four requirements are checkable and are checked, so turning the toolchain on later
|
||||
* is a lane to add rather than a contract to write.
|
||||
*/
|
||||
public final class GrpcKotlinCompatibilityGate {
|
||||
|
||||
private GrpcKotlinCompatibilityGate() {}
|
||||
|
||||
/**
|
||||
* Every reason a Kotlin adapter is not yet supportable.
|
||||
*
|
||||
* @param toolchainLaneRan whether a Kotlin compile lane actually ran against this profile
|
||||
* @return an empty list only when the profile is complete and its lane has run
|
||||
*/
|
||||
public static List<String> blockers(GrpcKotlinProfile profile, boolean toolchainLaneRan) {
|
||||
if (profile == null) {
|
||||
throw new IllegalArgumentException("a Kotlin profile is required");
|
||||
}
|
||||
List<String> blockers = new ArrayList<>();
|
||||
if (!profile.sharesSchemaSourceWithJava()) {
|
||||
blockers.add(
|
||||
"the Kotlin contract does not share one schema source with the Java contract; two "
|
||||
+ "sources diverge where only somebody reading both would notice");
|
||||
}
|
||||
if (!profile.propagatesCoroutineCancellation()) {
|
||||
blockers.add(
|
||||
"coroutine cancellation does not reach the gRPC call; a cancelled scope would leave the "
|
||||
+ "call running");
|
||||
}
|
||||
if (!profile.respectsStableFlowControl()) {
|
||||
blockers.add(
|
||||
"Flow backpressure bypasses the Stable buffer bounds; a slow collector would buffer "
|
||||
+ "without a limit rather than terminating the stream");
|
||||
}
|
||||
if (!profile.preservesPlatformEvidenceTypes()) {
|
||||
blockers.add(
|
||||
"the adapter does not preserve the platform's evidence, status and deadline types; a "
|
||||
+ "Kotlin-idiomatic re-creation is a second model of the same facts");
|
||||
}
|
||||
if (!toolchainLaneRan) {
|
||||
blockers.add(
|
||||
"no Kotlin toolchain lane has run against "
|
||||
+ profile.kotlinToolchainVersion()
|
||||
+ "; this repository has no Kotlin toolchain, so the compile evidence does not exist");
|
||||
}
|
||||
return List.copyOf(blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Kotlin adapter is supportable in this repository as it stands.
|
||||
*
|
||||
* <p>False. The four contract requirements can be satisfied; the toolchain lane cannot.
|
||||
*/
|
||||
public static boolean supportableHere() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.grpc.advanced.kotlin;
|
||||
|
||||
/**
|
||||
* How a Kotlin adapter must behave, stated from the Java side.
|
||||
*
|
||||
* <p>Java-side because this repository has no Kotlin toolchain (adaptation design D7). What can be
|
||||
* expressed without one is every rule that constrains the adapter: one schema source shared with
|
||||
* the Java contract, coroutine cancellation propagated to the call, Flow backpressure inside the
|
||||
* Stable buffer bounds, and the platform's evidence and status types preserved rather than
|
||||
* re-created in Kotlin idiom.
|
||||
*
|
||||
* <p>Each is a component so the gate can check them individually. A single "compatible" flag would
|
||||
* let a partially compliant adapter through, and the most likely partial failure — a Flow that
|
||||
* buffers without a bound — is the one with the worst production behaviour.
|
||||
*/
|
||||
public record GrpcKotlinProfile(
|
||||
boolean sharesSchemaSourceWithJava,
|
||||
boolean propagatesCoroutineCancellation,
|
||||
boolean respectsStableFlowControl,
|
||||
boolean preservesPlatformEvidenceTypes,
|
||||
String kotlinToolchainVersion) {
|
||||
|
||||
/** Refuses a profile that claims support without naming a toolchain. */
|
||||
public GrpcKotlinProfile {
|
||||
if (kotlinToolchainVersion == null || kotlinToolchainVersion.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a Kotlin profile names the toolchain it was verified against; 'Kotlin' is not a version");
|
||||
}
|
||||
}
|
||||
|
||||
/** A profile for a toolchain this repository has not verified. */
|
||||
public static GrpcKotlinProfile unverified(String kotlinToolchainVersion) {
|
||||
return new GrpcKotlinProfile(false, false, false, false, kotlinToolchainVersion);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.grpc.advanced.reactor;
|
||||
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
|
||||
import java.time.Instant;
|
||||
import java.util.function.Supplier;
|
||||
import reactor.core.Disposable;
|
||||
|
||||
/**
|
||||
* Turns a Reactor subscription's cancellation into the platform's.
|
||||
*
|
||||
* <p>Both directions, and both are needed. A client that disposes its {@code Mono} has stopped
|
||||
* caring, and without this bridge the server keeps computing an answer nobody will read; a call the
|
||||
* platform cancelled — deadline, drain, revoked credential — has to stop the reactive pipeline, or
|
||||
* the work continues after the response has been closed.
|
||||
*/
|
||||
public final class GrpcReactorCancellationBridge {
|
||||
|
||||
private GrpcReactorCancellationBridge() {}
|
||||
|
||||
/**
|
||||
* A callback for {@code doOnCancel} that cancels the platform call.
|
||||
*
|
||||
* @param now supplies the moment, so a test does not depend on the wall clock
|
||||
*/
|
||||
public static Runnable onReactorCancel(
|
||||
GrpcCancellationCoordinator coordinator, Supplier<Instant> now) {
|
||||
if (coordinator == null || now == null) {
|
||||
throw new IllegalArgumentException("a cancellation bridge needs a coordinator and a clock");
|
||||
}
|
||||
return () -> coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED, now.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes the reactive pipeline when the platform cancels.
|
||||
*
|
||||
* <p>Registered as a cancellable operation, so it is reached by the same cancellation that stops
|
||||
* the database query and the stream writer rather than by a second mechanism.
|
||||
*/
|
||||
public static void bindPlatformCancellation(
|
||||
GrpcCancellationCoordinator coordinator, Disposable subscription, String operationName) {
|
||||
if (coordinator == null || subscription == null) {
|
||||
throw new IllegalArgumentException("binding needs a coordinator and a subscription");
|
||||
}
|
||||
if (operationName == null || operationName.isBlank()) {
|
||||
throw new IllegalArgumentException("a cancellable operation needs a name");
|
||||
}
|
||||
coordinator.register(
|
||||
new dev.caskeleton.grpc.deadline.GrpcCancellableOperation() {
|
||||
@Override
|
||||
public String name() {
|
||||
return operationName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel(GrpcCancellationReason reason) {
|
||||
subscription.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.grpc.advanced.reactor;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
|
||||
import java.util.Optional;
|
||||
import reactor.util.context.Context;
|
||||
import reactor.util.context.ContextView;
|
||||
|
||||
/**
|
||||
* Carries the call context between gRPC's {@code Context} and Reactor's.
|
||||
*
|
||||
* <p>Explicitly, because neither propagates into the other. gRPC's context is a thread-local
|
||||
* mechanism and Reactor's travels with the subscription, so an operator that hops threads leaves
|
||||
* the gRPC context behind and a gRPC interceptor cannot see the Reactor one. Work downstream of
|
||||
* that boundary then runs with no actor, no tenant and no deadline, which is the failure that
|
||||
* produces a change attributed to nobody.
|
||||
*/
|
||||
public final class GrpcReactorContextBridge {
|
||||
|
||||
private static final String CONTEXT_KEY = "dev.caskeleton.grpc.contextSnapshot";
|
||||
|
||||
private GrpcReactorContextBridge() {}
|
||||
|
||||
/** Puts the snapshot into a Reactor context. */
|
||||
public static Context write(Context context, GrpcContextSnapshot snapshot) {
|
||||
if (context == null || snapshot == null) {
|
||||
throw new IllegalArgumentException("bridging needs a Reactor context and a snapshot");
|
||||
}
|
||||
return context.put(CONTEXT_KEY, snapshot);
|
||||
}
|
||||
|
||||
/** Reads the snapshot out of a Reactor context, if it is there. */
|
||||
public static Optional<GrpcContextSnapshot> read(ContextView context) {
|
||||
if (context == null) {
|
||||
throw new IllegalArgumentException("a Reactor context view is required");
|
||||
}
|
||||
return context.hasKey(CONTEXT_KEY) ? Optional.of(context.get(CONTEXT_KEY)) : Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the snapshot, failing when it is absent.
|
||||
*
|
||||
* @throws IllegalStateException because reactive work with no call context has no actor, no
|
||||
* tenant and no deadline
|
||||
*/
|
||||
public static GrpcContextSnapshot require(ContextView context) {
|
||||
return read(context)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"no gRPC call context is in the Reactor context; reactive work that runs "
|
||||
+ "without one produces a change attributed to nobody"));
|
||||
}
|
||||
|
||||
/** The key the snapshot travels under, for a test or a diagnostic to look at. */
|
||||
public static String contextKey() {
|
||||
return CONTEXT_KEY;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.grpc.advanced.reactor;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Exposes a unary call as a {@code Mono} and a server stream as a {@code Flux}.
|
||||
*
|
||||
* <p>Only here. The Stable contract types stay free of Reactor, so a deployment that does not use
|
||||
* it does not carry it, and the reactive shape is a view over the platform rather than the
|
||||
* platform's own vocabulary.
|
||||
*
|
||||
* <p>The {@code Flux} respects the Stable bounded flow control rather than replacing it. Reactor's
|
||||
* backpressure and gRPC's are two mechanisms over one connection; letting a subscriber's {@code
|
||||
* request(n)} drive the writer directly would bypass the bounded queue that decides what happens
|
||||
* when a consumer falls behind.
|
||||
*/
|
||||
public final class ReactiveGrpcClient {
|
||||
|
||||
private ReactiveGrpcClient() {}
|
||||
|
||||
/**
|
||||
* A unary call as a {@code Mono}, with the call context carried in the Reactor context.
|
||||
*
|
||||
* @param call the blocking invocation, run on {@code boundedElasticScheduler} rather than on the
|
||||
* calling thread. A blocking call on an event loop stalls every other call sharing it, and
|
||||
* the mistake is invisible until load arrives.
|
||||
*/
|
||||
public static <R> Mono<R> unary(
|
||||
Supplier<R> call, GrpcContextSnapshot snapshot, reactor.core.scheduler.Scheduler blocking) {
|
||||
if (call == null || snapshot == null || blocking == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a reactive unary call needs an invocation, a context and a scheduler");
|
||||
}
|
||||
return Mono.fromSupplier(call)
|
||||
.subscribeOn(blocking)
|
||||
.contextWrite(context -> GrpcReactorContextBridge.write(context, snapshot));
|
||||
}
|
||||
|
||||
/**
|
||||
* A server stream as a {@code Flux}.
|
||||
*
|
||||
* @param messages the already-bounded message source. Taking a list rather than a producer is
|
||||
* deliberate: the bound belongs to the Stable stream writer, and a producer here would be a
|
||||
* second place to get it wrong.
|
||||
*/
|
||||
public static <R> Flux<R> serverStream(List<R> messages, GrpcContextSnapshot snapshot) {
|
||||
if (messages == null || snapshot == null) {
|
||||
throw new IllegalArgumentException("a reactive stream needs its messages and a context");
|
||||
}
|
||||
return Flux.fromIterable(messages)
|
||||
.contextWrite(context -> GrpcReactorContextBridge.write(context, snapshot));
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.grpc.advanced.reactor;
|
||||
|
||||
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
|
||||
import dev.caskeleton.grpc.context.GrpcRequestContext;
|
||||
import java.util.function.BiFunction;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
|
||||
/**
|
||||
* Runs a reactive use case behind a gRPC service adapter.
|
||||
*
|
||||
* <p>The blocking scheduler is a required argument rather than a default. A reactive pipeline that
|
||||
* touches JPA or a blocking SDK and does not say where that happens runs it on the event loop, and
|
||||
* the symptom — every call on the connection slowing together — appears only under load and points
|
||||
* at the wrong component.
|
||||
*
|
||||
* @param <C> the application command type
|
||||
* @param <R> the application result type
|
||||
*/
|
||||
public final class ReactiveGrpcServerAdapter<C, R> {
|
||||
|
||||
private final BiFunction<C, GrpcRequestContext, Mono<R>> useCase;
|
||||
private final Scheduler blockingScheduler;
|
||||
|
||||
/** Binds an adapter to its use case and the scheduler blocking work runs on. */
|
||||
public ReactiveGrpcServerAdapter(
|
||||
BiFunction<C, GrpcRequestContext, Mono<R>> useCase, Scheduler blockingScheduler) {
|
||||
if (useCase == null || blockingScheduler == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a reactive adapter needs a use case and the scheduler its blocking work runs on");
|
||||
}
|
||||
this.useCase = useCase;
|
||||
this.blockingScheduler = blockingScheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the use case with the context in both the Reactor context and the argument.
|
||||
*
|
||||
* <p>Both, because they are read by different code: application operators read the Reactor
|
||||
* context, and the use case signature reads the argument. Supplying only one leaves the other
|
||||
* empty at a boundary nobody expects.
|
||||
*/
|
||||
public Mono<R> invoke(C command, GrpcRequestContext context, GrpcContextSnapshot snapshot) {
|
||||
if (command == null || context == null || snapshot == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"an invocation needs a command, a request context and the snapshot to carry");
|
||||
}
|
||||
return Mono.defer(() -> useCase.apply(command, context))
|
||||
.subscribeOn(blockingScheduler)
|
||||
.contextWrite(reactorContext -> GrpcReactorContextBridge.write(reactorContext, snapshot));
|
||||
}
|
||||
|
||||
/** The scheduler blocking work runs on. */
|
||||
public Scheduler blockingScheduler() {
|
||||
return blockingScheduler;
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.grpc.advanced.servlet;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a Servlet container can and cannot do compared with Netty.
|
||||
*
|
||||
* <p>The gaps are not incidental. A Servlet container owns the socket, so everything below the
|
||||
* request abstraction belongs to it: keepalive, connection age, and the flow-control window are the
|
||||
* container's settings, not gRPC's. Naming them is what stops a deployment from configuring a
|
||||
* keepalive that is silently ignored and concluding the client is at fault.
|
||||
*/
|
||||
public enum GrpcServletCapabilityMatrix {
|
||||
/** HTTP/2 request and response. Available. */
|
||||
HTTP2(true),
|
||||
/** TLS. Available, terminated by the container. */
|
||||
TLS(true),
|
||||
/** Inbound message and metadata limits. Available. */
|
||||
MESSAGE_LIMITS(true),
|
||||
/** The standard health service. Available. */
|
||||
HEALTH(true),
|
||||
/** Server reflection. Available. */
|
||||
REFLECTION(true),
|
||||
/** Graceful shutdown. Available through the container's lifecycle. */
|
||||
GRACEFUL_SHUTDOWN(true),
|
||||
/** gRPC-level keepalive tuning. The container owns the connection. */
|
||||
KEEPALIVE_TUNING(false),
|
||||
/** Maximum connection age. The container owns the connection. */
|
||||
MAX_CONNECTION_AGE(false),
|
||||
/** HTTP/2 flow-control window tuning. The container owns the transport. */
|
||||
FLOW_CONTROL_WINDOW_TUNING(false);
|
||||
|
||||
private final boolean available;
|
||||
|
||||
GrpcServletCapabilityMatrix(boolean available) {
|
||||
this.available = available;
|
||||
}
|
||||
|
||||
/** Whether the Servlet transport provides this. */
|
||||
public boolean available() {
|
||||
return available;
|
||||
}
|
||||
|
||||
/** Everything the Servlet transport cannot do. */
|
||||
public static Set<GrpcServletCapabilityMatrix> unavailable() {
|
||||
return java.util.Arrays.stream(values())
|
||||
.filter(capability -> !capability.available())
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.grpc.advanced.servlet;
|
||||
|
||||
import dev.caskeleton.grpc.server.GrpcServerTransport;
|
||||
|
||||
/**
|
||||
* A deployment that serves gRPC from its Servlet container, on the web server's own port.
|
||||
*
|
||||
* <p>Attractive because it is one port, one TLS configuration and one lifecycle. The cost is that
|
||||
* the container owns the transport, so the Netty settings a Stable profile carries have no effect —
|
||||
* and this profile refuses to pretend otherwise.
|
||||
*
|
||||
* <p>Its evidence never counts as Netty certification. A suite that passes here has established
|
||||
* that the container serves gRPC, not that the platform's Netty profile is correct.
|
||||
*/
|
||||
public record GrpcServletCompatibilityProfile(
|
||||
String contextPath, boolean containerOwnsTls, boolean asyncSupported) {
|
||||
|
||||
/** Refuses a configuration the container cannot honour. */
|
||||
public GrpcServletCompatibilityProfile {
|
||||
if (contextPath == null || !contextPath.startsWith("/")) {
|
||||
throw new IllegalArgumentException(
|
||||
"a servlet profile needs a context path starting with '/'");
|
||||
}
|
||||
if (!asyncSupported) {
|
||||
throw new IllegalArgumentException(
|
||||
"gRPC over Servlet requires async support; without it every streaming call blocks a "
|
||||
+ "container thread for its lifetime");
|
||||
}
|
||||
if (!containerOwnsTls) {
|
||||
throw new IllegalArgumentException(
|
||||
"the container owns the socket, so it owns TLS; a profile that claims otherwise "
|
||||
+ "configures a setting nothing reads");
|
||||
}
|
||||
}
|
||||
|
||||
/** The transport this profile runs on. */
|
||||
public GrpcServerTransport transport() {
|
||||
return GrpcServerTransport.SERVLET;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a run under this profile certifies the platform's Netty transport.
|
||||
*
|
||||
* <p>Always false. The container's HTTP/2 is real, but it is the container's: its keepalive, its
|
||||
* connection age and its flow-control window. Stable certification is a statement about the
|
||||
* platform's own Netty profile, and a Servlet run establishes nothing about it.
|
||||
*/
|
||||
public boolean certifiesNettyTransport() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.grpc.advanced.servlet;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Refuses a Servlet deployment that asks for something the container will not do.
|
||||
*
|
||||
* <p>Refuses rather than warns, because the setting would otherwise be accepted and ignored. A
|
||||
* keepalive configured on a Servlet deployment does nothing, the connections behave as the
|
||||
* container decides, and the investigation starts from the assumption that the setting is in force.
|
||||
*/
|
||||
public final class GrpcServletStartupValidator {
|
||||
|
||||
private GrpcServletStartupValidator() {}
|
||||
|
||||
/**
|
||||
* Every requested capability the Servlet transport cannot provide.
|
||||
*
|
||||
* @param requestedCapabilities the transport settings the deployment configured
|
||||
* @return an empty list when everything requested is available
|
||||
*/
|
||||
public static List<String> violations(
|
||||
GrpcServletCompatibilityProfile profile,
|
||||
Set<GrpcServletCapabilityMatrix> requestedCapabilities) {
|
||||
if (profile == null || requestedCapabilities == null) {
|
||||
throw new IllegalArgumentException("validation needs the profile and the requested set");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
requestedCapabilities.stream()
|
||||
.filter(capability -> !capability.available())
|
||||
.sorted()
|
||||
.forEach(
|
||||
capability ->
|
||||
violations.add(
|
||||
capability
|
||||
+ " is not available on the Servlet transport; the container owns the "
|
||||
+ "connection, so this setting would be accepted and ignored"));
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a Servlet run may stand in for Netty certification.
|
||||
*
|
||||
* <p>Always false.
|
||||
*/
|
||||
public static boolean substitutesForNettyCertification() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package dev.caskeleton.grpc.advanced.web;
|
||||
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Checks that everything exposed to browsers can actually be served to them, on the same schema as
|
||||
* the native clients.
|
||||
*
|
||||
* <p>The same schema is the point. Two schemas — one for browsers, one for services — is how a
|
||||
* field ends up meaning something different depending on which client asked, and the divergence is
|
||||
* only visible to whoever reads both files.
|
||||
*/
|
||||
public final class GrpcWebCompatibilityGate {
|
||||
|
||||
private GrpcWebCompatibilityGate() {}
|
||||
|
||||
/**
|
||||
* Every method this profile could not serve.
|
||||
*
|
||||
* @param exposedMethods the methods a browser is meant to be able to call
|
||||
* @return an empty list when every exposed method is servable
|
||||
*/
|
||||
public static List<String> violations(Map<GrpcMethodName, RpcType> exposedMethods) {
|
||||
if (exposedMethods == null) {
|
||||
throw new IllegalArgumentException("an exposed method map is required");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
exposedMethods.entrySet().stream()
|
||||
.sorted(java.util.Comparator.comparing(entry -> entry.getKey().canonical()))
|
||||
.forEach(
|
||||
entry -> {
|
||||
if (!GrpcWebRpcSupport.supported(entry.getValue())) {
|
||||
violations.add(
|
||||
"method '"
|
||||
+ entry.getKey().canonical()
|
||||
+ "' is "
|
||||
+ entry.getValue()
|
||||
+ ", which gRPC-Web cannot carry");
|
||||
}
|
||||
});
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the browser and native suites must run against one schema.
|
||||
*
|
||||
* <p>Always true. Stated as a method so the property is asserted rather than described.
|
||||
*/
|
||||
public static boolean sharesOneSchemaWithNativeClients() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.grpc.advanced.web;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* How a browser reaches the platform, and under what browser-specific rules.
|
||||
*
|
||||
* <p>Cookie credentials and bearer credentials are separated because their attack surfaces differ.
|
||||
* A cookie is attached by the browser to every request to the origin, which makes CSRF a real
|
||||
* concern and a CSRF defence mandatory; a bearer token the application attaches explicitly is not
|
||||
* sent automatically, and requiring a CSRF token there is ceremony. A profile that treats them the
|
||||
* same either leaves the first exposed or burdens the second.
|
||||
*/
|
||||
public record GrpcWebProfile(
|
||||
CredentialStyle credentialStyle,
|
||||
Set<String> allowedOrigins,
|
||||
boolean csrfProtection,
|
||||
boolean tlsTerminatedAtProxy) {
|
||||
|
||||
/** How the browser presents its credential. */
|
||||
public enum CredentialStyle {
|
||||
/** No credential. */
|
||||
NONE,
|
||||
/** A cookie the browser attaches automatically. Needs a CSRF defence. */
|
||||
COOKIE,
|
||||
/** A bearer token the application attaches explicitly. */
|
||||
BEARER
|
||||
}
|
||||
|
||||
/** Refuses a profile a browser could be tricked into using. */
|
||||
public GrpcWebProfile {
|
||||
if (credentialStyle == null) {
|
||||
throw new IllegalArgumentException("a gRPC-Web profile names its credential style");
|
||||
}
|
||||
if (allowedOrigins == null) {
|
||||
throw new IllegalArgumentException("a gRPC-Web profile states its origin allowlist");
|
||||
}
|
||||
allowedOrigins = Set.copyOf(allowedOrigins);
|
||||
if (allowedOrigins.contains("*")) {
|
||||
throw new IllegalArgumentException(
|
||||
"a wildcard origin lets any site call this API with the browser's ambient credentials");
|
||||
}
|
||||
if (credentialStyle != CredentialStyle.NONE && allowedOrigins.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a credentialed gRPC-Web profile needs an origin allowlist");
|
||||
}
|
||||
if (credentialStyle == CredentialStyle.COOKIE && !csrfProtection) {
|
||||
throw new IllegalArgumentException(
|
||||
"cookie credentials are attached by the browser to every request to this origin, so a "
|
||||
+ "CSRF defence is not optional");
|
||||
}
|
||||
if (!tlsTerminatedAtProxy && credentialStyle != CredentialStyle.NONE) {
|
||||
throw new IllegalArgumentException(
|
||||
"a credentialed browser call needs TLS terminated at the proxy");
|
||||
}
|
||||
}
|
||||
|
||||
/** A bearer-token profile for a single origin. */
|
||||
public static GrpcWebProfile bearer(String origin) {
|
||||
return new GrpcWebProfile(CredentialStyle.BEARER, Set.of(origin), false, true);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.grpc.advanced.web;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What a gRPC-Web proxy must be configured to do.
|
||||
*
|
||||
* <p>The exposed-trailer rule is the one that costs the most time when it is missing. A gRPC status
|
||||
* arrives as a trailer, a browser cannot read a trailer the proxy did not expose, and the symptom
|
||||
* is a call that appears to succeed at the network level and produces no status at all — which
|
||||
* looks like an application bug and is a proxy configuration.
|
||||
*/
|
||||
public final class GrpcWebProxyContract {
|
||||
|
||||
/** The trailers a browser client has to be able to read. */
|
||||
private static final Set<String> REQUIRED_EXPOSED_HEADERS = Set.of("grpc-status", "grpc-message");
|
||||
|
||||
private GrpcWebProxyContract() {}
|
||||
|
||||
/** The headers a proxy must expose. */
|
||||
public static Set<String> requiredExposedHeaders() {
|
||||
return REQUIRED_EXPOSED_HEADERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem with a proxy configuration.
|
||||
*
|
||||
* @param exposedHeaders the CORS {@code expose_headers} the proxy is configured with
|
||||
* @param allowedOrigins the CORS origin allowlist the proxy is configured with
|
||||
* @return an empty list when the proxy would work for a browser client
|
||||
*/
|
||||
public static List<String> violations(
|
||||
GrpcWebProfile profile, Set<String> exposedHeaders, Set<String> allowedOrigins) {
|
||||
if (profile == null || exposedHeaders == null || allowedOrigins == null) {
|
||||
throw new IllegalArgumentException("a proxy check needs the profile and both CORS sets");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
REQUIRED_EXPOSED_HEADERS.stream()
|
||||
.sorted()
|
||||
.filter(header -> !exposedHeaders.contains(header))
|
||||
.forEach(
|
||||
header ->
|
||||
violations.add(
|
||||
"the proxy does not expose '"
|
||||
+ header
|
||||
+ "'; a browser cannot read a trailer it was not given, so the call "
|
||||
+ "produces no status at all"));
|
||||
|
||||
if (allowedOrigins.contains("*")) {
|
||||
violations.add("the proxy allows any origin, which defeats the profile's allowlist");
|
||||
}
|
||||
profile.allowedOrigins().stream()
|
||||
.sorted()
|
||||
.filter(origin -> !allowedOrigins.contains(origin))
|
||||
.forEach(
|
||||
origin ->
|
||||
violations.add(
|
||||
"origin '" + origin + "' is in the profile but not in the proxy's allowlist"));
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.grpc.advanced.web;
|
||||
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
|
||||
/**
|
||||
* Which RPC shapes gRPC-Web can actually carry.
|
||||
*
|
||||
* <p>Two, and the limit is the protocol's rather than this platform's: gRPC-Web has no way for a
|
||||
* browser to send a stream of messages, so client and bidirectional streaming are not slow or
|
||||
* partial there — they do not exist. Declaring support for them produces a schema a browser client
|
||||
* cannot use and a discovery that happens in the browser.
|
||||
*/
|
||||
public final class GrpcWebRpcSupport {
|
||||
|
||||
private GrpcWebRpcSupport() {}
|
||||
|
||||
/** Whether {@code rpcType} can travel over gRPC-Web. */
|
||||
public static boolean supported(RpcType rpcType) {
|
||||
if (rpcType == null) {
|
||||
throw new IllegalArgumentException("an RPC type is required");
|
||||
}
|
||||
return rpcType == RpcType.UNARY || rpcType == RpcType.SERVER_STREAMING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when a method shape cannot be served over gRPC-Web.
|
||||
*
|
||||
* @throws IllegalArgumentException naming what the browser cannot do
|
||||
*/
|
||||
public static void require(RpcType rpcType) {
|
||||
if (!supported(rpcType)) {
|
||||
throw new IllegalArgumentException(
|
||||
rpcType
|
||||
+ " cannot travel over gRPC-Web: a browser has no way to send a stream of messages, "
|
||||
+ "so declaring support for it produces a schema no browser client can use");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# The gRPC-Web proxy contract, as a reference Envoy configuration.
|
||||
#
|
||||
# Shipped as a resource rather than as documentation prose because GrpcWebProxyContract asserts
|
||||
# against it: the CORS allowlist, the exposed trailer headers and the TLS termination are the three
|
||||
# things a browser client silently fails without, and a contract nobody checks is a contract that
|
||||
# drifts from whatever is actually deployed.
|
||||
#
|
||||
# The exposed headers matter most and are the least obvious. gRPC statuses arrive as trailers, and a
|
||||
# browser cannot read a trailer the proxy did not expose; the symptom is a call that appears to hang
|
||||
# and then fails with no status at all.
|
||||
static_resources:
|
||||
listeners:
|
||||
- name: grpc_web_listener
|
||||
address:
|
||||
socket_address: { address: 0.0.0.0, port_value: 8443 }
|
||||
filter_chains:
|
||||
- filters:
|
||||
- name: envoy.filters.network.http_connection_manager
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||
stat_prefix: grpc_web
|
||||
codec_type: AUTO
|
||||
route_config:
|
||||
name: grpc_web_route
|
||||
virtual_hosts:
|
||||
- name: grpc_web_host
|
||||
domains: ["*"]
|
||||
routes:
|
||||
- match: { prefix: "/" }
|
||||
route:
|
||||
cluster: grpc_backend
|
||||
timeout: 30s
|
||||
cors:
|
||||
allow_origin_string_match:
|
||||
- exact: "https://app.example.com"
|
||||
allow_methods: "POST,OPTIONS"
|
||||
allow_headers: "content-type,x-grpc-web,x-correlation-id,authorization"
|
||||
expose_headers: "grpc-status,grpc-message,error-code,error-category"
|
||||
max_age: "1728000"
|
||||
http_filters:
|
||||
- name: envoy.filters.http.grpc_web
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.grpc_web.v3.GrpcWeb
|
||||
- name: envoy.filters.http.cors
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
|
||||
- name: envoy.filters.http.router
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||
transport_socket:
|
||||
name: envoy.transport_sockets.tls
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
|
||||
clusters:
|
||||
- name: grpc_backend
|
||||
connect_timeout: 1s
|
||||
type: STRICT_DNS
|
||||
lb_policy: ROUND_ROBIN
|
||||
typed_extension_protocol_options:
|
||||
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
|
||||
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
|
||||
explicit_http_config:
|
||||
http2_protocol_options: {}
|
||||
load_assignment:
|
||||
cluster_name: grpc_backend
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address: { address: documents, port_value: 9090 }
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationBridgePolicy;
|
||||
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationInboundGateway;
|
||||
import dev.caskeleton.grpc.advanced.integration.GrpcIntegrationOutboundGateway;
|
||||
import dev.caskeleton.grpc.context.GrpcClientIdentity;
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataBudget;
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataKey;
|
||||
import dev.caskeleton.grpc.context.GrpcRequestContext;
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
|
||||
class GrpcIntegrationBridgePolicyTest {
|
||||
|
||||
private static final GrpcMetadataKey CORRELATION = GrpcMetadataKey.ascii("x-correlation-id");
|
||||
private static final GrpcMethodName GET =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
|
||||
|
||||
private static GrpcRequestContext requestContext() {
|
||||
return GrpcRequestContext.create(
|
||||
GET,
|
||||
RpcType.UNARY,
|
||||
GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"),
|
||||
GrpcDeadlineBudget.forEntryPoint(
|
||||
Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))),
|
||||
new GrpcCancellationToken(),
|
||||
Map.of(CORRELATION, "corr-1"),
|
||||
Set.of(CORRELATION),
|
||||
GrpcMetadataBudget.standard(),
|
||||
null);
|
||||
}
|
||||
|
||||
private static GrpcIntegrationBridgePolicy policy() {
|
||||
return new GrpcIntegrationBridgePolicy(Set.of(CORRELATION), Set.of(String.class.getName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the bridge copies only allowlisted headers onto metadata")
|
||||
void theBridgeCopiesOnlyAllowlistedHeaders() {
|
||||
GrpcIntegrationOutboundGateway<String> outbound =
|
||||
new GrpcIntegrationOutboundGateway<>(policy());
|
||||
Message<String> message =
|
||||
MessageBuilder.withPayload("payload")
|
||||
.setHeader(CORRELATION.name(), "corr-1")
|
||||
.setHeader("errorChannel", "internal-errors")
|
||||
.setHeader("replyChannel", "internal-replies")
|
||||
.build();
|
||||
|
||||
assertThat(outbound.metadataFrom(message)).containsOnlyKeys(CORRELATION);
|
||||
assertThat(outbound.payloadFrom(message)).isEqualTo("payload");
|
||||
assertThat(outbound.policy()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unregistered payload type is refused rather than converted by reflection")
|
||||
void anUnregisteredPayloadIsRefused() {
|
||||
GrpcIntegrationBridgePolicy otherType =
|
||||
new GrpcIntegrationBridgePolicy(Set.of(CORRELATION), Set.of("some.other.Type"));
|
||||
GrpcIntegrationInboundGateway<String> inbound = new GrpcIntegrationInboundGateway<>(otherType);
|
||||
|
||||
assertThatThrownBy(() -> inbound.toMessage("payload", requestContext()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("converting by reflection");
|
||||
Message<String> message = MessageBuilder.withPayload("payload").build();
|
||||
GrpcIntegrationOutboundGateway<String> outbound =
|
||||
new GrpcIntegrationOutboundGateway<>(otherType);
|
||||
assertThatThrownBy(() -> outbound.payloadFrom(message))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bridge with no registered converter is refused at construction")
|
||||
void aBridgeWithNoConverterIsRefused() {
|
||||
assertThatThrownBy(() -> new GrpcIntegrationBridgePolicy(Set.of(), Set.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("leaving it to reflection");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the request context travels as one immutable header, not as scattered fields")
|
||||
void theContextTravelsAsOneHeader() {
|
||||
GrpcIntegrationInboundGateway<String> inbound = new GrpcIntegrationInboundGateway<>(policy());
|
||||
|
||||
Message<String> message = inbound.toMessage("payload", requestContext());
|
||||
|
||||
assertThat(message.getHeaders())
|
||||
.containsKey(GrpcIntegrationInboundGateway.CONTEXT_HEADER)
|
||||
.containsKey(CORRELATION.name())
|
||||
.doesNotContainKey("tenantId")
|
||||
.doesNotContainKey("actorId");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the bridge adds no broker semantics and replaces no generated API")
|
||||
void theBridgeClaimsNothingItCannotDo() {
|
||||
assertThat(policy().providesBrokerSemantics()).isFalse();
|
||||
assertThat(policy().replacesGeneratedApis()).isFalse();
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.kotlin.GrpcCoroutineContextBridge;
|
||||
import dev.caskeleton.grpc.advanced.kotlin.GrpcKotlinCompatibilityGate;
|
||||
import dev.caskeleton.grpc.advanced.kotlin.GrpcKotlinProfile;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcKotlinCompatibilityGateTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
@Test
|
||||
@DisplayName("the Kotlin adapter fails closed here, and says which lane is missing")
|
||||
void theGateFailsClosed() {
|
||||
GrpcKotlinProfile complete = new GrpcKotlinProfile(true, true, true, true, "2.1.0");
|
||||
|
||||
assertThat(GrpcKotlinCompatibilityGate.blockers(complete, false))
|
||||
.singleElement()
|
||||
.satisfies(blocker -> assertThat(blocker).contains("no Kotlin toolchain"));
|
||||
assertThat(GrpcKotlinCompatibilityGate.blockers(complete, true)).isEmpty();
|
||||
assertThat(GrpcKotlinCompatibilityGate.supportableHere()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each Kotlin contract requirement is checked on its own")
|
||||
void eachRequirementIsCheckedSeparately() {
|
||||
assertThat(GrpcKotlinCompatibilityGate.blockers(GrpcKotlinProfile.unverified("2.1.0"), true))
|
||||
.hasSize(4)
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("one schema source"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("cancelled scope"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("buffer without a limit"))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("second model of the same facts"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a profile that claims support without naming a toolchain is refused")
|
||||
void aProfileNamesItsToolchain() {
|
||||
assertThatThrownBy(() -> GrpcKotlinProfile.unverified(" "))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("not a version");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("coroutine cancellation reaches the platform, and the platform reaches the scope")
|
||||
void cancellationCrossesBothWays() {
|
||||
GrpcCancellationCoordinator inbound =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
GrpcCoroutineContextBridge.onCoroutineCancelled(inbound, () -> NOW).run();
|
||||
assertThat(inbound.cancelled()).isTrue();
|
||||
|
||||
GrpcCancellationCoordinator outbound =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
AtomicBoolean scopeCancelled = new AtomicBoolean();
|
||||
GrpcCoroutineContextBridge.cancelCoroutineScope(
|
||||
outbound, () -> scopeCancelled.set(true), "document-flow");
|
||||
outbound.cancel(GrpcCancellationReason.SERVER_DRAIN, NOW);
|
||||
|
||||
assertThat(scopeCancelled).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("binding a coroutine scope without a name is refused")
|
||||
void bindingNeedsAnOperationName() {
|
||||
GrpcCancellationCoordinator coordinator =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> GrpcCoroutineContextBridge.cancelCoroutineScope(coordinator, () -> {}, " "))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.reactor.GrpcReactorCancellationBridge;
|
||||
import dev.caskeleton.grpc.advanced.reactor.GrpcReactorContextBridge;
|
||||
import dev.caskeleton.grpc.context.GrpcClientIdentity;
|
||||
import dev.caskeleton.grpc.context.GrpcContextSnapshot;
|
||||
import dev.caskeleton.grpc.context.GrpcMetadataBudget;
|
||||
import dev.caskeleton.grpc.context.GrpcRequestContext;
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationCoordinator;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationReason;
|
||||
import dev.caskeleton.grpc.deadline.GrpcCancellationToken;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineBudget;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.util.context.Context;
|
||||
|
||||
class GrpcReactorContextBridgeTest {
|
||||
|
||||
private static final GrpcMethodName GET =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
private static GrpcContextSnapshot snapshot() {
|
||||
GrpcRequestContext request =
|
||||
GrpcRequestContext.create(
|
||||
GET,
|
||||
RpcType.UNARY,
|
||||
GrpcClientIdentity.fromVerifiedAuthentication("actor-1", "tenant-1", "jwt-issuer"),
|
||||
GrpcDeadlineBudget.forEntryPoint(
|
||||
Duration.ofSeconds(1), GrpcDeadlineProfile.of(Duration.ofSeconds(2))),
|
||||
new GrpcCancellationToken(),
|
||||
Map.of(),
|
||||
Set.of(),
|
||||
GrpcMetadataBudget.standard(),
|
||||
null);
|
||||
return GrpcContextSnapshot.of(request, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the call context crosses into and out of a Reactor context")
|
||||
void theContextCrossesIntoReactor() {
|
||||
GrpcContextSnapshot snapshot = snapshot();
|
||||
|
||||
Context context = GrpcReactorContextBridge.write(Context.empty(), snapshot);
|
||||
|
||||
assertThat(GrpcReactorContextBridge.read(context)).contains(snapshot);
|
||||
assertThat(GrpcReactorContextBridge.require(context).identity().actorId()).isEqualTo("actor-1");
|
||||
assertThat(GrpcReactorContextBridge.contextKey()).isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("reactive work with no call context fails closed")
|
||||
void contextlessReactiveWorkFailsClosed() {
|
||||
assertThat(GrpcReactorContextBridge.read(Context.empty())).isEmpty();
|
||||
assertThatThrownBy(() -> GrpcReactorContextBridge.require(Context.empty()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("attributed to nobody");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a Reactor cancellation reaches the platform coordinator")
|
||||
void reactorCancellationReachesThePlatform() {
|
||||
GrpcCancellationCoordinator coordinator =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
|
||||
GrpcReactorCancellationBridge.onReactorCancel(coordinator, () -> NOW).run();
|
||||
|
||||
assertThat(coordinator.cancelled()).isTrue();
|
||||
assertThat(coordinator.reason()).contains(GrpcCancellationReason.CLIENT_CANCELLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a platform cancellation disposes the reactive subscription")
|
||||
void platformCancellationDisposesTheSubscription() {
|
||||
GrpcCancellationCoordinator coordinator =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
AtomicBoolean disposed = new AtomicBoolean();
|
||||
|
||||
GrpcReactorCancellationBridge.bindPlatformCancellation(
|
||||
coordinator, () -> disposed.set(true), "document-query");
|
||||
coordinator.cancel(GrpcCancellationReason.DEADLINE_EXCEEDED, NOW);
|
||||
|
||||
assertThat(disposed).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("binding a subscription without a name is refused")
|
||||
void bindingNeedsAnOperationName() {
|
||||
GrpcCancellationCoordinator coordinator =
|
||||
new GrpcCancellationCoordinator(new GrpcCancellationToken());
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcReactorCancellationBridge.bindPlatformCancellation(coordinator, () -> {}, " "))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.servlet.GrpcServletCapabilityMatrix;
|
||||
import dev.caskeleton.grpc.advanced.servlet.GrpcServletCompatibilityProfile;
|
||||
import dev.caskeleton.grpc.advanced.servlet.GrpcServletStartupValidator;
|
||||
import dev.caskeleton.grpc.server.GrpcServerTransport;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcServletStartupValidatorTest {
|
||||
|
||||
private static final GrpcServletCompatibilityProfile PROFILE =
|
||||
new GrpcServletCompatibilityProfile("/grpc", true, true);
|
||||
|
||||
@Test
|
||||
@DisplayName("the Servlet transport names what it cannot do")
|
||||
void theMatrixNamesItsGaps() {
|
||||
assertThat(GrpcServletCapabilityMatrix.unavailable())
|
||||
.containsExactlyInAnyOrder(
|
||||
GrpcServletCapabilityMatrix.KEEPALIVE_TUNING,
|
||||
GrpcServletCapabilityMatrix.MAX_CONNECTION_AGE,
|
||||
GrpcServletCapabilityMatrix.FLOW_CONTROL_WINDOW_TUNING);
|
||||
assertThat(GrpcServletCapabilityMatrix.HTTP2.available()).isTrue();
|
||||
assertThat(GrpcServletCapabilityMatrix.GRACEFUL_SHUTDOWN.available()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a deployment asking for a Netty-only setting is refused, not warned")
|
||||
void aNettyOnlySettingIsRefused() {
|
||||
assertThat(
|
||||
GrpcServletStartupValidator.violations(
|
||||
PROFILE, Set.of(GrpcServletCapabilityMatrix.KEEPALIVE_TUNING)))
|
||||
.singleElement()
|
||||
.satisfies(violation -> assertThat(violation).contains("accepted and ignored"));
|
||||
assertThat(
|
||||
GrpcServletStartupValidator.violations(
|
||||
PROFILE,
|
||||
Set.of(
|
||||
GrpcServletCapabilityMatrix.KEEPALIVE_TUNING,
|
||||
GrpcServletCapabilityMatrix.MAX_CONNECTION_AGE)))
|
||||
.hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an available capability passes")
|
||||
void anAvailableCapabilityPasses() {
|
||||
assertThat(
|
||||
GrpcServletStartupValidator.violations(
|
||||
PROFILE,
|
||||
Set.of(GrpcServletCapabilityMatrix.HTTP2, GrpcServletCapabilityMatrix.TLS)))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a Servlet run never substitutes for Netty certification")
|
||||
void servletIsNotNettyCertification() {
|
||||
assertThat(GrpcServletStartupValidator.substitutesForNettyCertification()).isFalse();
|
||||
assertThat(PROFILE.certifiesNettyTransport()).isFalse();
|
||||
assertThat(PROFILE.transport()).isEqualTo(GrpcServerTransport.SERVLET);
|
||||
assertThat(GrpcServerTransport.SERVLET.certifiesNetworkBehaviour()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a profile without async support or container-owned TLS is refused")
|
||||
void anIncoherentProfileIsRefused() {
|
||||
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("/grpc", true, false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("blocks a container thread");
|
||||
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("/grpc", false, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("a setting nothing reads");
|
||||
assertThatThrownBy(() -> new GrpcServletCompatibilityProfile("grpc", true, true))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package dev.caskeleton.grpc.advanced.compat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.web.GrpcWebCompatibilityGate;
|
||||
import dev.caskeleton.grpc.advanced.web.GrpcWebProfile;
|
||||
import dev.caskeleton.grpc.advanced.web.GrpcWebProxyContract;
|
||||
import dev.caskeleton.grpc.advanced.web.GrpcWebRpcSupport;
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcWebCompatibilityGateTest {
|
||||
|
||||
private static final GrpcMethodName GET =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
|
||||
private static final GrpcMethodName UPLOAD =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/UploadDocument");
|
||||
|
||||
@Test
|
||||
@DisplayName("gRPC-Web carries unary and server streaming, and nothing else")
|
||||
void grpcWebCarriesTwoShapes() {
|
||||
assertThat(GrpcWebRpcSupport.supported(RpcType.UNARY)).isTrue();
|
||||
assertThat(GrpcWebRpcSupport.supported(RpcType.SERVER_STREAMING)).isTrue();
|
||||
assertThat(GrpcWebRpcSupport.supported(RpcType.CLIENT_STREAMING)).isFalse();
|
||||
assertThat(GrpcWebRpcSupport.supported(RpcType.BIDI_STREAMING)).isFalse();
|
||||
assertThatThrownBy(() -> GrpcWebRpcSupport.require(RpcType.BIDI_STREAMING))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("no way to send a stream of messages");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a client-streaming method exposed to browsers is reported")
|
||||
void anUnservableMethodIsReported() {
|
||||
assertThat(
|
||||
GrpcWebCompatibilityGate.violations(
|
||||
Map.of(GET, RpcType.UNARY, UPLOAD, RpcType.CLIENT_STREAMING)))
|
||||
.singleElement()
|
||||
.satisfies(violation -> assertThat(violation).contains("UploadDocument"));
|
||||
assertThat(GrpcWebCompatibilityGate.violations(Map.of(GET, RpcType.UNARY))).isEmpty();
|
||||
assertThat(GrpcWebCompatibilityGate.sharesOneSchemaWithNativeClients()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a wildcard origin is refused, and cookie credentials require CSRF protection")
|
||||
void browserCredentialRulesDifferByStyle() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcWebProfile(GrpcWebProfile.CredentialStyle.BEARER, Set.of("*"), false, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("ambient credentials");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcWebProfile(
|
||||
GrpcWebProfile.CredentialStyle.COOKIE,
|
||||
Set.of("https://app.example.com"),
|
||||
false,
|
||||
true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("CSRF defence is not optional");
|
||||
assertThat(GrpcWebProfile.bearer("https://app.example.com").csrfProtection()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a credentialed profile needs an origin allowlist and TLS at the proxy")
|
||||
void aCredentialedProfileNeedsBoth() {
|
||||
assertThatThrownBy(
|
||||
() -> new GrpcWebProfile(GrpcWebProfile.CredentialStyle.BEARER, Set.of(), false, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("origin allowlist");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcWebProfile(
|
||||
GrpcWebProfile.CredentialStyle.BEARER,
|
||||
Set.of("https://app.example.com"),
|
||||
false,
|
||||
false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("TLS terminated at the proxy");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a proxy that hides the status trailers is reported")
|
||||
void aProxyMustExposeTheStatusTrailers() {
|
||||
GrpcWebProfile profile = GrpcWebProfile.bearer("https://app.example.com");
|
||||
|
||||
assertThat(
|
||||
GrpcWebProxyContract.violations(
|
||||
profile, Set.of("grpc-status"), Set.of("https://app.example.com")))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("grpc-message"));
|
||||
assertThat(
|
||||
GrpcWebProxyContract.violations(
|
||||
profile,
|
||||
GrpcWebProxyContract.requiredExposedHeaders(),
|
||||
Set.of("https://app.example.com")))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a proxy allowing any origin, or missing the profile's, is reported")
|
||||
void proxyOriginMismatchesAreReported() {
|
||||
GrpcWebProfile profile = GrpcWebProfile.bearer("https://app.example.com");
|
||||
|
||||
assertThat(
|
||||
GrpcWebProxyContract.violations(
|
||||
profile, GrpcWebProxyContract.requiredExposedHeaders(), Set.of("*")))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("defeats the profile's allowlist"));
|
||||
assertThat(
|
||||
GrpcWebProxyContract.violations(
|
||||
profile, GrpcWebProxyContract.requiredExposedHeaders(), Set.of()))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("not in the proxy's allowlist"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the reference Envoy configuration exposes the trailers and pins the origin")
|
||||
void theReferenceProxyConfigurationIsCorrect() {
|
||||
String envoy = resource("envoy/envoy.yaml");
|
||||
|
||||
assertThat(envoy)
|
||||
.contains("expose_headers: \"grpc-status,grpc-message")
|
||||
.contains("exact: \"https://app.example.com\"")
|
||||
.contains("envoy.filters.http.grpc_web");
|
||||
}
|
||||
|
||||
private static String resource(String path) {
|
||||
try (InputStream stream =
|
||||
GrpcWebCompatibilityGateTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalStateException("missing resource " + path);
|
||||
}
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Channelz/CSDS diagnostics for administrators, with the redactor that keeps socket authority,
|
||||
// credentials, certificate material, metadata and payload out of a snapshot, plus the advanced
|
||||
// infrastructure testkit contract (gRPC-Web proxy, Servlet container, xDS control-plane failure).
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-client')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* What an advanced capability has to be exercised against before it counts as verified.
|
||||
*
|
||||
* <p>Real infrastructure, named per capability. gRPC-Web without a proxy tests a code path no
|
||||
* browser will take; a Servlet profile without a container tests the profile object; xDS without a
|
||||
* control plane cannot exercise the case that matters, which is the control plane going away. In
|
||||
* all three, a suite that runs without the infrastructure passes and establishes nothing, which is
|
||||
* worse than not having one.
|
||||
*/
|
||||
public final class GrpcAdvancedInfrastructureTestkit {
|
||||
|
||||
private GrpcAdvancedInfrastructureTestkit() {}
|
||||
|
||||
/** A piece of infrastructure a capability's suite needs. */
|
||||
public enum Infrastructure {
|
||||
/** An Envoy or equivalent gRPC-Web proxy. */
|
||||
GRPC_WEB_PROXY,
|
||||
/** A Servlet container serving HTTP/2. */
|
||||
SERVLET_CONTAINER,
|
||||
/** An xDS control plane that can be stopped. */
|
||||
XDS_CONTROL_PLANE,
|
||||
/** A Kotlin toolchain. */
|
||||
KOTLIN_TOOLCHAIN
|
||||
}
|
||||
|
||||
/** What {@code capability} needs before its evidence means anything. */
|
||||
public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) {
|
||||
if (capability == null) {
|
||||
throw new IllegalArgumentException("a capability is required");
|
||||
}
|
||||
return switch (capability) {
|
||||
case GRPC_WEB -> Set.of(Infrastructure.GRPC_WEB_PROXY);
|
||||
case SERVLET_COMPAT -> Set.of(Infrastructure.SERVLET_CONTAINER);
|
||||
case XDS -> Set.of(Infrastructure.XDS_CONTROL_PLANE);
|
||||
case KOTLIN -> Set.of(Infrastructure.KOTLIN_TOOLCHAIN);
|
||||
case EDITION_2024,
|
||||
EDITION_2026,
|
||||
CLIENT_STREAMING,
|
||||
BIDI_STREAMING,
|
||||
MANUAL_FLOW_CONTROL,
|
||||
HEDGING,
|
||||
CUSTOM_RESOLVER,
|
||||
CUSTOM_LOAD_BALANCER,
|
||||
INTEGRATION_BRIDGE,
|
||||
REACTOR,
|
||||
CHANNEL_DIAGNOSTICS ->
|
||||
Set.of();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every piece of infrastructure a capability needs and does not have.
|
||||
*
|
||||
* @return an empty list when the suite can produce evidence that means something
|
||||
*/
|
||||
public static List<String> missingInfrastructure(
|
||||
GrpcAdvancedCapability capability, Set<Infrastructure> available) {
|
||||
if (available == null) {
|
||||
throw new IllegalArgumentException("the available infrastructure set is required");
|
||||
}
|
||||
Set<Infrastructure> required = requiredFor(capability);
|
||||
if (required.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
Set<Infrastructure> missing = EnumSet.copyOf(required);
|
||||
missing.removeAll(available);
|
||||
List<String> gaps = new ArrayList<>();
|
||||
missing.stream()
|
||||
.sorted()
|
||||
.forEach(
|
||||
infrastructure ->
|
||||
gaps.add(
|
||||
capability.flagName()
|
||||
+ " needs "
|
||||
+ infrastructure
|
||||
+ "; a suite that runs without it passes and establishes nothing"));
|
||||
return List.copyOf(gaps);
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuard;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Who may read diagnostics, and which services are registered at all.
|
||||
*
|
||||
* <p>CSDS is registered only when xDS is enabled. A CSDS service on a deployment that does not use
|
||||
* xDS answers every query with nothing, which is harmless, and advertises a control-plane surface
|
||||
* that does not exist, which is not: it is one more endpoint to scan and one more thing whose
|
||||
* absence of authorization nobody notices.
|
||||
*/
|
||||
public record GrpcChannelDiagnosticsPolicy(Set<String> adminNetworks, Set<String> adminRoles) {
|
||||
|
||||
/** Refuses a policy with only one gate. */
|
||||
public GrpcChannelDiagnosticsPolicy {
|
||||
if (adminNetworks == null || adminRoles == null) {
|
||||
throw new IllegalArgumentException("a diagnostics policy states both gates");
|
||||
}
|
||||
adminNetworks = Set.copyOf(adminNetworks);
|
||||
adminRoles = Set.copyOf(adminRoles);
|
||||
if (adminNetworks.isEmpty() || adminRoles.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"diagnostics need both a network and a role gate; Channelz holds every socket's peer and "
|
||||
+ "security detail, so either gate alone is the whole surface");
|
||||
}
|
||||
}
|
||||
|
||||
/** This repository's default. */
|
||||
public static GrpcChannelDiagnosticsPolicy standard() {
|
||||
return new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"));
|
||||
}
|
||||
|
||||
/** Whether this caller may read a snapshot. */
|
||||
public boolean mayRead(String callerNetwork, Set<String> callerRoles) {
|
||||
return callerNetwork != null
|
||||
&& adminNetworks.contains(callerNetwork)
|
||||
&& callerRoles != null
|
||||
&& callerRoles.stream().anyMatch(adminRoles::contains);
|
||||
}
|
||||
|
||||
/** Whether the Channelz service should be registered. */
|
||||
public static boolean registerChannelz(GrpcAdvancedFeatureFlags flags) {
|
||||
return GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS);
|
||||
}
|
||||
|
||||
/** Whether the CSDS service should be registered. Only when xDS is actually in use. */
|
||||
public static boolean registerCsds(GrpcAdvancedFeatureFlags flags) {
|
||||
return registerChannelz(flags)
|
||||
&& GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* What Channelz and CSDS report, after redaction.
|
||||
*
|
||||
* <p>Counters and states rather than call contents. The questions a diagnostics endpoint exists to
|
||||
* answer — which subchannels are connected, how many calls are in flight, which xDS resources the
|
||||
* control plane last sent — are all answerable from aggregates, and aggregates cannot leak a
|
||||
* caller's data.
|
||||
*/
|
||||
public record GrpcChannelDiagnosticsSnapshot(
|
||||
String channelProfile,
|
||||
String connectivityState,
|
||||
int subchannelCount,
|
||||
long callsStarted,
|
||||
long callsSucceeded,
|
||||
long callsFailed,
|
||||
List<String> maskedSocketAddresses,
|
||||
Map<String, String> xdsResourceVersions,
|
||||
Instant capturedAt) {
|
||||
|
||||
/** Refuses a snapshot carrying something it should not. */
|
||||
public GrpcChannelDiagnosticsSnapshot {
|
||||
if (channelProfile == null || channelProfile.isBlank()) {
|
||||
throw new IllegalArgumentException("a diagnostics snapshot names its channel profile");
|
||||
}
|
||||
if (connectivityState == null || connectivityState.isBlank()) {
|
||||
throw new IllegalArgumentException("a diagnostics snapshot carries a connectivity state");
|
||||
}
|
||||
if (subchannelCount < 0 || callsStarted < 0 || callsSucceeded < 0 || callsFailed < 0) {
|
||||
throw new IllegalArgumentException("diagnostics counters must not be negative");
|
||||
}
|
||||
if (maskedSocketAddresses == null || xdsResourceVersions == null || capturedAt == null) {
|
||||
throw new IllegalArgumentException("every snapshot section must be present");
|
||||
}
|
||||
maskedSocketAddresses = List.copyOf(maskedSocketAddresses);
|
||||
xdsResourceVersions = Map.copyOf(xdsResourceVersions);
|
||||
|
||||
maskedSocketAddresses.stream()
|
||||
.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
|
||||
.findFirst()
|
||||
.ifPresent(
|
||||
unmasked -> {
|
||||
throw new IllegalArgumentException(
|
||||
"socket address '"
|
||||
+ unmasked
|
||||
+ "' is not masked; a diagnostics endpoint that publishes peer addresses "
|
||||
+ "publishes every tenant's connection");
|
||||
});
|
||||
xdsResourceVersions.keySet().stream()
|
||||
.filter(GrpcDiagnosticsRedactor::forbiddenField)
|
||||
.findFirst()
|
||||
.ifPresent(
|
||||
forbidden -> {
|
||||
throw new IllegalArgumentException(
|
||||
"field '" + forbidden + "' may not appear in a diagnostics snapshot");
|
||||
});
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Removes from a diagnostics snapshot everything that is not a diagnostic.
|
||||
*
|
||||
* <p>Channelz is unusually dangerous to expose because it is genuinely useful: it holds every
|
||||
* socket's local and remote address, the security details of each connection, and per-call state.
|
||||
* An administrator debugging a routing problem needs the shape of that; nobody needs the peer
|
||||
* addresses of every tenant's connection, and once the endpoint exists the whole of it is one
|
||||
* authorization mistake away from being readable.
|
||||
*
|
||||
* <p>Addresses are masked rather than dropped. An operator has to be able to tell two subchannels
|
||||
* apart, and a stable mask does that without publishing where they point.
|
||||
*/
|
||||
public final class GrpcDiagnosticsRedactor {
|
||||
|
||||
private static final Pattern SENSITIVE_FIELD =
|
||||
Pattern.compile(
|
||||
"(?i).*(authorization|bearer|password|secret|token|private[_-]?key|certificate|"
|
||||
+ "credential|payload|metadata).*");
|
||||
|
||||
private static final Pattern IPV4_WITH_PORT =
|
||||
Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:\\d{1,5})?\\b");
|
||||
|
||||
private GrpcDiagnosticsRedactor() {}
|
||||
|
||||
/** Whether a field name may appear in a snapshot at all. */
|
||||
public static boolean forbiddenField(String fieldName) {
|
||||
return fieldName != null && SENSITIVE_FIELD.matcher(fieldName).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks an address so two of them stay distinguishable without being resolvable.
|
||||
*
|
||||
* <p>The last two octets go; the first two stay, because "which subnet" is a real diagnostic
|
||||
* question and "which host" is not one the diagnostics endpoint should answer.
|
||||
*/
|
||||
public static String maskAddress(String address) {
|
||||
if (address == null || address.isBlank()) {
|
||||
return "unknown";
|
||||
}
|
||||
return IPV4_WITH_PORT
|
||||
.matcher(address)
|
||||
.replaceAll(matchResult -> matchResult.group(1) + "." + matchResult.group(2) + ".x.x");
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot map with forbidden fields removed and addresses masked.
|
||||
*
|
||||
* @param addressFields which keys hold addresses, since a mask applied to everything would mangle
|
||||
* version strings and counters
|
||||
*/
|
||||
public static Map<String, String> redact(
|
||||
Map<String, String> raw, java.util.Set<String> addressFields) {
|
||||
if (raw == null || addressFields == null) {
|
||||
throw new IllegalArgumentException("redaction needs the map and the address field set");
|
||||
}
|
||||
Map<String, String> redacted = new LinkedHashMap<>();
|
||||
raw.forEach(
|
||||
(key, value) -> {
|
||||
if (forbiddenField(key)) {
|
||||
return;
|
||||
}
|
||||
redacted.put(key, addressFields.contains(key) ? maskAddress(value) : value);
|
||||
});
|
||||
return Map.copyOf(redacted);
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package dev.caskeleton.grpc.advanced.diagnostics;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcChannelDiagnosticsPolicyTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
@Test
|
||||
@DisplayName("diagnostics need both an admin network and an admin role")
|
||||
void diagnosticsNeedBothGates() {
|
||||
GrpcChannelDiagnosticsPolicy policy = GrpcChannelDiagnosticsPolicy.standard();
|
||||
|
||||
assertThat(policy.mayRead("admin", Set.of("ROLE_PLATFORM_ADMIN"))).isTrue();
|
||||
assertThat(policy.mayRead("public", Set.of("ROLE_PLATFORM_ADMIN"))).isFalse();
|
||||
assertThat(policy.mayRead("admin", Set.of("ROLE_USER"))).isFalse();
|
||||
assertThatThrownBy(() -> new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("either gate alone is the whole surface");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("CSDS is registered only when xDS is actually enabled")
|
||||
void csdsFollowsXds() {
|
||||
GrpcAdvancedFeatureFlags diagnosticsOnly =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment()
|
||||
.enable(GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS);
|
||||
GrpcAdvancedFeatureFlags withXds =
|
||||
GrpcAdvancedFeatureFlags.forDevelopment()
|
||||
.enable(GrpcAdvancedCapability.CHANNEL_DIAGNOSTICS)
|
||||
.enable(GrpcAdvancedCapability.XDS);
|
||||
|
||||
assertThat(GrpcChannelDiagnosticsPolicy.registerChannelz(diagnosticsOnly)).isTrue();
|
||||
assertThat(GrpcChannelDiagnosticsPolicy.registerCsds(diagnosticsOnly)).isFalse();
|
||||
assertThat(GrpcChannelDiagnosticsPolicy.registerCsds(withXds)).isTrue();
|
||||
assertThat(
|
||||
GrpcChannelDiagnosticsPolicy.registerChannelz(
|
||||
GrpcAdvancedFeatureFlags.forDevelopment()))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("credential, certificate, metadata and payload fields are dropped outright")
|
||||
void sensitiveFieldsAreDropped() {
|
||||
Map<String, String> raw = new LinkedHashMap<>();
|
||||
raw.put("connectivityState", "READY");
|
||||
raw.put("authorization", "Bearer abc");
|
||||
raw.put("peerCertificate", "-----BEGIN CERTIFICATE-----");
|
||||
raw.put("lastCallMetadata", "x-tenant=acme");
|
||||
raw.put("requestPayload", "{...}");
|
||||
|
||||
Map<String, String> redacted = GrpcDiagnosticsRedactor.redact(raw, Set.of());
|
||||
|
||||
assertThat(redacted).containsOnlyKeys("connectivityState");
|
||||
assertThat(GrpcDiagnosticsRedactor.forbiddenField("privateKeyRef")).isTrue();
|
||||
assertThat(GrpcDiagnosticsRedactor.forbiddenField("subchannelCount")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("addresses are masked so two subchannels stay distinguishable but unresolvable")
|
||||
void addressesAreMaskedRatherThanDropped() {
|
||||
assertThat(GrpcDiagnosticsRedactor.maskAddress("10.4.13.201:9090")).isEqualTo("10.4.x.x");
|
||||
assertThat(GrpcDiagnosticsRedactor.maskAddress("10.9.13.201")).isEqualTo("10.9.x.x");
|
||||
assertThat(GrpcDiagnosticsRedactor.maskAddress(null)).isEqualTo("unknown");
|
||||
|
||||
Map<String, String> redacted =
|
||||
GrpcDiagnosticsRedactor.redact(
|
||||
Map.of("remoteAddress", "10.4.13.201:9090"), Set.of("remoteAddress"));
|
||||
assertThat(redacted).containsEntry("remoteAddress", "10.4.x.x");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a snapshot carrying an unmasked address is refused")
|
||||
void anUnmaskedAddressIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcChannelDiagnosticsSnapshot(
|
||||
"documents-read",
|
||||
"READY",
|
||||
3,
|
||||
100L,
|
||||
98L,
|
||||
2L,
|
||||
List.of("10.4.13.201:9090"),
|
||||
Map.of(),
|
||||
NOW))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("every tenant's connection");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a snapshot carries counters and states, and is accepted when masked")
|
||||
void aMaskedSnapshotIsAccepted() {
|
||||
GrpcChannelDiagnosticsSnapshot snapshot =
|
||||
new GrpcChannelDiagnosticsSnapshot(
|
||||
"documents-read",
|
||||
"READY",
|
||||
3,
|
||||
100L,
|
||||
98L,
|
||||
2L,
|
||||
List.of("10.4.x.x", "10.5.x.x"),
|
||||
Map.of("documents-cluster", "v7"),
|
||||
NOW);
|
||||
|
||||
assertThat(snapshot.subchannelCount()).isEqualTo(3);
|
||||
assertThat(snapshot.xdsResourceVersions()).containsEntry("documents-cluster", "v7");
|
||||
assertThat(snapshot.capturedAt()).isEqualTo(NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a snapshot carrying a forbidden field name is refused")
|
||||
void aForbiddenFieldNameIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcChannelDiagnosticsSnapshot(
|
||||
"documents-read",
|
||||
"READY",
|
||||
1,
|
||||
1L,
|
||||
1L,
|
||||
0L,
|
||||
List.of(),
|
||||
Map.of("controlPlaneToken", "abc"),
|
||||
NOW))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("may not appear in a diagnostics snapshot");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the committed CSDS fixture is redacted into something publishable")
|
||||
void theCommittedCsdsFixtureIsRedacted() {
|
||||
String raw = resource("xds/control-plane-snapshot.json");
|
||||
|
||||
// The fixture deliberately contains the three shapes a snapshot must never carry, so the
|
||||
// redactor is exercised against data shaped like the real thing.
|
||||
assertThat(raw)
|
||||
.contains("controlPlaneToken")
|
||||
.contains("peerCertificate")
|
||||
.contains("10.4.13.201:9090");
|
||||
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
fields.put("connectivityState", "READY");
|
||||
fields.put("subchannelCount", "3");
|
||||
fields.put("controlPlaneToken", "must-not-appear-in-a-snapshot");
|
||||
fields.put("peerCertificate", "-----BEGIN CERTIFICATE-----");
|
||||
fields.put("lastCallMetadata", "x-tenant=acme");
|
||||
fields.put("remoteAddress", "10.4.13.201:9090");
|
||||
|
||||
Map<String, String> redacted = GrpcDiagnosticsRedactor.redact(fields, Set.of("remoteAddress"));
|
||||
|
||||
assertThat(redacted).containsOnlyKeys("connectivityState", "subchannelCount", "remoteAddress");
|
||||
assertThat(redacted.get("remoteAddress")).isEqualTo("10.4.x.x");
|
||||
assertThat(String.join("|", redacted.values())).doesNotContain("must-not-appear-in-a-snapshot");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a snapshot built from the fixture's resource versions is accepted")
|
||||
void aSnapshotFromTheFixtureIsAccepted() {
|
||||
assertThat(resource("xds/control-plane-snapshot.json")).contains("documents-cluster");
|
||||
|
||||
GrpcChannelDiagnosticsSnapshot snapshot =
|
||||
new GrpcChannelDiagnosticsSnapshot(
|
||||
"documents-read",
|
||||
"READY",
|
||||
3,
|
||||
100L,
|
||||
98L,
|
||||
2L,
|
||||
List.of("10.4.x.x", "10.4.x.x", "10.5.x.x"),
|
||||
Map.of("documents-cluster", "v7", "documents-route", "v7", "documents-listener", "v6"),
|
||||
NOW);
|
||||
|
||||
assertThat(snapshot.xdsResourceVersions()).hasSize(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("each capability names the real infrastructure its evidence depends on")
|
||||
void capabilitiesNameTheirInfrastructure() {
|
||||
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.GRPC_WEB))
|
||||
.containsExactly(GrpcAdvancedInfrastructureTestkit.Infrastructure.GRPC_WEB_PROXY);
|
||||
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.XDS))
|
||||
.containsExactly(GrpcAdvancedInfrastructureTestkit.Infrastructure.XDS_CONTROL_PLANE);
|
||||
assertThat(GrpcAdvancedInfrastructureTestkit.requiredFor(GrpcAdvancedCapability.HEDGING))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a suite without its infrastructure is reported as establishing nothing")
|
||||
void missingInfrastructureIsReported() {
|
||||
assertThat(
|
||||
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
|
||||
GrpcAdvancedCapability.XDS, Set.of()))
|
||||
.singleElement()
|
||||
.satisfies(gap -> assertThat(gap).contains("establishes nothing"));
|
||||
assertThat(
|
||||
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
|
||||
GrpcAdvancedCapability.XDS,
|
||||
Set.of(GrpcAdvancedInfrastructureTestkit.Infrastructure.XDS_CONTROL_PLANE)))
|
||||
.isEmpty();
|
||||
assertThat(
|
||||
GrpcAdvancedInfrastructureTestkit.missingInfrastructure(
|
||||
GrpcAdvancedCapability.HEDGING, Set.of()))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
private static String resource(String path) {
|
||||
try (java.io.InputStream stream =
|
||||
GrpcChannelDiagnosticsPolicyTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalStateException("missing test resource " + path);
|
||||
}
|
||||
return new String(stream.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
} catch (java.io.IOException e) {
|
||||
throw new java.io.UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_comment": [
|
||||
"What CSDS reports for one client, as the diagnostics layer receives it before redaction.",
|
||||
"The fixture deliberately contains three things a snapshot must not publish - a control-plane",
|
||||
"token, a peer certificate and a raw socket address - so the redactor is tested against data",
|
||||
"shaped like the real thing rather than against a string somebody invented for the assertion."
|
||||
],
|
||||
"version_info": "v7",
|
||||
"resources": {
|
||||
"documents-cluster": "v7",
|
||||
"documents-route": "v7",
|
||||
"documents-listener": "v6"
|
||||
},
|
||||
"connectivityState": "READY",
|
||||
"subchannelCount": 3,
|
||||
"sockets": [
|
||||
{ "remoteAddress": "10.4.13.201:9090", "state": "READY" },
|
||||
{ "remoteAddress": "10.4.19.87:9090", "state": "READY" },
|
||||
{ "remoteAddress": "10.5.2.44:9090", "state": "TRANSIENT_FAILURE" }
|
||||
],
|
||||
"controlPlaneToken": "must-not-appear-in-a-snapshot",
|
||||
"peerCertificate": "-----BEGIN CERTIFICATE----- must-not-appear-in-a-snapshot",
|
||||
"lastCallMetadata": "x-tenant=acme"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer
|
||||
// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records
|
||||
// release/toolchain status and is refused as a Stable contract source.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-proto-contract')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=compileClasspath,runtimeClasspath
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Decides whether Edition 2024 may be adopted, and keeps its failures out of the Stable release.
|
||||
*
|
||||
* <p>The separation the Stable plan asks for, in one place: an Edition 2024 failure blocks the
|
||||
* edition's own promotion and does not block a proto3 release. Without that split, an opt-in lane
|
||||
* that nobody depends on can hold up every release, and the first response to that is to stop
|
||||
* running the lane.
|
||||
*/
|
||||
public final class GrpcEdition2024Gate {
|
||||
|
||||
private GrpcEdition2024Gate() {}
|
||||
|
||||
/**
|
||||
* Every reason Edition 2024 may not be promoted.
|
||||
*
|
||||
* @return an empty list when the edition is ready to adopt
|
||||
*/
|
||||
public static List<String> promotionBlockers(
|
||||
GrpcEditionCompatibilityReport report,
|
||||
boolean consumerMigrationPlanned,
|
||||
boolean promotionAdr) {
|
||||
if (report == null) {
|
||||
throw new IllegalArgumentException("a compatibility report is required");
|
||||
}
|
||||
List<String> blockers = new ArrayList<>(report.incompatibilities());
|
||||
if (!consumerMigrationPlanned) {
|
||||
blockers.add(
|
||||
"no consumer migration is planned; moving a public service to an edition breaks whichever "
|
||||
+ "consumer's generator treats its features differently");
|
||||
}
|
||||
if (!promotionAdr) {
|
||||
blockers.add(
|
||||
"no promotion ADR records the decision to move onto Edition "
|
||||
+ GrpcEdition2024Policy.EDITION);
|
||||
}
|
||||
return List.copyOf(blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Edition 2024 failure blocks a proto3 Stable release.
|
||||
*
|
||||
* <p>Always false. Stated as a method so the property is tested rather than described.
|
||||
*/
|
||||
public static boolean blocksStableRelease() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Edition 2024 failure blocks the edition's own promotion.
|
||||
*
|
||||
* <p>Always true, for the same reason.
|
||||
*/
|
||||
public static boolean blocksEditionPromotion() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Where Edition 2024 may be used, and where it may not.
|
||||
*
|
||||
* <p>Module-level opt-in, and never for a public service without a promotion decision. The reason
|
||||
* is that an edition change is invisible to the schema's owner and consequential for its consumers:
|
||||
* the wire bytes are usually identical, so nothing fails locally, and the breakage appears in
|
||||
* whichever consumer's generator handles the edition's features differently.
|
||||
*/
|
||||
public record GrpcEdition2024Policy(
|
||||
Set<String> optedInModules, Set<String> publicServices, boolean promotionApproved) {
|
||||
|
||||
/** The edition this policy governs. */
|
||||
public static final String EDITION = "2024";
|
||||
|
||||
/** Copies both sets and refuses an approval nobody recorded. */
|
||||
public GrpcEdition2024Policy {
|
||||
if (optedInModules == null || publicServices == null) {
|
||||
throw new IllegalArgumentException("an edition policy states both sets");
|
||||
}
|
||||
optedInModules = Set.copyOf(optedInModules);
|
||||
publicServices = Set.copyOf(publicServices);
|
||||
}
|
||||
|
||||
/** The default: nothing opted in, no promotion. */
|
||||
public static GrpcEdition2024Policy notAdopted() {
|
||||
return new GrpcEdition2024Policy(Set.of(), Set.of(), false);
|
||||
}
|
||||
|
||||
/** Whether {@code moduleId} may use Edition 2024 schema sources. */
|
||||
public boolean allowedIn(String moduleId) {
|
||||
return optedInModules.contains(moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@code serviceName} may move onto Edition 2024.
|
||||
*
|
||||
* <p>False for any public service until a promotion is approved, regardless of module opt-in: the
|
||||
* opt-in is a build decision and the promotion is a consumer-migration decision.
|
||||
*/
|
||||
public boolean serviceMayMove(String serviceName) {
|
||||
return !publicServices.contains(serviceName) || promotionApproved;
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
/**
|
||||
* Refuses Edition 2026 as a schema source, whatever a report says.
|
||||
*
|
||||
* <p>The guard is deliberately not conditional on the watch report. A watch lane's purpose is to
|
||||
* record what is true, and letting the same record also authorise use means the moment somebody
|
||||
* marks four fields SUPPORTED, a schema can move onto an edition with no promotion decision, no
|
||||
* consumer migration and no ADR. Turning the watch into a lane that can be used is a code change
|
||||
* here, and that is the point.
|
||||
*/
|
||||
public final class GrpcEdition2026Guard {
|
||||
|
||||
private GrpcEdition2026Guard() {}
|
||||
|
||||
/** Whether Edition 2026 may be used as a Stable contract source. Always false. */
|
||||
public static boolean allowedAsStableSource() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when Edition 2026 is used as a schema source.
|
||||
*
|
||||
* @throws IllegalStateException naming what is still outstanding, so the refusal is actionable
|
||||
*/
|
||||
public static void requireNotUsedAsSource(GrpcEdition2026WatchReport report) {
|
||||
if (report == null) {
|
||||
throw new IllegalArgumentException("a watch report is required");
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Edition "
|
||||
+ GrpcEdition2026WatchReport.EDITION
|
||||
+ " is a watch lane, not a schema source"
|
||||
+ (report.outstanding().isEmpty()
|
||||
? "; every toolchain gate is satisfied, so the next step is a promotion decision "
|
||||
+ "rather than a schema change"
|
||||
: "; outstanding: " + report.outstanding()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an Edition 2026 CI failure blocks the Stable build.
|
||||
*
|
||||
* <p>False. A watch lane that can break the build is a watch lane somebody deletes.
|
||||
*/
|
||||
public static boolean blocksStableBuild() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
/**
|
||||
* The four independent things that have to be true before an edition is usable, tracked separately.
|
||||
*
|
||||
* <p>Separately, because they land at different times and in different projects. An edition can be
|
||||
* released by the specification while {@code protoc} does not emit it, or emitted while Buf cannot
|
||||
* lint it, or lintable while the Java runtime does not implement its features. A single "supported
|
||||
* yes/no" flag collapses four different waiting states into one, and the answer to "what are we
|
||||
* waiting for" is then nobody's.
|
||||
*/
|
||||
public enum GrpcEdition2026Status {
|
||||
/** Nothing is known yet. */
|
||||
UNKNOWN,
|
||||
/** Announced or drafted, not released. */
|
||||
DRAFT,
|
||||
/** Released by the specification. */
|
||||
RELEASED,
|
||||
/** Supported by the toolchain component in question. */
|
||||
SUPPORTED,
|
||||
/** Explicitly not supported, and not expected to be. */
|
||||
UNSUPPORTED;
|
||||
|
||||
/** Whether this status permits use. */
|
||||
public boolean usable() {
|
||||
return this == SUPPORTED;
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What is currently true about Edition 2026 across the four things that gate it.
|
||||
*
|
||||
* <p>Recorded with a date. A watch report without one is indistinguishable from a stale note, and
|
||||
* the whole purpose of a watch lane is to be re-read later by somebody deciding whether the wait is
|
||||
* over.
|
||||
*/
|
||||
public record GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status specificationStatus,
|
||||
GrpcEdition2026Status protocStatus,
|
||||
GrpcEdition2026Status bufStatus,
|
||||
GrpcEdition2026Status javaRuntimeStatus,
|
||||
Instant observedAt) {
|
||||
|
||||
/** The edition this report tracks. */
|
||||
public static final String EDITION = "2026";
|
||||
|
||||
/** Requires every status and a date. */
|
||||
public GrpcEdition2026WatchReport {
|
||||
if (specificationStatus == null
|
||||
|| protocStatus == null
|
||||
|| bufStatus == null
|
||||
|| javaRuntimeStatus == null) {
|
||||
throw new IllegalArgumentException("a watch report records all four statuses");
|
||||
}
|
||||
if (observedAt == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a watch report is dated; without a date it cannot be told from a stale note");
|
||||
}
|
||||
}
|
||||
|
||||
/** Nothing known yet, as of {@code observedAt}. */
|
||||
public static GrpcEdition2026WatchReport nothingKnown(Instant observedAt) {
|
||||
return new GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
observedAt);
|
||||
}
|
||||
|
||||
/** What is still missing, named. */
|
||||
public List<String> outstanding() {
|
||||
List<String> waiting = new ArrayList<>();
|
||||
if (!specificationStatus.usable()) {
|
||||
waiting.add("specification is " + specificationStatus);
|
||||
}
|
||||
if (!protocStatus.usable()) {
|
||||
waiting.add("protoc support is " + protocStatus);
|
||||
}
|
||||
if (!bufStatus.usable()) {
|
||||
waiting.add("Buf support is " + bufStatus);
|
||||
}
|
||||
if (!javaRuntimeStatus.usable()) {
|
||||
waiting.add("Java runtime support is " + javaRuntimeStatus);
|
||||
}
|
||||
return List.copyOf(waiting);
|
||||
}
|
||||
|
||||
/** Whether every gate is satisfied. */
|
||||
public boolean readyToEvaluate() {
|
||||
return outstanding().isEmpty();
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* How an Edition schema compares with its proto3 twin, per consumer toolchain.
|
||||
*
|
||||
* <p>Three comparisons rather than one. Wire compatibility says stored and in-flight messages keep
|
||||
* decoding; JSON compatibility says a REST transcoder or a browser client keeps working; source
|
||||
* compatibility says the generated code still compiles. An edition migration can preserve the first
|
||||
* two and break the third for a language whose generator handles the edition's features differently
|
||||
* — which is exactly the failure this lane exists to find before a public service moves.
|
||||
*/
|
||||
public record GrpcEditionCompatibilityReport(
|
||||
String editionName,
|
||||
boolean wireCompatible,
|
||||
boolean jsonCompatible,
|
||||
Map<String, Boolean> sourceCompatibleByToolchain) {
|
||||
|
||||
/** Requires an edition and at least one toolchain result. */
|
||||
public GrpcEditionCompatibilityReport {
|
||||
if (editionName == null || editionName.isBlank()) {
|
||||
throw new IllegalArgumentException("a compatibility report names its edition");
|
||||
}
|
||||
if (sourceCompatibleByToolchain == null || sourceCompatibleByToolchain.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a report with no toolchain result compares nothing; Java alone is not cross-language "
|
||||
+ "evidence");
|
||||
}
|
||||
sourceCompatibleByToolchain = Map.copyOf(sourceCompatibleByToolchain);
|
||||
}
|
||||
|
||||
/** The toolchains whose generated code stopped compiling. */
|
||||
public Set<String> brokenToolchains() {
|
||||
return sourceCompatibleByToolchain.entrySet().stream()
|
||||
.filter(entry -> !entry.getValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
/** Every incompatibility, described. */
|
||||
public List<String> incompatibilities() {
|
||||
List<String> problems = new ArrayList<>();
|
||||
if (!wireCompatible) {
|
||||
problems.add(
|
||||
editionName + " is not wire-compatible with proto3; stored messages would break");
|
||||
}
|
||||
if (!jsonCompatible) {
|
||||
problems.add(
|
||||
editionName
|
||||
+ " is not JSON-compatible with proto3; transcoded and browser clients would break");
|
||||
}
|
||||
brokenToolchains().stream()
|
||||
.sorted()
|
||||
.forEach(
|
||||
toolchain ->
|
||||
problems.add(
|
||||
editionName
|
||||
+ " generated source does not compile for toolchain '"
|
||||
+ toolchain
|
||||
+ "'"));
|
||||
return List.copyOf(problems);
|
||||
}
|
||||
|
||||
/** Whether every comparison passed. */
|
||||
public boolean fullyCompatible() {
|
||||
return incompatibilities().isEmpty();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
edition = "2024";
|
||||
|
||||
package hyeonworks.grpc.edition.v1;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "hyeonworks.grpc.edition.v1.generated";
|
||||
|
||||
// The Edition 2024 comparison fixture.
|
||||
//
|
||||
// It exists to be compiled beside its proto3 twin and compared: same fields, same numbers, same JSON
|
||||
// names, with presence expressed by the edition's features rather than by `optional`. The lane's
|
||||
// question is whether the two produce the same wire bytes and the same JSON, and answering it needs
|
||||
// both files to exist.
|
||||
//
|
||||
// Not a Stable contract source. No public service moves onto an edition until the lane has produced
|
||||
// cross-consumer compile evidence and a promotion ADR — see GrpcEdition2024Gate.
|
||||
message DocumentSummary {
|
||||
string id = 1;
|
||||
string title = 2 [features.field_presence = EXPLICIT];
|
||||
int64 revision = 3;
|
||||
repeated string labels = 4;
|
||||
}
|
||||
|
||||
enum DocumentState {
|
||||
DOCUMENT_STATE_UNSPECIFIED = 0;
|
||||
DOCUMENT_STATE_DRAFT = 1;
|
||||
DOCUMENT_STATE_PUBLISHED = 2;
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcEdition2024GateTest {
|
||||
|
||||
private static GrpcEditionCompatibilityReport report(
|
||||
boolean wire, boolean json, Map<String, Boolean> toolchains) {
|
||||
return new GrpcEditionCompatibilityReport("2024", wire, json, toolchains);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Edition 2024 is module-level opt-in, and nothing is opted in by default")
|
||||
void editionIsOptInPerModule() {
|
||||
GrpcEdition2024Policy policy = GrpcEdition2024Policy.notAdopted();
|
||||
|
||||
assertThat(policy.allowedIn("grpc-proto-contract")).isFalse();
|
||||
assertThat(
|
||||
new GrpcEdition2024Policy(Set.of("grpc-advanced-edition"), Set.of(), false)
|
||||
.allowedIn("grpc-advanced-edition"))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a public service may not move onto the edition without a promotion")
|
||||
void publicServicesNeedAPromotion() {
|
||||
GrpcEdition2024Policy withoutPromotion =
|
||||
new GrpcEdition2024Policy(
|
||||
Set.of("grpc-advanced-edition"),
|
||||
Set.of("hyeonworks.document.v1.DocumentService"),
|
||||
false);
|
||||
GrpcEdition2024Policy withPromotion =
|
||||
new GrpcEdition2024Policy(
|
||||
Set.of("grpc-advanced-edition"),
|
||||
Set.of("hyeonworks.document.v1.DocumentService"),
|
||||
true);
|
||||
|
||||
assertThat(withoutPromotion.serviceMayMove("hyeonworks.document.v1.DocumentService")).isFalse();
|
||||
assertThat(withPromotion.serviceMayMove("hyeonworks.document.v1.DocumentService")).isTrue();
|
||||
assertThat(withoutPromotion.serviceMayMove("hyeonworks.internal.v1.ScratchService")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Java compiling alone is not cross-language evidence")
|
||||
void javaAloneIsNotEvidence() {
|
||||
GrpcEditionCompatibilityReport javaOnlyBroken =
|
||||
report(true, true, Map.of("java", true, "go", false, "python", true));
|
||||
|
||||
assertThat(javaOnlyBroken.fullyCompatible()).isFalse();
|
||||
assertThat(javaOnlyBroken.brokenToolchains()).containsExactly("go");
|
||||
assertThatThrownBy(() -> report(true, true, Map.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Java alone is not cross-language");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("wire, JSON and source compatibility are compared separately")
|
||||
void threeComparisonsAreSeparate() {
|
||||
assertThat(report(false, true, Map.of("java", true)).incompatibilities())
|
||||
.singleElement()
|
||||
.satisfies(problem -> assertThat(problem).contains("stored messages"));
|
||||
assertThat(report(true, false, Map.of("java", true)).incompatibilities())
|
||||
.singleElement()
|
||||
.satisfies(problem -> assertThat(problem).contains("browser clients"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("promotion needs compatibility, a consumer migration and an ADR")
|
||||
void promotionNeedsAllThree() {
|
||||
GrpcEditionCompatibilityReport clean = report(true, true, Map.of("java", true, "go", true));
|
||||
|
||||
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, true, true)).isEmpty();
|
||||
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, false, true))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("consumer migration"));
|
||||
assertThat(GrpcEdition2024Gate.promotionBlockers(clean, true, false))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("promotion ADR"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an Edition failure blocks the edition's promotion and not a proto3 release")
|
||||
void editionFailuresAreIsolatedFromStable() {
|
||||
assertThat(GrpcEdition2024Gate.blocksStableRelease()).isFalse();
|
||||
assertThat(GrpcEdition2024Gate.blocksEditionPromotion()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the Edition 2024 comparison fixture ships beside its proto3 twin")
|
||||
void theComparisonFixtureShips() {
|
||||
String source = resource("proto/edition2024/compatibility.proto");
|
||||
|
||||
assertThat(source)
|
||||
.startsWith("edition = \"2024\";")
|
||||
.contains("features.field_presence = EXPLICIT")
|
||||
.contains("DOCUMENT_STATE_UNSPECIFIED = 0");
|
||||
}
|
||||
|
||||
private static String resource(String path) {
|
||||
try (InputStream stream =
|
||||
GrpcEdition2024GateTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalStateException("missing resource " + path);
|
||||
}
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package dev.caskeleton.grpc.advanced.edition;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.time.Instant;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcEdition2026GuardTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
@Test
|
||||
@DisplayName("the watch tracks four gates separately, because they land at different times")
|
||||
void theWatchTracksFourGates() {
|
||||
GrpcEdition2026WatchReport partial =
|
||||
new GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status.RELEASED,
|
||||
GrpcEdition2026Status.DRAFT,
|
||||
GrpcEdition2026Status.UNKNOWN,
|
||||
GrpcEdition2026Status.UNSUPPORTED,
|
||||
NOW);
|
||||
|
||||
assertThat(partial.outstanding())
|
||||
.hasSize(4)
|
||||
.anySatisfy(item -> assertThat(item).contains("protoc support is DRAFT"))
|
||||
.anySatisfy(item -> assertThat(item).contains("Buf support is UNKNOWN"))
|
||||
.anySatisfy(item -> assertThat(item).contains("Java runtime support is UNSUPPORTED"));
|
||||
assertThat(partial.readyToEvaluate()).isFalse();
|
||||
assertThat(GrpcEdition2026WatchReport.nothingKnown(NOW).outstanding()).hasSize(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a watch report is dated, so it cannot be told from a stale note")
|
||||
void aWatchReportIsDated() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
null))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("stale note");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("only SUPPORTED counts as usable")
|
||||
void onlySupportedIsUsable() {
|
||||
assertThat(GrpcEdition2026Status.SUPPORTED.usable()).isTrue();
|
||||
assertThat(GrpcEdition2026Status.RELEASED.usable()).isFalse();
|
||||
assertThat(GrpcEdition2026Status.DRAFT.usable()).isFalse();
|
||||
assertThat(GrpcEdition2026Status.UNKNOWN.usable()).isFalse();
|
||||
assertThat(GrpcEdition2026Status.UNSUPPORTED.usable()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Edition 2026 is refused as a schema source even when every gate is satisfied")
|
||||
void theGuardIsNotConditionalOnTheReport() {
|
||||
GrpcEdition2026WatchReport allSupported =
|
||||
new GrpcEdition2026WatchReport(
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
GrpcEdition2026Status.SUPPORTED,
|
||||
NOW);
|
||||
|
||||
assertThat(allSupported.readyToEvaluate()).isTrue();
|
||||
assertThat(GrpcEdition2026Guard.allowedAsStableSource()).isFalse();
|
||||
assertThatThrownBy(() -> GrpcEdition2026Guard.requireNotUsedAsSource(allSupported))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("promotion decision rather than a schema change");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a refusal names what is still outstanding, so it is actionable")
|
||||
void aRefusalNamesWhatIsOutstanding() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GrpcEdition2026Guard.requireNotUsedAsSource(
|
||||
GrpcEdition2026WatchReport.nothingKnown(NOW)))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("outstanding:");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a watch lane failure does not break the Stable build")
|
||||
void theWatchLaneDoesNotBlockTheStableBuild() {
|
||||
assertThat(GrpcEdition2026Guard.blocksStableBuild()).isFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom
|
||||
// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-policy')
|
||||
api project(':grpc:grpc-client')
|
||||
api project(':grpc:grpc-discovery')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* A custom name resolver, with the safety rules applied on the way in.
|
||||
*
|
||||
* <p>Everything an update can do wrong is checked here rather than by the listener, because the
|
||||
* listener is the channel and the channel will believe whatever it is told. Stale revisions and
|
||||
* empty endpoint sets are dropped rather than propagated, and once closed the resolver accepts
|
||||
* nothing at all.
|
||||
*/
|
||||
public final class GrpcCustomResolver implements AutoCloseable {
|
||||
|
||||
private final String authority;
|
||||
private final Consumer<GrpcResolverUpdate> listener;
|
||||
private final AtomicReference<GrpcEndpointSnapshot> applied = new AtomicReference<>();
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
|
||||
/** Binds a resolver to the authority it resolves and the listener it feeds. */
|
||||
public GrpcCustomResolver(String authority, Consumer<GrpcResolverUpdate> listener) {
|
||||
if (authority == null || authority.isBlank()) {
|
||||
throw new IllegalArgumentException("a resolver names the authority it resolves");
|
||||
}
|
||||
if (listener == null) {
|
||||
throw new IllegalArgumentException("a resolver needs a listener to deliver updates to");
|
||||
}
|
||||
this.authority = authority;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers an update.
|
||||
*
|
||||
* @return the violations that stopped it, empty when it was applied
|
||||
*/
|
||||
public List<String> offer(GrpcResolverUpdate update) {
|
||||
if (closed.get()) {
|
||||
return List.of(
|
||||
"the resolver is closed; an update after close resurrects routing for a channel nobody "
|
||||
+ "is using");
|
||||
}
|
||||
List<String> violations = GrpcResolverSafetyPolicy.violations(update, applied.get());
|
||||
if (!violations.isEmpty()) {
|
||||
return violations;
|
||||
}
|
||||
applied.set(update.snapshot());
|
||||
listener.accept(update);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/** The snapshot currently in force. */
|
||||
public Optional<GrpcEndpointSnapshot> currentSnapshot() {
|
||||
return Optional.ofNullable(applied.get());
|
||||
}
|
||||
|
||||
/** The authority this resolver answers for. */
|
||||
public String authority() {
|
||||
return authority;
|
||||
}
|
||||
|
||||
/** Whether the resolver has been closed. */
|
||||
public boolean closed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed.set(true);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
/**
|
||||
* One endpoint a picker may choose, described only by what routing is allowed to consider.
|
||||
*
|
||||
* <p>The field list is the allowlist. Health, connectivity, weight and ejection are properties of
|
||||
* the endpoint; a tenant id or a request field is a property of the caller, and routing on those
|
||||
* turns a load balancer into a router with an authorization decision buried in it.
|
||||
*/
|
||||
public record GrpcEndpointCandidate(
|
||||
String address, boolean healthy, boolean connected, int weight, boolean ejected) {
|
||||
|
||||
/** Requires an address and a sane weight. */
|
||||
public GrpcEndpointCandidate {
|
||||
if (address == null || address.isBlank()) {
|
||||
throw new IllegalArgumentException("an endpoint candidate needs an address");
|
||||
}
|
||||
if (weight < 0) {
|
||||
throw new IllegalArgumentException("a weight must not be negative");
|
||||
}
|
||||
if (weight > 1000) {
|
||||
throw new IllegalArgumentException(
|
||||
"a weight above 1000 is a scale nobody can reason about against the others");
|
||||
}
|
||||
}
|
||||
|
||||
/** A healthy, connected endpoint at the default weight. */
|
||||
public static GrpcEndpointCandidate ready(String address) {
|
||||
return new GrpcEndpointCandidate(address, true, true, 100, false);
|
||||
}
|
||||
|
||||
/** Whether this endpoint may receive a request. */
|
||||
public boolean selectable() {
|
||||
return healthy && connected && !ejected && weight > 0;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* One resolver update: a monotonic revision and the complete endpoint set at that revision.
|
||||
*
|
||||
* <p>Complete, not a delta. A delta protocol needs both sides to agree on what they last saw, and a
|
||||
* resolver that reconnects to its discovery source has no way to establish that; a full set at each
|
||||
* revision makes a missed update harmless.
|
||||
*
|
||||
* <p>The revision is what makes a late update safe to drop. Without it, an update that arrives out
|
||||
* of order replaces newer endpoints with older ones, and the channel routes to instances that were
|
||||
* removed.
|
||||
*/
|
||||
public record GrpcEndpointSnapshot(long revision, String authority, List<String> endpoints) {
|
||||
|
||||
/** Requires a positive revision, an authority and a non-empty endpoint set. */
|
||||
public GrpcEndpointSnapshot {
|
||||
if (revision < 1) {
|
||||
throw new IllegalArgumentException("resolver revisions are 1-based; got " + revision);
|
||||
}
|
||||
if (authority == null || authority.isBlank()) {
|
||||
throw new IllegalArgumentException("a snapshot names the authority it resolves");
|
||||
}
|
||||
if (endpoints == null || endpoints.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an empty endpoint set is refused; a resolver that reports zero endpoints during its own "
|
||||
+ "outage would take the channel down with it");
|
||||
}
|
||||
if (endpoints.stream().anyMatch(endpoint -> endpoint == null || endpoint.isBlank())) {
|
||||
throw new IllegalArgumentException("every endpoint must be a non-blank address");
|
||||
}
|
||||
endpoints = List.copyOf(endpoints);
|
||||
if (Set.copyOf(endpoints).size() != endpoints.size()) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate endpoints skew a round-robin picker towards whichever address is repeated");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this snapshot supersedes {@code other}. */
|
||||
public boolean supersedes(GrpcEndpointSnapshot other) {
|
||||
return other == null || (authority.equals(other.authority()) && revision > other.revision());
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Which endpoint a picker chose, or why it chose none.
|
||||
*
|
||||
* <p>{@link Verdict#DETERMINISTIC_FALLBACK} is separate from {@link Verdict#NO_ENDPOINT_AVAILABLE}
|
||||
* because they say different things about the picker. The first means custom logic failed and the
|
||||
* platform took over, which is a defect to fix; the second means there was genuinely nowhere to
|
||||
* send the request, which is an outage. A picker that reports both the same way hides its own bugs
|
||||
* inside the backend's.
|
||||
*/
|
||||
public record GrpcLoadBalancerDecision(
|
||||
Verdict verdict, Optional<GrpcEndpointCandidate> chosen, String reason) {
|
||||
|
||||
/** What the picker decided. */
|
||||
public enum Verdict {
|
||||
/** The picker chose an endpoint. */
|
||||
PICKED,
|
||||
/** The picker failed; the platform chose deterministically instead. */
|
||||
DETERMINISTIC_FALLBACK,
|
||||
/** Nothing was selectable. */
|
||||
NO_ENDPOINT_AVAILABLE
|
||||
}
|
||||
|
||||
/** Requires an endpoint on the two verdicts that have one. */
|
||||
public GrpcLoadBalancerDecision {
|
||||
if (verdict == null || chosen == null) {
|
||||
throw new IllegalArgumentException("a picker decision has a verdict and the Optional");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new IllegalArgumentException("a picker decision explains itself");
|
||||
}
|
||||
if (verdict == Verdict.NO_ENDPOINT_AVAILABLE && chosen.isPresent()) {
|
||||
throw new IllegalArgumentException("a decision with no endpoint available carries none");
|
||||
}
|
||||
if (verdict != Verdict.NO_ENDPOINT_AVAILABLE && chosen.isEmpty()) {
|
||||
throw new IllegalArgumentException("a decision that picked carries the endpoint it picked");
|
||||
}
|
||||
}
|
||||
|
||||
/** The picker's own choice. */
|
||||
public static GrpcLoadBalancerDecision picked(GrpcEndpointCandidate endpoint, String reason) {
|
||||
return new GrpcLoadBalancerDecision(Verdict.PICKED, Optional.of(endpoint), reason);
|
||||
}
|
||||
|
||||
/** The platform's fallback after a picker failure. */
|
||||
public static GrpcLoadBalancerDecision fallback(GrpcEndpointCandidate endpoint, String reason) {
|
||||
return new GrpcLoadBalancerDecision(
|
||||
Verdict.DETERMINISTIC_FALLBACK, Optional.of(endpoint), reason);
|
||||
}
|
||||
|
||||
/** Nothing was selectable. */
|
||||
public static GrpcLoadBalancerDecision none(String reason) {
|
||||
return new GrpcLoadBalancerDecision(Verdict.NO_ENDPOINT_AVAILABLE, Optional.empty(), reason);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Chooses one endpoint from the candidates the resolver supplied.
|
||||
*
|
||||
* <p>The signature is the safety property. A picker receives a list of candidates and nothing else:
|
||||
* no request, no metadata, no caller. It therefore cannot route on a tenant, and the rule "business
|
||||
* data is not a routing input" is enforced by there being no business data to reach.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface GrpcLoadBalancerPicker {
|
||||
|
||||
/**
|
||||
* Picks an endpoint.
|
||||
*
|
||||
* @param candidates the selectable endpoints, never empty
|
||||
* @return the chosen endpoint, which must be one of {@code candidates}
|
||||
*/
|
||||
GrpcEndpointCandidate pick(List<GrpcEndpointCandidate> candidates);
|
||||
|
||||
/** Round-robin, as the deterministic default and the fallback. */
|
||||
static GrpcLoadBalancerPicker roundRobin() {
|
||||
java.util.concurrent.atomic.AtomicInteger cursor =
|
||||
new java.util.concurrent.atomic.AtomicInteger();
|
||||
return candidates -> candidates.get(Math.floorMod(cursor.getAndIncrement(), candidates.size()));
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Runs a custom picker and refuses to let it do something the resolver did not authorise.
|
||||
*
|
||||
* <p>Two rules. A picker may only return an endpoint the resolver supplied, because a picker that
|
||||
* can invent an address can send a request anywhere; and a picker that throws produces a
|
||||
* deterministic fallback rather than a failed call, because a picker bug should degrade the
|
||||
* balancing rather than the availability.
|
||||
*
|
||||
* <p>Weighted and load-aware pickers are not refused here, but the plan requires evidence before
|
||||
* they ship: {@link #requiresLoadEvidence} names which shapes those are.
|
||||
*/
|
||||
public final class GrpcLoadBalancerSafetyPolicy {
|
||||
|
||||
private final GrpcLoadBalancerPicker picker;
|
||||
private final GrpcLoadBalancerPicker fallback;
|
||||
|
||||
/** Wraps a custom picker with the platform's deterministic fallback. */
|
||||
public GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker picker) {
|
||||
this(picker, GrpcLoadBalancerPicker.roundRobin());
|
||||
}
|
||||
|
||||
/** Wraps a custom picker with an explicit fallback. */
|
||||
public GrpcLoadBalancerSafetyPolicy(
|
||||
GrpcLoadBalancerPicker picker, GrpcLoadBalancerPicker fallback) {
|
||||
if (picker == null || fallback == null) {
|
||||
throw new IllegalArgumentException("a safety policy needs a picker and a fallback");
|
||||
}
|
||||
this.picker = picker;
|
||||
this.fallback = fallback;
|
||||
}
|
||||
|
||||
/** Picks an endpoint, or explains why none was chosen. */
|
||||
public GrpcLoadBalancerDecision pick(List<GrpcEndpointCandidate> candidates) {
|
||||
if (candidates == null) {
|
||||
throw new IllegalArgumentException("a candidate list is required");
|
||||
}
|
||||
List<GrpcEndpointCandidate> selectable =
|
||||
candidates.stream().filter(GrpcEndpointCandidate::selectable).toList();
|
||||
if (selectable.isEmpty()) {
|
||||
return GrpcLoadBalancerDecision.none(
|
||||
"no endpoint is healthy, connected, un-ejected and non-zero weight");
|
||||
}
|
||||
GrpcEndpointCandidate chosen;
|
||||
try {
|
||||
chosen = picker.pick(selectable);
|
||||
} catch (RuntimeException pickerFailure) {
|
||||
return GrpcLoadBalancerDecision.fallback(
|
||||
fallback.pick(selectable),
|
||||
"the custom picker threw ("
|
||||
+ pickerFailure.getClass().getSimpleName()
|
||||
+ "); falling back deterministically rather than failing the call");
|
||||
}
|
||||
if (chosen == null || !selectable.contains(chosen)) {
|
||||
return GrpcLoadBalancerDecision.fallback(
|
||||
fallback.pick(selectable),
|
||||
"the custom picker returned an endpoint the resolver did not supply; a picker that can "
|
||||
+ "invent an address can send a request anywhere");
|
||||
}
|
||||
return GrpcLoadBalancerDecision.picked(chosen, "chosen by the custom picker");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a picker of this shape needs performance, fairness and failover evidence before it
|
||||
* ships.
|
||||
*
|
||||
* @param loadAware whether the picker uses reported load or latency
|
||||
* @param weighted whether the picker uses endpoint weights
|
||||
*/
|
||||
public static boolean requiresLoadEvidence(boolean loadAware, boolean weighted) {
|
||||
return loadAware || weighted;
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* What a custom resolver is allowed to say, and what it may never carry.
|
||||
*
|
||||
* <p>A resolver runs inside the channel and speaks to something outside the deployment. Everything
|
||||
* it can put into an update is therefore attacker-influenced in the worst case and
|
||||
* operator-influenced in the ordinary one, so the safety rules are about limiting what an update
|
||||
* can change: addresses and a validated service config, never a credential and never business
|
||||
* metadata.
|
||||
*/
|
||||
public final class GrpcResolverSafetyPolicy {
|
||||
|
||||
private static final Pattern AUTHORITY =
|
||||
Pattern.compile("[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\\d{1,5})?");
|
||||
|
||||
private static final Pattern CREDENTIAL_SHAPED =
|
||||
Pattern.compile("(?i).*(authorization|bearer|password|secret|token|api[_-]?key).*");
|
||||
|
||||
private GrpcResolverSafetyPolicy() {}
|
||||
|
||||
/**
|
||||
* Every problem with an update, given what was last accepted.
|
||||
*
|
||||
* @param lastAccepted the newest snapshot already applied, or null when none has been
|
||||
* @return an empty list when the update is safe to apply
|
||||
*/
|
||||
public static List<String> violations(
|
||||
GrpcResolverUpdate update, GrpcEndpointSnapshot lastAccepted) {
|
||||
if (update == null) {
|
||||
throw new IllegalArgumentException("an update is required");
|
||||
}
|
||||
List<String> violations = new ArrayList<>();
|
||||
GrpcEndpointSnapshot snapshot = update.snapshot();
|
||||
|
||||
if (!AUTHORITY.matcher(snapshot.authority()).matches()) {
|
||||
violations.add(
|
||||
"authority '"
|
||||
+ snapshot.authority()
|
||||
+ "' is not a plain host or host:port; a resolver that can change the authority can "
|
||||
+ "change which certificate the channel accepts");
|
||||
}
|
||||
if (!snapshot.supersedes(lastAccepted)) {
|
||||
violations.add(
|
||||
"revision "
|
||||
+ snapshot.revision()
|
||||
+ " does not supersede the applied revision "
|
||||
+ (lastAccepted == null ? "none" : lastAccepted.revision())
|
||||
+ "; applying it would replace newer endpoints with older ones");
|
||||
}
|
||||
update
|
||||
.serviceConfigJson()
|
||||
.ifPresent(
|
||||
config -> {
|
||||
if (CREDENTIAL_SHAPED.matcher(config).find()) {
|
||||
violations.add(
|
||||
"the pushed service config contains a credential-shaped field; a resolver "
|
||||
+ "supplies addresses and policy, never authentication material");
|
||||
}
|
||||
});
|
||||
return List.copyOf(violations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a closed resolver's update should be applied.
|
||||
*
|
||||
* <p>Always false. A resolver that keeps delivering after close is one whose discovery source has
|
||||
* not noticed the channel is gone, and applying its updates resurrects routing for a channel
|
||||
* nobody is using.
|
||||
*/
|
||||
public static boolean acceptAfterClose() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether a resolver may supply caller identity or business metadata. Always false. */
|
||||
public static boolean mayCarryBusinessMetadata() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The service config an update may contribute, once validated. */
|
||||
public static Optional<String> acceptedServiceConfig(
|
||||
GrpcResolverUpdate update, GrpcEndpointSnapshot lastAccepted) {
|
||||
return violations(update, lastAccepted).isEmpty()
|
||||
? update.serviceConfigJson()
|
||||
: Optional.empty();
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.caskeleton.grpc.advanced.discovery;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* A resolver's report: endpoints, and optionally the service config that goes with them.
|
||||
*
|
||||
* <p>The service config is optional and, when present, is validated as if a human had written it. A
|
||||
* resolver that can push retry policy is a resolver that can turn on retries for a non-idempotent
|
||||
* method from outside the codebase, and the fact that a control plane sent it is not evidence that
|
||||
* anyone reviewed it.
|
||||
*/
|
||||
public record GrpcResolverUpdate(
|
||||
GrpcEndpointSnapshot snapshot, Optional<String> serviceConfigJson) {
|
||||
|
||||
/** Requires a snapshot and the Optional. */
|
||||
public GrpcResolverUpdate {
|
||||
if (snapshot == null || serviceConfigJson == null) {
|
||||
throw new IllegalArgumentException("a resolver update carries a snapshot and the Optional");
|
||||
}
|
||||
serviceConfigJson.ifPresent(
|
||||
config -> {
|
||||
if (config.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a present service config must not be blank; absent and empty are different states");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** An update with endpoints only. */
|
||||
public static GrpcResolverUpdate endpointsOnly(GrpcEndpointSnapshot snapshot) {
|
||||
return new GrpcResolverUpdate(snapshot, Optional.empty());
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Caps duplicate attempts as a fraction of real traffic.
|
||||
*
|
||||
* <p>Necessary for the same reason a retry budget is, and more urgently. A retry happens after a
|
||||
* failure; a hedge happens on a call that might have succeeded, so a fleet that hedges without a
|
||||
* budget doubles its backend load in the steady state and doubles it again the moment latency
|
||||
* rises.
|
||||
*/
|
||||
public final class GrpcHedgingBudget {
|
||||
|
||||
private final long maxTokens;
|
||||
private final long tokensPerHedge;
|
||||
private final AtomicLong tokens;
|
||||
|
||||
/**
|
||||
* A budget that starts full.
|
||||
*
|
||||
* @param ratio hedges permitted per completed call, e.g. 0.1 for one hedge in ten
|
||||
* @param maxTokens how much credit may accumulate, which bounds a burst after a quiet period
|
||||
*/
|
||||
public static GrpcHedgingBudget of(double ratio, long maxTokens) {
|
||||
if (ratio <= 0.0d || ratio > 0.5d) {
|
||||
throw new IllegalArgumentException(
|
||||
"a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a "
|
||||
+ "load decision rather than a latency one");
|
||||
}
|
||||
if (maxTokens < 1) {
|
||||
throw new IllegalArgumentException("a budget needs at least one token");
|
||||
}
|
||||
return new GrpcHedgingBudget(maxTokens, Math.round(1.0d / ratio));
|
||||
}
|
||||
|
||||
private GrpcHedgingBudget(long maxTokens, long tokensPerHedge) {
|
||||
this.maxTokens = maxTokens;
|
||||
this.tokensPerHedge = tokensPerHedge;
|
||||
this.tokens = new AtomicLong(maxTokens);
|
||||
}
|
||||
|
||||
/** Takes the credit for one hedge, if there is any. */
|
||||
public boolean tryConsume() {
|
||||
while (true) {
|
||||
long observed = tokens.get();
|
||||
if (observed < tokensPerHedge) {
|
||||
return false;
|
||||
}
|
||||
if (tokens.compareAndSet(observed, observed - tokensPerHedge)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Records a completed call, which earns credit back. */
|
||||
public void recordCompletion() {
|
||||
tokens.updateAndGet(observed -> Math.min(maxTokens, observed + 1L));
|
||||
}
|
||||
|
||||
/** How much credit is left. */
|
||||
public long availableTokens() {
|
||||
return tokens.get();
|
||||
}
|
||||
|
||||
/** Whether another hedge could be afforded. */
|
||||
public boolean exhausted() {
|
||||
return tokens.get() < tokensPerHedge;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import dev.caskeleton.grpc.policy.GrpcMethodPolicy;
|
||||
import dev.caskeleton.grpc.policy.RpcIdempotencyProfile;
|
||||
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Whether a method may be hedged at all.
|
||||
*
|
||||
* <p>Read-only unary, and nothing else. A hedged mutation runs twice by design rather than by
|
||||
* accident — both attempts are in flight, both may reach the server, and an idempotency key does
|
||||
* not help because the second attempt is not a retry of a failure but a duplicate of a success in
|
||||
* progress. A hedged stream is worse still: two streams deliver two prefixes.
|
||||
*/
|
||||
public final class GrpcHedgingEligibility {
|
||||
|
||||
private GrpcHedgingEligibility() {}
|
||||
|
||||
/**
|
||||
* Why {@code policy} may not be hedged, or empty when it may.
|
||||
*
|
||||
* @return a refusal reason, or empty when hedging is permitted
|
||||
*/
|
||||
public static Optional<String> refusalReason(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
|
||||
if (policy == null || retryOwner == null) {
|
||||
throw new IllegalArgumentException("eligibility needs a method policy and a retry owner");
|
||||
}
|
||||
if (policy.rpcType() != RpcType.UNARY) {
|
||||
return Optional.of(
|
||||
"method '"
|
||||
+ policy.method().canonical()
|
||||
+ "' is "
|
||||
+ policy.rpcType()
|
||||
+ "; two hedged streams deliver two prefixes");
|
||||
}
|
||||
if (policy.idempotency() != RpcIdempotencyProfile.READ_ONLY) {
|
||||
return Optional.of(
|
||||
"method '"
|
||||
+ policy.method().canonical()
|
||||
+ "' is "
|
||||
+ policy.idempotency()
|
||||
+ "; a hedged mutation runs twice by design, and an idempotency key does not help "
|
||||
+ "because the second attempt duplicates a success in progress rather than retrying a "
|
||||
+ "failure");
|
||||
}
|
||||
if (!retryOwner.hedgingAllowed()) {
|
||||
return Optional.of(
|
||||
"retry owner is " + retryOwner + ", which does not permit in-process hedging");
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/** Whether {@code policy} may be hedged. */
|
||||
public static boolean eligible(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
|
||||
return refusalReason(policy, retryOwner).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails when a method may not be hedged.
|
||||
*
|
||||
* @throws IllegalStateException with the reason
|
||||
*/
|
||||
public static void require(GrpcMethodPolicy policy, GrpcRetryOwner retryOwner) {
|
||||
refusalReason(policy, retryOwner)
|
||||
.ifPresent(
|
||||
reason -> {
|
||||
throw new IllegalStateException("hedging refused: " + reason);
|
||||
});
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Duplicate in-flight attempts for a read, and the two bounds that keep them affordable.
|
||||
*
|
||||
* <p>Hedging trades backend load for tail latency: a second attempt goes out before the first has
|
||||
* failed, so a slow replica stops mattering. The cost is that every hedged call may cost two, and
|
||||
* it costs two precisely when the backend is already slow — which is why the attempt cap starts at
|
||||
* 2 and the delay is required to be meaningfully above the median.
|
||||
*/
|
||||
public record GrpcHedgingPolicy(int maxAttempts, Duration hedgingDelay, Duration totalDeadline) {
|
||||
|
||||
/** The initial cap. Raising it is a deliberate decision with load evidence behind it. */
|
||||
public static final int INITIAL_MAX_ATTEMPTS = 2;
|
||||
|
||||
/** Refuses a policy whose duplicate load is unbounded or whose delay is meaningless. */
|
||||
public GrpcHedgingPolicy {
|
||||
if (maxAttempts < 2) {
|
||||
throw new IllegalArgumentException("hedging means at least two attempts; got " + maxAttempts);
|
||||
}
|
||||
if (maxAttempts > INITIAL_MAX_ATTEMPTS) {
|
||||
throw new IllegalArgumentException(
|
||||
"hedging is capped at "
|
||||
+ INITIAL_MAX_ATTEMPTS
|
||||
+ " attempts until load evidence justifies more; each extra attempt multiplies "
|
||||
+ "backend load exactly when the backend is already slow");
|
||||
}
|
||||
if (hedgingDelay == null || hedgingDelay.isNegative()) {
|
||||
throw new IllegalArgumentException("a hedging delay must be present and non-negative");
|
||||
}
|
||||
if (hedgingDelay.isZero()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a zero hedging delay sends every attempt at once, which doubles load for every call "
|
||||
+ "rather than for the slow ones");
|
||||
}
|
||||
if (totalDeadline == null || totalDeadline.isZero() || totalDeadline.isNegative()) {
|
||||
throw new IllegalArgumentException("hedging needs a total deadline to fit inside");
|
||||
}
|
||||
if (hedgingDelay.compareTo(totalDeadline) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"the hedging delay is at or above the total deadline, so the second attempt never starts");
|
||||
}
|
||||
}
|
||||
|
||||
/** A policy that hedges once after {@code hedgingDelay}. */
|
||||
public static GrpcHedgingPolicy hedgeOnce(Duration hedgingDelay, Duration totalDeadline) {
|
||||
return new GrpcHedgingPolicy(INITIAL_MAX_ATTEMPTS, hedgingDelay, totalDeadline);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* What a hedged call cost and what it saved.
|
||||
*
|
||||
* <p>Both numbers, because hedging is a trade and a dashboard that shows only the latency
|
||||
* improvement makes it look free. {@code duplicateBackendCalls} is what the backend team sees, and
|
||||
* {@code cancelledLoserAttempts} is how much of that work was thrown away.
|
||||
*/
|
||||
public record GrpcHedgingResult(
|
||||
int attemptsIssued,
|
||||
int winningAttempt,
|
||||
int cancelledLoserAttempts,
|
||||
int duplicateBackendCalls,
|
||||
Duration observedLatency) {
|
||||
|
||||
/** Requires coherent counts. */
|
||||
public GrpcHedgingResult {
|
||||
if (attemptsIssued < 1) {
|
||||
throw new IllegalArgumentException("a hedged call issues at least one attempt");
|
||||
}
|
||||
if (winningAttempt < 1 || winningAttempt > attemptsIssued) {
|
||||
throw new IllegalArgumentException("the winning attempt is one of the attempts issued");
|
||||
}
|
||||
if (cancelledLoserAttempts < 0 || cancelledLoserAttempts > attemptsIssued - 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"at most every attempt but the winner can be a cancelled loser");
|
||||
}
|
||||
if (duplicateBackendCalls < 0 || duplicateBackendCalls > attemptsIssued - 1) {
|
||||
throw new IllegalArgumentException(
|
||||
"duplicate backend calls are the attempts beyond the first");
|
||||
}
|
||||
if (observedLatency == null || observedLatency.isNegative()) {
|
||||
throw new IllegalArgumentException("a hedged call records its latency");
|
||||
}
|
||||
}
|
||||
|
||||
/** A call that did not need to hedge. */
|
||||
public static GrpcHedgingResult firstAttemptWon(Duration latency) {
|
||||
return new GrpcHedgingResult(1, 1, 0, 0, latency);
|
||||
}
|
||||
|
||||
/** Whether this call actually issued a duplicate. */
|
||||
public boolean hedged() {
|
||||
return attemptsIssued > 1;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.caskeleton.grpc.advanced.xds;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* What happens when the control plane goes away.
|
||||
*
|
||||
* <p>Last-known-good, with a bound. Serving forever from a stale snapshot means a decommissioned
|
||||
* backend keeps receiving traffic indefinitely; failing immediately means a control-plane restart
|
||||
* takes every client down with it. The bound is where the deployment decides which risk it prefers,
|
||||
* and it has to be stated rather than inherited.
|
||||
*/
|
||||
public record GrpcXdsFailurePolicy(
|
||||
Duration maxStaleness, boolean failFastOnMissingResource, Duration initialFetchTimeout) {
|
||||
|
||||
/** Refuses a policy without a staleness bound. */
|
||||
public GrpcXdsFailurePolicy {
|
||||
if (maxStaleness == null || maxStaleness.isZero() || maxStaleness.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"last-known-good needs a staleness bound; without one a decommissioned backend keeps "
|
||||
+ "receiving traffic indefinitely");
|
||||
}
|
||||
if (initialFetchTimeout == null
|
||||
|| initialFetchTimeout.isZero()
|
||||
|| initialFetchTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a client with no snapshot yet needs a bound on how long it waits before failing");
|
||||
}
|
||||
}
|
||||
|
||||
/** The default: fifteen minutes of last-known-good, fail fast on a resource that vanished. */
|
||||
public static GrpcXdsFailurePolicy standard() {
|
||||
return new GrpcXdsFailurePolicy(Duration.ofMinutes(15), true, Duration.ofSeconds(15));
|
||||
}
|
||||
|
||||
/** What a client should do given the newest snapshot it holds. */
|
||||
public Decision decide(Optional<GrpcXdsResourceSnapshot> snapshot, Instant now) {
|
||||
if (snapshot == null || now == null) {
|
||||
throw new IllegalArgumentException("a decision needs the snapshot Optional and a moment");
|
||||
}
|
||||
if (snapshot.isEmpty()) {
|
||||
return Decision.NO_SNAPSHOT_YET;
|
||||
}
|
||||
return snapshot.get().ageAt(now).compareTo(maxStaleness) > 0
|
||||
? Decision.STALE_BEYOND_BOUND
|
||||
: Decision.SERVE_LAST_KNOWN_GOOD;
|
||||
}
|
||||
|
||||
/** What the client does about a control-plane outage. */
|
||||
public enum Decision {
|
||||
/** Nothing has arrived yet; wait until the initial fetch timeout, then fail. */
|
||||
NO_SNAPSHOT_YET,
|
||||
/** Keep routing on the snapshot in hand. */
|
||||
SERVE_LAST_KNOWN_GOOD,
|
||||
/** The snapshot is older than the bound; stop trusting it. */
|
||||
STALE_BEYOND_BOUND
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.grpc.advanced.xds;
|
||||
|
||||
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
|
||||
import java.net.URI;
|
||||
|
||||
/**
|
||||
* A proxyless xDS deployment's configuration.
|
||||
*
|
||||
* <p>xDS moves routing, load balancing, retries and often mTLS out of the application and into a
|
||||
* control plane. The consequence this profile encodes is that the application must stop configuring
|
||||
* them: retry policy defined in both places is defined twice, and which one wins depends on
|
||||
* resolution order rather than on anyone's decision.
|
||||
*/
|
||||
public record GrpcXdsProfile(
|
||||
URI target,
|
||||
String bootstrapReference,
|
||||
String resourceNamespace,
|
||||
GrpcRetryOwner retryOwner,
|
||||
boolean controlPlaneMutualTls) {
|
||||
|
||||
/** The only scheme an xDS target may use. */
|
||||
public static final String XDS_SCHEME = "xds";
|
||||
|
||||
/** Refuses a profile that would leave retries or routing owned in two places. */
|
||||
public GrpcXdsProfile {
|
||||
if (target == null || !XDS_SCHEME.equals(target.getScheme())) {
|
||||
throw new IllegalArgumentException(
|
||||
"an xDS profile needs an 'xds:///' target; got '" + target + "'");
|
||||
}
|
||||
if (bootstrapReference == null || bootstrapReference.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"xDS needs a bootstrap reference; without one the client has no control plane to ask");
|
||||
}
|
||||
if (resourceNamespace == null || resourceNamespace.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an xDS profile names its resource namespace; a client that subscribes to everything "
|
||||
+ "receives another team's routing");
|
||||
}
|
||||
if (retryOwner != GrpcRetryOwner.SERVICE_MESH) {
|
||||
throw new IllegalArgumentException(
|
||||
"xDS routing means the control plane owns retries; a retry owner of "
|
||||
+ retryOwner
|
||||
+ " would define retry policy in two places, and which wins depends on resolution "
|
||||
+ "order rather than on a decision");
|
||||
}
|
||||
if (!controlPlaneMutualTls) {
|
||||
throw new IllegalArgumentException(
|
||||
"the control-plane connection carries routing and often certificates; it is authenticated "
|
||||
+ "in both directions or it is a channel that can be impersonated");
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package dev.caskeleton.grpc.advanced.xds;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What the control plane last said, and when.
|
||||
*
|
||||
* <p>The timestamp is what makes last-known-good usable. A snapshot with no age cannot answer
|
||||
* whether the control plane has been silent for a minute or a day, and those are a transient blip
|
||||
* and a serious incident.
|
||||
*/
|
||||
public record GrpcXdsResourceSnapshot(
|
||||
String versionInfo, List<String> resourceNames, Instant receivedAt) {
|
||||
|
||||
/** Requires a version, at least one resource and a receipt time. */
|
||||
public GrpcXdsResourceSnapshot {
|
||||
if (versionInfo == null || versionInfo.isBlank()) {
|
||||
throw new IllegalArgumentException("an xDS snapshot carries the control plane's version");
|
||||
}
|
||||
if (resourceNames == null || resourceNames.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an empty resource set is not a snapshot; a control plane that returns nothing has not "
|
||||
+ "told the client its routing was removed");
|
||||
}
|
||||
if (receivedAt == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"a snapshot records when it arrived; without it, last-known-good cannot say how old it is");
|
||||
}
|
||||
resourceNames = List.copyOf(resourceNames);
|
||||
}
|
||||
|
||||
/** How old this snapshot is at {@code now}. */
|
||||
public java.time.Duration ageAt(Instant now) {
|
||||
if (now == null) {
|
||||
throw new IllegalArgumentException("an age needs a moment");
|
||||
}
|
||||
return java.time.Duration.between(receivedAt, now);
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package dev.caskeleton.grpc.advanced.xds;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuard;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Refuses to start an xDS channel that is not fully configured, and refuses to let xDS be described
|
||||
* as Stable support.
|
||||
*
|
||||
* <p>The second refusal is the one worth having in code. xDS working in a deployment is not the
|
||||
* same claim as the platform supporting it: it brings a control plane, its outage modes, its own
|
||||
* security boundary and its own version skew, and the Stable support statement covers DNS and
|
||||
* static targets. A support matrix that quietly widens is a support matrix nobody can rely on.
|
||||
*/
|
||||
public final class GrpcXdsStartupGuard {
|
||||
|
||||
private GrpcXdsStartupGuard() {}
|
||||
|
||||
/**
|
||||
* Every reason an xDS channel may not start.
|
||||
*
|
||||
* @return an empty list when the profile and flags permit it
|
||||
*/
|
||||
public static List<String> startupBlockers(
|
||||
GrpcXdsProfile profile, GrpcAdvancedFeatureFlags flags, boolean applicationDefinesRetries) {
|
||||
if (profile == null || flags == null) {
|
||||
throw new IllegalArgumentException("startup validation needs a profile and the flags");
|
||||
}
|
||||
List<String> blockers = new ArrayList<>();
|
||||
if (!GrpcAdvancedModuleGuard.available(flags, GrpcAdvancedCapability.XDS)) {
|
||||
blockers.add(
|
||||
"the xds capability is not available; it is "
|
||||
+ flags.gradeOf(GrpcAdvancedCapability.XDS)
|
||||
+ " and "
|
||||
+ (flags.flagSet(GrpcAdvancedCapability.XDS)
|
||||
? "production has not approved it"
|
||||
: "its flag is not set"));
|
||||
}
|
||||
if (applicationDefinesRetries) {
|
||||
blockers.add(
|
||||
"the application also defines retry policy; with xDS the control plane owns it, and "
|
||||
+ "defining it in both places makes the winner depend on resolution order");
|
||||
}
|
||||
return List.copyOf(blockers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every disagreement between a deployment's profile and the bootstrap file its client will read.
|
||||
*
|
||||
* <p>Checked because the two are written by different people in different repositories, and the
|
||||
* failure is silent: a client whose bootstrap names a namespace the deployment did not configure
|
||||
* subscribes successfully and receives another team's routing. Nothing errors — the control plane
|
||||
* answers, the resources parse, and traffic goes somewhere nobody chose.
|
||||
*
|
||||
* <p>Matched textually rather than with a JSON parser, deliberately. This leaf's test classpath
|
||||
* is plain JUnit and AssertJ, and adding a JSON library to check three fields would put a parser
|
||||
* on the runtime classpath of every deployment that enables xDS.
|
||||
*
|
||||
* @param bootstrapJson the bootstrap document's contents
|
||||
* @return an empty list when the bootstrap and the profile agree
|
||||
*/
|
||||
public static List<String> bootstrapMismatches(GrpcXdsProfile profile, String bootstrapJson) {
|
||||
if (profile == null) {
|
||||
throw new IllegalArgumentException("a profile is required");
|
||||
}
|
||||
if (bootstrapJson == null || bootstrapJson.isBlank()) {
|
||||
throw new IllegalArgumentException(
|
||||
"the bootstrap document is required; a client with no bootstrap has no control plane to ask");
|
||||
}
|
||||
List<String> mismatches = new ArrayList<>();
|
||||
if (!bootstrapJson.contains("\"xds_servers\"")) {
|
||||
mismatches.add("the bootstrap declares no xds_servers");
|
||||
}
|
||||
if (!bootstrapJson.contains("\"channel_creds\"") || !bootstrapJson.contains("\"tls\"")) {
|
||||
mismatches.add(
|
||||
"the bootstrap's control-plane channel is not TLS; that connection carries routing and "
|
||||
+ "often certificates, so an unauthenticated one can be impersonated");
|
||||
}
|
||||
if (!bootstrapJson.contains(profile.resourceNamespace())) {
|
||||
mismatches.add(
|
||||
"the bootstrap does not name the profile's resource namespace '"
|
||||
+ profile.resourceNamespace()
|
||||
+ "'; a client that subscribes outside its namespace receives another team's routing, "
|
||||
+ "and nothing about that fails");
|
||||
}
|
||||
return List.copyOf(mismatches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether xDS may be advertised as part of Stable discovery support.
|
||||
*
|
||||
* <p>Always false. Stable support is DNS and static.
|
||||
*/
|
||||
public static boolean advertisableAsStableSupport() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.core.GrpcMethodName;
|
||||
import dev.caskeleton.grpc.core.RpcType;
|
||||
import dev.caskeleton.grpc.deadline.GrpcDeadlineProfile;
|
||||
import dev.caskeleton.grpc.policy.GrpcMethodPolicy;
|
||||
import dev.caskeleton.grpc.policy.RpcIdempotencyProfile;
|
||||
import dev.caskeleton.grpc.policy.WaitForReadyPolicy;
|
||||
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcHedgingEligibilityTest {
|
||||
|
||||
private static final GrpcMethodName GET =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/GetDocument");
|
||||
private static final GrpcMethodName CREATE =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/CreateDocument");
|
||||
private static final GrpcMethodName WATCH =
|
||||
GrpcMethodName.parse("hyeonworks.document.v1.DocumentService/WatchDocuments");
|
||||
private static final GrpcDeadlineProfile TWO_SECONDS =
|
||||
GrpcDeadlineProfile.of(Duration.ofSeconds(2));
|
||||
|
||||
@Test
|
||||
@DisplayName("only a read-only unary method may be hedged")
|
||||
void onlyReadOnlyUnaryMayBeHedged() {
|
||||
assertThat(
|
||||
GrpcHedgingEligibility.eligible(
|
||||
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.GRPC_PLATFORM))
|
||||
.isTrue();
|
||||
assertThat(
|
||||
GrpcHedgingEligibility.refusalReason(
|
||||
GrpcMethodPolicy.nonIdempotentUnary(CREATE, TWO_SECONDS),
|
||||
GrpcRetryOwner.GRPC_PLATFORM))
|
||||
.hasValueSatisfying(
|
||||
reason -> assertThat(reason).contains("duplicates a success in progress"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a keyed mutation is still refused; an idempotency key does not make hedging safe")
|
||||
void aKeyedMutationIsStillRefused() {
|
||||
GrpcMethodPolicy keyed =
|
||||
new GrpcMethodPolicy(
|
||||
CREATE,
|
||||
RpcType.UNARY,
|
||||
RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED,
|
||||
TWO_SECONDS,
|
||||
WaitForReadyPolicy.DISABLED,
|
||||
false,
|
||||
1024,
|
||||
1024);
|
||||
|
||||
assertThat(GrpcHedgingEligibility.eligible(keyed, GrpcRetryOwner.GRPC_PLATFORM)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a streaming method may not be hedged")
|
||||
void streamingMayNotBeHedged() {
|
||||
GrpcMethodPolicy streaming =
|
||||
new GrpcMethodPolicy(
|
||||
WATCH,
|
||||
RpcType.SERVER_STREAMING,
|
||||
RpcIdempotencyProfile.READ_ONLY,
|
||||
TWO_SECONDS,
|
||||
WaitForReadyPolicy.DISABLED,
|
||||
false,
|
||||
1024,
|
||||
1024);
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> GrpcHedgingEligibility.require(streaming, GrpcRetryOwner.GRPC_PLATFORM))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("two prefixes");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a mesh-owned channel may not hedge in-process")
|
||||
void aMeshOwnedChannelMayNotHedge() {
|
||||
assertThat(
|
||||
GrpcHedgingEligibility.eligible(
|
||||
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.SERVICE_MESH))
|
||||
.isFalse();
|
||||
assertThat(
|
||||
GrpcHedgingEligibility.eligible(
|
||||
GrpcMethodPolicy.readOnlyUnary(GET, TWO_SECONDS), GrpcRetryOwner.APPLICATION))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hedging is capped at two attempts and needs a meaningful delay")
|
||||
void hedgingIsCappedAndDelayed() {
|
||||
assertThat(
|
||||
GrpcHedgingPolicy.hedgeOnce(Duration.ofMillis(50), Duration.ofSeconds(2)).maxAttempts())
|
||||
.isEqualTo(2);
|
||||
assertThatThrownBy(() -> new GrpcHedgingPolicy(3, Duration.ofMillis(50), Duration.ofSeconds(2)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("capped at 2");
|
||||
assertThatThrownBy(() -> new GrpcHedgingPolicy(2, Duration.ZERO, Duration.ofSeconds(2)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("doubles load for every call");
|
||||
assertThatThrownBy(() -> new GrpcHedgingPolicy(2, Duration.ofSeconds(3), Duration.ofSeconds(2)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("never starts");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the hedging budget bounds duplicate load and refuses an absurd ratio")
|
||||
void theHedgingBudgetBoundsDuplicateLoad() {
|
||||
// ratio 0.5 costs two tokens per hedge, so a four-token budget affords two hedges and then
|
||||
// needs two completions before it can afford another.
|
||||
GrpcHedgingBudget budget = GrpcHedgingBudget.of(0.5d, 4L);
|
||||
|
||||
assertThat(budget.tryConsume()).isTrue();
|
||||
assertThat(budget.tryConsume()).isTrue();
|
||||
assertThat(budget.tryConsume()).isFalse();
|
||||
assertThat(budget.availableTokens()).isZero();
|
||||
|
||||
budget.recordCompletion();
|
||||
assertThat(budget.exhausted()).isTrue();
|
||||
budget.recordCompletion();
|
||||
assertThat(budget.exhausted()).isFalse();
|
||||
|
||||
assertThatThrownBy(() -> GrpcHedgingBudget.of(0.9d, 10L))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("load decision rather than a latency one");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a hedged result records both the saving and the duplicate load")
|
||||
void aHedgedResultRecordsBothSides() {
|
||||
GrpcHedgingResult hedged = new GrpcHedgingResult(2, 2, 1, 1, Duration.ofMillis(40));
|
||||
|
||||
assertThat(hedged.hedged()).isTrue();
|
||||
assertThat(hedged.duplicateBackendCalls()).isEqualTo(1);
|
||||
assertThat(hedged.cancelledLoserAttempts()).isEqualTo(1);
|
||||
assertThat(GrpcHedgingResult.firstAttemptWon(Duration.ofMillis(10)).hedged()).isFalse();
|
||||
assertThatThrownBy(() -> new GrpcHedgingResult(2, 3, 0, 0, Duration.ZERO))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(() -> new GrpcHedgingResult(2, 1, 2, 0, Duration.ZERO))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcEndpointCandidate;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerDecision;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerPicker;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcLoadBalancerSafetyPolicy;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcLoadBalancerSafetyPolicyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("a picker may only choose an endpoint the resolver supplied")
|
||||
void aPickerMayNotInventAnEndpoint() {
|
||||
GrpcLoadBalancerSafetyPolicy policy =
|
||||
new GrpcLoadBalancerSafetyPolicy(candidates -> GrpcEndpointCandidate.ready("10.9.9.9"));
|
||||
|
||||
GrpcLoadBalancerDecision decision =
|
||||
policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1")));
|
||||
|
||||
assertThat(decision.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
|
||||
assertThat(decision.reason()).contains("can send a request anywhere");
|
||||
assertThat(decision.chosen())
|
||||
.hasValueSatisfying(endpoint -> assertThat(endpoint.address()).isEqualTo("10.0.0.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a picker that throws degrades balancing, not availability")
|
||||
void aThrowingPickerFallsBack() {
|
||||
GrpcLoadBalancerSafetyPolicy policy =
|
||||
new GrpcLoadBalancerSafetyPolicy(
|
||||
candidates -> {
|
||||
throw new IllegalStateException("picker bug");
|
||||
});
|
||||
|
||||
GrpcLoadBalancerDecision decision =
|
||||
policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1")));
|
||||
|
||||
assertThat(decision.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
|
||||
assertThat(decision.reason()).contains("IllegalStateException");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a picker returning null falls back rather than failing the call")
|
||||
void aNullPickFallsBack() {
|
||||
GrpcLoadBalancerSafetyPolicy policy = new GrpcLoadBalancerSafetyPolicy(candidates -> null);
|
||||
|
||||
assertThat(policy.pick(List.of(GrpcEndpointCandidate.ready("10.0.0.1"))).verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.DETERMINISTIC_FALLBACK);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no selectable endpoint is a different verdict from a picker failure")
|
||||
void noEndpointDiffersFromAPickerFailure() {
|
||||
GrpcLoadBalancerSafetyPolicy policy =
|
||||
new GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker.roundRobin());
|
||||
|
||||
assertThat(
|
||||
policy
|
||||
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", false, true, 100, false)))
|
||||
.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
|
||||
assertThat(
|
||||
policy
|
||||
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", true, true, 100, true)))
|
||||
.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
|
||||
assertThat(
|
||||
policy
|
||||
.pick(List.of(new GrpcEndpointCandidate("10.0.0.1", true, true, 0, false)))
|
||||
.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.NO_ENDPOINT_AVAILABLE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a healthy endpoint is picked and reported as the picker's own choice")
|
||||
void aHealthyEndpointIsPicked() {
|
||||
GrpcLoadBalancerSafetyPolicy policy =
|
||||
new GrpcLoadBalancerSafetyPolicy(GrpcLoadBalancerPicker.roundRobin());
|
||||
|
||||
assertThat(
|
||||
policy
|
||||
.pick(
|
||||
List.of(
|
||||
GrpcEndpointCandidate.ready("10.0.0.1"),
|
||||
GrpcEndpointCandidate.ready("10.0.0.2")))
|
||||
.verdict())
|
||||
.isEqualTo(GrpcLoadBalancerDecision.Verdict.PICKED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a picker sees endpoints only, never a request or a caller")
|
||||
void aPickerSeesEndpointsOnly() {
|
||||
assertThat(GrpcLoadBalancerPicker.class.getMethods())
|
||||
.filteredOn(method -> "pick".equals(method.getName()))
|
||||
.singleElement()
|
||||
.satisfies(method -> assertThat(method.getParameterCount()).isEqualTo(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a candidate carries only routing-relevant state, bounded")
|
||||
void aCandidateCarriesOnlyRoutingState() {
|
||||
assertThat(GrpcEndpointCandidate.ready("10.0.0.1").selectable()).isTrue();
|
||||
assertThatThrownBy(() -> new GrpcEndpointCandidate("10.0.0.1", true, true, 5000, false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("scale nobody can reason about");
|
||||
assertThatThrownBy(() -> new GrpcEndpointCandidate(" ", true, true, 100, false))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a load-aware or weighted picker requires evidence before it ships")
|
||||
void loadAwarePickersRequireEvidence() {
|
||||
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(true, false)).isTrue();
|
||||
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(false, true)).isTrue();
|
||||
assertThat(GrpcLoadBalancerSafetyPolicy.requiresLoadEvidence(false, false)).isFalse();
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcCustomResolver;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcEndpointSnapshot;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcResolverSafetyPolicy;
|
||||
import dev.caskeleton.grpc.advanced.discovery.GrpcResolverUpdate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcResolverSafetyPolicyTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("an empty or duplicate endpoint set is refused")
|
||||
void anEmptyOrDuplicateEndpointSetIsRefused() {
|
||||
assertThatThrownBy(() -> new GrpcEndpointSnapshot(1L, "documents", List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("take the channel down with it");
|
||||
assertThatThrownBy(
|
||||
() -> new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1", "10.0.0.1")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("skew a round-robin picker");
|
||||
assertThatThrownBy(() -> new GrpcEndpointSnapshot(0L, "documents", List.of("10.0.0.1")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a stale revision is dropped rather than applied")
|
||||
void aStaleRevisionIsDropped() {
|
||||
List<GrpcResolverUpdate> delivered = new ArrayList<>();
|
||||
try (GrpcCustomResolver resolver = new GrpcCustomResolver("documents", delivered::add)) {
|
||||
assertThat(
|
||||
resolver.offer(
|
||||
GrpcResolverUpdate.endpointsOnly(
|
||||
new GrpcEndpointSnapshot(2L, "documents", List.of("10.0.0.1")))))
|
||||
.isEmpty();
|
||||
assertThat(
|
||||
resolver.offer(
|
||||
GrpcResolverUpdate.endpointsOnly(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.9")))))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("older ones"));
|
||||
assertThat(delivered).hasSize(1);
|
||||
assertThat(resolver.currentSnapshot())
|
||||
.hasValueSatisfying(snapshot -> assertThat(snapshot.revision()).isEqualTo(2L));
|
||||
assertThat(resolver.authority()).isEqualTo("documents");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a resolver accepts nothing after close")
|
||||
void aClosedResolverAcceptsNothing() {
|
||||
List<GrpcResolverUpdate> delivered = new ArrayList<>();
|
||||
GrpcCustomResolver resolver = new GrpcCustomResolver("documents", delivered::add);
|
||||
resolver.close();
|
||||
|
||||
assertThat(resolver.closed()).isTrue();
|
||||
assertThat(
|
||||
resolver.offer(
|
||||
GrpcResolverUpdate.endpointsOnly(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")))))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("resolver is closed"));
|
||||
assertThat(delivered).isEmpty();
|
||||
assertThat(GrpcResolverSafetyPolicy.acceptAfterClose()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a resolver may not push credentials or business metadata")
|
||||
void aResolverMayNotPushCredentials() {
|
||||
GrpcResolverUpdate withCredential =
|
||||
new GrpcResolverUpdate(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
|
||||
Optional.of("{\"authorization\":\"Bearer abc\"}"));
|
||||
|
||||
assertThat(GrpcResolverSafetyPolicy.violations(withCredential, null))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("never authentication material"));
|
||||
assertThat(GrpcResolverSafetyPolicy.mayCarryBusinessMetadata()).isFalse();
|
||||
assertThat(GrpcResolverSafetyPolicy.acceptedServiceConfig(withCredential, null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a clean service config survives validation")
|
||||
void aCleanServiceConfigSurvives() {
|
||||
GrpcResolverUpdate clean =
|
||||
new GrpcResolverUpdate(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
|
||||
Optional.of("{\"loadBalancingConfig\":[{\"round_robin\":{}}]}"));
|
||||
|
||||
assertThat(GrpcResolverSafetyPolicy.violations(clean, null)).isEmpty();
|
||||
assertThat(GrpcResolverSafetyPolicy.acceptedServiceConfig(clean, null)).isPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an invalid authority is refused, because it decides which certificate is accepted")
|
||||
void anInvalidAuthorityIsRefused() {
|
||||
assertThat(
|
||||
GrpcResolverSafetyPolicy.violations(
|
||||
GrpcResolverUpdate.endpointsOnly(
|
||||
new GrpcEndpointSnapshot(1L, "Documents Service", List.of("10.0.0.1"))),
|
||||
null))
|
||||
.anySatisfy(violation -> assertThat(violation).contains("which certificate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a present service config may not be blank")
|
||||
void aPresentServiceConfigMayNotBeBlank() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcResolverUpdate(
|
||||
new GrpcEndpointSnapshot(1L, "documents", List.of("10.0.0.1")),
|
||||
Optional.of(" ")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("different states");
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package dev.caskeleton.grpc.advanced.resilience;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedCapability;
|
||||
import dev.caskeleton.grpc.advanced.bootstrap.GrpcAdvancedFeatureFlags;
|
||||
import dev.caskeleton.grpc.advanced.xds.GrpcXdsFailurePolicy;
|
||||
import dev.caskeleton.grpc.advanced.xds.GrpcXdsProfile;
|
||||
import dev.caskeleton.grpc.advanced.xds.GrpcXdsResourceSnapshot;
|
||||
import dev.caskeleton.grpc.advanced.xds.GrpcXdsStartupGuard;
|
||||
import dev.caskeleton.grpc.resilience.GrpcRetryOwner;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GrpcXdsStartupGuardTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-08-30T10:00:00Z");
|
||||
|
||||
private static GrpcXdsProfile profile(String namespace) {
|
||||
return new GrpcXdsProfile(
|
||||
URI.create("xds:///documents"),
|
||||
"classpath:/xds/bootstrap.json",
|
||||
namespace,
|
||||
GrpcRetryOwner.SERVICE_MESH,
|
||||
true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an xDS profile requires an xds target, a namespace, mesh retries and mTLS")
|
||||
void anXdsProfileRequiresItsFourConditions() {
|
||||
assertThat(profile("hyeonworks/documents").target().getScheme()).isEqualTo("xds");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsProfile(
|
||||
URI.create("dns:///documents"),
|
||||
"file:/etc/grpc/bootstrap.json",
|
||||
"ns",
|
||||
GrpcRetryOwner.SERVICE_MESH,
|
||||
true))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsProfile(
|
||||
URI.create("xds:///documents"), " ", "ns", GrpcRetryOwner.SERVICE_MESH, true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("no control plane to ask");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsProfile(
|
||||
URI.create("xds:///documents"),
|
||||
"file:/etc/grpc/bootstrap.json",
|
||||
"ns",
|
||||
GrpcRetryOwner.APPLICATION,
|
||||
true))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("two places");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsProfile(
|
||||
URI.create("xds:///documents"),
|
||||
"file:/etc/grpc/bootstrap.json",
|
||||
"ns",
|
||||
GrpcRetryOwner.SERVICE_MESH,
|
||||
false))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("impersonated");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("xDS needs its capability approved and refuses duplicate retry ownership")
|
||||
void startupRequiresApprovalAndSingleRetryOwner() {
|
||||
GrpcAdvancedFeatureFlags unapproved =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of()).enable(GrpcAdvancedCapability.XDS);
|
||||
GrpcAdvancedFeatureFlags approved =
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of(GrpcAdvancedCapability.XDS))
|
||||
.enable(GrpcAdvancedCapability.XDS);
|
||||
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), unapproved, false))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("production has not approved it"));
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), approved, false))
|
||||
.isEmpty();
|
||||
assertThat(GrpcXdsStartupGuard.startupBlockers(profile("hyeonworks/documents"), approved, true))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("resolution order"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unflagged capability is reported as unflagged rather than unapproved")
|
||||
void anUnflaggedCapabilityIsReportedAsSuch() {
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.startupBlockers(
|
||||
profile("hyeonworks/documents"),
|
||||
GrpcAdvancedFeatureFlags.forProduction(Set.of()),
|
||||
false))
|
||||
.anySatisfy(blocker -> assertThat(blocker).contains("its flag is not set"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the committed bootstrap fixture agrees with the profile it is meant to serve")
|
||||
void theBootstrapFixtureAgreesWithItsProfile() {
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.bootstrapMismatches(
|
||||
profile("hyeonworks/documents"), resource("xds/bootstrap.json")))
|
||||
.isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bootstrap naming another namespace is reported, since nothing else would fail")
|
||||
void aNamespaceMismatchIsReported() {
|
||||
assertThat(
|
||||
GrpcXdsStartupGuard.bootstrapMismatches(
|
||||
profile("hyeonworks/billing"), resource("xds/bootstrap.json")))
|
||||
.anySatisfy(mismatch -> assertThat(mismatch).contains("another team's routing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a bootstrap with an unauthenticated control-plane channel is reported")
|
||||
void anUnauthenticatedControlPlaneIsReported() {
|
||||
String insecure =
|
||||
resource("xds/bootstrap.json")
|
||||
.replace("{ \"type\": \"tls\" }", "{ \"type\": \"insecure\" }");
|
||||
|
||||
assertThat(GrpcXdsStartupGuard.bootstrapMismatches(profile("hyeonworks/documents"), insecure))
|
||||
.anySatisfy(mismatch -> assertThat(mismatch).contains("can be impersonated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a missing bootstrap is refused rather than treated as an empty one")
|
||||
void aMissingBootstrapIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() -> GrpcXdsStartupGuard.bootstrapMismatches(profile("hyeonworks/documents"), " "))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("no control plane to ask");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("last-known-good is bounded, and an empty resource set is not a snapshot")
|
||||
void lastKnownGoodIsBounded() {
|
||||
GrpcXdsFailurePolicy policy = GrpcXdsFailurePolicy.standard();
|
||||
GrpcXdsResourceSnapshot snapshot =
|
||||
new GrpcXdsResourceSnapshot("v7", List.of("documents-cluster"), NOW);
|
||||
|
||||
assertThat(policy.decide(Optional.of(snapshot), NOW.plusSeconds(60)))
|
||||
.isEqualTo(GrpcXdsFailurePolicy.Decision.SERVE_LAST_KNOWN_GOOD);
|
||||
assertThat(policy.decide(Optional.of(snapshot), NOW.plusSeconds(1000)))
|
||||
.isEqualTo(GrpcXdsFailurePolicy.Decision.STALE_BEYOND_BOUND);
|
||||
assertThat(policy.decide(Optional.empty(), NOW))
|
||||
.isEqualTo(GrpcXdsFailurePolicy.Decision.NO_SNAPSHOT_YET);
|
||||
assertThat(snapshot.ageAt(NOW.plusSeconds(60))).isEqualTo(java.time.Duration.ofSeconds(60));
|
||||
assertThatThrownBy(() -> new GrpcXdsResourceSnapshot("v7", List.of(), NOW))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GrpcXdsFailurePolicy(
|
||||
java.time.Duration.ZERO, true, java.time.Duration.ofSeconds(1)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("receiving traffic indefinitely");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("xDS is not part of the Stable discovery support statement")
|
||||
void xdsIsNotStableSupport() {
|
||||
assertThat(GrpcXdsStartupGuard.advertisableAsStableSupport()).isFalse();
|
||||
}
|
||||
|
||||
private static String resource(String path) {
|
||||
try (InputStream stream =
|
||||
GrpcXdsStartupGuardTest.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (stream == null) {
|
||||
throw new IllegalStateException("missing test resource " + path);
|
||||
}
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"_comment": [
|
||||
"An xDS bootstrap fixture. The client reads this file to learn where its control plane is and",
|
||||
"who it claims to be; GrpcXdsProfile validates the deployment settings that must agree with it.",
|
||||
"The two fields the profile actually checks against are server_uri (the control plane must be",
|
||||
"reached over authenticated mTLS) and the node id's namespace (a client that subscribes outside",
|
||||
"its namespace receives another team's routing)."
|
||||
],
|
||||
"xds_servers": [
|
||||
{
|
||||
"server_uri": "xds-control-plane.hyeonworks.internal:15010",
|
||||
"channel_creds": [
|
||||
{ "type": "tls" }
|
||||
],
|
||||
"server_features": ["xds_v3"]
|
||||
}
|
||||
],
|
||||
"node": {
|
||||
"id": "hyeonworks/documents/documents-7f9c4",
|
||||
"cluster": "documents",
|
||||
"metadata": {
|
||||
"NAMESPACE": "hyeonworks/documents"
|
||||
},
|
||||
"locality": {
|
||||
"region": "ap-northeast-2",
|
||||
"zone": "ap-northeast-2a"
|
||||
}
|
||||
},
|
||||
"authorities": {
|
||||
"hyeonworks.internal": {
|
||||
"xds_servers": [
|
||||
{
|
||||
"server_uri": "xds-control-plane.hyeonworks.internal:15010",
|
||||
"channel_creds": [{ "type": "tls" }],
|
||||
"server_features": ["xds_v3"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
apply plugin: 'java-library'
|
||||
|
||||
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
|
||||
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
|
||||
// manual flow-control approval API.
|
||||
dependencies {
|
||||
api project(':grpc:grpc-core-api')
|
||||
api project(':grpc:grpc-policy')
|
||||
api project(':grpc-advanced:grpc-advanced-bootstrap')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.28.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.2.1-android=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath,testCompileClasspath
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.grpc:grpc-api:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
empty=
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user