Skip to content

[HYPERSHELL-240] feat(ci): ephemeral PR environments for OpenShift e2e - #267

Merged
squizzi merged 35 commits into
mainfrom
squizzi/reconcile-ephemeral-openshift-e2e-ci
Sep 15, 2026
Merged

squizzi merged 35 commits into
mainfrom
squizzi/reconcile-ephemeral-openshift-e2e-ci

Conversation

@squizzi

@squizzi squizzi commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

What

Implement ephemeral OpenShift environments for every origin pull request, enabling live testing against the PR's built container images with full e2e validation.

Highlights

  • Per-PR namespace lifecycle: Continuous deployment (opened/reopened/synchronize/closed), automatic rollout to head commit, timebox + out-of-band reaper for cleanup
  • GitHub-brokered access: OAuth IdP in Keycloak realm, org gate + allowlist enforcement (handoff), per-PR comment with access details
  • Automated e2e testing: Shared harness (tests/e2e/e2e-openshell.sh) with E2E_INFRA_DRIVER=openshift and E2E_OIDC_GRANT=client_credentials; token auth via hypershell-e2e confidential client (admin path: client-credentials; developer path: Keycloak token-exchange impersonation)
  • Image gating + swap: Konflux digest-pinning; per-PR builds waited on, then injected by manifest digest to prevent mutable-tag mutations
  • Declarative Keycloak realm: GitHub IdP, roles, e2e client secret, and org attributes live in realm ConfigMap with environment-gated defaults; absent on Kind/local/stage, so those realms remain inert
  • Out-of-band reaper: CronJob-driven cleanup (expires-at annotation) survives CI runs; actively worked PRs stay timebox-refreshed and are never reaped mid-flight

Scope

Implemented (W1-W4):

  • Grant-agnostic driver auth: password grant (Kind/manual), client-credentials + token-exchange (GitHub-brokered PR)
  • Declarative realm: GitHub IdP (disabled by default), hypershell-e2e client, org/allowlist attributes, hardcoded-role mappers
  • PR-environment workflow: identity, deploy/reconcile, image swap, e2e, comment, trust boundary (origin-only)
  • Namespace timebox + reaper: expires-at annotation, CronJob-based cleanup, reaper predicates unit-tested

Handoff (requires human provisioning before merge):

  1. GitHub Actions secrets/vars: OPENSHIFT_PR_ENV_SERVER_URL, OPENSHIFT_PR_ENV_TOKEN, OAuth client id/secret/callback URL, org gate + allowlist
  2. CI service account: Create an SA on the target cluster with rights to apply the overlay and patch namespaces/RBAC
  3. GitHub OAuth App: Register with stable cluster callback URL
  4. Deploy the reaper: kustomize build --load-restrictor=LoadRestrictionsNone deploy/e2e/reaper | oc apply -f -
  5. Keycloak org/allowlist enforcement: Provide an SPI/extension for the first-broker-login authenticator (realm data wires up to that point)
  6. Kind smoke test: Verify ${VAR:default} placeholders resolve correctly on Kind Keycloak boot (blast radius = realm import)

See skills/RECONCILE.md (ephemeral-pr-environments.spec.md section) for the full handoff checklist.

@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Advanced

Run ID: 26f10ee5-6d56-4f1d-abfb-d10b11f745ae

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@amber-review-bot

amber-review-bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: changes requested

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

HyperShell environment updated to commit 2d45f33

This pull request has a live ephemeral OpenShift environment running commit
2d45f33.

Fact Value
Namespaces Platform: hypershell-ci-pr-267 Keycloak: hypershell-ci-pr-267-keycloak
OpenShift console https://console-openshift-console.apps.rosa.hysh-aws-01.c6uk.p3.openshiftapps.com
API https://hypershell-api-hypershell-ci-pr-267.apps.rosa.hysh-aws-01.c6uk.p3.openshiftapps.com
Web console https://hypershell-web-console-hypershell-ci-pr-267.apps.rosa.hysh-aws-01.c6uk.p3.openshiftapps.com

Log in through the web console with your GitHub account (you must be a member of
the configured organization or on its allowlist). The environment is time-boxed
and refreshed on every new commit.

CLI access
oc login --server=https://api.hysh-aws-01.c6uk.p3.openshiftapps.com:443 --web

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review

Status: Failed

The review stopped at 2026-09-10T22:52:38Z. A later job can retry this commit.

@amber-review-bot

amber-review-bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@squizzi
squizzi force-pushed the squizzi/reconcile-ephemeral-openshift-e2e-ci branch from 4579607 to 4bcbf9f Compare September 11, 2026 18:35
@amber-review-bot

amber-review-bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

amber-review-bot

This comment was marked as outdated.

@amber-review-bot

amber-review-bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

squizzi and others added 20 commits September 15, 2026 11:28
Keycloak 26 standard token-exchange rejects requested_subject, which
broke OpenShift e2e impersonation. Enable the legacy token-exchange
feature, turn off the standard-exchange client switch, and send a
client-credentials subject_token before impersonating.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>
Area 4 failed with "Client not allowed to exchange" because Keycloak 26
legacy token-exchange needs FGAP v1 permissions on the target client.
Enable admin-fine-grained-authz:v1 and have the control plane grant
hypershell-e2e exchange onto gateway and frontend clients.

