feat(gateway): per-Route TLS issuer for client-facing HTTP/2 (gRPC) - #222
Conversation
Reencrypt Routes riding the shared default *.apps wildcard are denied
ALPN h2 by the OpenShift router's connection-coalescing protection, so
gRPC (grpcs://) can't negotiate HTTP/2 even with HTTP/2 enabled on the
IngressController. A Route advertises h2 to clients only when it carries
its own spec.tls.certificate.
Add a GATEWAY_ROUTE_TLS_ISSUER env knob: when set (and termination is
reencrypt), the controller annotates the gateway Route with
cert-manager.io/issuer-name + issuer-kind: ClusterIssuer so the
openshift-routes controller injects a per-Route cert, and preserves that
injected spec.tls.{certificate,key} across reconciles (the reconciler's
full Update would otherwise clobber it -- two controllers co-own the
Route). No new RBAC; the annotation and cert-preservation reuse the
existing route.openshift.io/routes access.
- reconciler.go: routeTLSIssuer() + readInjectedRouteCert() helpers,
annotate-and-preserve wiring in reconcileRouteResources
- reconciler_test.go: 4 subtests (annotate on reencrypt, passthrough
ignores issuer, no-issuer no-annotation, injected cert preserved)
- spec + ibm-cluster SKILL.md: document the h2/ALPN prerequisite
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Central YAML (inherited) Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
Amber reviewStatus: Complete VerdictCOMMENT. This is a tight, well-scoped change: the Findings[Major] The helper returns Suggested fix: distinguish Cross-PR coordinationAnother open pull request (#194) reworks gateway deployment from the imperative manifest/ Findings Summary (ordered by severity, highest first)
Convention Checklist
|
jsell-rh
left a comment
There was a problem hiding this comment.
Verdict
COMMENT. This is a tight, well-scoped change: the GATEWAY_ROUTE_TLS_ISSUER knob is off by default, the reencrypt-only gating is correct, and the spec/skill docs and four additive tests match the behavior precisely. One robustness gap in the cert-preservation read path is worth addressing before merge, but it is self-healing and not a blocker.
Findings
[Major] readInjectedRouteCert swallows all GET errors, defeating cert preservation on transient failures — Error Handling / Reconciliation (reconciler.go L2128–2131). Confidence: Medium.
The helper returns "", "" for any GET error, including transient API errors, not only IsNotFound. Because reconcileResource does a full Update (replace) of the Route, a transient GET failure during a reconcile causes the injected spec.tls.certificate/key to be omitted and therefore stripped on that write — which flaps ALPN h2 and can tear down active long-lived gRPC streams. That is precisely the failure the preservation logic (and the 3600s router timeout) exist to prevent, and silently dropping the value on error also runs against the "never silently swallow partial failures" convention.
Suggested fix: distinguish k8serrors.IsNotFound(err) (genuinely no cert yet → proceed) from other errors (log and either fail closed by returning an error from reconcileRouteResources so the reconcile retries, or otherwise avoid clobbering the existing cert). A cleaner alternative is to preserve the cert from the existing object that reconcileResource already fetches, avoiding the extra GET entirely.
Cross-PR coordination
Another open pull request (#194) reworks gateway deployment from the imperative manifest/reconcileRouteResources path to a Helm-chart–based deployment, removing the per-resource Route manifest and restructuring the same reconcile flow this PR extends. That is a competing design for how the tenant openshell-gateway Route is produced: this PR embeds the cert-manager issuer annotation and the injected-cert preservation directly in reconcileRouteResources, whereas under the Helm model that logic would have to be re-expressed through chart values and Helm upgrade semantics (where the full-replace clobber concern changes shape). Maintainers should decide the merge order and where the GATEWAY_ROUTE_TLS_ISSUER behavior lives so it is not silently lost or duplicated when the two directions meet.
Findings Summary (ordered by severity, highest first)
- [Major]
readInjectedRouteCertswallows non-NotFound GET errors, stripping the injected edge cert and flapping ALPNh2/gRPC on transient failures — Error Handling / Reconciliation (L2128–2131)
Convention Checklist
| Convention | Result |
|---|---|
No panic() in production code |
Pass |
Errors wrapped with fmt.Errorf context |
Pass |
errors.IsNotFound handled for 404 scenarios |
Fail |
| No secrets in logs or responses | Pass |
| Input validated | Pass |
| Reconcile pattern used (not create-or-skip) | Pass |
| Proper context propagation | Pass |
| Test Diff Scrutiny (no flipped assertions; tests additive) | Pass |
| Config separate from code (env knob, default off) | Pass |
| Conventional commit message | Pass |
| gvr := schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"} | ||
| existing, err := dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, "openshell-gateway", metav1.GetOptions{}) | ||
| if err != nil { | ||
| return "", "" |
There was a problem hiding this comment.
The read swallows every GET error, not just IsNotFound, and returns "", "". Because reconcileResource replaces the whole Route via Update, a transient GET failure here drops the injected spec.tls.certificate/key on that reconcile — stripping the edge cert, flapping ALPN h2, and potentially killing active gRPC streams (the exact failure this preservation is meant to prevent). Distinguish k8serrors.IsNotFound(err) (no cert yet → proceed) from other errors (log and fail closed / avoid clobbering), or preserve from the existing object that reconcileResource already fetches.
The new per-Route-cert requirement section shifted the GitOps directory-tree illustration (vteam-stage/vteam-uat example paths) down 59 lines, staling its whitelist entry (1105 -> 1164). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Amber reviewStatus: Complete VerdictThis is a focused, well-documented change that adds an optional |
jsell-rh
left a comment
There was a problem hiding this comment.
Verdict
This is a focused, well-documented change that adds an optional GATEWAY_ROUTE_TLS_ISSUER knob so a reencrypt Route gets its own cert-manager-issued certificate and can advertise ALPN h2 for gRPC, and it correctly preserves the co-owned edge cert across full-replace reconciles. The implementation is additive (default-off, ignored for passthrough), the four new subtests are purely additive with no rewritten assertions, and the spec/skill docs are updated in lockstep; I have no blocking findings, only a minor robustness note and a cross-PR coordination item.
Amber Assessment
What the PR does well
- Correctly scopes the new behavior: the cert-manager annotations are only applied when
termination == reencryptand the issuer is set, and the reencrypttlsConfigmap is the one that receives the carried-forwardcertificate/key, so passthrough Routes are untouched. - The two-controller co-ownership problem (control plane owns
termination/destinationCACertificate, openshift-routes owns the edge cert) is called out explicitly and handled by carrying the injected cert forward, avoiding an ALPNh2flap on every reconcile. - Tests are additive and match the review's Test Diff Scrutiny bar: no pre-existing assertion was flipped from accept->reject or optional->required; each new subtest asserts a distinct branch (annotated, ignored-for-passthrough, ignored-when-unset, cert preserved).
- Spec (
global-architecture.spec.md) and skill (ibm-cluster/SKILL.md) are updated with matching scenarios, and the change adds no new control-plane RBAC (reuses existing Route access). - No
panic(), errors wrapped with context,IsNotFound->create/skip preserved inreconcileResource, no secrets logged. The Route certificate/key are carried in-memory only and not logged.
Minor findings
-
[Minor]
readInjectedRouteCert(reconciler.go:2129-2130) collapses all Get errors to"", "", not justIsNotFound. On a transient API error the function reports "no injected cert," and becausereconcileResourcethen does a full replace, the previously injectedcertificate/keyare stripped for that reconcile - the exact ALPNh2/gRPC flap this PR is trying to prevent. It self-heals on the next successful reconcile, but consider distinguishingIsNotFound(expected on first create -> return empty) from other errors (log and treat as "unknown" so the strip is avoided). This also aligns with the project's "never silently swallow partial failures" convention. -
[Minor / informational] There is a small TOCTOU window:
readInjectedRouteCertperforms its own Get (reconciler.go:624) beforereconcileResourceperforms a second Get + Update. If openshift-routes injects the cert between those two reads, this reconcile omits it and strips it once before self-healing next pass. Acceptable given the design, but worth a one-line comment acknowledging the window (or reading the cert from the same objectreconcileResourcefetches).
Cross-PR coordination
There is a material design conflict with the open pull request that adopts the upstream OpenShell Helm chart for gateway deployments (feat(control-plane): adopt upstream OpenShell Helm chart for gateway deployments). That PR removes the large majority of the imperative provisioning in components/control-plane/internal/gateway/reconciler.go (~1000 lines, including the Route path) and its static manifests/gateway/route.yaml, relocating gateway/Route ownership into the Helm chart + values mapping. This PR instead extends the imperative reconcileRouteResources path with the cert-manager annotation and edge-cert preservation logic. These are competing designs for who owns and mints the gateway Route certificate, and they cannot both stand as written.
Maintainers need to decide (a) whether per-Route TLS-issuer ownership lives in the imperative reconciler or is expressed as Helm chart values, and (b) the merge order: if the Helm-adoption PR merges first, this PR's annotation + cert-preservation behavior must be re-expressed in the chart/values path; if this PR merges first, the Helm-adoption PR must carry the cert-manager.io/issuer-* annotation and injected-cert preservation forward into the Helm-based Route so the gRPC/ALPN fix is not lost.
Findings Summary (ordered by severity, highest first):
- [Minor]
readInjectedRouteCertswallows non-IsNotFoundGet errors, allowing a transient error to strip the injected edge cert for one reconcile - Error Handling / Reconciliation (L2129) - [Minor] TOCTOU window between the cert read and the full-replace update can transiently strip the injected cert - Reconciliation (L624)
Convention Checklist:
| Convention | Result |
|---|---|
No panic() in production code |
Pass |
Errors wrapped with fmt.Errorf context |
Pass |
errors.IsNotFound handled for 404 scenarios |
Pass (in reconcileResource) |
| No secrets in logs or responses | Pass |
| Reconcile pattern (update-or-create) used | Pass |
| SecurityContext on pod specs | N/A (no pod spec changed) |
| Test Diff Scrutiny (no flipped assertions) | Pass |
| Conventional commit messages | Pass |
| Config separate from code (env-driven knob) | Pass |
| gvr := schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"} | ||
| existing, err := dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, "openshell-gateway", metav1.GetOptions{}) | ||
| if err != nil { | ||
| return "", "" |
There was a problem hiding this comment.
[Minor] This collapses all Get errors to "", "", not just IsNotFound. On a transient API error the caller sees "no injected cert", and since reconcileResource then does a full replace, the previously injected certificate/key are stripped for this reconcile - the exact ALPN h2/gRPC flap the PR aims to avoid. It self-heals next pass, but consider distinguishing IsNotFound (expected first-create -> return empty) from other errors (log + treat as unknown so the strip is skipped). Also aligns with the "never silently swallow partial failures" convention. Confidence: Medium.
| // co-owns this Route (we own termination + destinationCACertificate, it | ||
| // owns the edge cert), so carry forward any certificate/key it has already | ||
| // injected -- otherwise each reconcile strips the cert and flaps h2. | ||
| if cert, key := readInjectedRouteCert(ctx, dynamicClient, namespace); cert != "" { |
There was a problem hiding this comment.
[Minor / informational] TOCTOU window: this Get runs before reconcileResource does its own Get + full-replace Update. If openshift-routes injects the cert between the two reads, this reconcile omits and strips it once before self-healing. Acceptable given the co-ownership design, but worth a comment noting the window - or read the cert from the same object reconcileResource fetches. Confidence: Medium.

Problem
The reencrypt Route fix (#221) eliminates the CLI's
UnknownIssuererror — the OpenShift router terminates with the trusted Let's Encrypt*.appswildcard — but the openshell CLI still can't connect over gRPC.Root cause: OpenShift edge/reencrypt Routes advertise client-facing ALPN
h2only when the Route carries its ownspec.tls.certificate. Routes riding the shared default*.appswildcard are deliberately deniedh2(the router's cross-route connection-coalescing protection). gRPC (grpcs://) requires HTTP/2-over-TLS via ALPN, so a default-cert reencrypt Route can't serve gRPC even withROUTER_DISABLE_HTTP2=falseon the IngressController.Confirmed empirically (openssl
-alpn h2→ "No ALPN negotiated"; pythonssl.set_alpn_protocols→None; curl--http2→ HTTP/1.1), and via a reversible test: stamping a per-Route cert flips all three to h2 +Verify return code: 0.Change
New
GATEWAY_ROUTE_TLS_ISSUERenv knob. When set and termination isreencrypt, the controller:cert-manager.io/issuer-name: <issuer>+cert-manager.io/issuer-kind: ClusterIssuer, so the openshift-routes controller requests a cert-manager cert and injects it intoRoute.spec.tls.{certificate,key}.Updatewould otherwise clobber the injected fields every reconcile — the two controllers co-own the Route.No new RBAC: the annotation and cert-preservation reuse the existing
route.openshift.io/routesaccess. When the knob is unset the behavior is unchanged.Tests
reconciler_test.go— 4 new subtests:go build✅ ·go test ./internal/gateway/✅ ·go vet✅Docs
specs/platform/global-architecture.spec.md— clarifies HTTP/2-on-IngressController is necessary-but-not-sufficient; adds a per-gateway-cert requirement with scenarios.skills/deploy/ibm-cluster/SKILL.md— documents the h2/ALPN prerequisite and theGATEWAY_ROUTE_TLS_ISSUERknob.Cluster prerequisites
The ClusterIssuer (
letsencrypt-http01) and theopenshift-routesinjector must exist on the cluster — provisioned via the companion hypershell-gitops PR.🤖 Generated with Claude Code