Also register the GitHub hardcoded-role mapper under the Keycloak 26
provider id, store the broker GitHub token for the org gate, and stop
printing admin/admin in the OpenShift banner.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>
The BFF org gate denied openshift-online members because Keycloak 26
serves the stored GitHub token as form-urlencoded with a JSON content
type, and hypershell-frontend omitted the roles scope so the access
token never carried broker.read-token. Parse form-encoded broker
bodies regardless of content type, include roles on the frontend
client, and store GitHub tokens as JSON.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Public membership is visible without an org-approved OAuth App, so the
gate checks /orgs/{org}/public_members/{user} first. Private membership
uses /user/memberships/orgs/{org} and still fail-closes on 403. Helmet
hashes the denied-page inline styles so CSP style-src 'self' does not
blank the page.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>
Kind already waits on plan-images and honors should_run. OpenShift ran
on every origin PR, so docs-only changes still waited on Deploy PR
environment and could fail Tests CI Gate. Gate the suite the same way;
PR Environment deploy stays unconditional.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>
Gateway provisioning drove gateways to Failed on every e2e job because
EnsureE2ETokenExchange could not grant token-exchange against a fresh
per-PR Keycloak realm, and the create-client path rolls the client back
on that failure, looping create -> 404 -> delete -> retry.

Two coupled issues:

- findE2EClientPolicy treated Keycloak's policy search-by-name 404 as
  fatal. Keycloak returns 404 (not an empty 200/204) when no policy of
  that name exists yet, so the first reconcile always failed. Treat 404
  as "not found" and let the caller create the policy.
- The policy search/create ran before any client's management
  permissions were enabled. Enabling FGAP is what lazily initializes
  realm-management's authorization resource server, without which the
  policy endpoints 404 outright. Enable permissions on the target (and
  frontend) client first, then search/create/attach.

Split attachTokenExchangePolicy into enableTokenExchangePermissions plus
attachPolicyToPermission so enabling happens once, up front. The test
fake now returns 404 for a missing policy to reproduce real Keycloak and
lock in the regression.

Assisted-by: Claude Opus 4.8
Assisted-by: Cursor Grok 4.6
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Ubuntu mawk does not treat escaped parentheses the same way macOS awk
does, so the seed_via_api range never matched and CI failed the
json_named_id assertion. Switch the checks to fixed-string grep.

Assisted-by: Cursor Grok 4.6
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
PR environments can lose API seed rows when openshift-up recreates the
database and fails before openshift-seed. Discovery now explains empty
vs Error list bodies, pins local-openshift/dev-release, and re-runs
platform seed once when both lists are empty.

Developer and platform-admin gateway create checks follow the API
server's RBAC_DEFAULT_ROLES: Kind's unset default still expects 201;
OpenShift's empty value expects 403.

openshift-down and the PR-env reaper also delete control-plane-managed
sibling namespaces labeled with the platform instance so leftover
openshell-* projects do not outlive the environment.

Assisted-by: Cursor Grok 4.6
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
oc set env without -c only patches spec.containers, so
HYPERSHELL_CONSOLE_HOST never reached render-realm-config. A later
start-dev --import-realm then restored localhost URIs and Keycloak
rejected the BFF callback. Stamp the host with a strategic-merge patch
of the init container; a full-object replace races Deployment status.

Skip the oauth-secret recycle when the annotation already matches. A
later reconcile also keeps the existing PR access-fact table instead of
replacing it with the first-deploy placeholder.

Assisted-by: Cursor Grok 4.6
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
TestListenAndServeServesPlaintextProvisionerCalls dials the server from
a goroutine racing ListenAndServe's own net.Listen call, so gRPC's
default dial can fail fast on "connection refused" before the listener
is bound under CI scheduling delay. Add grpc.WaitForReady(true) so the
call retries until callCtx's deadline instead.

Unrelated to the Keycloak realm fix in daf7b8c; this test failure was
pre-existing and reproduced 10/10 flaky under load, 0/20 after the fix.

Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Keycloak runs start-dev on in-memory H2 with no persistent volume, so
any Keycloak pod restart discards dev-gateway's dynamically-provisioned
OIDC client while its row survives untouched in PostgreSQL. Reusing
that stale dev-gateway permanently strands it in status "Keycloak
client is missing": the GatewayReconciler deliberately never
auto-recreates a missing client, since doing so without also restoring
RoleBindings and console mappers would leave it silently
half-provisioned.

Until Keycloak has durable storage across restarts, seed_via_api now
deletes an existing dev-gateway and creates a fresh one on every run
instead of reusing it. Other named seed resources (ManagedCluster,
GatewayRelease, ManagedDatabase) are unaffected and continue to be
reused.

Updates openshift-development.spec.md and
ephemeral-pr-environments.spec.md to reflect the dev-gateway exception,
and adds shell assertions in lib_test.sh.

Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Amber requested changes because hypershell-e2e shipped to every imported
realm and token-exchange ran on production gateway reconcile. Omit that
client, its service account, and the impersonation mapping unless
enabled, and skip EnsureE2ETokenExchange unless the client is enabled.
Admit Keycloak sessions with no GitHub broker identity when the org gate
is on so seeded password users still work. Parse oc image info JSON
instead of jsonpath for the digest fallback.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>
The org gate treated a 403 (never linked to GitHub, e.g. seeded
password users) and a 404 (linked to GitHub but Keycloak has no
stored token) from Keycloak's broker token endpoint as the same
"not linked" case and admitted both. A 404 actually indicates a
verifiable GitHub identity we failed to verify, so treating it
like an unlinked account let a linked-but-unreadable session skip
the org check entirely.

404 now throws instead of returning "not linked", routing through
the existing lookup-error handling so the session is denied and
the failure is logged. 403 keeps admitting, since that only occurs
for accounts that never went through the GitHub broker.

Addresses review feedback on PR #267.

Assisted-by: Claude Sonnet 5
Web console quality gates flagged a line over the printWidth in the
test file added by the previous fail-closed-on-404 commit.

Assisted-by: Claude Sonnet 5
CI's OpenShift e2e run failed acquiring a per-gateway OIDC token
with the openshell-admin role: the controller assigned the role
at 16:15:56, two seconds after the 120s poll deadline expired at
16:15:54. The controller pod had been recycled mid-run and needed
to reconnect its watch streams and replay its reconcile queue
before it got to the role grant, which ordinary reconcile latency
on a shared ROSA cluster only barely fits inside 120s.

OpenShift reconciles measurably slower than Kind across the board,
so raise every short reconcile-wait default (role-sync polling,
controller rollout waits, gateway provisioning, sandbox creation,
namespace GC, runtime version) from 90-180s to a uniform 300s.
These are ceilings, not sleeps, so passing runs are unaffected.

Assisted-by: Claude Sonnet 5
You were right that both e2e failures were races, not flakes or
stale deploys. This is the second one: UserDao.Upsert did a SELECT
to check for an existing username, then Create or Save depending on
the result. Two concurrent requests JIT-provisioning the same
brand-new identity (e.g. the developer user's first-ever call to
the HyperShell API) could both miss the SELECT, both attempt
Create, and the loser would hit the username unique-constraint
violation. UserProvisioningMiddleware treats that error as "leave
userID unset" rather than retrying, so the losing request's RBAC
check saw an empty userID and 403'd -- exactly the "Developer user:
gateway list returned HTTP 403" failure, and why a solo manual
retry always passed (no concurrent racer, or the row already
existed from a prior run).

Replaced the SELECT-then-Create/Save with a single INSERT ... ON
CONFLICT (username) DO UPDATE, which Postgres resolves atomically.
GORM does not reliably scan the post-conflict row back into a
struct with a client-generated (non-autoincrement) primary key, so
the loser could still get back its own locally-generated KSUID
instead of the row that actually persisted; re-read by username
after the upsert so every caller resolves to the one canonical row.

Added TestUserUpsert_ConcurrentFirstTimeProvisioning, which fires 8
concurrent upserts for the same new username and asserts they all
succeed and resolve to the same ID. It reproduced the bug reliably
against the old implementation and passes against the fix.

Assisted-by: Claude Sonnet 5
@squizzi
squizzi force-pushed the squizzi/reconcile-ephemeral-openshift-e2e-ci branch from 8d72c9b to 08b1a60 Compare September 15, 2026 18:30
CI's golangci-lint flagged SA5011 on a combined "verbosity == nil ||
vmodule == nil" check guarding two later pointer dereferences:
staticcheck's flow analysis doesn't reliably carry non-nil for both
pointers past a single ||-joined t.Fatal guard, only past
individual ones. Splitting into two separate nil checks (each with
its own t.Fatal) resolves the false positive with no behavior
change.

Assisted-by: Claude Sonnet 5
amber-review-bot

This comment was marked as outdated.

The previous fix (splitting the compound nil check into two ifs)
still tripped SA5011 in CI on the exact pinned golangci-lint
version (v2.12.2) even though it was clean locally against the
same version -- staticcheck's flow analysis apparently still
doesn't treat two sequential single-pointer nil-check-then-Fatal
blocks in the same function as clearing both pointers before their
later dereferences.

Moved the nil-check-and-fail into its own mustLookupFlag helper, so
each pointer's nil check and only use of that pointer sit in a
function boundary of their own. This is the standard, more robust
pattern for this SA5011 false-positive class. Verified against the
CI-pinned golangci-lint v2.12.2 binary directly (not just whatever
version happened to be installed locally), including a linux/amd64
cross-compiled run to rule out a platform-specific difference.

Assisted-by: Claude Sonnet 5

@amber-review-bot amber-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

COMMENT - The only change since the last review (db1b033a -> 2d45f33) is a test-only refactor in components/api-server/cmd/hypershell/main_test.go: the two inline flag.Lookup nil checks are factored into a mustLookupFlag(t, name) helper to satisfy staticcheck SA5011. The guarantee is unchanged (a missing v/vmodule flag still t.Fatalfs), so no prior blocker/major is affected; all previously addressed findings still hold at this head and two items need cross-PR maintainer coordination.

Summary

This head adds only mustLookupFlag in main_test.go; it is a staticcheck-driven extraction of an existing nil check, not a weakened assertion (a Test Diff Scrutiny pass). The substantive PR (ephemeral OpenShift PR environments, GitHub IdP + BFF org gate, e2e token-exchange, reaper CronJob, race-free user upsert) was reviewed at db1b033a and re-verified unchanged here.

Cross-PR coordination

Two material coordination items require a maintainer decision.

  • Contradictory user-inventory authorization contract (PR #279). This PR and #279 both edit components/api-server/pkg/rbac/authorization.go, authorization_test.go, and plugins/users/integration_test.go, and they encode incompatible authorization models. This PR keeps TestUserList_AllowedForHypershellAdmin asserting that the legacy hypershell-admins realm role lists the user inventory (200). #279 renames that exact test to TestUserList_ForbiddenForHypershellAdminWithoutPlatformAdmin (403) and re-points the other user-list tests to roles.RolePlatformAdmin, deliberately requiring effective platform:admin and dropping legacy hypershell-admins alone. Independently, #279 changes the NewRBACAuthzMiddleware(lookup, config) signature to add an activityRecorder argument, while this PR adds a new test (TestAuthorizeApiAllowsUserWithNoBindingsToListGateways) that calls the two-argument form and adds a new gateways-collection-GET allow rule to the same isAuthorized function. This is a design decision (does legacy hypershell-admins alone still authorize the user inventory, and what is the middleware constructor signature?), not a text merge - whichever lands second must not silently re-loosen or re-tighten the contract or leave the constructor/tests inconsistent. Maintainers must pick the model and reconcile the shared files/tests. (Both PRs also make the same trivial staticcheck split in cmd/hypershell/main_test.go; noted only so the duplicate is not mistaken for a semantic clash.)

  • Management API JWT audience enforcement (PR #182). #182 flips the OpenShift API-server deployment to validate iss and require aud=hypershell-frontend on management calls (adds JWT_ISSUER/--jwt-audience to deploy/openshift/kustomization.yaml) and rejects tokens not minted for the management audience. This PR's OpenShift PR-env authenticates via the hypershell-e2e client_credentials grant and relies on control-plane EnsureE2ETokenExchange to mint tokens onto the hypershell-frontend audience for management calls. They agree on the audience name, so this is an ordering/assumption dependency rather than a contradiction: whoever merges second must confirm (a) this PR's PR-env API-server carries the same --jwt-audience/--jwt-issuer values #182 introduces, and (b) #182's reject-non-management-audience rule does not reject the exchanged token this PR's e2e flow depends on. Confirm the shared audience/issuer contract and merge order explicitly rather than by coincidence.

Previous concerns

All prior Amber findings were re-checked against the code at 2d45f33. Every file behind a previously addressed finding is unchanged from the last-reviewed db1b033a (only main_test.go moved), so the earlier fixes stand.

  • [Addressed] Per-run e2e secret desync from imported realm (r3982129640) - workflow reuses the existing e2e-client-secret, generating only on first boot.
  • [Addressed] Digest pinning silently degraded to a mutable tag / skopeo absent / registry auth (r3983888857) - resolve_by_digest hard-fails for built on-pr-<sha> images; only baseline tags may fall back per the spec last-resort clause (scripts/ci/swap-openshift-images-by-digest.sh).
  • [Addressed] Privileged hypershell-e2e client + well-known default secret enabled everywhere (r3984253723) - "enabled": "${HYPERSHELL_E2E_CLIENT_ENABLED:false}" with an empty default secret (deploy/base/keycloak/keycloak.yaml L394).
  • [Addressed] e2e client/service-account/impersonation shipped to non-PR realms (r4006939520) - render-realm-config.py strips them unless HYPERSHELL_E2E_CLIENT_ENABLED=true.
  • [Addressed] Token-exchange skip keyed on presence, not enablement (r4006939532) - EnsureE2ETokenExchange returns early on if !e2e.Enabled (components/control-plane/internal/keycloak/client.go L449).
  • [Addressed] addReadTokenRoleOnCreate missing -> broker-token 403 lockout (r3992501011) - set true on the GitHub IdP (keycloak.yaml L765).
  • [Addressed] Init-container / reaper hardening (seccomp + requests/limits) (r3983888858, r3984253728) - runAsNonRoot + seccompProfile: RuntimeDefault and requests/limits present.
  • [Addressed] Secret to GITHUB_OUTPUT, dead plan-images early-exit, oc login targeting the app Route, (admin/admin) hint, broker-token parser, read:org comment (r3982252488, r3983570371, r3983708258, r3993396469, r3993396477, r3992501031) - all confirmed fixed in the current tree.
  • [Addressed] Shell unit tests not wired to a CI gate (r3982252493) - resolved on main: make ci-test -> scripts/run-shell-unit-tests.sh auto-discovers *_test.sh, invoked by .github/workflows/unit-tests.yml; answered in the existing thread.
  • [Still present - accepted design, on record] Hardcoded-role IdP mappers grant platform:admin + gateway:creator to every brokered GitHub login; org/allowlist enforcement lives only in the web-console BFF (r3993624648). A token minted directly from Keycloak bypasses the BFF gate. Accepted by the maintainer as a prod-aligned trade-off for the disposable PR realm and acknowledged in the existing thread; keep Keycloak-layer enforcement a hard prerequisite before this IdP is reused on any shared/long-lived realm. Not blocking.
  • [Still present - by design for ephemeral realms] Fail-open for sessions with no readable GitHub identity (r4016758519). A 403 from /broker/github/token admits the session; correct for the ephemeral realm (fresh H2 users + addReadTokenRoleOnCreate), residual risk only on a re-used realm. Recorded.
  • [Still present - accepted trade-off] Reaper cluster-wide delete (r3993736446). The guard is the unit-tested pr_env_is_reapable predicate; treat predicate/reserved-name edits as security-critical.
  • [Still present - minor UX trade-off] BFF org gate fail-closes on transient GitHub failures (r3992110589). Fail-closed is the correct default; denied-legit-member on a transient blip is the acknowledged residual.
  • [Cannot fully verify from static review] Python builder image on the boot-critical realm-render path (r3992501015). Digest-pinned and consistent with the repo's other Dockerfiles; the Kind Keycloak-boot smoke test remains the gate.
  • [Still present - Minor, low confidence] Upsert confirmation re-read returns without MarkForRollback (r4019249781). dao.go is unchanged at this head: the Take re-read (L101-102) still returns its error without db.MarkForRollback(ctx, err), so a committed write can diverge from an error response. Self-healing on retry given the idempotent upsert; see the existing thread.

Findings Summary

No new finding at 2d45f33 (the sole change is the mustLookupFlag test-helper extraction, which preserves the assertion).

  1. [Minor] Upsert re-read failure after a successful INSERT ... ON CONFLICT returns the error without db.MarkForRollback(ctx, err), so a committed write and the error response can diverge - Data Integrity (plugins/users/dao.go L101-102). Low confidence; self-healing on retry. Tracked in the existing thread (r4019249781).

Prior blockers/majors are all addressed; remaining items are accepted-by-design residuals already on record with the maintainer.

Convention Checklist

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
No secrets in logs / step outputs Pass
SecurityContext + resources on pod specs Pass
Reconcile / upsert is race-free (atomic ON CONFLICT) Pass
Image references digest-pinned / fail-closed swap Pass
Test diff scrutiny (helper extraction preserves the nil-flag guarantee) Pass

@squizzi
squizzi added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit ca534e1 Sep 15, 2026
34 checks passed
@squizzi
squizzi deleted the squizzi/reconcile-ephemeral-openshift-e2e-ci branch September 15, 2026 19:38
markturansky pushed a commit that referenced this pull request Sep 15, 2026
#267)

* feat(ci): add ephemeral PR environments for OpenShift e2e

Deploy a full HyperShell stack per pull request on the shared OpenShift
e2e cluster so PRs can be exercised end to end before merge, instead of
relying solely on Kind. Adds the pr-environment workflow, supporting CI
scripts for provisioning/stamping/commenting on PR environments and
swapping in PR-built image digests, and a namespace reaper CronJob plus
RBAC that garbage-collects expired PR environments on a schedule.

The reaper's ose-cli image is pinned to a digest (rather than :latest)
to satisfy the repo's dependency-pinning policy enforced by `make
check`.

Assisted-by: Claude Sonnet 5

* fix(ci): render realm placeholders in an init container, not Keycloak

Keycloak's declarative --import-realm does not perform ${VAR:default}
substitution on imported realm JSON (upstream keycloak#20199,
keycloak#12069). The ephemeral PR environment realm relied on that
substitution for six placeholders, including a boolean field
(identityProviders[0].enabled). Since Keycloak never resolves it, the
literal string "${PR_ENV_GITHUB_IDP_ENABLED:false}" gets fed to a
boolean field during JSON deserialization, which is a fatal type
mismatch that crashes the JVM on every boot (CrashLoopBackOff).

Add a render-realm-config init container that reads the keycloak-realm
ConfigMap, resolves the placeholders itself from the same
Secret-backed env vars (JSON-escaping string values, emitting a real
true/false for the boolean), validates the result with json.loads,
and writes it to an emptyDir that the keycloak container imports from
instead of mounting the ConfigMap directly. The GitHub OAuth env vars
move from the keycloak container to the init container, since that's
where the substitution now happens.

Assisted-by: Claude Sonnet 5

* fix(ci): shorten hypershell-e2e client description under DB column limit

Keycloak's CLIENT.DESCRIPTION column is VARCHAR(255). The
hypershell-e2e client's description was 531 characters, so the
realm import's UPDATE CLIENT statement failed with "Value too long
for column", crashing Keycloak's boot right after the previous
placeholder-substitution fix started working (confirmed via the
container's boot log on the live PR-267 OpenShift environment).

Trimmed the description to 86 characters and moved the full
rationale into a YAML comment above the ConfigMap, since comments
there are not persisted to Keycloak's database and have no length
limit. Validated end to end with `make openshift-up` against the
hypershell-ci-pr-267 namespace: Keycloak now boots clean and the
GitHub identity provider is enabled with real client credentials.

Assisted-by: Claude Sonnet 5

* fix(ci): extend Keycloak's OpenShift rollout timeout to 10m

The shared e2e cluster is capacity-constrained: bringing up a new
Keycloak pod (e.g. after a ConfigMap or image change) can trigger the
cluster autoscaler to provision a whole new node, which alone can
take several minutes before the pod is even Scheduled. The previous
180s (3m) rollout timeout, shared with every other OpenShift
deployment, was too tight for that and made `make openshift-up` fail
even though the pod was healthy and just waiting on capacity.

Give wait_for_keycloak its own 600s (10m) timeout instead of raising
the shared default, since api-server/controller/web-console/postgres
don't hit this autoscaler path. Kind's Keycloak wait stays at its
existing 180s/120s timeouts (scripts/kind/up.sh) since Kind doesn't
have this cluster-capacity failure mode.

Assisted-by: Claude Sonnet 5

* fix(ci): stop using admin/admin test users in GitHub-brokered environments

Ephemeral PR environments broker interactive login to GitHub and have
no password grant for real users (ephemeral-pr-environments.spec.md),
but make openshift-up still seeded platform resources by logging in
as the realm's admin/admin test user via a password grant, and its
completion banner still advertised admin/admin and developer/developer
as if they were the intended login path. Both contradict the PR's own
design and would mislead anyone reading the banner on a PR environment.

Add github_idp_enabled(), which checks the hypershell-github-oauth
Secret's idp-enabled key to detect a brokered environment. When
brokered, seed_via_api() now authenticates as the hypershell-e2e
service account via client_credentials (it already holds
platform:admin + gateway:creator for this purpose) instead of a
password grant against admin/admin, and the banner prints that login
is GitHub-brokered instead of listing test user credentials. Standard
(non-brokered) openshift-up usage is unaffected since it has no
hypershell-github-oauth Secret to detect.

Validated live against the GitHub-brokered hypershell-ci-pr-267
environment: seeding succeeded via the hypershell-e2e client
credentials grant with no password grant attempted, and the banner
correctly printed the GitHub-brokered message.

Assisted-by: Claude Sonnet 5

* fix(ci): gate OpenShift e2e behind PR Environment deploy, drop token comment

The OpenShift e2e suite ran inline inside the "PR Environment" workflow,
so a deploy failure and an e2e failure were indistinguishable as a single
check. Move it to its own "E2E OpenShift" job in e2e.yml, triggered via
workflow_run once "PR Environment" completes, so the two failure modes
surface as distinct checks. The new job re-verifies the deploy job itself
succeeded (a fork PR's run reports overall success even though its deploy
is skipped) and that the PR is still open before testing, since the
environment can be superseded or torn down between the deploy run
finishing and this job starting.

Also switch the PR access comment's CLI snippet from a redacted
`--token=<redacted>` template to `oc login --web`, so OpenShift drives the
developer's browser through the same GitHub-gated OAuth flow the web
console uses instead of requiring a separate out-of-band credential
delivery step.

Assisted-by: Claude Sonnet 5

* fix(ci): reskin the GitHub social login button on the Keycloak theme

The GitHub "sign in" button on the login page rendered as a raw, unstyled
PatternFly secondary button: full width, a heavy blue outline, and an
oversized icon, because its <svg> ships with no width/height attributes
and browsers fall back to a 300x150 default replaced-element size.

Reskin it to match PatternFly's tertiary button (transparent fill,
rounded pill border) and size the icon explicitly. Getting this right
required working around three upstream quirks, each verified against
Keycloak 26.7.2's actual login theme source rather than guessed:

- Keycloak's base theme applies a CSS filter (invert/sepia/hue-rotate) to
  recolor the icon's black fill into Keycloak's own blue; it repaints
  pixels after fill, so it fights any custom icon color and has to be
  cancelled explicitly.
- PatternFly's secondary/tertiary buttons paint their border via a
  ::after pseudo-element with its own separate border-radius variable
  (defaulting to a small radius), not the button box's own border-radius,
  so rounding the box alone left the visible border square.
- The provider name ships with PatternFly's pf-v5-u-m-auto utility
  (margin: auto !important), which absorbs the button's flex free space
  on its own and pins the icon to the far left regardless of
  justify-content.

Also zero out a doubled bottom padding: the provider list reuses the
.pf-v5-c-login__main-body class for its horizontal edge-to-edge padding,
but that class also carries the same large PaddingBottom as the real
card body wrapping it, leaving a large empty gap under the button.

Assisted-by: Claude Sonnet 5

* fix(ci): fail-closed GitHub IdP, pin built images by digest, harden Keycloak

Leave the GitHub identity provider disabled until the org-gate authenticator
exists, refuse on-pr image swaps that cannot resolve a digest, and match the
Keycloak SecurityContext to the restricted container convention.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): close remaining Amber findings except the CI shell-test gate

Reuse the first-boot hypershell-e2e secret, disable that admin client outside
PR environments, inspect Konflux with the cluster pull secret, and correct
the access-comment oc login URL. Reaper RBAC documents its cluster-wide
delete blast radius and the CronJob now has resource bounds.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): re-enable the GitHub IdP when OAuth secrets are present

PR environments already fail closed if the OAuth App is unset. Leaving
idp-enabled=false hid Login with GitHub after realm import. Recycle
Keycloak when the oauth secret hash changes so an already-booted realm
re-imports the enabled identity provider.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ci): gate PR-env GitHub login in the BFF

Check org membership and allowlist after the OIDC callback so denied
users never get a HyperShell session, and thus no API bearer. Kind
stays ungated. Post the access comment as a deploying placeholder
first so it stays near the top of the PR timeline.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* ci: move OpenShift e2e under Tests / E2E / OpenShift

The suite was nested under PR Environment via workflow_call because
GitHub needs: cannot cross workflows. Polling Deploy PR environment
from e2e.yml is enough, so deploy and e2e stay distinct checks
without burying the suite under the deploy workflow.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): persist Keycloak console redirects and split skipped jobs

Keycloak pod recycle re-imported localhost-only frontend redirect URIs,
so the BFF callback failed with Invalid parameter: redirect_uri. Bake
the console host into realm render, and mint per-gateway admin tokens
via token-exchange instead of client_credentials.

Move push-to-main OpenShift e2e and PR-env teardown into dedicated
workflows so those jobs do not appear as skipped checks on PRs.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): use legacy token-exchange for per-gateway e2e tokens

Keycloak 26 standard token-exchange rejects requested_subject, which
broke OpenShift e2e impersonation. Enable the legacy token-exchange
feature, turn off the standard-exchange client switch, and send a
client-credentials subject_token before impersonating.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): grant Keycloak v1 token-exchange for OpenShift e2e

Area 4 failed with "Client not allowed to exchange" because Keycloak 26
legacy token-exchange needs FGAP v1 permissions on the target client.
Enable admin-fine-grained-authz:v1 and have the control plane grant
hypershell-e2e exchange onto gateway and frontend clients.

Also register the GitHub hardcoded-role mapper under the Keycloak 26
provider id, store the broker GitHub token for the org gate, and stop
printing admin/admin in the OpenShift banner.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): admit org members through the GitHub broker token

The BFF org gate denied openshift-online members because Keycloak 26
serves the stored GitHub token as form-urlencoded with a JSON content
type, and hypershell-frontend omitted the roles scope so the access
token never carried broker.read-token. Parse form-encoded broker
bodies regardless of content type, include roles on the frontend
client, and store GitHub tokens as JSON.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(keycloak): pad the logout button on theme

Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>

* fix(web-console): admit public GitHub org members without a broker token

Public membership is visible without an org-approved OAuth App, so the
gate checks /orgs/{org}/public_members/{user} first. Private membership
uses /user/memberships/orgs/{org} and still fail-closes on 403. Helmet
hashes the denied-page inline styles so CSP style-src 'self' does not
blank the page.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): skip OpenShift e2e when plan-images says not to run

Kind already waits on plan-images and honors should_run. OpenShift ran
on every origin PR, so docs-only changes still waited on Deploy PR
environment and could fail Tests CI Gate. Gate the suite the same way;
PR Environment deploy stays unconditional.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(keycloak): create e2e token-exchange policy on a fresh realm

Gateway provisioning drove gateways to Failed on every e2e job because
EnsureE2ETokenExchange could not grant token-exchange against a fresh
per-PR Keycloak realm, and the create-client path rolls the client back
on that failure, looping create -> 404 -> delete -> retry.

Two coupled issues:

- findE2EClientPolicy treated Keycloak's policy search-by-name 404 as
  fatal. Keycloak returns 404 (not an empty 200/204) when no policy of
  that name exists yet, so the first reconcile always failed. Treat 404
  as "not found" and let the caller create the policy.
- The policy search/create ran before any client's management
  permissions were enabled. Enabling FGAP is what lazily initializes
  realm-management's authorization resource server, without which the
  policy endpoints 404 outright. Enable permissions on the target (and
  frontend) client first, then search/create/attach.

Split attachTokenExchangePolicy into enableTokenExchangePermissions plus
attachPolicyToPermission so enabling happens once, up front. The test
fake now returns 404 for a missing policy to reproduce real Keycloak and
lock in the regression.

Assisted-by: Claude Opus 4.8

* fix(scripts): ensure seeded resources are not recreated

Assisted-by: Cursor Grok 4.6
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>

* test(scripts): assert seed lookups with portable grep

Ubuntu mawk does not treat escaped parentheses the same way macOS awk
does, so the seed_via_api range never matched and CI failed the
json_named_id assertion. Switch the checks to fixed-string grep.

Assisted-by: Cursor Grok 4.6
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(e2e): recover empty OpenShift seed inventory and honor RBAC defaults

PR environments can lose API seed rows when openshift-up recreates the
database and fails before openshift-seed. Discovery now explains empty
vs Error list bodies, pins local-openshift/dev-release, and re-runs
platform seed once when both lists are empty.

Developer and platform-admin gateway create checks follow the API
server's RBAC_DEFAULT_ROLES: Kind's unset default still expects 201;
OpenShift's empty value expects 403.

openshift-down and the PR-env reaper also delete control-plane-managed
sibling namespaces labeled with the platform instance so leftover
openshell-* projects do not outlive the environment.

Assisted-by: Cursor Grok 4.6
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): persist Keycloak console redirects across OpenShift recycle

oc set env without -c only patches spec.containers, so
HYPERSHELL_CONSOLE_HOST never reached render-realm-config. A later
start-dev --import-realm then restored localhost URIs and Keycloak
rejected the BFF callback. Stamp the host with a strategic-merge patch
of the init container; a full-object replace races Deployment status.

Skip the oauth-secret recycle when the annotation already matches. A
later reconcile also keeps the existing PR access-fact table instead of
replacing it with the first-deploy placeholder.

Assisted-by: Cursor Grok 4.6
Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: no more segfault during web-console build on arm64

Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>

* fix: E2E token auth within keycloak

Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>

* fix(control-plane): stop flaky provisioner transport test

TestListenAndServeServesPlaintextProvisionerCalls dials the server from
a goroutine racing ListenAndServe's own net.Listen call, so gRPC's
default dial can fail fast on "connection refused" before the listener
is bound under CI scheduling delay. Add grpc.WaitForReady(true) so the
call retries until callCtx's deadline instead.

Unrelated to the Keycloak realm fix in daf7b8c; this test failure was
pre-existing and reproduced 10/10 flaky under load, 0/20 after the fix.

Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>

* fix(ci): always recreate dev-gateway instead of reusing it

Keycloak runs start-dev on in-memory H2 with no persistent volume, so
any Keycloak pod restart discards dev-gateway's dynamically-provisioned
OIDC client while its row survives untouched in PostgreSQL. Reusing
that stale dev-gateway permanently strands it in status "Keycloak
client is missing": the GatewayReconciler deliberately never
auto-recreates a missing client, since doing so without also restoring
RoleBindings and console mappers would leave it silently
half-provisioned.

Until Keycloak has durable storage across restarts, seed_via_api now
deletes an existing dev-gateway and creates a fresh one on every run
instead of reusing it. Other named seed resources (ManagedCluster,
GatewayRelease, ManagedDatabase) are unaffected and continue to be
reused.

Updates openshift-development.spec.md and
ephemeral-pr-environments.spec.md to reflect the dev-gateway exception,
and adds shell assertions in lib_test.sh.

Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>

* fix(security): keep e2e impersonation out of production realms

Amber requested changes because hypershell-e2e shipped to every imported
realm and token-exchange ran on production gateway reconcile. Omit that
client, its service account, and the impersonation mapping unless
enabled, and skip EnsureE2ETokenExchange unless the client is enabled.
Admit Keycloak sessions with no GitHub broker identity when the org gate
is on so seeded password users still work. Parse oc image info JSON
instead of jsonpath for the digest fallback.

Assisted-by: Cursor Grok 4.6
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(security): fail closed for unreadable GitHub broker tokens

The org gate treated a 403 (never linked to GitHub, e.g. seeded
password users) and a 404 (linked to GitHub but Keycloak has no
stored token) from Keycloak's broker token endpoint as the same
"not linked" case and admitted both. A 404 actually indicates a
verifiable GitHub identity we failed to verify, so treating it
like an unlinked account let a linked-but-unreadable session skip
the org check entirely.

404 now throws instead of returning "not linked", routing through
the existing lookup-error handling so the session is denied and
the failure is logged. 403 keeps admitting, since that only occurs
for accounts that never went through the GitHub broker.

Addresses review feedback on PR #267.

Assisted-by: Claude Sonnet 5

* style: fix prettier formatting in github-org-gate test

Web console quality gates flagged a line over the printWidth in the
test file added by the previous fail-closed-on-404 commit.

Assisted-by: Claude Sonnet 5

* fix(e2e): raise reconcile-wait timeouts to 300s for OpenShift

CI's OpenShift e2e run failed acquiring a per-gateway OIDC token
with the openshell-admin role: the controller assigned the role
at 16:15:56, two seconds after the 120s poll deadline expired at
16:15:54. The controller pod had been recycled mid-run and needed
to reconnect its watch streams and replay its reconcile queue
before it got to the role grant, which ordinary reconcile latency
on a shared ROSA cluster only barely fits inside 120s.

OpenShift reconciles measurably slower than Kind across the board,
so raise every short reconcile-wait default (role-sync polling,
controller rollout waits, gateway provisioning, sandbox creation,
namespace GC, runtime version) from 90-180s to a uniform 300s.
These are ceilings, not sleeps, so passing runs are unaffected.

Assisted-by: Claude Sonnet 5

* fix(api-server): make user provisioning upsert atomic

You were right that both e2e failures were races, not flakes or
stale deploys. This is the second one: UserDao.Upsert did a SELECT
to check for an existing username, then Create or Save depending on
the result. Two concurrent requests JIT-provisioning the same
brand-new identity (e.g. the developer user's first-ever call to
the HyperShell API) could both miss the SELECT, both attempt
Create, and the loser would hit the username unique-constraint
violation. UserProvisioningMiddleware treats that error as "leave
userID unset" rather than retrying, so the losing request's RBAC
check saw an empty userID and 403'd -- exactly the "Developer user:
gateway list returned HTTP 403" failure, and why a solo manual
retry always passed (no concurrent racer, or the row already
existed from a prior run).

Replaced the SELECT-then-Create/Save with a single INSERT ... ON
CONFLICT (username) DO UPDATE, which Postgres resolves atomically.
GORM does not reliably scan the post-conflict row back into a
struct with a client-generated (non-autoincrement) primary key, so
the loser could still get back its own locally-generated KSUID
instead of the row that actually persisted; re-read by username
after the upsert so every caller resolves to the one canonical row.

Added TestUserUpsert_ConcurrentFirstTimeProvisioning, which fires 8
concurrent upserts for the same new username and asserts they all
succeed and resolve to the same ID. It reproduced the bug reliably
against the old implementation and passes against the fix.

Assisted-by: Claude Sonnet 5

* fix(api-server): split compound nil check to satisfy staticcheck

CI's golangci-lint flagged SA5011 on a combined "verbosity == nil ||
vmodule == nil" check guarding two later pointer dereferences:
staticcheck's flow analysis doesn't reliably carry non-nil for both
pointers past a single ||-joined t.Fatal guard, only past
individual ones. Splitting into two separate nil checks (each with
its own t.Fatal) resolves the false positive with no behavior
change.

Assisted-by: Claude Sonnet 5

* fix(api-server): isolate flag nil-check in a helper for staticcheck

The previous fix (splitting the compound nil check into two ifs)
still tripped SA5011 in CI on the exact pinned golangci-lint
version (v2.12.2) even though it was clean locally against the
same version -- staticcheck's flow analysis apparently still
doesn't treat two sequential single-pointer nil-check-then-Fatal
blocks in the same function as clearing both pointers before their
later dereferences.

Moved the nil-check-and-fail into its own mustLookupFlag helper, so
each pointer's nil check and only use of that pointer sit in a
function boundary of their own. This is the standard, more robust
pattern for this SA5011 false-positive class. Verified against the
CI-pinned golangci-lint v2.12.2 binary directly (not just whatever
version happened to be installed locally), including a linux/amd64
cross-compiled run to rule out a platform-specific difference.

Assisted-by: Claude Sonnet 5

---------

Signed-off-by: Kyle Squizzato <kysquizz@redhat.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants