From 507f95ec57550047181e85683e0b96536c0c0c94 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Mon, 31 Aug 2026 11:20:25 +0200 Subject: [PATCH 01/16] ACM-42589: Migrate console backend to Go with Node sidecar (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ACM-42589: Rename backend directory to backend-node Move the existing Node.js console backend into backend-node/ and update repository references, build scripts, CI configs, and documentation so the renamed package remains the single backend entry point. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor * ACM-42589: Add Go console backend with Node sidecar proxy Introduce a Go public listener in backend/ that owns TLS, health probes, shared config, and auth helpers while reverse-proxying unmigrated routes to the existing Node implementation in backend-node/. ## Strategy (executive summary) This change follows a strangler-fig migration: 1. **Free the backend path** — the existing Node server was moved to `backend-node/` so `backend/` can host the new Go entry point without breaking historical paths for config, certs, and `.env`. 2. **Dual-process local dev** — Go listens on `BACKEND_PORT` (4000) as the browser-facing backend; Node runs as a sidecar on `NODE_BACKEND_PORT` (4001) for routes not yet ported. 3. **Proxy-first cutover** — Go registers only health endpoints natively; all other traffic is forwarded to the sidecar with the original URL (including `/multicloud`) so the Node router keeps working unchanged. 4. **Shared runtime artifacts** — `backend/.env`, `backend/config/`, and `backend/certs/` remain the single source of truth; the sidecar reads them via `ENV_FILE`, `CONFIG_DIR`, and `CERTS_DIR`. 5. **Incremental porting** — new Go packages (`internal/server`, `proxy`, `health`, `config`, `auth`) establish the foundation; routes can migrate from Node to Go one at a time without frontend changes. Root npm scripts, setup, and docs were updated for the Go + sidecar workflow. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor * ACM-42589: Adopt golangci-lint for Go backend and fix initial findings Replace go vet with golangci-lint in backend check/lint scripts, add a backend/.golangci.yml config, and introduce scripts/golangci-lint-backend.sh to install and run the linter. Fix the first lint findings: variable shadowing in main.go and server_test.go, and US spelling in the RBAC informer comment. Update AGENTS.md and Makefile.prow to include the new backend lint/check steps. Signed-off-by: Enrique Mingorance Cano * ACM-42589: Validate RBAC event tokens via /api and skip logger wrapping for SSE Replace the TokenReviewer-based auth in the RBAC events handler with a new ValidateUserToken helper that checks tokens by GET /api, matching the Node sidecar behavior and avoiding TokenReview failures for some identities. Also bypass the request logger response wrapper for /events/rbac so HTTP/2 can flush SSE events to EventSource. Signed-off-by: Enrique Mingorance Cano * ACM-42589: Revert incomplete RBAC merge that breaks Go backend build The RBAC event token validation commit referenced APIs from ACM-42589_roles (RESTConfig, WithRBACEvents, events/rbac) that are not on this branch, so go run ./cmd/console failed and the plugin proxy could not reach :4000. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor * ACM-42589: Add Air live reload for Go backend development Configure github.com/air-verse/air to rebuild and restart the Go console backend when cmd/ or internal/ files change. Add scripts/air-backend.sh to install Air if missing, wire it into npm run start:backend:go, and update AGENTS.md, .gitignore, and clean scripts for the new backend/tmp directory. Signed-off-by: Enrique Mingorance Cano * Use named constants for Bearer authorization scheme prefix in token extraction Signed-off-by: Enrique Mingorance Cano * Go to 1.26 Signed-off-by: Enrique Mingorance Cano * ACM-42589: Migrate ClusterRole watch to Go /events/rbac SSE (#47) * ACM-42589: Migrate ClusterRole watch to Go /events/rbac SSE Move vm-clusterroles ClusterRole watching from the Node sidecar to a dedicated Go SSE stream with per-user SSAR filtering, and wire the frontend to consume it via LoadRbacEvents while keeping Search-based role assignments on the sidecar. Signed-off-by: Auto Co-authored-by: Cursor * ACM-42589: Adopt golangci-lint for Go backend and fix initial findings Replace go vet with golangci-lint in backend check/lint scripts, add a backend/.golangci.yml config, and introduce scripts/golangci-lint-backend.sh to install and run the linter. Fix the first lint findings: variable shadowing in main.go and server_test.go, and US spelling in the RBAC informer comment. Update AGENTS.md and Makefile.prow to include the new backend lint/check steps. Signed-off-by: Enrique Mingorance Cano * ACM-42589: Validate RBAC event tokens via /api and skip logger wrapping for SSE Replace the TokenReviewer-based auth in the RBAC events handler with a new ValidateUserToken helper that checks tokens by GET /api, matching the Node sidecar behavior and avoiding TokenReview failures for some identities. Also bypass the request logger response wrapper for /events/rbac so HTTP/2 can flush SSE events to EventSource. Signed-off-by: Enrique Mingorance Cano * ACM-42589: Harden RBAC events SSE with per-user fallback and proxy passthrough - Refactor the RBAC events handler to accept an Authenticator interface and use APIAuth backed by GET /api. - Add a per-user ClusterRole list fallback when the shared informer store is empty. - Make informer cache-sync timeout non-fatal so SSE starts even without clusterrole watch rights. - Set no-store/no-transform SSE headers and X-Accel-Buffering: no. - Treat /events/rbac as an event-stream path and proxy /multicloud/events/rbac through the webpack dev server. Signed-off-by: Enrique Mingorance Cano * ACM-42589: Fix auth merge corruption breaking Go backend and RBAC SSE Restores RESTConfig to return *rest.Config, re-adds ValidateUserToken and NewTokenReviewer, and skips the request logger wrapper for /events/rbac so the backend compiles and role events stream correctly after merging ACM-42589. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor * ACM-42589: Refactor frontend event streams into composable LoadDataAbstract abstraction Split the monolithic LoadData component into LoadEventsData and LoadRbacData, both built on a reusable LoadDataAbstract component. Extract shared event stream handling into useWatchEventStream and applyWatchEventsToCache hooks, replace LoadRbacEvents with LoadRbacData, and add unit tests for the new abstractions. Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Auto Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --------- Signed-off-by: Enrique Mingorance Cano Signed-off-by: Auto Co-authored-by: Cursor --- .dockerignore | 18 +- .github/workflows/backend-upgrade.yml | 2 +- .tekton/console-acm-51-pull-request.yaml | 2 +- .tekton/console-acm-51-push.yaml | 2 +- .tekton/console-acm-52-pull-request.yaml | 2 +- .tekton/console-acm-52-push.yaml | 2 +- .tekton/console-mce-mce-51-pull-request.yaml | 2 +- .tekton/console-mce-mce-51-push.yaml | 2 +- .tekton/console-mce-mce-52-pull-request.yaml | 2 +- .tekton/console-mce-mce-52-push.yaml | 2 +- .tool-versions | 1 + .vscode/launch.json | 18 +- AGENTS.md | 30 +- CONTRIBUTING.md | 18 +- Containerfile.acm | 14 +- Containerfile.mce | 14 +- Makefile.prow | 10 +- README.md | 59 +- backend-node/.gitignore | 5 + {backend => backend-node}/.vscode/launch.json | 0 backend-node/AGENTS.md | 129 +++ backend-node/CLAUDE.md | 1 + {backend => backend-node}/eslint.config.mjs | 0 {backend => backend-node}/package-lock.json | 0 {backend => backend-node}/package.json | 2 - {backend => backend-node}/src/app.ts | 0 {backend => backend-node}/src/lib/agent.ts | 0 .../src/lib/authenticated.ts | 0 .../src/lib/batch-promise-all.ts | 0 .../src/lib/body-parser.ts | 0 .../src/lib/compression.ts | 0 {backend => backend-node}/src/lib/config.ts | 5 +- {backend => backend-node}/src/lib/cookies.ts | 0 {backend => backend-node}/src/lib/cors.ts | 0 {backend => backend-node}/src/lib/delay.ts | 0 .../src/lib/fetch-retry.ts | 0 .../src/lib/fileWatch.ts | 0 .../src/lib/getServiceToken.ts | 0 {backend => backend-node}/src/lib/gigantic.ts | 0 .../src/lib/json-request.ts | 0 {backend => backend-node}/src/lib/logger.ts | 0 {backend => backend-node}/src/lib/main.ts | 3 +- .../src/lib/managed-cluster-addon.ts | 0 {backend => backend-node}/src/lib/memory.ts | 0 .../src/lib/multi-cluster-engine.ts | 0 .../src/lib/multi-cluster-hub.ts | 0 {backend => backend-node}/src/lib/noop.ts | 0 .../src/lib/pagination.ts | 0 backend-node/src/lib/paths.ts | 18 + .../src/lib/placementDebugCAWatch.ts | 0 .../src/lib/random-string.ts | 0 .../src/lib/request-retry.ts | 0 {backend => backend-node}/src/lib/respond.ts | 0 {backend => backend-node}/src/lib/search.ts | 0 .../src/lib/server-side-events.ts | 1 - {backend => backend-node}/src/lib/server.ts | 5 +- .../src/lib/serviceAccountToken.ts | 0 .../src/lib/tlsProfileWatch.ts | 0 {backend => backend-node}/src/lib/token.ts | 6 +- .../src/lib/virtual-machine.ts | 0 .../src/resources/resource-list.ts | 0 .../src/resources/resource.ts | 0 .../src/resources/route.ts | 0 .../src/resources/secret.ts | 0 .../src/resources/status.ts | 0 .../src/resources/watch-options.ts | 0 .../src/routes/aggregator.ts | 0 .../src/routes/aggregators/appSetData.ts | 0 .../src/routes/aggregators/applications.ts | 0 .../routes/aggregators/applicationsArgo.ts | 0 .../src/routes/aggregators/applicationsOCP.ts | 0 .../aggregators/applicationsPushModel.ts | 0 .../src/routes/aggregators/statuses.ts | 0 .../src/routes/aggregators/utils.ts | 0 .../src/routes/ansibletower.ts | 0 .../src/routes/apiPaths.ts | 0 .../src/routes/clusterVersion.ts | 0 .../src/routes/configure.ts | 0 .../src/routes/events.ts | 5 - {backend => backend-node}/src/routes/hub.ts | 0 .../src/routes/hypershift-status.ts | 0 .../src/routes/liveness.ts | 0 .../src/routes/managedClusterProxy.ts | 0 .../src/routes/metricsProxy.ts | 0 .../routes/multiClusterEngineComponents.ts | 0 .../src/routes/multiClusterHubComponents.ts | 0 {backend => backend-node}/src/routes/oauth.ts | 0 .../src/routes/operatorCheck.ts | 0 .../src/routes/placementDebug.ts | 0 {backend => backend-node}/src/routes/proxy.ts | 0 .../src/routes/readiness.ts | 0 .../src/routes/rosaWizardApi.ts | 0 .../src/routes/search.ts | 0 {backend => backend-node}/src/routes/serve.ts | 0 .../src/routes/upgrade-risks-prediction.ts | 0 .../src/routes/username.ts | 0 .../src/routes/userpreference.ts | 0 .../src/routes/virtualMachineProxy.ts | 0 {backend => backend-node}/test/app.test.ts | 0 {backend => backend-node}/test/jest-setup.ts | 3 + .../test/lib/agent.test.ts | 0 .../test/lib/batch-promise-all.test.ts | 0 .../test/lib/compression.test.ts | 0 .../test/lib/fileWatch.test.ts | 0 .../test/lib/getServiceToken.test.ts | 0 backend-node/test/lib/paths.test.ts | 35 + .../test/lib/placementDebugCAWatch.test.ts | 0 .../test/lib/tlsProfileWatch.test.ts | 0 .../test/mock-request.ts | 0 .../test/routes/aggregator.test.ts | 0 .../routes/aggregators/applications.test.ts | 0 .../applicationsArgoMergePush.test.ts | 0 .../aggregators/applicationsPushModel.test.ts | 0 .../test/routes/aggregators/utils.test.ts | 0 .../test/routes/ansibletower.test.ts | 0 .../test/routes/apiPath.test.ts | 0 .../test/routes/clusterVersion.test.ts | 0 .../test/routes/configure.test.ts | 0 .../test/routes/events.test.ts | 0 .../test/routes/hub.test.ts | 0 .../test/routes/hypershift-status.test.ts | 0 .../test/routes/liveness.test.ts | 0 .../test/routes/managedClusterProxy.test.ts | 0 .../test/routes/metricsProxy.test.ts | 0 .../test/routes/operatorCheck.test.ts | 0 .../test/routes/ping.test.ts | 0 .../test/routes/placementDebug.test.ts | 0 .../test/routes/proxy.test.ts | 0 .../test/routes/readiness.test.ts | 0 .../test/routes/rosaWizardApi.test.ts | 0 .../test/routes/search.test.ts | 0 .../test/routes/searchWebSocket.test.ts | 0 .../test/routes/serve.test.ts | 0 .../routes/upgrade-risks-prediction.test.ts | 0 .../test/routes/username.test.ts | 0 .../test/routes/userpreference.test.ts | 0 .../test/routes/virtualMachineProxy.test.ts | 0 {backend => backend-node}/test/tsconfig.json | 0 {backend => backend-node}/tsconfig.build.json | 0 {backend => backend-node}/tsconfig.json | 0 backend/.air.toml | 20 + backend/.gitignore | 9 +- backend/.golangci.yml | 34 + backend/AGENTS.md | 148 +--- backend/README.md | 27 + backend/cmd/console/main.go | 76 ++ backend/go.mod | 52 ++ backend/go.sum | 160 ++++ backend/internal/auth/auth.go | 173 ++++ backend/internal/auth/auth_test.go | 134 +++ backend/internal/config/config.go | 180 ++++ backend/internal/config/config_test.go | 102 +++ backend/internal/events/rbac/access.go | 111 +++ backend/internal/events/rbac/handler.go | 187 ++++ backend/internal/events/rbac/handler_test.go | 243 +++++ backend/internal/events/rbac/informer.go | 69 ++ backend/internal/events/rbac/list.go | 38 + backend/internal/events/rbac/list_test.go | 36 + backend/internal/events/rbac/store.go | 122 +++ backend/internal/health/health.go | 66 ++ backend/internal/health/health_test.go | 65 ++ backend/internal/log/log.go | 46 + backend/internal/proxy/proxy.go | 30 + backend/internal/server/server.go | 192 ++++ backend/internal/server/server_test.go | 202 +++++ console.code-workspace | 3 + docs/ARCHITECTURE.md | 2 + docs/RESOURCES.md | 2 +- frontend/src/components/LoadData.tsx | 834 +----------------- .../src/components/LoadDataAbstract.test.tsx | 127 +++ frontend/src/components/LoadDataAbstract.tsx | 176 ++++ frontend/src/components/LoadEventsData.tsx | 696 +++++++++++++++ frontend/src/components/LoadRbacData.test.tsx | 142 +++ frontend/src/components/LoadRbacData.tsx | 17 + .../src/hooks/applyWatchEventsToCache.test.ts | 66 ++ frontend/src/hooks/applyWatchEventsToCache.ts | 37 + .../src/hooks/useWatchEventStream.test.ts | 128 +++ frontend/src/hooks/useWatchEventStream.ts | 110 +++ frontend/src/lib/test-event-source.ts | 48 + .../src/resources/utils/resource-request.ts | 2 +- frontend/webpack.config.ts | 1 + lint-staged.config.js | 3 +- package.json | 40 +- port-defaults.sh | 1 + scripts/air-backend.sh | 21 + scripts/copyright-fix.ts | 5 +- scripts/copyright.ts | 4 +- scripts/golangci-lint-backend.sh | 20 + setup.sh | 2 + sonar-project.properties | 8 +- 190 files changed, 4398 insertions(+), 1037 deletions(-) create mode 100644 backend-node/.gitignore rename {backend => backend-node}/.vscode/launch.json (100%) create mode 100644 backend-node/AGENTS.md create mode 100644 backend-node/CLAUDE.md rename {backend => backend-node}/eslint.config.mjs (100%) rename {backend => backend-node}/package-lock.json (100%) rename {backend => backend-node}/package.json (93%) rename {backend => backend-node}/src/app.ts (100%) rename {backend => backend-node}/src/lib/agent.ts (100%) rename {backend => backend-node}/src/lib/authenticated.ts (100%) rename {backend => backend-node}/src/lib/batch-promise-all.ts (100%) rename {backend => backend-node}/src/lib/body-parser.ts (100%) rename {backend => backend-node}/src/lib/compression.ts (100%) rename {backend => backend-node}/src/lib/config.ts (94%) rename {backend => backend-node}/src/lib/cookies.ts (100%) rename {backend => backend-node}/src/lib/cors.ts (100%) rename {backend => backend-node}/src/lib/delay.ts (100%) rename {backend => backend-node}/src/lib/fetch-retry.ts (100%) rename {backend => backend-node}/src/lib/fileWatch.ts (100%) rename {backend => backend-node}/src/lib/getServiceToken.ts (100%) rename {backend => backend-node}/src/lib/gigantic.ts (100%) rename {backend => backend-node}/src/lib/json-request.ts (100%) rename {backend => backend-node}/src/lib/logger.ts (100%) rename {backend => backend-node}/src/lib/main.ts (96%) rename {backend => backend-node}/src/lib/managed-cluster-addon.ts (100%) rename {backend => backend-node}/src/lib/memory.ts (100%) rename {backend => backend-node}/src/lib/multi-cluster-engine.ts (100%) rename {backend => backend-node}/src/lib/multi-cluster-hub.ts (100%) rename {backend => backend-node}/src/lib/noop.ts (100%) rename {backend => backend-node}/src/lib/pagination.ts (100%) create mode 100644 backend-node/src/lib/paths.ts rename {backend => backend-node}/src/lib/placementDebugCAWatch.ts (100%) rename {backend => backend-node}/src/lib/random-string.ts (100%) rename {backend => backend-node}/src/lib/request-retry.ts (100%) rename {backend => backend-node}/src/lib/respond.ts (100%) rename {backend => backend-node}/src/lib/search.ts (100%) rename {backend => backend-node}/src/lib/server-side-events.ts (99%) rename {backend => backend-node}/src/lib/server.ts (98%) rename {backend => backend-node}/src/lib/serviceAccountToken.ts (100%) rename {backend => backend-node}/src/lib/tlsProfileWatch.ts (100%) rename {backend => backend-node}/src/lib/token.ts (94%) rename {backend => backend-node}/src/lib/virtual-machine.ts (100%) rename {backend => backend-node}/src/resources/resource-list.ts (100%) rename {backend => backend-node}/src/resources/resource.ts (100%) rename {backend => backend-node}/src/resources/route.ts (100%) rename {backend => backend-node}/src/resources/secret.ts (100%) rename {backend => backend-node}/src/resources/status.ts (100%) rename {backend => backend-node}/src/resources/watch-options.ts (100%) rename {backend => backend-node}/src/routes/aggregator.ts (100%) rename {backend => backend-node}/src/routes/aggregators/appSetData.ts (100%) rename {backend => backend-node}/src/routes/aggregators/applications.ts (100%) rename {backend => backend-node}/src/routes/aggregators/applicationsArgo.ts (100%) rename {backend => backend-node}/src/routes/aggregators/applicationsOCP.ts (100%) rename {backend => backend-node}/src/routes/aggregators/applicationsPushModel.ts (100%) rename {backend => backend-node}/src/routes/aggregators/statuses.ts (100%) rename {backend => backend-node}/src/routes/aggregators/utils.ts (100%) rename {backend => backend-node}/src/routes/ansibletower.ts (100%) rename {backend => backend-node}/src/routes/apiPaths.ts (100%) rename {backend => backend-node}/src/routes/clusterVersion.ts (100%) rename {backend => backend-node}/src/routes/configure.ts (100%) rename {backend => backend-node}/src/routes/events.ts (99%) rename {backend => backend-node}/src/routes/hub.ts (100%) rename {backend => backend-node}/src/routes/hypershift-status.ts (100%) rename {backend => backend-node}/src/routes/liveness.ts (100%) rename {backend => backend-node}/src/routes/managedClusterProxy.ts (100%) rename {backend => backend-node}/src/routes/metricsProxy.ts (100%) rename {backend => backend-node}/src/routes/multiClusterEngineComponents.ts (100%) rename {backend => backend-node}/src/routes/multiClusterHubComponents.ts (100%) rename {backend => backend-node}/src/routes/oauth.ts (100%) rename {backend => backend-node}/src/routes/operatorCheck.ts (100%) rename {backend => backend-node}/src/routes/placementDebug.ts (100%) rename {backend => backend-node}/src/routes/proxy.ts (100%) rename {backend => backend-node}/src/routes/readiness.ts (100%) rename {backend => backend-node}/src/routes/rosaWizardApi.ts (100%) rename {backend => backend-node}/src/routes/search.ts (100%) rename {backend => backend-node}/src/routes/serve.ts (100%) rename {backend => backend-node}/src/routes/upgrade-risks-prediction.ts (100%) rename {backend => backend-node}/src/routes/username.ts (100%) rename {backend => backend-node}/src/routes/userpreference.ts (100%) rename {backend => backend-node}/src/routes/virtualMachineProxy.ts (100%) rename {backend => backend-node}/test/app.test.ts (100%) rename {backend => backend-node}/test/jest-setup.ts (72%) rename {backend => backend-node}/test/lib/agent.test.ts (100%) rename {backend => backend-node}/test/lib/batch-promise-all.test.ts (100%) rename {backend => backend-node}/test/lib/compression.test.ts (100%) rename {backend => backend-node}/test/lib/fileWatch.test.ts (100%) rename {backend => backend-node}/test/lib/getServiceToken.test.ts (100%) create mode 100644 backend-node/test/lib/paths.test.ts rename {backend => backend-node}/test/lib/placementDebugCAWatch.test.ts (100%) rename {backend => backend-node}/test/lib/tlsProfileWatch.test.ts (100%) rename {backend => backend-node}/test/mock-request.ts (100%) rename {backend => backend-node}/test/routes/aggregator.test.ts (100%) rename {backend => backend-node}/test/routes/aggregators/applications.test.ts (100%) rename {backend => backend-node}/test/routes/aggregators/applicationsArgoMergePush.test.ts (100%) rename {backend => backend-node}/test/routes/aggregators/applicationsPushModel.test.ts (100%) rename {backend => backend-node}/test/routes/aggregators/utils.test.ts (100%) rename {backend => backend-node}/test/routes/ansibletower.test.ts (100%) rename {backend => backend-node}/test/routes/apiPath.test.ts (100%) rename {backend => backend-node}/test/routes/clusterVersion.test.ts (100%) rename {backend => backend-node}/test/routes/configure.test.ts (100%) rename {backend => backend-node}/test/routes/events.test.ts (100%) rename {backend => backend-node}/test/routes/hub.test.ts (100%) rename {backend => backend-node}/test/routes/hypershift-status.test.ts (100%) rename {backend => backend-node}/test/routes/liveness.test.ts (100%) rename {backend => backend-node}/test/routes/managedClusterProxy.test.ts (100%) rename {backend => backend-node}/test/routes/metricsProxy.test.ts (100%) rename {backend => backend-node}/test/routes/operatorCheck.test.ts (100%) rename {backend => backend-node}/test/routes/ping.test.ts (100%) rename {backend => backend-node}/test/routes/placementDebug.test.ts (100%) rename {backend => backend-node}/test/routes/proxy.test.ts (100%) rename {backend => backend-node}/test/routes/readiness.test.ts (100%) rename {backend => backend-node}/test/routes/rosaWizardApi.test.ts (100%) rename {backend => backend-node}/test/routes/search.test.ts (100%) rename {backend => backend-node}/test/routes/searchWebSocket.test.ts (100%) rename {backend => backend-node}/test/routes/serve.test.ts (100%) rename {backend => backend-node}/test/routes/upgrade-risks-prediction.test.ts (100%) rename {backend => backend-node}/test/routes/username.test.ts (100%) rename {backend => backend-node}/test/routes/userpreference.test.ts (100%) rename {backend => backend-node}/test/routes/virtualMachineProxy.test.ts (100%) rename {backend => backend-node}/test/tsconfig.json (100%) rename {backend => backend-node}/tsconfig.build.json (100%) rename {backend => backend-node}/tsconfig.json (100%) create mode 100644 backend/.air.toml create mode 100644 backend/.golangci.yml create mode 100644 backend/README.md create mode 100644 backend/cmd/console/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/auth/auth.go create mode 100644 backend/internal/auth/auth_test.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/config/config_test.go create mode 100644 backend/internal/events/rbac/access.go create mode 100644 backend/internal/events/rbac/handler.go create mode 100644 backend/internal/events/rbac/handler_test.go create mode 100644 backend/internal/events/rbac/informer.go create mode 100644 backend/internal/events/rbac/list.go create mode 100644 backend/internal/events/rbac/list_test.go create mode 100644 backend/internal/events/rbac/store.go create mode 100644 backend/internal/health/health.go create mode 100644 backend/internal/health/health_test.go create mode 100644 backend/internal/log/log.go create mode 100644 backend/internal/proxy/proxy.go create mode 100644 backend/internal/server/server.go create mode 100644 backend/internal/server/server_test.go create mode 100644 frontend/src/components/LoadDataAbstract.test.tsx create mode 100644 frontend/src/components/LoadDataAbstract.tsx create mode 100644 frontend/src/components/LoadEventsData.tsx create mode 100644 frontend/src/components/LoadRbacData.test.tsx create mode 100644 frontend/src/components/LoadRbacData.tsx create mode 100644 frontend/src/hooks/applyWatchEventsToCache.test.ts create mode 100644 frontend/src/hooks/applyWatchEventsToCache.ts create mode 100644 frontend/src/hooks/useWatchEventStream.test.ts create mode 100644 frontend/src/hooks/useWatchEventStream.ts create mode 100644 frontend/src/lib/test-event-source.ts create mode 100755 scripts/air-backend.sh create mode 100755 scripts/golangci-lint-backend.sh diff --git a/.dockerignore b/.dockerignore index 280b8f90403..28d464b388a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,15 +5,19 @@ /backend/.vscode/ /backend/certs/ /backend/coverage/ -/backend/build/ -/backend/node_modules/ -/backend/public/ -/backend/.dockerignore -/backend/.gitignore -/backend/.Dockerfile -/backend/*.md +/backend/bin/ /backend/.env +/backend-node/.vscode/ +/backend-node/coverage/ +/backend-node/build/ +/backend-node/node_modules/ +/backend-node/public/ +/backend-node/.dockerignore +/backend-node/.gitignore +/backend-node/.Dockerfile +/backend-node/*.md + /frontend/.vscode/ /frontend/build/ /frontend/node_modules/ diff --git a/.github/workflows/backend-upgrade.yml b/.github/workflows/backend-upgrade.yml index d3b098e71b9..01a3e55d91f 100644 --- a/.github/workflows/backend-upgrade.yml +++ b/.github/workflows/backend-upgrade.yml @@ -10,7 +10,7 @@ jobs: timeout-minutes: 10 defaults: run: - working-directory: backend + working-directory: backend-node steps: - uses: actions/checkout@v7 with: diff --git a/.tekton/console-acm-51-pull-request.yaml b/.tekton/console-acm-51-pull-request.yaml index 061ba462636..85530873ba1 100644 --- a/.tekton/console-acm-51-pull-request.yaml +++ b/.tekton/console-acm-51-pull-request.yaml @@ -41,7 +41,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' - name: enable-cache-proxy value: "true" pipelineRef: diff --git a/.tekton/console-acm-51-push.yaml b/.tekton/console-acm-51-push.yaml index e6ad3c736ab..038b73201f4 100644 --- a/.tekton/console-acm-51-push.yaml +++ b/.tekton/console-acm-51-push.yaml @@ -38,7 +38,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' - name: send-slack-notification value: true - name: konflux-application-name diff --git a/.tekton/console-acm-52-pull-request.yaml b/.tekton/console-acm-52-pull-request.yaml index 69851bec9f8..c5de1f55015 100644 --- a/.tekton/console-acm-52-pull-request.yaml +++ b/.tekton/console-acm-52-pull-request.yaml @@ -41,7 +41,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' - name: enable-cache-proxy value: "true" pipelineRef: diff --git a/.tekton/console-acm-52-push.yaml b/.tekton/console-acm-52-push.yaml index 979b1469f06..9a54bdde8c8 100644 --- a/.tekton/console-acm-52-push.yaml +++ b/.tekton/console-acm-52-push.yaml @@ -38,7 +38,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' - name: send-slack-notification value: true - name: konflux-application-name diff --git a/.tekton/console-mce-mce-51-pull-request.yaml b/.tekton/console-mce-mce-51-pull-request.yaml index 770d8f311d0..b50d8bf88bf 100644 --- a/.tekton/console-mce-mce-51-pull-request.yaml +++ b/.tekton/console-mce-mce-51-pull-request.yaml @@ -41,7 +41,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' - name: enable-cache-proxy value: "true" pipelineRef: diff --git a/.tekton/console-mce-mce-51-push.yaml b/.tekton/console-mce-mce-51-push.yaml index eb8bc28e31f..d7a570f3b00 100644 --- a/.tekton/console-mce-mce-51-push.yaml +++ b/.tekton/console-mce-mce-51-push.yaml @@ -38,7 +38,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' - name: send-slack-notification value: true - name: konflux-application-name diff --git a/.tekton/console-mce-mce-52-pull-request.yaml b/.tekton/console-mce-mce-52-pull-request.yaml index cdbebc2b6d1..c6220ff1991 100644 --- a/.tekton/console-mce-mce-52-pull-request.yaml +++ b/.tekton/console-mce-mce-52-pull-request.yaml @@ -41,7 +41,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' - name: enable-cache-proxy value: "true" pipelineRef: diff --git a/.tekton/console-mce-mce-52-push.yaml b/.tekton/console-mce-mce-52-push.yaml index 88beaa90808..c3ccd7edd46 100644 --- a/.tekton/console-mce-mce-52-push.yaml +++ b/.tekton/console-mce-mce-52-push.yaml @@ -38,7 +38,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' - name: send-slack-notification value: true - name: konflux-application-name diff --git a/.tool-versions b/.tool-versions index d0b9920e80e..8d94989cd45 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1,2 @@ +golang 1.26.5 nodejs 24.11.0 \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 0a4369b92a3..c9820c675c2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,7 +38,7 @@ }, { "type": "node", - "name": "vscode-jest-tests.v2.backend", + "name": "vscode-jest-tests.v2.backend-node", "request": "launch", "args": [ "--runInBand", @@ -49,10 +49,22 @@ "--runTestsByPath", "${jest.testFile}" ], - "cwd": "${workspaceFolder}/backend", + "cwd": "${workspaceFolder}/backend-node", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", - "program": "${workspaceFolder}/backend/node_modules/.bin/jest" + "program": "${workspaceFolder}/backend-node/node_modules/.bin/jest" + }, + { + "name": "Go backend", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/backend/cmd/console", + "cwd": "${workspaceFolder}/backend", + "env": { + "PORT": "4000", + "NODE_BACKEND_URL": "https://127.0.0.1:4001" + } } ] } diff --git a/AGENTS.md b/AGENTS.md index 64ef5138c81..ed5c6c4beef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,8 @@ console/ │ ├── react-form-wizard/ # @patternfly-labs/react-form-wizard │ ├── eslint-config/ # @stolostron/eslint-config │ └── prettier-config/ # @stolostron/prettier-config -├── backend/ # Node.js ESM proxy server +├── backend/ # Go console backend (public listener) +├── backend-node/ # Node sidecar for routes not yet migrated to Go ├── docs/ # Architecture documentation ├── scripts/ # Build and development scripts └── resources/ # Sample K8s YAML fixtures @@ -25,21 +26,25 @@ console/ ## Prerequisites - **Node.js** (version pinned in `.nvmrc` and `.tool-versions`) and **npm** +- **Go** (1.26+) for the console backend - **OpenShift 4.x cluster** with ACM or MCE installed for full functionality - **openssl** for certificate generation ## Setup ```bash -npm run setup # Configure cluster connection (creates backend/.env) -npm ci # Install dependencies for frontend and backend +npm ci # installs frontend, backend-node; go mod download when Go is installed +npm run setup # writes backend/.env from the current oc context +npm run generate-certs # writes backend/certs/ (required for local TLS) ``` +After `oc login` to a new hub: `npm run setup:hub` (regenerates `.env` and certs). + ## Development Commands | Command | Purpose | |---------|---------| -| `npm start` | Start frontend + backend in standalone mode | +| `npm start` | Start frontend + backend in standalone mode (Go live-reloads on `.go` changes) | | `npm run plugins` | Run as dynamic plugins with local OCP console (**recommended dev mode**) | | `npm test` | Run all tests (frontend + backend) | | `npm run check` | Run lint, format, and type checking across the entire project | @@ -53,9 +58,9 @@ npm ci # Install dependencies for frontend and backend Run checks against only one side of the monorepo: -- `npm run test:frontend` / `npm run test:backend` -- `npm run check:frontend` / `npm run check:backend` -- `npm run lint:frontend` / `npm run lint:backend` +- `npm run test:frontend` / `npm run test:backend` / `npm run test:backend-node` +- `npm run check:frontend` / `npm run check:backend` / `npm run check:backend-node` +- `npm run lint:frontend` / `npm run lint:backend` / `npm run lint:backend-node` ### Port Configuration @@ -64,7 +69,8 @@ Ports are customizable via environment variables defined in `port-defaults.sh`: | Variable | Default | Purpose | |----------|---------|---------| | `FRONTEND_PORT` | 3000 | Standalone console | -| `BACKEND_PORT` | 4000 | Backend APIs | +| `BACKEND_PORT` | 4000 | Backend APIs (Go listener) | +| `NODE_BACKEND_PORT` | 4001 | Node sidecar (unmigrated routes) | | `CONSOLE_PORT` | 9000 | OpenShift console | | `MCE_PORT` | 3001 | MCE plugin | | `ACM_PORT` | 3002 | ACM plugin | @@ -79,7 +85,8 @@ Use `npm run plugins` for development; it matches the production deployment mode ## Code Quality Standards -- TypeScript strict mode in frontend; backend uses `noImplicitAny` but not full strict mode +- TypeScript strict mode in frontend; `backend-node` uses `noImplicitAny` but not full strict mode +- Go backend: `gofmt`, `golangci-lint`, and `go test ./...` (`npm run check:backend`) - ESLint with `@stolostron/eslint-config` (flat config) - Prettier with `@stolostron/prettier-config` (120 char width, no semicolons, single quotes) - Husky `commit-msg` hook enforces a `Signed-off-by` line on every commit @@ -131,6 +138,7 @@ Features can be enabled/disabled via the `console-config` ConfigMap in the insta ## Troubleshooting -- **Certificate errors** — Remove `backend/certs/` and run `npm run ci:backend` to regenerate +- **`concurrently: command not found`** — Run `npm ci` at the repo root first +- **Certificate errors** — Remove `backend/certs/` and run `npm run generate-certs` - **Module resolution errors** — Verify Node.js and npm versions match `.nvmrc` / `.tool-versions`; version mismatches break ESM resolution -- **Missing `.env`** — Run `npm run setup` to generate `backend/.env` with cluster connection details +- **Missing `.env`** — Run `npm run setup` (or `npm run setup:hub` after `oc login` to a new cluster) to generate `backend/.env` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 216895df10f..852f8ccba19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,16 +58,29 @@ npm test Make sure your `kubectl` context is set to your target cluster and have Red Hat Advanced Cluster Management installed on the target cluster. +Prerequisites match [README.md](README.md#prerequisites) (Node.js 24, Go 1.26+, openssl, `oc`, and podman/docker for plugin mode). + +#### First-time setup + +```bash +npm ci +npm run setup +npm run generate-certs +``` + +`npm ci` runs a `postinstall` that installs `frontend`, `backend-node`, and (when Go is installed) `go mod download` in `backend/`. + +After `oc login` to a different hub, run `npm run setup:hub` to regenerate `backend/.env` and `backend/certs/`. + #### Recommended: Run as OpenShift Console plugins This is the production deployment model. **Always test in this mode before submitting a PR.** ```bash -npm run setup npm run plugins ``` -_WARNING: Running this script will update some parts of the cluster specified in your `KUBECONFIG` context._ +_WARNING: `npm run setup` updates some parts of the cluster specified in your `KUBECONFIG` context._ Access the console at **http://localhost:9000** @@ -76,7 +89,6 @@ Access the console at **http://localhost:9000** For rapid iteration on features that don't depend on OpenShift Console integration. ```bash -npm run setup npm start ``` diff --git a/Containerfile.acm b/Containerfile.acm index 0e20cdaf73f..72320176014 100644 --- a/Containerfile.acm +++ b/Containerfile.acm @@ -16,25 +16,25 @@ RUN npm ci --legacy-peer-deps RUN npm run build:plugin:acm FROM build-base as backend -WORKDIR /app/backend +WORKDIR /app/backend-node # Copy only package.json and package-lock.json so that the docker layer cache only changes if those change # This will cause the npm ci to only rerun if the package.json or package-lock.json changes -COPY ./backend/package.json ./backend/package-lock.json ./ +COPY ./backend-node/package.json ./backend-node/package-lock.json ./ RUN npm ci --omit=optional -COPY ./backend . +COPY ./backend-node . RUN npm run build FROM build-base as production -WORKDIR /app/backend -COPY ./backend/package-lock.json ./backend/package.json ./ +WORKDIR /app/backend-node +COPY ./backend-node/package-lock.json ./backend-node/package.json ./ RUN npm ci --omit=optional --only=production FROM ${NODE_BASE} COPY --from=crypto-policy /etc/crypto-policies /etc/crypto-policies WORKDIR /app ENV NODE_ENV production -COPY --from=production /app/backend/node_modules ./node_modules -COPY --from=backend /app/backend/backend.mjs ./ +COPY --from=production /app/backend-node/node_modules ./node_modules +COPY --from=backend /app/backend-node/backend.mjs ./ COPY --from=dynamic-plugin /app/frontend/plugins/acm/dist ./public/plugin USER 1001 CMD ["node", "backend.mjs"] diff --git a/Containerfile.mce b/Containerfile.mce index e3c5e1e4800..ddf12ade504 100644 --- a/Containerfile.mce +++ b/Containerfile.mce @@ -16,25 +16,25 @@ RUN npm ci --legacy-peer-deps RUN npm run build:plugin:mce FROM build-base as backend -WORKDIR /app/backend +WORKDIR /app/backend-node # Copy only package.json and package-lock.json so that the docker layer cache only changes if those change # This will cause the npm ci to only rerun if the package.json or package-lock.json changes -COPY ./backend/package.json ./backend/package-lock.json ./ +COPY ./backend-node/package.json ./backend-node/package-lock.json ./ RUN npm ci --omit=optional -COPY ./backend . +COPY ./backend-node . RUN npm run build FROM build-base as production -WORKDIR /app/backend -COPY ./backend/package-lock.json ./backend/package.json ./ +WORKDIR /app/backend-node +COPY ./backend-node/package-lock.json ./backend-node/package.json ./ RUN npm ci --omit=optional --only=production FROM ${NODE_BASE} COPY --from=crypto-policy /etc/crypto-policies /etc/crypto-policies WORKDIR /app ENV NODE_ENV production -COPY --from=production /app/backend/node_modules ./node_modules -COPY --from=backend /app/backend/backend.mjs ./ +COPY --from=production /app/backend-node/node_modules ./node_modules +COPY --from=backend /app/backend-node/backend.mjs ./ COPY --from=dynamic-plugin /app/frontend/plugins/mce/dist ./public/plugin USER 1001 CMD ["node", "backend.mjs"] diff --git a/Makefile.prow b/Makefile.prow index 639857a7d50..2d4778656de 100644 --- a/Makefile.prow +++ b/Makefile.prow @@ -9,19 +9,21 @@ install: .PHONY: build build: - npm run build + npm run build:frontend + npm run build:backend-node .PHONY: check check: - npm run check + npx concurrently --kill-others-on-fail npm:copyright:check npm:check:frontend npm:check:backend npm:check:backend-node -c green,blue,magenta .PHONY: lint lint: - npm run lint + npx concurrently --kill-others-on-fail npm:lint:frontend npm:lint:backend npm:lint:backend-node -c green,blue .PHONY: unit-tests unit-tests: if [ ! -d "test-output" ]; then \ mkdir test-output; \ fi - npm run test -- --maxWorkers=2 + npm run test:frontend -- --maxWorkers=2 + npm run test:backend-node -- --maxWorkers=2 diff --git a/README.md b/README.md index e211975abc6..181e600e072 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,10 @@ Go to the [Contributing guide](CONTRIBUTING.md) to learn how to get involved. ## Prerequisites -- [Node.js](https://nodejs.org) 20 -- NPM 8 +- [Node.js](https://nodejs.org) 24 (see [`.nvmrc`](.nvmrc) and [`.tool-versions`](.tool-versions)) +- npm 10+ +- [Go](https://go.dev/dl/) 1.26+ (local Go backend; `npm ci` runs `go mod download` when `go` is on your `PATH`) +- [openssl](https://www.openssl.org/) CLI (for `npm run generate-certs`) - [oc](https://docs.openshift.com/container-platform/latest/cli_reference/openshift_cli/getting-started-cli.html) (OpenShift CLI) - [podman](https://podman.io/) or [docker](https://www.docker.com/) (required for `npm run plugins`) - [jq](https://stedolan.github.io/jq/download/) @@ -77,6 +79,8 @@ The recommended way to run the console for development is as OpenShift Console d npm ci ``` + The root `postinstall` installs `frontend`, `backend-node`, and (when Go is installed) runs `go mod download` in `backend/`. You may see `[backend] ci:backend` in the output — that is expected. + 3. Configure environment You need: @@ -87,15 +91,23 @@ The recommended way to run the console for development is as OpenShift Console d npm run setup ``` - This will create a `.env` file in the `backend` directory containing environment variables for the cluster connection. + This creates `backend/.env` with cluster connection variables. Some optional routes (for example ACM Observability) may log `NotFound` if the component is not installed on the cluster; local development can continue. + +4. Generate TLS certificates + + ```sh + npm run generate-certs + ``` + + Writes self-signed certs to `backend/certs/` for the Go backend and Node sidecar. After `oc login` to a different hub, run `npm run setup:hub` instead to regenerate `.env` and certs together. -4. Start the console plugins +5. Start the console plugins ```sh npm run plugins ``` - This concurrently starts the backend server, the frontend webpack development server (serving both ACM and MCE plugins), and a local OpenShift Console container. The console will be available at **http://localhost:9000**. + This concurrently starts the Go backend (reverse-proxying unmigrated routes to a Node sidecar), the frontend webpack development server (serving both ACM and MCE plugins), and a local OpenShift Console container. The console will be available at **http://localhost:9000**. ### Options @@ -119,8 +131,9 @@ The `npm start` command runs a standalone development console that **does not** Use this mode for rapid iteration on features that don't depend on OpenShift Console APIs, but **always verify your work with `npm run plugins` before submitting**. +Complete the [setup steps above](#setup) (`npm ci`, `npm run setup`, `npm run generate-certs`), then: + ```sh -npm run setup # if not already done npm start ``` @@ -154,7 +167,8 @@ All ports are customizable via environment variables. The default values are def | Port Variable | Default | Description | Used by | | -------------- | ------- | ----------------------------------------------------------------------------------- | ------------------------------- | | FRONTEND_PORT | 3000 | Port for standalone console (access at https://localhost:FRONTEND_PORT) | `npm run setup`, `npm start` | -| BACKEND_PORT | 4000 | Port for the backend APIs used by both standalone and plugin modes | `npm run setup`, `npm start`, `npm run plugins` | +| BACKEND_PORT | 4000 | Port for the Go backend APIs used by both standalone and plugin modes | `npm run setup`, `npm start`, `npm run plugins` | +| NODE_BACKEND_PORT | 4001 | Port for the Node sidecar (unmigrated routes; not used by the browser) | `npm start`, `npm run plugins` | | CONSOLE_PORT | 9000 | Port for OpenShift Console (access at http://localhost:CONSOLE_PORT) | `npm run setup`, `npm run plugins` | | MCE_PORT | 3001 | Port on which the `mce` dynamic plugin is served to OpenShift Console | `npm run plugins` | | ACM_PORT | 3002 | Port on which the `acm` dynamic plugin is served to OpenShift Console | `npm run plugins` | @@ -201,9 +215,10 @@ Enabling this feature will allow the user to create a cluster that only contains ### Testing ```bash -npm test # Run all tests (frontend + backend) +npm test # Run all tests (frontend + Go backend + Node sidecar) npm run test:frontend # Run frontend tests only -npm run test:backend # Run backend tests only +npm run test:backend # Run Go backend tests only +npm run test:backend-node # Run Node sidecar tests only npm test -- # Run tests matching a file pattern ``` @@ -265,6 +280,14 @@ It is possible to enable/disable certain features by changing `spec.overrides.co ## Troubleshooting +### `concurrently: command not found` + +`npm start` and `npm run plugins` use `concurrently` from the repo root. Install dependencies first: + +```sh +npm ci +``` + ### Apple Silicon (ARM64) podman crash: `lfstack.push` When running `npm run plugins` (or `npm run ocp-console`) on a Mac with an Apple Silicon chip, the OpenShift Console container may crash immediately with an error like: @@ -293,15 +316,19 @@ This is due to wrong node/npm set of versions. See [Prerequisites section](#prer ### `[start:backend] ERROR:Error reading service account token` -After executing the `npm start` command (either at the root level of the project or at `./backend` folder) an error on `backend` project is produced like +After executing the `npm start` command an error on the backend is produced like + +```text +[go] service account token missing +``` + +or on the sidecar: ```text -[start:backend] ERROR:Error reading service account token -[start:backend] ERROR:process exit, code:1 -[start:backend] [nodemon] app crashed - waiting for file changes before starting... +[sidecar] ERROR:Error reading service account token ``` -`./backend/.env` file is not present or it is wrongly produced. Please follow [Running section guidelines](#running-recommended-openshift-console-plugins). +`./backend/.env` is missing or stale. Run `npm run setup` or `npm run setup:hub` after `oc login`. ### Certs issues @@ -320,9 +347,9 @@ And if the logs are inspected right after running `npm start` command an error i The problem is about the certs not being generated properly, `./backend/certs` folder is most probably empty. -The solution is to completely remove `./backend/certs` folder and then execute `npm run ci:backend` at the root level of the project. +The solution is to remove `./backend/certs` and run `npm run generate-certs` at the repo root (or `npm run setup:hub` after switching clusters). -> Be sure openssl library is installed before running `npm run ci:backend` command. +> Be sure the openssl CLI is installed before running `npm run generate-certs`. ## Related Packages diff --git a/backend-node/.gitignore b/backend-node/.gitignore new file mode 100644 index 00000000000..c0da78ff4c2 --- /dev/null +++ b/backend-node/.gitignore @@ -0,0 +1,5 @@ +# Copyright Contributors to the Open Cluster Management project +build +coverage +backend.mjs +test-report.xml \ No newline at end of file diff --git a/backend/.vscode/launch.json b/backend-node/.vscode/launch.json similarity index 100% rename from backend/.vscode/launch.json rename to backend-node/.vscode/launch.json diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md new file mode 100644 index 00000000000..a390b170d07 --- /dev/null +++ b/backend-node/AGENTS.md @@ -0,0 +1,129 @@ +# Backend + +Node.js ESM proxy server. Sits between the browser and the hub cluster API server, handling authentication, RBAC enforcement, resource watching, and API proxying. + +## Key Technologies + +- **Runtime**: Node.js with native ESM (`"type": "module"`) +- **Router**: `find-my-way` for HTTP/2 route matching +- **Proxy**: `node:https` + `pipeline` for main API proxy; `http2-proxy` for managed cluster proxy +- **Logging**: Pino with structured JSON output (use `pino-zen` for dev formatting) +- **Metrics**: Prometheus metrics proxied via `metricsProxy` route +- **HTTP Client**: `got` for outbound requests +- **WebSocket**: upgrade handler routes to search (bidirectional relay via `ws` with token injection) and managed cluster proxy (via `http2-proxy`) + +## Source Layout + +| Directory | Purpose | +|-----------|---------| +| `src/lib/` | Core server: `main.ts` entry, `server.ts`, auth, cookies, CORS, proxy, search, SSE, logging, config | +| `src/routes/` | HTTP route handlers: proxy, OAuth, search, events, hub, serve, metrics, managed cluster proxy, etc. | +| `src/resources/` | Backend resource watchers and handlers | +| `test/` | Jest test files | +| `config/` | Runtime configuration lives in `../backend/config` (Go backend) | +| `certs/` | TLS certificates live in `../backend/certs` (`npm run generate-certs` at repo root) | + +## Commands + +Run from the `backend-node/` directory, or use the `npm run *:backend-node` variants from the repo root. + +| Command | Purpose | +|---------|---------| +| `npm start` | Start dev server with nodemon + inspector | +| `npm test` | Run Jest tests | +| `npm run lint` | ESLint check | +| `npm run tsc` | TypeScript type check | +| `npm run check` | Run lint + prettier + tsc together | +| `npm run build` | Production build via tsc + rollup → `backend.mjs` | + +## Architecture + +The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated. + +```text +Browser / plugin → Go :4000 → Node sidecar (this package) → Hub Cluster API Server + ↓ + Watches resources via service account + Enforces RBAC via user token + SubjectAccessReview + Streams events to frontend via SSE +``` + +## Route Handlers + +- Route handler signature: `(req: Http2ServerRequest, res: Http2ServerResponse): Promise` +- Router uses `maxParamLength: 500` for long Kubernetes resource names +- URL rewriting: `/multicloud` prefix is stripped before routing in `app.ts` for HTTP and `server.ts` for WebSocket upgrades (e.g., `/multicloud/proxy/search` → `/proxy/search`) +- Use `pipeline()` from `node:stream` for proxy and streaming operations to ensure proper backpressure and cleanup +- Use `getEncodeStream()` for SSE compression + +## Security + +- Never log sensitive data (tokens, passwords, credentials) +- Validate and sanitize all inputs +- Guard against injection vulnerabilities (command injection, path traversal) +- Ensure proper authentication and authorization checks on all routes +- Use `SelfSubjectAccessReview` for permission checks +- Log at appropriate levels with Pino (error, warn, info, debug) — include relevant context but never sensitive data + +## Configuration + +### Environment Variables (`.env`) + +Generated by `npm run setup` from the repo root into **`../backend/.env`**. The sidecar loads it via `ENV_FILE` (default `../backend/.env`). These are cluster-specific: + +| Variable | Purpose | +|----------|---------| +| `PORT` | Sidecar listen port (`NODE_BACKEND_PORT`, default 4001). Public `PORT` in `.env` is the Go listener (4000). | +| `ENV_FILE` / `CONFIG_DIR` / `CERTS_DIR` | Shared artifacts owned by the Go backend (`../backend/.env`, `config`, `certs`) | +| `NODE_ENV` | `development` or `production` — controls CORS, caching, logging, cert behavior | +| `CLUSTER_API_URL` | Hub cluster API server URL — used extensively for all K8s API calls | +| `TOKEN` | Service account token for backend-initiated cluster requests | +| `CA_CERT` / `SERVICE_CA_CERT` | Cluster CA certificates for TLS verification | +| `OAUTH2_CLIENT_ID` / `OAUTH2_CLIENT_SECRET` | OAuth client credentials for login flow | +| `OAUTH2_REDIRECT_URL` | OAuth callback URL | +| `OIDC_ISSUER_URL` | OIDC issuer URL (when using external OIDC instead of OpenShift OAuth) | +| `FRONTEND_URL` | Frontend URL for post-login redirect | +| `SEARCH_API_URL` | Search API route URL | +| `PLACEMENT_DEBUG_URL` | Placement debug service route URL | +| `CLUSTER_PROXY_ADDON_USER_HOST` / `CLUSTER_PROXY_ADDON_USER_ROUTE` | Managed cluster proxy endpoint | +| `OBSERVABILITY_ROUTE` | Observability query proxy route (requires ACM Observability) | +| `PROMETHEUS_ROUTE` | Prometheus route for metrics proxy | + +Optional development/debug variables (not in `.env` by default): + +| Variable | Purpose | +|----------|---------| +| `HTTPS_PROXY` | HTTP proxy for outbound requests | +| `DELAY` / `RANDOM_DELAY` | Artificial delay for dev testing (development mode only) | +| `MOCK_CLUSTERS` | Number of mock clusters to generate for testing | +| `DISABLE_EVENTS` | Set to `true` to disable SSE event streams | +| `DISABLE_STREAM_COMPRESSION` | Set to `true` to disable SSE compression | +| `PUBLIC_FOLDER` | Override static file serving path (default `./public`) | + +### Settings (`../backend/config/` directory) + +Files in the Go backend `config/` directory are loaded at startup and watched for dynamic updates. + +- `LOG_*` keys (`LOG_LEVEL`, `LOG_ACCESS`, `LOG_EVENTS`, `LOG_MEMORY`, `LOG_WATCH`) — control logging behavior +- `APP_SEARCH_*` keys (`APP_SEARCH_INTERVAL`, `APP_SEARCH_LIMIT`) — application search tuning +- `globalSearchFeatureFlag` — enables federated search endpoint +- `UPGRADE_RISKS_PREDICTION_URL` — override for upgrade risk prediction service + +Other config files (e.g., `singleNodeOpenshift`, `ansibleIntegration`, `awsPrivateWizardStep`) are sent to the frontend as settings but not promoted to backend env vars. The frontend uses these to toggle UI features like single-node cluster creation and Ansible automation options. + +### Feature Flags + +Feature flags come from two mechanisms: +- **MultiClusterHub components** — the `/multiclusterhub/components` route exposes MCH component status, used by the frontend to determine which features are installed +- **Config settings** — files in `config/` act as feature toggles (e.g., `singleNodeOpenshift`, `ansibleIntegration`), pushed to the frontend via SSE and checked with `settings. === 'enabled'` + +## Testing + +- Test files are in `test/` +- Tests should meaningfully cover behavior, not just achieve coverage metrics +- Properly mock and isolate dependencies +- Async tests must handle promises correctly + +## Environment + +The sidecar requires `../backend/.env` (generated by `npm run setup` from the repo root). Key variables include the cluster API URL, OAuth credentials, and service account token. diff --git a/backend-node/CLAUDE.md b/backend-node/CLAUDE.md new file mode 100644 index 00000000000..43c994c2d36 --- /dev/null +++ b/backend-node/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/backend/eslint.config.mjs b/backend-node/eslint.config.mjs similarity index 100% rename from backend/eslint.config.mjs rename to backend-node/eslint.config.mjs diff --git a/backend/package-lock.json b/backend-node/package-lock.json similarity index 100% rename from backend/package-lock.json rename to backend-node/package-lock.json diff --git a/backend/package.json b/backend-node/package.json similarity index 93% rename from backend/package.json rename to backend-node/package.json index 27ee88e6cb6..aacefb2824f 100644 --- a/backend/package.json +++ b/backend-node/package.json @@ -5,8 +5,6 @@ "type": "module", "scripts": { "start": "NODE_ENV=development nodemon --watch './**/*.ts' --exec 'node --inspect --experimental-transform-types --import extensionless/register src/lib/main.ts' | pino-zen -r msg=6 -d fields -d labels -d apiVersion -e error", - "postinstall": "[ ! -d ./certs ] && npm run generate-certs || true", - "generate-certs": "mkdir -p certs && openssl req -subj '/C=US' -new -newkey rsa:2048 -sha256 -days 365 -nodes -x509 -keyout certs/tls.key -out certs/tls.crt", "build": "tsc -p tsconfig.build.json --sourceMap false --declaration false && npx rollup --format es --file backend.mjs -- build/lib/main.js", "tsc": "tsc --noEmit", "clean": "rm -rf coverage build", diff --git a/backend/src/app.ts b/backend-node/src/app.ts similarity index 100% rename from backend/src/app.ts rename to backend-node/src/app.ts diff --git a/backend/src/lib/agent.ts b/backend-node/src/lib/agent.ts similarity index 100% rename from backend/src/lib/agent.ts rename to backend-node/src/lib/agent.ts diff --git a/backend/src/lib/authenticated.ts b/backend-node/src/lib/authenticated.ts similarity index 100% rename from backend/src/lib/authenticated.ts rename to backend-node/src/lib/authenticated.ts diff --git a/backend/src/lib/batch-promise-all.ts b/backend-node/src/lib/batch-promise-all.ts similarity index 100% rename from backend/src/lib/batch-promise-all.ts rename to backend-node/src/lib/batch-promise-all.ts diff --git a/backend/src/lib/body-parser.ts b/backend-node/src/lib/body-parser.ts similarity index 100% rename from backend/src/lib/body-parser.ts rename to backend-node/src/lib/body-parser.ts diff --git a/backend/src/lib/compression.ts b/backend-node/src/lib/compression.ts similarity index 100% rename from backend/src/lib/compression.ts rename to backend-node/src/lib/compression.ts diff --git a/backend/src/lib/config.ts b/backend-node/src/lib/config.ts similarity index 94% rename from backend/src/lib/config.ts rename to backend-node/src/lib/config.ts index 7ae0341b168..f09d077f3ce 100644 --- a/backend/src/lib/config.ts +++ b/backend-node/src/lib/config.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import type { SettingsEvent } from '../routes/events' import { watchFile } from './fileWatch' import { logger } from './logger' +import { configDir } from './paths' import { ServerSideEvents } from './server-side-events' let settingsEventID = 0 @@ -26,10 +27,10 @@ export async function loadConfigSettings(): Promise { const settings: Record = {} const readPaths: string[] = [] try { - const filenames = await readdir('./config') + const filenames = await readdir(configDir()) for (const filename of filenames) { try { - const filePath = join('./config', filename) + const filePath = join(configDir(), filename) const stats = await stat(filePath) if (stats.isDirectory()) continue const contents = await readFile(filePath) diff --git a/backend/src/lib/cookies.ts b/backend-node/src/lib/cookies.ts similarity index 100% rename from backend/src/lib/cookies.ts rename to backend-node/src/lib/cookies.ts diff --git a/backend/src/lib/cors.ts b/backend-node/src/lib/cors.ts similarity index 100% rename from backend/src/lib/cors.ts rename to backend-node/src/lib/cors.ts diff --git a/backend/src/lib/delay.ts b/backend-node/src/lib/delay.ts similarity index 100% rename from backend/src/lib/delay.ts rename to backend-node/src/lib/delay.ts diff --git a/backend/src/lib/fetch-retry.ts b/backend-node/src/lib/fetch-retry.ts similarity index 100% rename from backend/src/lib/fetch-retry.ts rename to backend-node/src/lib/fetch-retry.ts diff --git a/backend/src/lib/fileWatch.ts b/backend-node/src/lib/fileWatch.ts similarity index 100% rename from backend/src/lib/fileWatch.ts rename to backend-node/src/lib/fileWatch.ts diff --git a/backend/src/lib/getServiceToken.ts b/backend-node/src/lib/getServiceToken.ts similarity index 100% rename from backend/src/lib/getServiceToken.ts rename to backend-node/src/lib/getServiceToken.ts diff --git a/backend/src/lib/gigantic.ts b/backend-node/src/lib/gigantic.ts similarity index 100% rename from backend/src/lib/gigantic.ts rename to backend-node/src/lib/gigantic.ts diff --git a/backend/src/lib/json-request.ts b/backend-node/src/lib/json-request.ts similarity index 100% rename from backend/src/lib/json-request.ts rename to backend-node/src/lib/json-request.ts diff --git a/backend/src/lib/logger.ts b/backend-node/src/lib/logger.ts similarity index 100% rename from backend/src/lib/logger.ts rename to backend-node/src/lib/logger.ts diff --git a/backend/src/lib/main.ts b/backend-node/src/lib/main.ts similarity index 96% rename from backend/src/lib/main.ts rename to backend-node/src/lib/main.ts index daa92af4caa..5efd9757cd1 100644 --- a/backend/src/lib/main.ts +++ b/backend-node/src/lib/main.ts @@ -3,9 +3,10 @@ import { config } from 'dotenv' import { cpus, totalmem } from 'node:os' import { start, stop } from '../app' import { logger } from './logger' +import { envFilePath } from './paths' try { - config({ path: '.env' }) + config({ path: envFilePath() }) } catch (err) { // Do Nothing } diff --git a/backend/src/lib/managed-cluster-addon.ts b/backend-node/src/lib/managed-cluster-addon.ts similarity index 100% rename from backend/src/lib/managed-cluster-addon.ts rename to backend-node/src/lib/managed-cluster-addon.ts diff --git a/backend/src/lib/memory.ts b/backend-node/src/lib/memory.ts similarity index 100% rename from backend/src/lib/memory.ts rename to backend-node/src/lib/memory.ts diff --git a/backend/src/lib/multi-cluster-engine.ts b/backend-node/src/lib/multi-cluster-engine.ts similarity index 100% rename from backend/src/lib/multi-cluster-engine.ts rename to backend-node/src/lib/multi-cluster-engine.ts diff --git a/backend/src/lib/multi-cluster-hub.ts b/backend-node/src/lib/multi-cluster-hub.ts similarity index 100% rename from backend/src/lib/multi-cluster-hub.ts rename to backend-node/src/lib/multi-cluster-hub.ts diff --git a/backend/src/lib/noop.ts b/backend-node/src/lib/noop.ts similarity index 100% rename from backend/src/lib/noop.ts rename to backend-node/src/lib/noop.ts diff --git a/backend/src/lib/pagination.ts b/backend-node/src/lib/pagination.ts similarity index 100% rename from backend/src/lib/pagination.ts rename to backend-node/src/lib/pagination.ts diff --git a/backend-node/src/lib/paths.ts b/backend-node/src/lib/paths.ts new file mode 100644 index 00000000000..fc051dca4a2 --- /dev/null +++ b/backend-node/src/lib/paths.ts @@ -0,0 +1,18 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { join } from 'node:path' + +export function envFilePath(): string { + return process.env.ENV_FILE || '.env' +} + +export function configDir(): string { + return process.env.CONFIG_DIR || './config' +} + +export function certsDir(): string { + return process.env.CERTS_DIR || './certs' +} + +export function certFile(name: string): string { + return join(certsDir(), name) +} diff --git a/backend/src/lib/placementDebugCAWatch.ts b/backend-node/src/lib/placementDebugCAWatch.ts similarity index 100% rename from backend/src/lib/placementDebugCAWatch.ts rename to backend-node/src/lib/placementDebugCAWatch.ts diff --git a/backend/src/lib/random-string.ts b/backend-node/src/lib/random-string.ts similarity index 100% rename from backend/src/lib/random-string.ts rename to backend-node/src/lib/random-string.ts diff --git a/backend/src/lib/request-retry.ts b/backend-node/src/lib/request-retry.ts similarity index 100% rename from backend/src/lib/request-retry.ts rename to backend-node/src/lib/request-retry.ts diff --git a/backend/src/lib/respond.ts b/backend-node/src/lib/respond.ts similarity index 100% rename from backend/src/lib/respond.ts rename to backend-node/src/lib/respond.ts diff --git a/backend/src/lib/search.ts b/backend-node/src/lib/search.ts similarity index 100% rename from backend/src/lib/search.ts rename to backend-node/src/lib/search.ts diff --git a/backend/src/lib/server-side-events.ts b/backend-node/src/lib/server-side-events.ts similarity index 99% rename from backend/src/lib/server-side-events.ts rename to backend-node/src/lib/server-side-events.ts index 73840733644..f56757a39d1 100644 --- a/backend/src/lib/server-side-events.ts +++ b/backend-node/src/lib/server-side-events.ts @@ -368,7 +368,6 @@ export class ServerSideEvents { case 'MulticlusterRoleAssignment': case 'User': case 'Group': - case 'ClusterRole': rbac.push(event) break case 'Search': diff --git a/backend/src/lib/server.ts b/backend-node/src/lib/server.ts similarity index 98% rename from backend/src/lib/server.ts rename to backend-node/src/lib/server.ts index aba8863bedf..5698da035b2 100644 --- a/backend/src/lib/server.ts +++ b/backend-node/src/lib/server.ts @@ -9,6 +9,7 @@ import { logger } from './logger' import { managedClusterProxy } from '../routes/managedClusterProxy' import { readFileSync } from 'node:fs' import { searchWebSocket } from '../routes/search' +import { certFile } from './paths' let server: Http2Server | undefined @@ -32,8 +33,8 @@ export function startServer(options: ServerOptions): Promise { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.ENV_FILE + delete process.env.CONFIG_DIR + delete process.env.CERTS_DIR + }) + + afterAll(() => { + process.env = originalEnv + }) + + it('uses defaults when env vars are unset', () => { + expect(envFilePath()).toBe('.env') + expect(configDir()).toBe('./config') + expect(certsDir()).toBe('./certs') + expect(certFile('tls.crt')).toBe('./certs/tls.crt') + }) + + it('uses env overrides when set', () => { + process.env.ENV_FILE = '../backend/.env' + process.env.CONFIG_DIR = '../backend/config' + process.env.CERTS_DIR = '../backend/certs' + + expect(envFilePath()).toBe('../backend/.env') + expect(configDir()).toBe('../backend/config') + expect(certsDir()).toBe('../backend/certs') + expect(certFile('tls.key')).toBe('../backend/certs/tls.key') + }) +}) diff --git a/backend/test/lib/placementDebugCAWatch.test.ts b/backend-node/test/lib/placementDebugCAWatch.test.ts similarity index 100% rename from backend/test/lib/placementDebugCAWatch.test.ts rename to backend-node/test/lib/placementDebugCAWatch.test.ts diff --git a/backend/test/lib/tlsProfileWatch.test.ts b/backend-node/test/lib/tlsProfileWatch.test.ts similarity index 100% rename from backend/test/lib/tlsProfileWatch.test.ts rename to backend-node/test/lib/tlsProfileWatch.test.ts diff --git a/backend/test/mock-request.ts b/backend-node/test/mock-request.ts similarity index 100% rename from backend/test/mock-request.ts rename to backend-node/test/mock-request.ts diff --git a/backend/test/routes/aggregator.test.ts b/backend-node/test/routes/aggregator.test.ts similarity index 100% rename from backend/test/routes/aggregator.test.ts rename to backend-node/test/routes/aggregator.test.ts diff --git a/backend/test/routes/aggregators/applications.test.ts b/backend-node/test/routes/aggregators/applications.test.ts similarity index 100% rename from backend/test/routes/aggregators/applications.test.ts rename to backend-node/test/routes/aggregators/applications.test.ts diff --git a/backend/test/routes/aggregators/applicationsArgoMergePush.test.ts b/backend-node/test/routes/aggregators/applicationsArgoMergePush.test.ts similarity index 100% rename from backend/test/routes/aggregators/applicationsArgoMergePush.test.ts rename to backend-node/test/routes/aggregators/applicationsArgoMergePush.test.ts diff --git a/backend/test/routes/aggregators/applicationsPushModel.test.ts b/backend-node/test/routes/aggregators/applicationsPushModel.test.ts similarity index 100% rename from backend/test/routes/aggregators/applicationsPushModel.test.ts rename to backend-node/test/routes/aggregators/applicationsPushModel.test.ts diff --git a/backend/test/routes/aggregators/utils.test.ts b/backend-node/test/routes/aggregators/utils.test.ts similarity index 100% rename from backend/test/routes/aggregators/utils.test.ts rename to backend-node/test/routes/aggregators/utils.test.ts diff --git a/backend/test/routes/ansibletower.test.ts b/backend-node/test/routes/ansibletower.test.ts similarity index 100% rename from backend/test/routes/ansibletower.test.ts rename to backend-node/test/routes/ansibletower.test.ts diff --git a/backend/test/routes/apiPath.test.ts b/backend-node/test/routes/apiPath.test.ts similarity index 100% rename from backend/test/routes/apiPath.test.ts rename to backend-node/test/routes/apiPath.test.ts diff --git a/backend/test/routes/clusterVersion.test.ts b/backend-node/test/routes/clusterVersion.test.ts similarity index 100% rename from backend/test/routes/clusterVersion.test.ts rename to backend-node/test/routes/clusterVersion.test.ts diff --git a/backend/test/routes/configure.test.ts b/backend-node/test/routes/configure.test.ts similarity index 100% rename from backend/test/routes/configure.test.ts rename to backend-node/test/routes/configure.test.ts diff --git a/backend/test/routes/events.test.ts b/backend-node/test/routes/events.test.ts similarity index 100% rename from backend/test/routes/events.test.ts rename to backend-node/test/routes/events.test.ts diff --git a/backend/test/routes/hub.test.ts b/backend-node/test/routes/hub.test.ts similarity index 100% rename from backend/test/routes/hub.test.ts rename to backend-node/test/routes/hub.test.ts diff --git a/backend/test/routes/hypershift-status.test.ts b/backend-node/test/routes/hypershift-status.test.ts similarity index 100% rename from backend/test/routes/hypershift-status.test.ts rename to backend-node/test/routes/hypershift-status.test.ts diff --git a/backend/test/routes/liveness.test.ts b/backend-node/test/routes/liveness.test.ts similarity index 100% rename from backend/test/routes/liveness.test.ts rename to backend-node/test/routes/liveness.test.ts diff --git a/backend/test/routes/managedClusterProxy.test.ts b/backend-node/test/routes/managedClusterProxy.test.ts similarity index 100% rename from backend/test/routes/managedClusterProxy.test.ts rename to backend-node/test/routes/managedClusterProxy.test.ts diff --git a/backend/test/routes/metricsProxy.test.ts b/backend-node/test/routes/metricsProxy.test.ts similarity index 100% rename from backend/test/routes/metricsProxy.test.ts rename to backend-node/test/routes/metricsProxy.test.ts diff --git a/backend/test/routes/operatorCheck.test.ts b/backend-node/test/routes/operatorCheck.test.ts similarity index 100% rename from backend/test/routes/operatorCheck.test.ts rename to backend-node/test/routes/operatorCheck.test.ts diff --git a/backend/test/routes/ping.test.ts b/backend-node/test/routes/ping.test.ts similarity index 100% rename from backend/test/routes/ping.test.ts rename to backend-node/test/routes/ping.test.ts diff --git a/backend/test/routes/placementDebug.test.ts b/backend-node/test/routes/placementDebug.test.ts similarity index 100% rename from backend/test/routes/placementDebug.test.ts rename to backend-node/test/routes/placementDebug.test.ts diff --git a/backend/test/routes/proxy.test.ts b/backend-node/test/routes/proxy.test.ts similarity index 100% rename from backend/test/routes/proxy.test.ts rename to backend-node/test/routes/proxy.test.ts diff --git a/backend/test/routes/readiness.test.ts b/backend-node/test/routes/readiness.test.ts similarity index 100% rename from backend/test/routes/readiness.test.ts rename to backend-node/test/routes/readiness.test.ts diff --git a/backend/test/routes/rosaWizardApi.test.ts b/backend-node/test/routes/rosaWizardApi.test.ts similarity index 100% rename from backend/test/routes/rosaWizardApi.test.ts rename to backend-node/test/routes/rosaWizardApi.test.ts diff --git a/backend/test/routes/search.test.ts b/backend-node/test/routes/search.test.ts similarity index 100% rename from backend/test/routes/search.test.ts rename to backend-node/test/routes/search.test.ts diff --git a/backend/test/routes/searchWebSocket.test.ts b/backend-node/test/routes/searchWebSocket.test.ts similarity index 100% rename from backend/test/routes/searchWebSocket.test.ts rename to backend-node/test/routes/searchWebSocket.test.ts diff --git a/backend/test/routes/serve.test.ts b/backend-node/test/routes/serve.test.ts similarity index 100% rename from backend/test/routes/serve.test.ts rename to backend-node/test/routes/serve.test.ts diff --git a/backend/test/routes/upgrade-risks-prediction.test.ts b/backend-node/test/routes/upgrade-risks-prediction.test.ts similarity index 100% rename from backend/test/routes/upgrade-risks-prediction.test.ts rename to backend-node/test/routes/upgrade-risks-prediction.test.ts diff --git a/backend/test/routes/username.test.ts b/backend-node/test/routes/username.test.ts similarity index 100% rename from backend/test/routes/username.test.ts rename to backend-node/test/routes/username.test.ts diff --git a/backend/test/routes/userpreference.test.ts b/backend-node/test/routes/userpreference.test.ts similarity index 100% rename from backend/test/routes/userpreference.test.ts rename to backend-node/test/routes/userpreference.test.ts diff --git a/backend/test/routes/virtualMachineProxy.test.ts b/backend-node/test/routes/virtualMachineProxy.test.ts similarity index 100% rename from backend/test/routes/virtualMachineProxy.test.ts rename to backend-node/test/routes/virtualMachineProxy.test.ts diff --git a/backend/test/tsconfig.json b/backend-node/test/tsconfig.json similarity index 100% rename from backend/test/tsconfig.json rename to backend-node/test/tsconfig.json diff --git a/backend/tsconfig.build.json b/backend-node/tsconfig.build.json similarity index 100% rename from backend/tsconfig.build.json rename to backend-node/tsconfig.build.json diff --git a/backend/tsconfig.json b/backend-node/tsconfig.json similarity index 100% rename from backend/tsconfig.json rename to backend-node/tsconfig.json diff --git a/backend/.air.toml b/backend/.air.toml new file mode 100644 index 00000000000..ed6a7e404d4 --- /dev/null +++ b/backend/.air.toml @@ -0,0 +1,20 @@ +# Live reload for the Go console backend (dev only). +# https://github.com/air-verse/air +root = "." +tmp_dir = "tmp" + +[build] + cmd = "go build -o ./tmp/console ./cmd/console" + bin = "./tmp/console" + entrypoint = ["./tmp/console"] + include_dir = ["cmd", "internal"] + include_ext = ["go"] + exclude_dir = ["tmp", "bin", "config", "certs", "src", "test", "vendor"] + exclude_regex = ["_test\\.go"] + delay = 500 + send_interrupt = true + kill_delay = "3s" + stop_on_error = false + +[misc] + clean_on_exit = true diff --git a/backend/.gitignore b/backend/.gitignore index c0da78ff4c2..777bd24bb2e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,5 +1,6 @@ # Copyright Contributors to the Open Cluster Management project -build -coverage -backend.mjs -test-report.xml \ No newline at end of file +bin/ +coverage/ +tmp/ +.env +certs/ diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 00000000000..b436b9a0fdb --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,34 @@ +# golangci-lint configuration +# https://golangci-lint.run/usage/configuration/ +run: + tests: true +linters: + enable: + - copyloopvar + - errcheck + - gosimple + - govet + - ineffassign + - misspell + - revive + - staticcheck + - unused +linters-settings: + copyloopvar: + check-alias: true + govet: + enable: + - shadow + misspell: + locale: US + revive: + rules: + - name: package-comments + disabled: true + gci: + sections: + - standard + - default + - prefix(github.com/stolostron/console/backend) + - blank + - dot diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 3d7a44a5ebc..b88e3fed620 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -1,128 +1,64 @@ -# Backend +# Backend (Go) -Node.js ESM proxy server. Sits between the browser and the hub cluster API server, handling authentication, RBAC enforcement, resource watching, and API proxying. +Public listener for the ACM/MCE console. During the Node-to-Go migration it owns TLS, health probes, config, and auth helpers, and reverse-proxies every unmigrated route to the Node sidecar in `../backend-node`. ## Key Technologies -- **Runtime**: Node.js with native ESM (`"type": "module"`) -- **Router**: `find-my-way` for HTTP/2 route matching -- **Proxy**: `node:https` + `pipeline` for main API proxy; `http2-proxy` for managed cluster proxy -- **Logging**: Pino with structured JSON output (use `pino-zen` for dev formatting) -- **Metrics**: Prometheus metrics proxied via `metricsProxy` route -- **HTTP Client**: `got` for outbound requests -- **WebSocket**: upgrade handler routes to search (bidirectional relay via `ws` with token injection) and managed cluster proxy (via `http2-proxy`) +- **Runtime**: Go 1.26+ (`net/http`; TLS enables HTTP/2 automatically) +- **Router**: `chi` — probes registered natively; everything else is `NotFound` → reverse proxy +- **Proxy**: `httputil.ReverseProxy` (HTTP/1.1 to the sidecar so WebSocket upgrades work; `FlushInterval: -1` for SSE) +- **Logging**: `log/slog` JSON (`method`, `path`, `status`, `duration`) +- **Config watch**: `fsnotify` on `config/` (1s debounce) +- **Auth**: cookie `acm-access-token-cookie` then `Authorization: Bearer`; TokenReview is a library, not a global gate ## Source Layout -| Directory | Purpose | -|-----------|---------| -| `src/lib/` | Core server: `main.ts` entry, `server.ts`, auth, cookies, CORS, proxy, search, SSE, logging, config | -| `src/routes/` | HTTP route handlers: proxy, OAuth, search, events, hub, serve, metrics, managed cluster proxy, etc. | -| `src/resources/` | Backend resource watchers and handlers | -| `test/` | Jest test files | -| `config/` | Runtime configuration | -| `certs/` | TLS certificates (auto-generated on `npm install`) | +| Path | Purpose | +|------|---------| +| `cmd/console` | Process entry: load config, require SA token, listen, SIGINT/SIGTERM | +| `internal/server` | TLS listener, chi mux, `/multicloud` probe aliases | +| `internal/proxy` | Reverse proxy to `NODE_BACKEND_URL` (original path, including `/multicloud`) | +| `internal/health` | `/ping`, `/livenessProbe` (Go only), `/readinessProbe` (Go + sidecar `/ping`) | +| `internal/config` | `.env` + `config/` directory (filename = key) | +| `internal/auth` | Cookie/Bearer, SA token/CA, TokenReview helper | +| `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | +| `internal/log` | slog JSON helper | +| `config/` | Runtime settings shared with the Node sidecar | +| `certs/` | TLS material (`npm run generate-certs` at repo root) | ## Commands -Run from the `backend/` directory, or use the `npm run *:backend` variants from the repo root. +From the repo root (preferred), or `cd backend`: | Command | Purpose | |---------|---------| -| `npm start` | Start dev server with nodemon + inspector | -| `npm test` | Run Jest tests | -| `npm run lint` | ESLint check | -| `npm run tsc` | TypeScript type check | -| `npm run check` | Run lint + prettier + tsc together | -| `npm run build` | Production build via tsc + rollup → `backend.mjs` | -| `npm run clean` | Remove build artifacts | -| `npm run generate-certs` | Regenerate TLS certificates | +| `npm start` / `npm run plugins` | Go `:4000` in front of Node sidecar `:4001`. Air rebuilds and restarts Go when `cmd/` or `internal/` change | +| `npm run test:backend` | `go test ./...` | +| `npm run lint:backend` | `golangci-lint` (see `backend/.golangci.yml`) | +| `npm run check:backend` | tests + golangci-lint | +| `npm run build:backend` | `go build -o bin/console ./cmd/console` | +| `npm run setup:hub` | Regenerate `backend/.env` and `backend/certs` after `oc login` to a new cluster | ## Architecture ```text -Browser → Backend (HTTP/2 proxy) → Hub Cluster API Server - ↓ - Watches resources via service account - Enforces RBAC via user token + SubjectAccessReview - Streams events to frontend via SSE +Browser / OpenShift Console plugin + │ + ▼ +Go backend :4000 (TLS / HTTP/2) + ├─ GET /livenessProbe, /readinessProbe, /ping + │ (also /multicloud/…) + ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) + └─ everything else (original URL) ──HTTP/1.1──► Node sidecar :4001 + │ + ▼ + Hub cluster API ``` -## Route Handlers +`/multicloud` is stripped only when matching Go-owned routes. The proxy forwards the original path so Node can keep stripping it. -- Route handler signature: `(req: Http2ServerRequest, res: Http2ServerResponse): Promise` -- Router uses `maxParamLength: 500` for long Kubernetes resource names -- URL rewriting: `/multicloud` prefix is stripped before routing in `app.ts` for HTTP and `server.ts` for WebSocket upgrades (e.g., `/multicloud/proxy/search` → `/proxy/search`) -- Use `pipeline()` from `node:stream` for proxy and streaming operations to ensure proper backpressure and cleanup -- Use `getEncodeStream()` for SSE compression +## Shared artifacts -## Security +`npm run setup` writes `backend/.env`. The sidecar loads the same file via `ENV_FILE` / `CONFIG_DIR` / `CERTS_DIR`. `godotenv` does not override `PORT`, so the sidecar can listen on `NODE_BACKEND_PORT` while `.env` still has `PORT=4000` for Go. -- Never log sensitive data (tokens, passwords, credentials) -- Validate and sanitize all inputs -- Guard against injection vulnerabilities (command injection, path traversal) -- Ensure proper authentication and authorization checks on all routes -- Use `SelfSubjectAccessReview` for permission checks -- Log at appropriate levels with Pino (error, warn, info, debug) — include relevant context but never sensitive data - -## Configuration - -### Environment Variables (`.env`) - -Generated by `npm run setup` from the repo root. These are cluster-specific and should not be set manually unless overriding for special cases: - -| Variable | Purpose | -|----------|---------| -| `PORT` | Backend server port (defaults via `port-defaults.sh`) | -| `NODE_ENV` | `development` or `production` — controls CORS, caching, logging, cert behavior | -| `CLUSTER_API_URL` | Hub cluster API server URL — used extensively for all K8s API calls | -| `TOKEN` | Service account token for backend-initiated cluster requests | -| `CA_CERT` / `SERVICE_CA_CERT` | Cluster CA certificates for TLS verification | -| `OAUTH2_CLIENT_ID` / `OAUTH2_CLIENT_SECRET` | OAuth client credentials for login flow | -| `OAUTH2_REDIRECT_URL` | OAuth callback URL | -| `OIDC_ISSUER_URL` | OIDC issuer URL (when using external OIDC instead of OpenShift OAuth) | -| `FRONTEND_URL` | Frontend URL for post-login redirect | -| `SEARCH_API_URL` | Search API route URL | -| `PLACEMENT_DEBUG_URL` | Placement debug service route URL | -| `CLUSTER_PROXY_ADDON_USER_HOST` / `CLUSTER_PROXY_ADDON_USER_ROUTE` | Managed cluster proxy endpoint | -| `OBSERVABILITY_ROUTE` | Observability query proxy route (requires ACM Observability) | -| `PROMETHEUS_ROUTE` | Prometheus route for metrics proxy | - -Optional development/debug variables (not in `.env` by default): - -| Variable | Purpose | -|----------|---------| -| `HTTPS_PROXY` | HTTP proxy for outbound requests | -| `DELAY` / `RANDOM_DELAY` | Artificial delay for dev testing (development mode only) | -| `MOCK_CLUSTERS` | Number of mock clusters to generate for testing | -| `DISABLE_EVENTS` | Set to `true` to disable SSE event streams | -| `DISABLE_STREAM_COMPRESSION` | Set to `true` to disable SSE compression | -| `PUBLIC_FOLDER` | Override static file serving path (default `./public`) | - -### Settings (`config/` directory) - -Files in `config/` are loaded at startup and watched for dynamic updates. Changes are pushed to the frontend via SSE `SETTINGS` events. Only specific keys are promoted to `process.env`: - -- `LOG_*` keys (`LOG_LEVEL`, `LOG_ACCESS`, `LOG_EVENTS`, `LOG_MEMORY`, `LOG_WATCH`) — control logging behavior -- `APP_SEARCH_*` keys (`APP_SEARCH_INTERVAL`, `APP_SEARCH_LIMIT`) — application search tuning -- `globalSearchFeatureFlag` — enables federated search endpoint -- `UPGRADE_RISKS_PREDICTION_URL` — override for upgrade risk prediction service - -Other config files (e.g., `singleNodeOpenshift`, `ansibleIntegration`, `awsPrivateWizardStep`) are sent to the frontend as settings but not promoted to backend env vars. The frontend uses these to toggle UI features like single-node cluster creation and Ansible automation options. - -### Feature Flags - -Feature flags come from two mechanisms: -- **MultiClusterHub components** — the `/multiclusterhub/components` route exposes MCH component status, used by the frontend to determine which features are installed -- **Config settings** — files in `config/` act as feature toggles (e.g., `singleNodeOpenshift`, `ansibleIntegration`), pushed to the frontend via SSE and checked with `settings. === 'enabled'` - -## Testing - -- Test files are in `test/` -- Tests should meaningfully cover behavior, not just achieve coverage metrics -- Properly mock and isolate dependencies -- Async tests must handle promises correctly - -## Environment - -The backend requires a `.env` file for cluster connection. Generate it with `npm run setup` from the repo root. Key variables include the cluster API URL, OAuth credentials, and service account token. +Go exits 1 at startup if the service-account token is missing (`TOKEN` or `/var/run/secrets/kubernetes.io/serviceaccount/token`). diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000000..8d2c743ee89 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,27 @@ +# Copyright Contributors to the Open Cluster Management project + +# Console backend (Go) + +This directory is the ACM/MCE console backend. During the Node-to-Go migration it fronts a Node sidecar (`../backend-node`) and reverse-proxies unmigrated routes. + +## Local development + +From the repo root: + +```sh +npm ci # required once; runs go mod download when Go is installed +npm run setup # writes backend/.env from the current oc context +npm run generate-certs +npm start # or npm run plugins +``` + +After `oc login` to a new hub: + +```sh +npm run setup:hub +# restart npm start / npm run plugins +``` + +See [AGENTS.md](AGENTS.md) for layout, architecture, and commands. + +Go listens on `BACKEND_PORT` (default 4000). The Node sidecar listens on `NODE_BACKEND_PORT` (default 4001). diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go new file mode 100644 index 00000000000..dc87ee88017 --- /dev/null +++ b/backend/cmd/console/main.go @@ -0,0 +1,76 @@ +// Copyright Contributors to the Open Cluster Management project + +package main + +import ( + "context" + "errors" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/config" + rbacevents "github.com/stolostron/console/backend/internal/events/rbac" + applog "github.com/stolostron/console/backend/internal/log" + "github.com/stolostron/console/backend/internal/server" + "k8s.io/client-go/kubernetes" +) + +func main() { + if err := run(); err != nil { + applog.Logger().Error("process exit", "error", err) + os.Exit(1) + } +} + +func run() error { + cfg := config.Load() + applog.SetLevel(cfg.LogLevel) + + sa, ok := auth.LoadServiceAccount(cfg) + if !ok { + applog.Logger().Error("service account token missing", + "msg", "set TOKEN or mount /var/run/secrets/kubernetes.io/serviceaccount/token") + return errMissingToken + } + + stopWatch, err := cfg.Watch() + if err != nil { + applog.Logger().Warn("config watch disabled", "error", err) + } else { + defer stopWatch() + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + restCfg, err := auth.RESTConfig(cfg, sa) + if err != nil { + return err + } + kube, err := kubernetes.NewForConfig(restCfg) + if err != nil { + return err + } + store := rbacevents.NewStore() + if err = rbacevents.StartInformer(ctx, kube, store); err != nil { + return err + } + rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacevents.NewSSARAccess(restCfg)) + + handler, err := server.Handler(cfg, server.WithRBACEvents(rbacHandler)) + if err != nil { + return err + } + + applog.Logger().Info("process start", + "PORT", cfg.Port, + "NODE_BACKEND_URL", cfg.NodeBackendURL, + slog.String("CONFIG_DIR", cfg.ConfigDir), + ) + return server.ListenAndServe(ctx, cfg, handler) +} + +var errMissingToken = errors.New("service account token missing") diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 00000000000..614231239c6 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,52 @@ +module github.com/stolostron/console/backend + +go 1.26.0 + +require ( + github.com/fsnotify/fsnotify v1.8.0 + github.com/go-chi/chi/v5 v5.2.1 + github.com/joho/godotenv v1.5.1 + k8s.io/api v0.32.3 + k8s.io/apimachinery v0.32.3 + k8s.io/client-go v0.32.3 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.25.0 // indirect + golang.org/x/text v0.19.0 // indirect + golang.org/x/time v0.7.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 00000000000..a2ee2bbbf30 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,160 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= +k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= +k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= +k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU= +k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 00000000000..3cd39e16634 --- /dev/null +++ b/backend/internal/auth/auth.go @@ -0,0 +1,173 @@ +// Copyright Contributors to the Open Cluster Management project + +package auth + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + authv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/config" +) + +const ( + AccessTokenCookie = "acm-access-token-cookie" + + bearerSchemePrefix = "Bearer " // RFC 6750 Authorization header scheme prefix + bearerSchemePrefixLen = len(bearerSchemePrefix) +) + +var serviceAccountBaseDir = "/var/run/secrets/kubernetes.io/serviceaccount" + +// SetServiceAccountDir overrides the SA mount path (tests). +func SetServiceAccountDir(dir string) func() { + prev := serviceAccountBaseDir + serviceAccountBaseDir = dir + return func() { serviceAccountBaseDir = prev } +} + +// ServiceAccount holds the in-cluster (or env-fallback) credentials. +type ServiceAccount struct { + Token string + CACert []byte +} + +// LoadServiceAccount reads the projected SA files, falling back to TOKEN / CA_CERT. +// If no token is available, ok is false (callers that require a token should exit). +func LoadServiceAccount(cfg *config.Config) (ServiceAccount, bool) { + token := readFileOrDefault(filepath.Join(serviceAccountBaseDir, "token"), cfg.Token) + caPath := filepath.Join(serviceAccountBaseDir, "ca.crt") + ca, err := os.ReadFile(caPath) + if err != nil { + if cfg.CACert != "" { + decoded, decErr := base64.StdEncoding.DecodeString(cfg.CACert) + if decErr == nil { + ca = decoded + } + } + } + if strings.TrimSpace(token) == "" { + return ServiceAccount{}, false + } + return ServiceAccount{Token: token, CACert: ca}, true +} + +func readFileOrDefault(path, fallback string) string { + data, err := os.ReadFile(path) + if err != nil { + return fallback + } + return string(data) +} + +// TokenFromRequest returns the user token: cookie first, then Bearer. +func TokenFromRequest(r *http.Request) string { + if r == nil { + return "" + } + if c, err := r.Cookie(AccessTokenCookie); err == nil && c.Value != "" { + return c.Value + } + authz := r.Header.Get("Authorization") + if len(authz) > bearerSchemePrefixLen && strings.EqualFold(authz[:bearerSchemePrefixLen], bearerSchemePrefix) { + return strings.TrimSpace(authz[bearerSchemePrefixLen:]) + } + return "" +} + +// TokenReviewer validates a bearer token against the hub API. +type TokenReviewer interface { + Review(ctx context.Context, token string) (bool, error) +} + +type kubeReviewer struct { + client kubernetes.Interface +} + +// RESTConfig builds a client-go rest.Config from the service account. +func RESTConfig(cfg *config.Config, sa ServiceAccount) (*rest.Config, error) { + if cfg.ClusterAPIURL == "" { + return nil, errors.New("CLUSTER_API_URL is not set") + } + restCfg := &rest.Config{ + Host: cfg.ClusterAPIURL, + BearerToken: sa.Token, + TLSClientConfig: rest.TLSClientConfig{ + CAData: sa.CACert, + }, + } + if len(sa.CACert) == 0 { + restCfg.TLSClientConfig.Insecure = true + } + return restCfg, nil +} + +// UserRESTConfig copies base and impersonates the user via Bearer token. +func UserRESTConfig(base *rest.Config, userToken string) *rest.Config { + c := rest.CopyConfig(base) + c.BearerToken = userToken + c.BearerTokenFile = "" + return c +} + +// ValidateUserToken checks the token the same way the Node sidecar does: GET /api. +// TokenReview is not used here because console-mce can create TokenReviews for some +// identities that still fail Review, while GET /api matches /events auth. +func ValidateUserToken(ctx context.Context, base *rest.Config, token string) error { + if base == nil { + return errors.New("rest config is required") + } + cfg := UserRESTConfig(base, token) + httpClient, err := rest.HTTPClientFor(cfg) + if err != nil { + return err + } + host := strings.TrimRight(cfg.Host, "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, host+"/api", nil) + if err != nil { + return err + } + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("token validation status %d", resp.StatusCode) + } + return nil +} + +// NewTokenReviewer builds a TokenReview client using the service account. +func NewTokenReviewer(cfg *config.Config, sa ServiceAccount) (TokenReviewer, error) { + restCfg, err := RESTConfig(cfg, sa) + if err != nil { + return nil, err + } + client, err := kubernetes.NewForConfig(restCfg) + if err != nil { + return nil, err + } + return &kubeReviewer{client: client}, nil +} + +func (k *kubeReviewer) Review(ctx context.Context, token string) (bool, error) { + tr, err := k.client.AuthenticationV1().TokenReviews().Create(ctx, &authv1.TokenReview{ + Spec: authv1.TokenReviewSpec{Token: token}, + }, metav1.CreateOptions{}) + if err != nil { + return false, err + } + return tr.Status.Authenticated, nil +} diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go new file mode 100644 index 00000000000..40b3213f1a1 --- /dev/null +++ b/backend/internal/auth/auth_test.go @@ -0,0 +1,134 @@ +// Copyright Contributors to the Open Cluster Management project + +package auth_test + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/config" +) + +func TestTokenFromRequest_CookieWinsOverBearer(t *testing.T) { + req := &http.Request{Header: http.Header{}} + req.Header.Set("Cookie", "acm-access-token-cookie=from-cookie") + req.Header.Set("Authorization", "Bearer from-header") + if got := auth.TokenFromRequest(req); got != "from-cookie" { + t.Fatalf("got %q, want cookie token", got) + } +} + +func TestTokenFromRequest_BearerFallback(t *testing.T) { + req := &http.Request{Header: http.Header{}} + req.Header.Set("Authorization", "Bearer from-header") + if got := auth.TokenFromRequest(req); got != "from-header" { + t.Fatalf("got %q, want bearer token", got) + } +} + +func TestTokenFromRequest_Missing(t *testing.T) { + req := &http.Request{Header: http.Header{}} + if got := auth.TokenFromRequest(req); got != "" { + t.Fatalf("got %q, want empty", got) + } +} + +func TestTokenFromRequest_CookieWithEquals(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: auth.AccessTokenCookie, Value: "abc=def"}) + if got := auth.TokenFromRequest(req); got != "abc=def" { + t.Fatalf("got %q, want cookie value with equals", got) + } +} + +func TestNewTokenReviewer_RequiresClusterAPIURL(t *testing.T) { + _, err := auth.NewTokenReviewer(&config.Config{}, auth.ServiceAccount{Token: "t"}) + if err == nil { + t.Fatal("expected error when CLUSTER_API_URL is empty") + } +} + +func TestValidateUserToken_OKAndUnauthorized(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer good" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"kind":"APIVersions"}`)) + })) + defer ts.Close() + + base := &rest.Config{Host: ts.URL, TLSClientConfig: rest.TLSClientConfig{Insecure: true}} + if err := auth.ValidateUserToken(context.Background(), base, "good"); err != nil { + t.Fatal(err) + } + if err := auth.ValidateUserToken(context.Background(), base, "bad"); err == nil { + t.Fatal("expected unauthorized token to fail") + } +} + +func TestLoadServiceAccount_EnvFallback(t *testing.T) { + dir := t.TempDir() + restore := auth.SetServiceAccountDir(dir) + defer restore() + + cfg := &config.Config{Token: "env-token", CACert: base64.StdEncoding.EncodeToString([]byte("ca-bytes"))} + sa, ok := auth.LoadServiceAccount(cfg) + if !ok { + t.Fatal("expected token from env") + } + if sa.Token != "env-token" { + t.Fatalf("token %q", sa.Token) + } + if string(sa.CACert) != "ca-bytes" { + t.Fatalf("ca %q", sa.CACert) + } +} + +func TestLoadServiceAccount_FromFiles(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "token"), []byte("file-token"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "ca.crt"), []byte("file-ca"), 0o600); err != nil { + t.Fatal(err) + } + restore := auth.SetServiceAccountDir(dir) + defer restore() + + cfg := &config.Config{Token: "ignored"} + sa, ok := auth.LoadServiceAccount(cfg) + if !ok { + t.Fatal("expected token from file") + } + if sa.Token != "file-token" { + t.Fatalf("token %q", sa.Token) + } + if string(sa.CACert) != "file-ca" { + t.Fatalf("ca %q", sa.CACert) + } +} + +func TestLoadServiceAccount_Missing(t *testing.T) { + dir := t.TempDir() + restore := auth.SetServiceAccountDir(dir) + defer restore() + + cfg := &config.Config{} + if _, ok := auth.LoadServiceAccount(cfg); ok { + t.Fatal("expected missing token") + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 00000000000..a39661fd372 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,180 @@ +// Copyright Contributors to the Open Cluster Management project + +package config + +import ( + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/joho/godotenv" + applog "github.com/stolostron/console/backend/internal/log" +) + +const debounce = time.Second + +// Config is process configuration loaded from env, .env, and the config/ directory. +type Config struct { + Port string + NodeBackendURL string + ConfigDir string + CertsDir string + EnvFile string + ClusterAPIURL string + Token string + CACert string + ServiceCACert string + LogLevel string + + mu sync.RWMutex + settings map[string]string +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// Load reads ENV_FILE (if present) then environment variables. +func Load() *Config { + envFile := envOr("ENV_FILE", ".env") + _ = godotenv.Load(envFile) + + cfg := &Config{ + Port: envOr("PORT", "4000"), + NodeBackendURL: envOr("NODE_BACKEND_URL", "https://127.0.0.1:4001"), + ConfigDir: envOr("CONFIG_DIR", "config"), + CertsDir: envOr("CERTS_DIR", "certs"), + EnvFile: envFile, + ClusterAPIURL: os.Getenv("CLUSTER_API_URL"), + Token: os.Getenv("TOKEN"), + CACert: os.Getenv("CA_CERT"), + ServiceCACert: os.Getenv("SERVICE_CA_CERT"), + LogLevel: envOr("LOG_LEVEL", "debug"), + settings: map[string]string{}, + } + _ = cfg.ReloadSettings() + return cfg +} + +// Settings returns a copy of filename→contents from the config directory. +func (c *Config) Settings() map[string]string { + c.mu.RLock() + defer c.mu.RUnlock() + out := make(map[string]string, len(c.settings)) + for k, v := range c.settings { + out[k] = v + } + return out +} + +// ReloadSettings reads the config directory and promotes selected keys to the process env. +func (c *Config) ReloadSettings() error { + entries, err := os.ReadDir(c.ConfigDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + next := map[string]string{} + for _, entry := range entries { + if entry.IsDir() { + continue + } + path := filepath.Join(c.ConfigDir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + next[entry.Name()] = string(data) + } + + c.mu.Lock() + prev := c.settings + c.settings = next + c.mu.Unlock() + + promote := func(key string) { + if val, ok := next[key]; ok { + _ = os.Setenv(key, val) + } else if _, had := prev[key]; had { + _ = os.Unsetenv(key) + } + } + + for key := range next { + if strings.HasPrefix(key, "LOG_") || strings.HasPrefix(key, "APP_SEARCH_") { + _ = os.Setenv(key, next[key]) + } + } + for key := range prev { + if (strings.HasPrefix(key, "LOG_") || strings.HasPrefix(key, "APP_SEARCH_")) && next[key] == "" { + _ = os.Unsetenv(key) + } + } + promote("globalSearchFeatureFlag") + promote("UPGRADE_RISKS_PREDICTION_URL") + + if lvl, ok := next["LOG_LEVEL"]; ok { + c.LogLevel = lvl + applog.SetLevel(lvl) + } + return nil +} + +// Watch reloads settings when files under ConfigDir change. Call cancel to stop. +func (c *Config) Watch() (cancel func(), err error) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + if err := os.MkdirAll(c.ConfigDir, 0o755); err != nil { + _ = watcher.Close() + return nil, err + } + if err := watcher.Add(c.ConfigDir); err != nil { + _ = watcher.Close() + return nil, err + } + + done := make(chan struct{}) + go func() { + timer := time.NewTimer(debounce) + if !timer.Stop() { + <-timer.C + } + pending := false + for { + select { + case <-done: + timer.Stop() + _ = watcher.Close() + return + case ev, ok := <-watcher.Events: + if !ok { + return + } + if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 { + continue + } + if !pending { + pending = true + timer.Reset(debounce) + } + case <-timer.C: + pending = false + _ = c.ReloadSettings() + case <-watcher.Errors: + } + } + }() + + return func() { close(done) }, nil +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 00000000000..d64f0c677c2 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,102 @@ +// Copyright Contributors to the Open Cluster Management project + +package config_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stolostron/console/backend/internal/config" +) + +func TestReloadSettings_PromotesKeys(t *testing.T) { + dir := t.TempDir() + write := func(name, val string) { + if err := os.WriteFile(filepath.Join(dir, name), []byte(val), 0o644); err != nil { + t.Fatal(err) + } + } + write("LOG_LEVEL", "info") + write("APP_SEARCH_LIMIT", "50") + write("globalSearchFeatureFlag", "enabled") + write("UPGRADE_RISKS_PREDICTION_URL", "https://example.invalid") + write("ansibleIntegration", "available") + + t.Setenv("LOG_LEVEL", "") + cfg := &config.Config{ConfigDir: dir} + if err := cfg.ReloadSettings(); err != nil { + t.Fatal(err) + } + if os.Getenv("LOG_LEVEL") != "info" { + t.Fatalf("LOG_LEVEL=%q", os.Getenv("LOG_LEVEL")) + } + if os.Getenv("APP_SEARCH_LIMIT") != "50" { + t.Fatalf("APP_SEARCH_LIMIT=%q", os.Getenv("APP_SEARCH_LIMIT")) + } + if os.Getenv("globalSearchFeatureFlag") != "enabled" { + t.Fatalf("flag=%q", os.Getenv("globalSearchFeatureFlag")) + } + if os.Getenv("UPGRADE_RISKS_PREDICTION_URL") != "https://example.invalid" { + t.Fatalf("upgrade url=%q", os.Getenv("UPGRADE_RISKS_PREDICTION_URL")) + } + if os.Getenv("ansibleIntegration") != "" { + t.Fatal("ansibleIntegration must not be promoted to env") + } + settings := cfg.Settings() + if settings["ansibleIntegration"] != "available" { + t.Fatalf("settings missing ansibleIntegration: %#v", settings) + } +} + +func TestLoad_FromEnvFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".env") + if err := os.WriteFile(path, []byte("CLUSTER_API_URL=https://from-file.example\n"), 0o600); err != nil { + t.Fatal(err) + } + orig := os.Getenv("CLUSTER_API_URL") + os.Unsetenv("CLUSTER_API_URL") + t.Cleanup(func() { + if orig == "" { + os.Unsetenv("CLUSTER_API_URL") + return + } + _ = os.Setenv("CLUSTER_API_URL", orig) + }) + t.Setenv("ENV_FILE", path) + cfg := config.Load() + if cfg.ClusterAPIURL != "https://from-file.example" { + t.Fatalf("CLUSTER_API_URL=%q", cfg.ClusterAPIURL) + } +} + +func TestWatch_ReloadsOnChange(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "LOG_LEVEL") + if err := os.WriteFile(path, []byte("debug"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.Config{ConfigDir: dir} + if err := cfg.ReloadSettings(); err != nil { + t.Fatal(err) + } + cancel, err := cfg.Watch() + if err != nil { + t.Fatal(err) + } + defer cancel() + + if err := os.WriteFile(path, []byte("error"), 0o644); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if os.Getenv("LOG_LEVEL") == "error" { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("LOG_LEVEL did not update, got %q", os.Getenv("LOG_LEVEL")) +} diff --git a/backend/internal/events/rbac/access.go b/backend/internal/events/rbac/access.go new file mode 100644 index 00000000000..e2e4fd4e188 --- /dev/null +++ b/backend/internal/events/rbac/access.go @@ -0,0 +1,111 @@ +// Copyright Contributors to the Open Cluster Management project + +package rbac + +import ( + "context" + "sync" + "time" + + authzv1 "k8s.io/api/authorization/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" +) + +const accessCacheTTL = 60 * time.Second + +// AccessChecker decides whether a user token may see a ClusterRole. +type AccessChecker interface { + CanSee(ctx context.Context, userToken string, role *rbacv1.ClusterRole) (bool, error) +} + +// AllowAllAccess is for tests. +type AllowAllAccess struct{} + +func (AllowAllAccess) CanSee(context.Context, string, *rbacv1.ClusterRole) (bool, error) { + return true, nil +} + +type cacheKey struct { + token string + verb string + name string +} + +type cacheEntry struct { + allowed bool + expiry time.Time +} + +// SSARAccess runs SelfSubjectAccessReview with the user token (parity with Node eventFilter). +type SSARAccess struct { + newClient func(userToken string) (kubernetes.Interface, error) + + mu sync.Mutex + cache map[cacheKey]cacheEntry +} + +func NewSSARAccess(base *rest.Config) *SSARAccess { + return NewSSARAccessWithClient(func(userToken string) (kubernetes.Interface, error) { + return kubernetes.NewForConfig(auth.UserRESTConfig(base, userToken)) + }) +} + +func NewSSARAccessWithClient(newClient func(userToken string) (kubernetes.Interface, error)) *SSARAccess { + return &SSARAccess{ + cache: map[cacheKey]cacheEntry{}, + newClient: newClient, + } +} + +func (a *SSARAccess) CanSee(ctx context.Context, userToken string, role *rbacv1.ClusterRole) (bool, error) { + if role == nil { + return false, nil + } + allowed, err := a.ssar(ctx, userToken, "list", "") + if err != nil { + return false, err + } + if allowed { + return true, nil + } + return a.ssar(ctx, userToken, "get", role.Name) +} + +func (a *SSARAccess) ssar(ctx context.Context, userToken, verb, name string) (bool, error) { + key := cacheKey{token: userToken, verb: verb, name: name} + now := time.Now() + a.mu.Lock() + if e, ok := a.cache[key]; ok && e.expiry.After(now) { + a.mu.Unlock() + return e.allowed, nil + } + a.mu.Unlock() + + client, err := a.newClient(userToken) + if err != nil { + return false, err + } + review, err := client.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Group: "rbac.authorization.k8s.io", + Resource: "clusterroles", + Verb: verb, + Name: name, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + return false, err + } + allowed := review.Status.Allowed + a.mu.Lock() + a.cache[key] = cacheEntry{allowed: allowed, expiry: now.Add(accessCacheTTL)} + a.mu.Unlock() + return allowed, nil +} diff --git a/backend/internal/events/rbac/handler.go b/backend/internal/events/rbac/handler.go new file mode 100644 index 00000000000..f0cf58e6ba9 --- /dev/null +++ b/backend/internal/events/rbac/handler.go @@ -0,0 +1,187 @@ +// Copyright Contributors to the Open Cluster Management project + +package rbac + +import ( + "context" + "encoding/json" + "net/http" + "time" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + applog "github.com/stolostron/console/backend/internal/log" +) + +const keepAlive = 10 * time.Second + +// Authenticator validates a user token (GET /api, same as Node /events). +type Authenticator interface { + Authenticate(ctx context.Context, token string) (bool, error) +} + +// APIAuth validates the browser token with GET /api using the user Bearer token. +type APIAuth struct { + base *rest.Config +} + +func NewAPIAuth(base *rest.Config) *APIAuth { + return &APIAuth{base: base} +} + +func (a *APIAuth) Authenticate(ctx context.Context, token string) (bool, error) { + if a == nil || a.base == nil { + return false, nil + } + err := auth.ValidateUserToken(ctx, a.base, token) + if err != nil { + return false, err + } + return true, nil +} + +// StaticAuth is for tests. +type StaticAuth struct { + OK bool +} + +func (s StaticAuth) Authenticate(context.Context, string) (bool, error) { + return s.OK, nil +} + +type watchPayload struct { + Type string `json:"type"` + Object *rbacv1.ClusterRole `json:"object,omitempty"` +} + +// Handler serves GET /events/rbac as uncompressed SSE. +type Handler struct { + store *Store + authn Authenticator + access AccessChecker + base *rest.Config +} + +func NewHandler(store *Store, authn Authenticator, access AccessChecker) *Handler { + h := &Handler{store: store, authn: authn, access: access} + if a, ok := authn.(*APIAuth); ok { + h.base = a.base + } + return h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + token := auth.TokenFromRequest(r) + if token == "" { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + ok, err := h.authn.Authenticate(r.Context(), token) + if err != nil || !ok { + applog.Logger().Warn("rbac events unauthorized", "error", err) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + // Match Node /events so proxies do not gzip or buffer the stream. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store, no-transform") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + ch := h.store.Subscribe() + defer h.store.Unsubscribe(ch) + + if err := writeSSE(w, flusher, watchPayload{Type: "START"}); err != nil { + return + } + for _, role := range h.snapshot(r.Context(), token) { + allowed, err := h.access.CanSee(r.Context(), token, role) + if err != nil { + applog.Logger().Warn("rbac ssar failed", "error", err, "name", role.Name) + continue + } + if !allowed { + continue + } + if err := writeSSE(w, flusher, watchPayload{Type: "ADDED", Object: role}); err != nil { + return + } + } + if err := writeSSE(w, flusher, watchPayload{Type: "EOP"}); err != nil { + return + } + if err := writeSSE(w, flusher, watchPayload{Type: "LOADED"}); err != nil { + return + } + + ping := time.NewTicker(keepAlive) + defer ping.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-ping.C: + if _, err := w.Write([]byte(": ping\n\n")); err != nil { + return + } + flusher.Flush() + case ev, ok := <-ch: + if !ok { + return + } + if ev.Type != "DELETED" { + allowed, err := h.access.CanSee(r.Context(), token, ev.Role) + if err != nil || !allowed { + continue + } + } + if err := writeSSE(w, flusher, watchPayload{Type: ev.Type, Object: ev.Role}); err != nil { + return + } + if err := writeSSE(w, flusher, watchPayload{Type: "EOP"}); err != nil { + return + } + } + } +} + +func writeSSE(w http.ResponseWriter, flusher http.Flusher, payload watchPayload) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + if _, err := w.Write([]byte("data: ")); err != nil { + return err + } + if _, err := w.Write(body); err != nil { + return err + } + if _, err := w.Write([]byte("\n\n")); err != nil { + return err + } + flusher.Flush() + return nil +} + +func (h *Handler) snapshot(ctx context.Context, token string) []*rbacv1.ClusterRole { + roles := h.store.List() + if len(roles) > 0 || h.base == nil { + return roles + } + listed, err := listVMClusterRolesForToken(ctx, h.base, token) + if err != nil { + applog.Logger().Warn("rbac user list fallback failed", "error", err) + return roles + } + return listed +} diff --git a/backend/internal/events/rbac/handler_test.go b/backend/internal/events/rbac/handler_test.go new file mode 100644 index 00000000000..74fbe69e4de --- /dev/null +++ b/backend/internal/events/rbac/handler_test.go @@ -0,0 +1,243 @@ +// Copyright Contributors to the Open Cluster Management project + +package rbac_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + authzv1 "k8s.io/api/authorization/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" + + "github.com/stolostron/console/backend/internal/auth" + rbacevents "github.com/stolostron/console/backend/internal/events/rbac" +) + +func labeledRole(name string) *rbacv1.ClusterRole { + return &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + UID: types.UID(name + "-uid"), + Labels: map[string]string{ + "rbac.open-cluster-management.io/filter": "vm-clusterroles", + }, + }, + } +} + +func TestStoreIgnoresUnlabeled(t *testing.T) { + s := rbacevents.NewStore() + s.Upsert("ADDED", &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "other"}}) + if len(s.List()) != 0 { + t.Fatalf("expected empty store, got %d", len(s.List())) + } +} + +func TestStoreUpsertDelete(t *testing.T) { + s := rbacevents.NewStore() + ch := s.Subscribe() + defer s.Unsubscribe(ch) + + role := labeledRole("kubevirt.io:view") + s.Upsert("ADDED", role) + if len(s.List()) != 1 { + t.Fatalf("list %d", len(s.List())) + } + select { + case ev := <-ch: + if ev.Type != "ADDED" || ev.Role.Name != role.Name { + t.Fatalf("event %+v", ev) + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for add") + } + + s.Delete(role) + if len(s.List()) != 0 { + t.Fatal("expected delete") + } + select { + case ev := <-ch: + if ev.Type != "DELETED" { + t.Fatalf("type %s", ev.Type) + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for delete") + } +} + +func TestHandlerUnauthorized(t *testing.T) { + h := rbacevents.NewHandler(rbacevents.NewStore(), rbacevents.StaticAuth{OK: true}, rbacevents.AllowAllAccess{}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/events/rbac", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } + + h = rbacevents.NewHandler(rbacevents.NewStore(), rbacevents.StaticAuth{OK: false}, rbacevents.AllowAllAccess{}) + req := httptest.NewRequest(http.MethodGet, "/events/rbac", nil) + req.Header.Set("Authorization", "Bearer bad") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } +} + +func TestHandlerSnapshotSSE(t *testing.T) { + store := rbacevents.NewStore() + store.Upsert("ADDED", labeledRole("kubevirt.io:admin")) + h := rbacevents.NewHandler(store, rbacevents.StaticAuth{OK: true}, rbacevents.AllowAllAccess{}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req := httptest.NewRequest(http.MethodGet, "/events/rbac", nil).WithContext(ctx) + req.AddCookie(&http.Cookie{Name: auth.AccessTokenCookie, Value: "user-token"}) + + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + h.ServeHTTP(rec, req) + close(done) + }() + + deadline := time.Now().Add(2 * time.Second) + var body string + for time.Now().Before(deadline) { + body = rec.Body.String() + if strings.Contains(body, `"type":"LOADED"`) { + break + } + time.Sleep(10 * time.Millisecond) + } + cancel() + <-done + + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, body) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") { + t.Fatalf("content-type %s", ct) + } + if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-transform") { + t.Fatalf("cache-control %s", cc) + } + for _, typ := range []string{"START", "ADDED", "EOP", "LOADED"} { + if !strings.Contains(body, `"type":"`+typ+`"`) { + t.Fatalf("missing %s in %s", typ, body) + } + } + if !strings.Contains(body, "kubevirt.io:admin") { + t.Fatalf("missing role in %s", body) + } + var payload struct { + Type string `json:"type"` + Object json.RawMessage `json:"object"` + } + for _, line := range strings.Split(body, "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err != nil { + t.Fatal(err) + } + if payload.Type == "ADDED" && payload.Object == nil { + t.Fatal("ADDED missing object") + } + } +} + +type denyAccess struct{} + +func (denyAccess) CanSee(context.Context, string, *rbacv1.ClusterRole) (bool, error) { + return false, nil +} + +func TestHandlerSSARDenyOmitsRole(t *testing.T) { + store := rbacevents.NewStore() + store.Upsert("ADDED", labeledRole("secret-role")) + h := rbacevents.NewHandler(store, rbacevents.StaticAuth{OK: true}, denyAccess{}) + + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/events/rbac", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer user-token") + rec := httptest.NewRecorder() + go func() { + h.ServeHTTP(rec, req) + }() + deadline := time.Now().Add(2 * time.Second) + var body string + for time.Now().Before(deadline) { + body = rec.Body.String() + if strings.Contains(body, `"type":"LOADED"`) { + break + } + time.Sleep(10 * time.Millisecond) + } + cancel() + if strings.Contains(body, "secret-role") { + t.Fatalf("denied role leaked: %s", body) + } +} + +func TestInformerLabelSelector(t *testing.T) { + keep := labeledRole("keep-me") + drop := &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "drop-me", UID: types.UID("drop-uid")}} + client := fake.NewSimpleClientset(keep, drop) + store := rbacevents.NewStore() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := rbacevents.StartInformer(ctx, client, store); err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, r := range store.List() { + names[r.Name] = true + } + if !names["keep-me"] { + t.Fatal("missing labeled role") + } + if names["drop-me"] { + t.Fatal("unlabeled role should not be stored") + } +} + +func TestSSARAccessListThenGet(t *testing.T) { + var verbs []string + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + review := create.GetObject().(*authzv1.SelfSubjectAccessReview) + verbs = append(verbs, review.Spec.ResourceAttributes.Verb) + allowed := review.Spec.ResourceAttributes.Verb == "get" + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: allowed}, + }, nil + }) + + a := rbacevents.NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { + return client, nil + }) + + ok, err := a.CanSee(context.Background(), "tok", labeledRole("r1")) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected get to allow") + } + if len(verbs) < 2 || verbs[0] != "list" || verbs[1] != "get" { + t.Fatalf("verbs %v", verbs) + } +} diff --git a/backend/internal/events/rbac/informer.go b/backend/internal/events/rbac/informer.go new file mode 100644 index 00000000000..8d4bb404c76 --- /dev/null +++ b/backend/internal/events/rbac/informer.go @@ -0,0 +1,69 @@ +// Copyright Contributors to the Open Cluster Management project + +package rbac + +import ( + "context" + "time" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + + applog "github.com/stolostron/console/backend/internal/log" +) + +const resync = 10 * time.Minute + +// StartInformer watches vm-clusterroles ClusterRoles into store until ctx is canceled. +func StartInformer(ctx context.Context, client kubernetes.Interface, store *Store) error { + factory := informers.NewSharedInformerFactoryWithOptions( + client, + resync, + informers.WithTweakListOptions(func(opts *metav1.ListOptions) { + opts.LabelSelector = VMClusterRolesSelector + }), + ) + informer := factory.Rbac().V1().ClusterRoles().Informer() + _, err := informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj any) { + if role, ok := obj.(*rbacv1.ClusterRole); ok { + store.Upsert("ADDED", role) + } + }, + UpdateFunc: func(_, newObj any) { + if role, ok := newObj.(*rbacv1.ClusterRole); ok { + store.Upsert("MODIFIED", role) + } + }, + DeleteFunc: func(obj any) { + role, ok := obj.(*rbacv1.ClusterRole) + if !ok { + tombstone, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + return + } + role, ok = tombstone.Obj.(*rbacv1.ClusterRole) + if !ok { + return + } + } + store.Delete(role) + }, + }) + if err != nil { + return err + } + + factory.Start(ctx.Done()) + syncCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if !cache.WaitForCacheSync(syncCtx.Done(), informer.HasSynced) { + // Do not block the public listener: console-mce may lack clusterroles + // list/watch. SSE still serves a per-user list fallback. + applog.Logger().Error("clusterrole informer cache sync timed out; continuing") + } + return nil +} diff --git a/backend/internal/events/rbac/list.go b/backend/internal/events/rbac/list.go new file mode 100644 index 00000000000..868b3b6839a --- /dev/null +++ b/backend/internal/events/rbac/list.go @@ -0,0 +1,38 @@ +// Copyright Contributors to the Open Cluster Management project + +package rbac + +import ( + "context" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" +) + +func listVMClusterRolesForToken(ctx context.Context, base *rest.Config, token string) ([]*rbacv1.ClusterRole, error) { + client, err := kubernetes.NewForConfig(auth.UserRESTConfig(base, token)) + if err != nil { + return nil, err + } + return listVMClusterRoles(ctx, client) +} + +func listVMClusterRoles(ctx context.Context, client kubernetes.Interface) ([]*rbacv1.ClusterRole, error) { + list, err := client.RbacV1().ClusterRoles().List(ctx, metav1.ListOptions{LabelSelector: VMClusterRolesSelector}) + if err != nil { + return nil, err + } + out := make([]*rbacv1.ClusterRole, 0, len(list.Items)) + for i := range list.Items { + role := list.Items[i] + if !matchesVMLabel(&role) { + continue + } + out = append(out, cloneRole(&role)) + } + return out, nil +} diff --git a/backend/internal/events/rbac/list_test.go b/backend/internal/events/rbac/list_test.go new file mode 100644 index 00000000000..0618871e9c1 --- /dev/null +++ b/backend/internal/events/rbac/list_test.go @@ -0,0 +1,36 @@ +// Copyright Contributors to the Open Cluster Management project + +package rbac + +import ( + "context" + "testing" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" +) + +func TestListVMClusterRolesKeepsLabeled(t *testing.T) { + keep := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kubevirt.io:view", + Labels: map[string]string{ + vmClusterRolesLabel: vmClusterRolesValue, + }, + }, + } + drop := &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "other"}} + client := fake.NewSimpleClientset([]runtime.Object{keep, drop}...) + roles, err := listVMClusterRoles(context.Background(), client) + if err != nil { + t.Fatal(err) + } + if len(roles) != 1 || roles[0].Name != "kubevirt.io:view" { + t.Fatalf("roles %+v", roles) + } + if roles[0].Kind != clusterRoleKind || roles[0].APIVersion != clusterRoleAPIVersion { + t.Fatalf("type meta %+v", roles[0]) + } +} diff --git a/backend/internal/events/rbac/store.go b/backend/internal/events/rbac/store.go new file mode 100644 index 00000000000..effe913e021 --- /dev/null +++ b/backend/internal/events/rbac/store.go @@ -0,0 +1,122 @@ +// Copyright Contributors to the Open Cluster Management project + +package rbac + +import ( + "sync" + + rbacv1 "k8s.io/api/rbac/v1" +) + +const ( + clusterRoleAPIVersion = "rbac.authorization.k8s.io/v1" + clusterRoleKind = "ClusterRole" + vmClusterRolesLabel = "rbac.open-cluster-management.io/filter" + vmClusterRolesValue = "vm-clusterroles" + VMClusterRolesSelector = vmClusterRolesLabel + "=" + vmClusterRolesValue +) + +// Event is a watch-style change from the ClusterRole informer. +type Event struct { + Type string + Role *rbacv1.ClusterRole +} + +const subscriberBuffer = 64 + +// Store holds labeled ClusterRoles and fans out informer events to SSE clients. +type Store struct { + mu sync.RWMutex + byUID map[string]*rbacv1.ClusterRole + subs map[chan Event]struct{} +} + +func NewStore() *Store { + return &Store{ + byUID: map[string]*rbacv1.ClusterRole{}, + subs: map[chan Event]struct{}{}, + } +} + +func roleUID(role *rbacv1.ClusterRole) string { + if role.UID != "" { + return string(role.UID) + } + return role.Name +} + +func cloneRole(role *rbacv1.ClusterRole) *rbacv1.ClusterRole { + cp := role.DeepCopy() + cp.APIVersion = clusterRoleAPIVersion + cp.Kind = clusterRoleKind + cp.ManagedFields = nil + return cp +} + +func matchesVMLabel(role *rbacv1.ClusterRole) bool { + if role == nil || role.Labels == nil { + return false + } + return role.Labels[vmClusterRolesLabel] == vmClusterRolesValue +} + +// Upsert stores the role and broadcasts Type (ADDED or MODIFIED). +func (s *Store) Upsert(eventType string, role *rbacv1.ClusterRole) { + if !matchesVMLabel(role) { + return + } + cp := cloneRole(role) + s.mu.Lock() + s.byUID[roleUID(role)] = cp + s.broadcastLocked(Event{Type: eventType, Role: cp}) + s.mu.Unlock() +} + +// Delete removes the role and broadcasts DELETED. +func (s *Store) Delete(role *rbacv1.ClusterRole) { + if role == nil { + return + } + cp := cloneRole(role) + s.mu.Lock() + delete(s.byUID, roleUID(role)) + s.broadcastLocked(Event{Type: "DELETED", Role: cp}) + s.mu.Unlock() +} + +func (s *Store) broadcastLocked(ev Event) { + for ch := range s.subs { + select { + case ch <- ev: + default: + // slow subscriber; drop rather than block the informer + } + } +} + +// List returns a snapshot of stored ClusterRoles. +func (s *Store) List() []*rbacv1.ClusterRole { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]*rbacv1.ClusterRole, 0, len(s.byUID)) + for _, role := range s.byUID { + out = append(out, role.DeepCopy()) + } + return out +} + +// Subscribe receives live events until Unsubscribe. +func (s *Store) Subscribe() chan Event { + ch := make(chan Event, subscriberBuffer) + s.mu.Lock() + s.subs[ch] = struct{}{} + s.mu.Unlock() + return ch +} + +func (s *Store) Unsubscribe(ch chan Event) { + s.mu.Lock() + delete(s.subs, ch) + s.mu.Unlock() + close(ch) +} diff --git a/backend/internal/health/health.go b/backend/internal/health/health.go new file mode 100644 index 00000000000..0cd02648604 --- /dev/null +++ b/backend/internal/health/health.go @@ -0,0 +1,66 @@ +// Copyright Contributors to the Open Cluster Management project + +package health + +import ( + "crypto/tls" + "net/http" + "net/url" + "sync/atomic" + "time" +) + +// Probes serves /livenessProbe, /readinessProbe, and /ping. +type Probes struct { + live atomic.Bool + sidecarURL *url.URL + client *http.Client +} + +func New(sidecarURL *url.URL, sidecarTLS *tls.Config) *Probes { + p := &Probes{sidecarURL: sidecarURL} + p.live.Store(true) + transport := &http.Transport{ + ForceAttemptHTTP2: false, + TLSClientConfig: sidecarTLS, + } + p.client = &http.Client{Transport: transport, Timeout: 2 * time.Second} + return p +} + +func (p *Probes) SetLive(v bool) { p.live.Store(v) } + +func (p *Probes) Ping(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) +} + +func (p *Probes) Liveness(w http.ResponseWriter, _ *http.Request) { + if !p.live.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + +func (p *Probes) Readiness(w http.ResponseWriter, _ *http.Request) { + if !p.live.Load() { + w.WriteHeader(http.StatusInternalServerError) + return + } + if p.sidecarURL == nil { + w.WriteHeader(http.StatusOK) + return + } + pingURL := p.sidecarURL.ResolveReference(&url.URL{Path: "/ping"}) + resp, err := p.client.Get(pingURL.String()) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/backend/internal/health/health_test.go b/backend/internal/health/health_test.go new file mode 100644 index 00000000000..ec48d5eae9e --- /dev/null +++ b/backend/internal/health/health_test.go @@ -0,0 +1,65 @@ +// Copyright Contributors to the Open Cluster Management project + +package health_test + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stolostron/console/backend/internal/health" +) + +func TestPingAndLiveness(t *testing.T) { + p := health.New(nil, nil) + for _, fn := range []http.HandlerFunc{p.Ping, p.Liveness} { + rec := httptest.NewRecorder() + fn(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("expected empty body, got %q", rec.Body.String()) + } + } +} + +func TestLivenessDead(t *testing.T) { + p := health.New(nil, nil) + p.SetLive(false) + rec := httptest.NewRecorder() + p.Liveness(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status %d", rec.Code) + } +} + +func TestReadinessRequiresSidecar(t *testing.T) { + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ping" { + t.Errorf("path %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + })) + defer sidecar.Close() + u, _ := url.Parse(sidecar.URL) + p := health.New(u, nil) + rec := httptest.NewRecorder() + p.Readiness(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } +} + +func TestReadinessSidecarDown(t *testing.T) { + u, _ := url.Parse("http://127.0.0.1:1") + p := health.New(u, nil) + rec := httptest.NewRecorder() + p.Readiness(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status %d", rec.Code) + } + _, _ = io.ReadAll(rec.Body) +} diff --git a/backend/internal/log/log.go b/backend/internal/log/log.go new file mode 100644 index 00000000000..4f5e750a245 --- /dev/null +++ b/backend/internal/log/log.go @@ -0,0 +1,46 @@ +// Copyright Contributors to the Open Cluster Management project + +package log + +import ( + "log/slog" + "os" + "strings" + "sync" +) + +var ( + mu sync.Mutex + logger *slog.Logger + level slog.LevelVar +) + +func init() { + SetLevel(os.Getenv("LOG_LEVEL")) + logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: &level, + })) +} + +func Logger() *slog.Logger { + mu.Lock() + defer mu.Unlock() + return logger +} + +func SetLevel(name string) { + var l slog.Level + switch strings.ToLower(strings.TrimSpace(name)) { + case "trace", "debug": + l = slog.LevelDebug + case "info": + l = slog.LevelInfo + case "warn", "warning": + l = slog.LevelWarn + case "error": + l = slog.LevelError + default: + l = slog.LevelDebug + } + level.Set(l) +} diff --git a/backend/internal/proxy/proxy.go b/backend/internal/proxy/proxy.go new file mode 100644 index 00000000000..c6cfe32bd65 --- /dev/null +++ b/backend/internal/proxy/proxy.go @@ -0,0 +1,30 @@ +// Copyright Contributors to the Open Cluster Management project + +package proxy + +import ( + "crypto/tls" + "net/http" + "net/http/httputil" + "net/url" + "time" +) + +// New returns a reverse proxy to the Node sidecar. HTTP/1.1 only so WebSocket +// upgrades succeed. Original request paths (including /multicloud) are kept. +func New(target *url.URL, tlsConfig *tls.Config) http.Handler { + transport := &http.Transport{ + ForceAttemptHTTP2: false, + TLSClientConfig: tlsConfig, + ResponseHeaderTimeout: 0, + } + rp := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(target) + r.Out.Host = target.Host + }, + Transport: transport, + FlushInterval: -1 * time.Millisecond, + } + return rp +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 00000000000..ca10357305f --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,192 @@ +// Copyright Contributors to the Open Cluster Management project + +package server + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/stolostron/console/backend/internal/config" + "github.com/stolostron/console/backend/internal/health" + applog "github.com/stolostron/console/backend/internal/log" + "github.com/stolostron/console/backend/internal/proxy" +) + +const multicloudPrefix = "/multicloud" + +type handlerOptions struct { + rbacEvents http.Handler +} + +// Option configures Handler. +type Option func(*handlerOptions) + +// WithRBACEvents registers GET /events/rbac (and /multicloud/events/rbac). +func WithRBACEvents(h http.Handler) Option { + return func(o *handlerOptions) { + o.rbacEvents = h + } +} + +// StripMulticloud returns the path used for Go-owned route matching. +func StripMulticloud(path string) string { + if path == multicloudPrefix { + return "/" + } + if strings.HasPrefix(path, multicloudPrefix+"/") || path == multicloudPrefix { + return path[len(multicloudPrefix):] + } + if strings.HasPrefix(path, multicloudPrefix) { + return path[len(multicloudPrefix):] + } + return path +} + +func isProbe(path string) bool { + switch path { + case "/livenessProbe", "/readinessProbe", "/ping": + return true + default: + return false + } +} + +func isEventStream(path string) bool { + return path == "/events/rbac" +} + +// TLSConfigForSidecar is for the loopback Node sidecar. Local generate-certs +// writes a self-signed cert with no SAN, so hostname verification cannot succeed. +func TLSConfigForSidecar(_ *config.Config) *tls.Config { + return &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // loopback sidecar; cert has no SAN + MinVersion: tls.VersionTLS12, + } +} + +// Handler builds the public mux: probes and migrated routes on Go, everything else to the sidecar. +func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { + o := &handlerOptions{} + for _, opt := range opts { + opt(o) + } + target, err := url.Parse(cfg.NodeBackendURL) + if err != nil { + return nil, err + } + sidecarTLS := TLSConfigForSidecar(cfg) + probes := health.New(target, sidecarTLS) + sidecar := proxy.New(target, sidecarTLS) + + r := chi.NewRouter() + r.Use(requestLogger) + r.Get("/livenessProbe", probes.Liveness) + r.Get("/readinessProbe", probes.Readiness) + r.Get("/ping", probes.Ping) + r.Get(multicloudPrefix+"/livenessProbe", probes.Liveness) + r.Get(multicloudPrefix+"/readinessProbe", probes.Readiness) + r.Get(multicloudPrefix+"/ping", probes.Ping) + if o.rbacEvents != nil { + r.Get("/events/rbac", o.rbacEvents.ServeHTTP) + r.Get(multicloudPrefix+"/events/rbac", o.rbacEvents.ServeHTTP) + } + r.NotFound(sidecar.ServeHTTP) + r.MethodNotAllowed(sidecar.ServeHTTP) + return r, nil +} + +func requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stripped := StripMulticloud(r.URL.Path) + // Do not wrap SSE: the wrapper can prevent HTTP/2 from flushing events to EventSource. + if isProbe(stripped) || isEventStream(stripped) { + next.ServeHTTP(w, r) + return + } + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + next.ServeHTTP(rec, r) + ms := time.Since(start).Milliseconds() + applog.Logger().Info("request", + "msg", strings.ToLower(r.Method), + "path", r.URL.Path, + "status", rec.status, + "duration", ms, + ) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func (s *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + h, ok := s.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, errors.New("hijacker not supported") + } + return h.Hijack() +} + +func (s *statusRecorder) Flush() { + if f, ok := s.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } + +// ListenAndServe starts TLS when certs exist (net/http enables HTTP/2 automatically), otherwise cleartext HTTP/1.1. +func ListenAndServe(ctx context.Context, cfg *config.Config, handler http.Handler) error { + addr := net.JoinHostPort("", cfg.Port) + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + certFile := filepath.Join(cfg.CertsDir, "tls.crt") + keyFile := filepath.Join(cfg.CertsDir, "tls.key") + if _, err := os.Stat(certFile); err == nil { + if _, err := os.Stat(keyFile); err == nil { + applog.Logger().Info("server start", "secure", true, "addr", addr) + errCh <- srv.ListenAndServeTLS(certFile, keyFile) + return + } + } + applog.Logger().Info("server start", "secure", false, "addr", addr) + errCh <- srv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + return ctx.Err() + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go new file mode 100644 index 00000000000..4561858bca5 --- /dev/null +++ b/backend/internal/server/server_test.go @@ -0,0 +1,202 @@ +// Copyright Contributors to the Open Cluster Management project + +package server_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stolostron/console/backend/internal/config" + "github.com/stolostron/console/backend/internal/server" +) + +func TestStripMulticloud(t *testing.T) { + cases := map[string]string{ + "/multicloud": "/", + "/multicloud/": "/", + "/multicloud/livenessProbe": "/livenessProbe", + "/multicloud/api/v1/pods": "/api/v1/pods", + "/livenessProbe": "/livenessProbe", + "/": "/", + } + for in, want := range cases { + if got := server.StripMulticloud(in); got != want { + t.Errorf("StripMulticloud(%q)=%q want %q", in, got, want) + } + } +} + +func TestProbesAndProxy(t *testing.T) { + var capturedPath, capturedMethod, capturedBody string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/ping" { + w.WriteHeader(http.StatusOK) + return + } + capturedPath = r.URL.Path + capturedMethod = r.Method + b, _ := io.ReadAll(r.Body) + capturedBody = string(b) + w.Header().Set("X-Sidecar", "yes") + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer sidecar.Close() + + cfg := &config.Config{ + NodeBackendURL: sidecar.URL, + CertsDir: t.TempDir(), + } + h, err := server.Handler(cfg) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/ping", "/livenessProbe", "/readinessProbe", "/multicloud/ping", "/multicloud/livenessProbe", "/multicloud/readinessProbe"} { + resp, getErr := ts.Client().Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + if len(body) != 0 { + t.Fatalf("%s expected empty body", path) + } + } + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/multicloud/hub", strings.NewReader("hello")) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("proxy status %d", resp.StatusCode) + } + if string(body) != `{"ok":true}` { + t.Fatalf("body %s", body) + } + if resp.Header.Get("X-Sidecar") != "yes" { + t.Fatal("missing sidecar header") + } + if capturedPath != "/multicloud/hub" { + t.Fatalf("sidecar path %q, want original /multicloud/hub", capturedPath) + } + if capturedMethod != http.MethodPost { + t.Fatalf("method %s", capturedMethod) + } + if capturedBody != "hello" { + t.Fatalf("body %q", capturedBody) + } +} + +func TestProxyForwardsAuthorization(t *testing.T) { + var capturedAuth string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusNoContent) + })) + defer sidecar.Close() + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/username", nil) + req.Header.Set("Authorization", "Bearer user-token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedAuth != "Bearer user-token" { + t.Fatalf("Authorization %q", capturedAuth) + } +} + +func TestWebSocketUpgradeForwardsOriginalPath(t *testing.T) { + var capturedPath, capturedUpgrade string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedUpgrade = r.Header.Get("Upgrade") + w.WriteHeader(http.StatusOK) + })) + defer sidecar.Close() + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/proxy/search", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedPath != "/multicloud/proxy/search" { + t.Fatalf("path %q", capturedPath) + } + if capturedUpgrade != "websocket" { + t.Fatalf("upgrade %q", capturedUpgrade) + } +} + +func TestRBACEventsNotProxied(t *testing.T) { + var proxied bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxied = true + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + rbac := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data: {\"type\":\"START\"}\n\n")) + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithRBACEvents(rbac)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/events/rbac", "/multicloud/events/rbac"} { + proxied = false + resp, getErr := ts.Client().Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if proxied { + t.Fatalf("%s was proxied to sidecar", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + if !strings.Contains(string(body), `"type":"START"`) { + t.Fatalf("%s body %s", path, body) + } + } +} diff --git a/console.code-workspace b/console.code-workspace index d6e3c775a8e..e53ce555def 100644 --- a/console.code-workspace +++ b/console.code-workspace @@ -6,6 +6,9 @@ { "path": "backend" }, + { + "path": "backend-node" + }, { "name": "cursor-config", "path": ".cursor" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3757662e99..2cfe2e5344e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,6 +29,8 @@ The frontend has two builds. One for the stand alone version and one for the dyn ## Console Backend +The public listener is a Go process (`backend/`). Routes that have not been migrated yet are reverse-proxied to the Node sidecar (`backend-node/`). The plugin and browser keep talking to the same Service and paths. + The console backend uses a service account to `list` and `watch` kubernetes cluster resources. Resource events are streamed to the console frontend. RBAC is enforced using the token passed from the console frontend. diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md index 298cfea2f00..0e8ce8f4ed6 100644 --- a/docs/RESOURCES.md +++ b/docs/RESOURCES.md @@ -1,6 +1,6 @@ # To add a new resource -1. Add a watch to `/backend/src/routes/events.ts` for the resource. +1. Add a watch to `/backend-node/src/routes/events.ts` for the resource. 2. Add a resource definition in `/fronend/src/resources`. 3. Add recoil setup for the resource in `/frontend/src/atoms.tsx`. 4. In `frontend` use the resources by diff --git a/frontend/src/components/LoadData.tsx b/frontend/src/components/LoadData.tsx index 61781b7378a..10c8ae065b4 100644 --- a/frontend/src/components/LoadData.tsx +++ b/frontend/src/components/LoadData.tsx @@ -1,824 +1,18 @@ /* Copyright Contributors to the Open Cluster Management project */ -import get from 'lodash/get' -import { Fragment, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil' -import { tokenExpired } from '../logout' -import { - AgentClusterInstallApiVersion, - AgentClusterInstallKind, - AgentKind, - AgentKindVersion, - AgentMachineApiVersion, - AgentMachineKind, - AgentServiceConfigKind, - AgentServiceConfigKindVersion, - AnsibleJobApiVersion, - AnsibleJobKind, - AnsibleWorkflowKind, - ApplicationApiVersion, - ApplicationKind, - BareMetalHostApiVersion, - BareMetalHostKind, - CertificateSigningRequestApiVersion, - CertificateSigningRequestKind, - ChannelApiVersion, - ChannelKind, - ClusterClaimApiVersion, - ClusterClaimKind, - ClusterCuratorApiVersion, - ClusterCuratorKind, - ClusterDeploymentApiVersion, - ClusterDeploymentKind, - ClusterImageSetApiVersion, - ClusterImageSetKind, - ClusterManagementAddOnApiVersion, - ClusterManagementAddOnKind, - ClusterPoolApiVersion, - ClusterPoolKind, - ClusterProvisionApiVersion, - ClusterProvisionKind, - ClusterRoleKind, - ClusterVersionApiVersion, - ClusterVersionKind, - ConfigMapApiVersion, - ConfigMapKind, - DiscoveredClusterApiVersion, - DiscoveredClusterKind, - DiscoveryConfigApiVersion, - DiscoveryConfigKind, - GitOpsClusterApiVersion, - GitOpsClusterKind, - GroupKind, - HelmReleaseApiVersion, - HelmReleaseKind, - HostedClusterApiVersion, - HostedClusterKind, - InfraEnvApiVersion, - InfraEnvKind, - InfrastructureApiVersion, - InfrastructureKind, - IResource, - MachinePoolApiVersion, - MachinePoolKind, - ManagedClusterAddOnApiVersion, - ManagedClusterAddOnKind, - ManagedClusterApiVersion, - ManagedClusterInfoApiVersion, - ManagedClusterInfoKind, - ManagedClusterKind, - ManagedClusterSetApiVersion, - ManagedClusterSetBindingApiVersion, - ManagedClusterSetBindingKind, - ManagedClusterSetKind, - MulticlusterRoleAssignmentApiVersion, - MulticlusterRoleAssignmentKind, - MultiClusterEngineApiVersion, - MultiClusterEngineKind, - NamespaceApiVersion, - NamespaceKind, - NMStateConfigApiVersion, - NMStateConfigKind, - NodePoolApiVersion, - NodePoolKind, - PlacementApiVersionAlpha, - PlacementBindingApiVersion, - PlacementBindingKind, - PlacementDecisionApiVersion, - PlacementDecisionKind, - PlacementKind, - PolicyApiVersion, - PolicyAutomationApiVersion, - PolicyAutomationKind, - PolicyKind, - PolicyReportApiVersion, - PolicyReportKind, - PolicySetApiVersion, - PolicySetKind, - RbacApiVersion, - SearchOperatorApiVersion, - SearchOperatorKind, - SecretApiVersion, - SecretKind, - StorageClassApiVersion, - StorageClassKind, - SubmarinerConfigApiVersion, - SubmarinerConfigKind, - SubscriptionApiVersion, - SubscriptionKind, - SubscriptionOperatorApiVersion, - SubscriptionOperatorKind, - ClusterExtensionApiVersion, - ClusterExtensionKind, - SubscriptionReportApiVersion, - SubscriptionReportKind, - UserApiVersion, - UserKind, - ServiceApiVersion, - ServiceKind, -} from '../resources' -import { getBackendUrl, getRequest } from '../resources/utils' -// eslint-disable-next-line @typescript-eslint/no-restricted-imports -import { - agentClusterInstallsState, - agentMachinesState, - agentServiceConfigsState, - agentsState, - ansibleJobState, - ansibleWorkflowState, - applicationsState, - argoCDsState, - bareMetalHostsState, - certificateSigningRequestsState, - channelsState, - claimMappingsState, - clusterClaimsState, - clusterCuratorsState, - clusterDeploymentsState, - clusterImageSetsState, - clusterManagementAddonsState, - clusterPoolsState, - clusterProvisionsState, - clusterVersionState, - configMapsState, - discoveredClusterState, - discoveryConfigState, - gitOpsClustersState, - groupsState, - helmReleaseState, - hostedClustersState, - infraEnvironmentsState, - infrastructuresState, - isDirectAuthenticationEnabledState, - isFineGrainedRbacEnabledState, - isGlobalHubState, - isHubSelfManagedState, - localHubNameState, - machinePoolsState, - managedClusterAddonsState, - managedClusterInfosState, - managedClusterSetBindingsState, - managedClusterSetsState, - managedClustersState, - multiClusterEnginesState, - multiclusterRoleAssignmentState, - namespacesState, - nmStateConfigsState, - nodePoolsState, - placementBindingsState, - placementDecisionsState, - placementsState, - policiesState, - policyAutomationState, - policyreportState, - policySetsState, - searchOperatorState, - secretsState, - ServerSideEventData, - settingsState, - useEventStreamIdleGracePeriod, - useEventStreamIdleTimeout, - servicesState, - storageClassState, - submarinerConfigsState, - subscriptionOperatorsState, - clusterExtensionsState, - subscriptionReportsState, - subscriptionsState, - usersState, - vmClusterRolesState, - WatchEvent, -} from '../atoms' -import { PluginDataContext } from '../lib/PluginDataContext' -import { useQuery } from '../lib/useQuery' -import { MultiClusterHubComponent } from '../resources/multi-cluster-hub-component' -import { ClaimMappings } from '~/resources/authentication' -import { usePageActivity } from '../lib/usePageActivity' - +import { ReactNode } from 'react' +import { LoadEventsData } from './LoadEventsData' +import { LoadRbacData } from './LoadRbacData' + +/** + * Composition root for backend event streams. + * One business domain → one GET /events/ → one LoadXxxData → one line here. + */ export function LoadData(props: { children?: ReactNode }) { - const { loadCompleted, setLoadStarted, setLoadCompleted, setIsStreamIdle, setIsReconnecting, mounted } = - useContext(PluginDataContext) - const [eventsLoaded, setEventsLoaded] = useState(false) - const idleTimeoutMs = useEventStreamIdleTimeout() - const gracePeriodMs = useEventStreamIdleGracePeriod() - const { isActive } = usePageActivity(idleTimeoutMs, mounted) - const wasActiveRef = useRef(true) - const isReconnectingRef = useRef(false) - const streamStoppedRef = useRef(false) - const graceTimerRef = useRef>() - const eventSourceRef = useRef() - const processIntervalRef = useRef>() - const [restartKey, setRestartKey] = useState(0) - - const setAgentClusterInstalls = useSetRecoilState(agentClusterInstallsState) - const setAgentMachinesState = useSetRecoilState(agentMachinesState) - const setAgents = useSetRecoilState(agentsState) - const setAgentServiceConfigs = useSetRecoilState(agentServiceConfigsState) - const setAnsibleJobs = useSetRecoilState(ansibleJobState) - const setAnsibleWorkflows = useSetRecoilState(ansibleWorkflowState) - const setApplicationsState = useSetRecoilState(applicationsState) - const setArgoCDsState = useSetRecoilState(argoCDsState) - const setBareMetalHosts = useSetRecoilState(bareMetalHostsState) - const setCertificateSigningRequests = useSetRecoilState(certificateSigningRequestsState) - const setChannelsState = useSetRecoilState(channelsState) - const setClusterClaims = useSetRecoilState(clusterClaimsState) - const setClusterCurators = useSetRecoilState(clusterCuratorsState) - const setClusterDeployments = useSetRecoilState(clusterDeploymentsState) - const setClusterImageSets = useSetRecoilState(clusterImageSetsState) - const setClusterManagementAddons = useSetRecoilState(clusterManagementAddonsState) - const setClusterPools = useSetRecoilState(clusterPoolsState) - const setClusterProvisions = useSetRecoilState(clusterProvisionsState) - const setVMClusterRoles = useSetRecoilState(vmClusterRolesState) - const setClusterVerions = useSetRecoilState(clusterVersionState) - const setConfigMaps = useSetRecoilState(configMapsState) - const setDiscoveredClusters = useSetRecoilState(discoveredClusterState) - const setDiscoveryConfigs = useSetRecoilState(discoveryConfigState) - const setGitOpsClustersState = useSetRecoilState(gitOpsClustersState) - const setGroups = useSetRecoilState(groupsState) - const setHelmReleases = useSetRecoilState(helmReleaseState) - const setHostedClustersState = useSetRecoilState(hostedClustersState) - const setInfraEnvironments = useSetRecoilState(infraEnvironmentsState) - const setInfrastructure = useSetRecoilState(infrastructuresState) - const setClaimMappings = useSetRecoilState(claimMappingsState) - const setIsDirectAuthenticationEnabled = useSetRecoilState(isDirectAuthenticationEnabledState) - const setIsFineGrainedRbacEnabled = useSetRecoilState(isFineGrainedRbacEnabledState) - const setIsGlobalHub = useSetRecoilState(isGlobalHubState) - const setIsHubSelfManaged = useSetRecoilState(isHubSelfManagedState) - const setlocalHubName = useSetRecoilState(localHubNameState) - const setMachinePools = useSetRecoilState(machinePoolsState) - const setManagedClusterAddons = useSetRecoilState(managedClusterAddonsState) - const setManagedClusterInfos = useSetRecoilState(managedClusterInfosState) - const setManagedClusterSetBindings = useSetRecoilState(managedClusterSetBindingsState) - const setManagedClusterSets = useSetRecoilState(managedClusterSetsState) - const setManagedClusters = useSetRecoilState(managedClustersState) - const setMultiClusterEngines = useSetRecoilState(multiClusterEnginesState) - const setMulticlusterRoleAssignments = useSetRecoilState(multiclusterRoleAssignmentState) - const setNamespaces = useSetRecoilState(namespacesState) - const setNMStateConfigs = useSetRecoilState(nmStateConfigsState) - const setNodePoolsState = useSetRecoilState(nodePoolsState) - const setPlacementBindingsState = useSetRecoilState(placementBindingsState) - const setPlacementDecisionsState = useSetRecoilState(placementDecisionsState) - const setPlacementsState = useSetRecoilState(placementsState) - const setPoliciesState = useSetRecoilState(policiesState) - const setPolicyAutomationState = useSetRecoilState(policyAutomationState) - const setPolicyReports = useSetRecoilState(policyreportState) - const setPolicySetsState = useSetRecoilState(policySetsState) - const setSearchOperator = useSetRecoilState(searchOperatorState) - const setSecrets = useSetRecoilState(secretsState) - const setSettings = useSetRecoilState(settingsState) - const setServices = useSetRecoilState(servicesState) - const setStorageClassState = useSetRecoilState(storageClassState) - const setSubmarinerConfigs = useSetRecoilState(submarinerConfigsState) - const setSubscriptionOperatorsState = useSetRecoilState(subscriptionOperatorsState) - const setClusterExtensionsState = useSetRecoilState(clusterExtensionsState) - const setSubscriptionReportsState = useSetRecoilState(subscriptionReportsState) - const setSubscriptionsState = useSetRecoilState(subscriptionsState) - const setUsers = useSetRecoilState(usersState) - - const { setters, mappers, caches } = useMemo(() => { - const setters: Record>> = {} - - const mappers: Record< - string, - Record< - string, - { - setter: SetterOrUpdater> - mcaches: Record>> - keyBy: string[] - } - > - > = {} - const caches: Record>> = {} - const mcaches: Record>> = {} - function addSetter(apiVersion: string, kind: string, setter: SetterOrUpdater) { - const groupVersion = apiVersion.split('/')[0] - if (!setters[groupVersion]) setters[groupVersion] = {} - setters[groupVersion][kind] = setter - if (!caches[groupVersion]) caches[groupVersion] = {} - caches[groupVersion][kind] = {} - } - function addMapper( - apiVersion: string, - kind: string, - setter: SetterOrUpdater>, - keyBy: string[] - ) { - const groupVersion = apiVersion.split('/')[0] - if (!mappers[groupVersion]) mappers[groupVersion] = {} - if (!mcaches[groupVersion]) mcaches[groupVersion] = {} - mcaches[groupVersion][kind] = {} - mappers[groupVersion][kind] = { setter, mcaches, keyBy } - } - - // mappers (key=>[values]) - addMapper(ManagedClusterAddOnApiVersion, ManagedClusterAddOnKind, setManagedClusterAddons, ['metadata.namespace']) - - // setters - addSetter('argoproj.io/v1alpha1', 'ArgoCD', setArgoCDsState) - addSetter(AgentClusterInstallApiVersion, AgentClusterInstallKind, setAgentClusterInstalls) - addSetter(AgentKindVersion, AgentKind, setAgents) - addSetter(AgentMachineApiVersion, AgentMachineKind, setAgentMachinesState) - addSetter(AgentServiceConfigKindVersion, AgentServiceConfigKind, setAgentServiceConfigs) - addSetter(AnsibleJobApiVersion, AnsibleJobKind, setAnsibleJobs) - addSetter(AnsibleJobApiVersion, AnsibleWorkflowKind, setAnsibleWorkflows) - addSetter(ApplicationApiVersion, ApplicationKind, setApplicationsState) - addSetter(BareMetalHostApiVersion, BareMetalHostKind, setBareMetalHosts) - addSetter(CertificateSigningRequestApiVersion, CertificateSigningRequestKind, setCertificateSigningRequests) - addSetter(ChannelApiVersion, ChannelKind, setChannelsState) - addSetter(ClusterClaimApiVersion, ClusterClaimKind, setClusterClaims) - addSetter(ClusterCuratorApiVersion, ClusterCuratorKind, setClusterCurators) - addSetter(ClusterDeploymentApiVersion, ClusterDeploymentKind, setClusterDeployments) - addSetter(ClusterImageSetApiVersion, ClusterImageSetKind, setClusterImageSets) - addSetter(ClusterManagementAddOnApiVersion, ClusterManagementAddOnKind, setClusterManagementAddons) - addSetter(ClusterPoolApiVersion, ClusterPoolKind, setClusterPools) - addSetter(ClusterProvisionApiVersion, ClusterProvisionKind, setClusterProvisions) - addSetter(ClusterVersionApiVersion, ClusterVersionKind, setClusterVerions) - addSetter(ConfigMapApiVersion, ConfigMapKind, setConfigMaps) - addSetter(DiscoveredClusterApiVersion, DiscoveredClusterKind, setDiscoveredClusters) - addSetter(DiscoveryConfigApiVersion, DiscoveryConfigKind, setDiscoveryConfigs) - addSetter(GitOpsClusterApiVersion, GitOpsClusterKind, setGitOpsClustersState) - addSetter(HelmReleaseApiVersion, HelmReleaseKind, setHelmReleases) - addSetter(HostedClusterApiVersion, HostedClusterKind, setHostedClustersState) - addSetter(InfraEnvApiVersion, InfraEnvKind, setInfraEnvironments) - addSetter(InfrastructureApiVersion, InfrastructureKind, setInfrastructure) - addSetter(MachinePoolApiVersion, MachinePoolKind, setMachinePools) - addSetter(ManagedClusterApiVersion, ManagedClusterKind, setManagedClusters) - addSetter(ManagedClusterInfoApiVersion, ManagedClusterInfoKind, setManagedClusterInfos) - addSetter(ManagedClusterSetApiVersion, ManagedClusterSetKind, setManagedClusterSets) - addSetter(ManagedClusterSetBindingApiVersion, ManagedClusterSetBindingKind, setManagedClusterSetBindings) - addSetter(MulticlusterRoleAssignmentApiVersion, MulticlusterRoleAssignmentKind, setMulticlusterRoleAssignments) - addSetter(MultiClusterEngineApiVersion, MultiClusterEngineKind, setMultiClusterEngines) - addSetter(NamespaceApiVersion, NamespaceKind, setNamespaces) - addSetter(NMStateConfigApiVersion, NMStateConfigKind, setNMStateConfigs) - addSetter(NodePoolApiVersion, NodePoolKind, setNodePoolsState) - addSetter(PlacementApiVersionAlpha, PlacementKind, setPlacementsState) - addSetter(PlacementBindingApiVersion, PlacementBindingKind, setPlacementBindingsState) - addSetter(PlacementDecisionApiVersion, PlacementDecisionKind, setPlacementDecisionsState) - addSetter(PolicyApiVersion, PolicyKind, setPoliciesState) - addSetter(PolicyAutomationApiVersion, PolicyAutomationKind, setPolicyAutomationState) - addSetter(PolicyReportApiVersion, PolicyReportKind, setPolicyReports) - addSetter(PolicySetApiVersion, PolicySetKind, setPolicySetsState) - addSetter(RbacApiVersion, ClusterRoleKind, setVMClusterRoles) - addSetter(SearchOperatorApiVersion, SearchOperatorKind, setSearchOperator) - addSetter(SecretApiVersion, SecretKind, setSecrets) - addSetter(ServiceApiVersion, ServiceKind, setServices) - addSetter(StorageClassApiVersion, StorageClassKind, setStorageClassState) - addSetter(SubmarinerConfigApiVersion, SubmarinerConfigKind, setSubmarinerConfigs) - addSetter(SubscriptionApiVersion, SubscriptionKind, setSubscriptionsState) - addSetter(SubscriptionOperatorApiVersion, SubscriptionOperatorKind, setSubscriptionOperatorsState) - addSetter(ClusterExtensionApiVersion, ClusterExtensionKind, setClusterExtensionsState) - addSetter(SubscriptionReportApiVersion, SubscriptionReportKind, setSubscriptionReportsState) - addSetter(UserApiVersion, GroupKind, setGroups) - addSetter(UserApiVersion, UserKind, setUsers) - - return { setters, mappers, caches } - }, [ - setAgentClusterInstalls, - setAgentMachinesState, - setAgents, - setAgentServiceConfigs, - setAnsibleJobs, - setAnsibleWorkflows, - setApplicationsState, - setArgoCDsState, - setBareMetalHosts, - setCertificateSigningRequests, - setChannelsState, - setClusterClaims, - setClusterCurators, - setClusterDeployments, - setClusterImageSets, - setClusterManagementAddons, - setClusterPools, - setClusterProvisions, - setVMClusterRoles, - setClusterVerions, - setConfigMaps, - setDiscoveredClusters, - setDiscoveryConfigs, - setGitOpsClustersState, - setGroups, - setHelmReleases, - setHostedClustersState, - setInfraEnvironments, - setInfrastructure, - setMachinePools, - setManagedClusterAddons, - setManagedClusterInfos, - setManagedClusterSetBindings, - setManagedClusterSets, - setManagedClusters, - setMultiClusterEngines, - setMulticlusterRoleAssignments, - setNamespaces, - setNMStateConfigs, - setNodePoolsState, - setPlacementBindingsState, - setPlacementDecisionsState, - setPlacementsState, - setPoliciesState, - setPolicyAutomationState, - setPolicyReports, - setPolicySetsState, - setSearchOperator, - setSecrets, - setServices, - setStorageClassState, - setSubmarinerConfigs, - setSubscriptionOperatorsState, - setClusterExtensionsState, - setSubscriptionReportsState, - setSubscriptionsState, - setUsers, - ]) - - const stopStream = useCallback(() => { - streamStoppedRef.current = true - eventSourceRef.current?.close() - eventSourceRef.current = undefined - if (processIntervalRef.current) { - clearInterval(processIntervalRef.current) - processIntervalRef.current = undefined - } - }, []) - - useEffect(() => { - if (!isActive && wasActiveRef.current) { - wasActiveRef.current = false - setIsStreamIdle(true) - if (gracePeriodMs <= 0) { - // No grace period: stop stream immediately - stopStream() - } else { - // Start grace timer (stream keeps running during grace period) - graceTimerRef.current = setTimeout(stopStream, gracePeriodMs) - } - } else if (isActive && !wasActiveRef.current) { - wasActiveRef.current = true - if (graceTimerRef.current) { - clearTimeout(graceTimerRef.current) - graceTimerRef.current = undefined - } - if (streamStoppedRef.current) { - // stopped → reconnecting: stream was killed, need full reload - streamStoppedRef.current = false - setIsStreamIdle(false) - isReconnectingRef.current = true - setIsReconnecting(true) - resetCaches(caches) - resetMapperCaches(mappers) - setEventsLoaded(false) - setRestartKey((k) => k + 1) - } else { - // idle → active: returned during grace period, just hide overlay - setIsStreamIdle(false) - } - } - }, [isActive, gracePeriodMs, caches, mappers, stopStream, setIsStreamIdle, setIsReconnecting]) - - useEffect(() => { - const eventQueue: WatchEvent[] = [] - - function processEventQueue() { - if (eventQueue.length === 0) return - - const resourceTypeMap = eventQueue?.reduce( - (resourceTypeMap, eventData) => { - const apiVersion = eventData.object.apiVersion - const groupVersion = apiVersion.split('/')[0] - const kind = eventData.object.kind - if (!resourceTypeMap[groupVersion]) resourceTypeMap[groupVersion] = {} - if (!resourceTypeMap[groupVersion][kind]) resourceTypeMap[groupVersion][kind] = [] - resourceTypeMap[groupVersion][kind].push(eventData) - return resourceTypeMap - }, - {} as Record> - ) - eventQueue.length = 0 - - for (const groupVersion in resourceTypeMap) { - for (const kind in resourceTypeMap[groupVersion]) { - const watchEvents = resourceTypeMap[groupVersion]?.[kind] - if (watchEvents) { - const setter = setters[groupVersion]?.[kind] - if (setter) { - updateSetterCache(caches, groupVersion, kind, watchEvents) - if (!isReconnectingRef.current) { - setter(Object.values(caches[groupVersion]?.[kind])) - } - } else { - const mapper = mappers[groupVersion]?.[kind] - if (mapper) { - updateMapperCache(mapper, groupVersion, kind, watchEvents) - if (!isReconnectingRef.current) { - mapper.setter({ ...mapper.mcaches[groupVersion]?.[kind] }) - } - } - } - } - } - } - } - - function flushCachesToRecoil() { - for (const groupVersion in setters) { - for (const kind in setters[groupVersion]) { - setters[groupVersion][kind](Object.values(caches[groupVersion]?.[kind])) - } - } - for (const groupVersion in mappers) { - for (const kind in mappers[groupVersion]) { - const { setter, mcaches } = mappers[groupVersion][kind] - setter({ ...mcaches[groupVersion]?.[kind] }) - } - } - } - - function processMessage(event: MessageEvent) { - if (event.data) { - try { - const data = JSON.parse(event.data) as ServerSideEventData - switch (data.type) { - case 'ADDED': - case 'MODIFIED': - case 'DELETED': - eventQueue.push(data) - break - case 'START': - eventQueue.length = 0 - break - // instead of waiting for entire backend data to load - // data is broken up into packets with list resources first - // tables show skeleton until firs packet is received - // then list grows as subsequent packets packets are received - case 'EOP': // END OF A PACKET - processEventQueue() - if (!isReconnectingRef.current) { - setLoadStarted(true) - } - break - case 'LOADED': - processEventQueue() - if (isReconnectingRef.current) { - flushCachesToRecoil() - isReconnectingRef.current = false - setIsReconnecting(false) - } - setEventsLoaded(true) - break - case 'SETTINGS': - setSettings(data.settings) - break - } - } catch (err) { - console.error(err) - } - } - } - - let evtSource: EventSource | undefined - function startWatch() { - evtSource = new EventSource(`${getBackendUrl()}/events`, { withCredentials: true }) - eventSourceRef.current = evtSource - evtSource.onmessage = processMessage - evtSource.onerror = function () { - console.log('EventSource', 'error', 'readyState', evtSource?.readyState) - if (streamStoppedRef.current) return - switch (evtSource?.readyState) { - case EventSource.CLOSED: - setTimeout(() => { - startWatch() - }, 1000) - break - } - } - } - startWatch() - - const timeout = setInterval(processEventQueue, 500) - processIntervalRef.current = timeout - return () => { - clearInterval(timeout) - if (evtSource) evtSource.close() - eventSourceRef.current = undefined - processIntervalRef.current = undefined - } - }, [caches, mappers, restartKey, setIsReconnecting, setLoadStarted, setSettings, setters]) - - const { - data: globalHubRes, - loading: globalHubLoading, - startPolling: globalHubStartPoll, - stopPolling: globalHubStopPoll, - } = useQuery( - globalHubQueryFn, - [ - { - isGlobalHub: false, - localHubName: 'local-cluster', - isHubSelfManaged: undefined, - authentication: { isDirectAuthenticationEnabled: false }, - }, - ], - { - pollInterval: 30, - } + return ( + <> + + + {props.children} + ) - - // Start all Polls for Global values here - useEffect(() => { - globalHubStartPoll() - return () => { - // Stop polls on dismount - globalHubStopPoll() - } - }, [globalHubStartPoll, globalHubStopPoll]) - - // Update global value setters when data has finished - const isGlobalHub = useRecoilValue(isGlobalHubState) - if (globalHubRes && !globalHubLoading && !isGlobalHub) { - setIsGlobalHub(globalHubRes[0]?.isGlobalHub) - setlocalHubName(globalHubRes[0]?.localHubName) - setIsHubSelfManaged(globalHubRes[0]?.isHubSelfManaged) - setIsDirectAuthenticationEnabled(globalHubRes[0]?.authentication?.isDirectAuthenticationEnabled ?? false) - setClaimMappings(globalHubRes[0]?.authentication?.claimMappings) - } - - const { - data: mchResponse, - loading: mchLoading, - startPolling: startMCHPoll, - stopPolling: stopMCHPoll, - } = useQuery(mchQueryFn, [], { - pollInterval: 30, - }) - - // Start all Polls for MCH resource - useEffect(() => { - startMCHPoll() - return () => { - // Stop polls on dismount - stopMCHPoll() - } - }, [startMCHPoll, stopMCHPoll]) - - // Update fine-grained RBAC state from mch response - const isFineGrainedRbacEnabled = useRecoilValue(isFineGrainedRbacEnabledState) - if (mchResponse && !mchLoading && !isFineGrainedRbacEnabled) { - setIsFineGrainedRbacEnabled(mchResponse?.find((e) => e?.name === 'fine-grained-rbac')?.enabled ?? false) - } - - // If all data not loaded (!loaded) & events data is loaded (eventsLoaded) && global hub value is loaded (!globalHubLoading) -> set loaded to true - if (!loadCompleted && eventsLoaded && !globalHubLoading) { - setLoadCompleted(true) - } - - useEffect(() => { - function checkLoggedIn() { - fetch(`${getBackendUrl()}/authenticated`, { - credentials: 'include', - headers: { accept: 'application/json' }, - }) - .then((res) => { - switch (res.status) { - case 200: - break - default: - /* istanbul ignore if */ - if (process.env.NODE_ENV === 'development' && res.status === 504) { - window.location.reload() - } else { - tokenExpired() - } - break - } - }) - .catch(() => { - tokenExpired() - }) - .finally(() => { - setTimeout(checkLoggedIn, 30 * 1000) - }) - } - - if (process.env.MODE !== 'plugin') { - checkLoggedIn() - } - }, []) - - const children = useMemo(() => {props.children}, [props.children]) - - return children -} - -function resetCaches(caches: Record>>) { - for (const groupVersion in caches) { - for (const kind in caches[groupVersion]) { - caches[groupVersion][kind] = {} - } - } -} - -function resetMapperCaches( - mappers: Record< - string, - Record< - string, - { - setter: SetterOrUpdater> - mcaches: Record>> - keyBy: string[] - } - > - > -) { - for (const groupVersion in mappers) { - for (const kind in mappers[groupVersion]) { - const { mcaches } = mappers[groupVersion][kind] - for (const gv in mcaches) { - for (const k in mcaches[gv]) { - mcaches[gv][k] = {} - } - } - } - } -} - -function updateSetterCache( - caches: Record>>, - groupVersion: string, - kind: string, - watchEvents: WatchEvent[] -) { - const cache = caches[groupVersion]?.[kind] - for (const watchEvent of watchEvents) { - const key = `${watchEvent.object.metadata.namespace}/${watchEvent.object.metadata.name}` - switch (watchEvent.type) { - case 'ADDED': - case 'MODIFIED': - cache[key] = watchEvent.object - break - case 'DELETED': - delete cache[key] - break - } - } -} - -function updateMapperCache( - mapper: { - setter: SetterOrUpdater> - mcaches: Record>> - keyBy: string[] - }, - groupVersion: string, - kind: string, - watchEvents: WatchEvent[] -) { - const { mcaches, keyBy } = mapper - const map = mcaches[groupVersion]?.[kind] - for (const watchEvent of watchEvents) { - const key = keyBy - .reduce((keys, partKey) => { - keys.push(get(watchEvent.object, partKey)) - return keys - }, [] as string[]) - .join('/') - map[key] = [...(map[key] || [])] - const arr = map[key] - const index = arr.findIndex( - (resource) => - resource.metadata?.name === watchEvent.object.metadata.name && - resource.metadata?.namespace === watchEvent.object.metadata.namespace - ) - switch (watchEvent.type) { - case 'ADDED': - case 'MODIFIED': - if (index !== -1) arr[index] = watchEvent.object - else arr.push(watchEvent.object) - break - case 'DELETED': - if (index !== -1) arr.splice(index, 1) - break - } - } -} - -// Query for GlobalHub check and name -const globalHubQueryFn = () => { - return getRequest<{ - isGlobalHub: boolean - localHubName: string - isHubSelfManaged: boolean | undefined - authentication: { - isDirectAuthenticationEnabled: boolean - claimMappings?: ClaimMappings - } - }>(getBackendUrl() + '/hub') -} - -// Query for GlobalHub check and name -const mchQueryFn = () => { - return getRequest(getBackendUrl() + '/multiclusterhub/components') } diff --git a/frontend/src/components/LoadDataAbstract.test.tsx b/frontend/src/components/LoadDataAbstract.test.tsx new file mode 100644 index 00000000000..041886e1930 --- /dev/null +++ b/frontend/src/components/LoadDataAbstract.test.tsx @@ -0,0 +1,127 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import { act, render, waitFor } from '@testing-library/react' +import { createElement, ReactElement, type ComponentProps } from 'react' +import { MutableSnapshot, RecoilRoot } from 'recoil' +import { settingsState } from '../atoms' +import { PluginDataContext, defaultContext, PluginData } from '../lib/PluginDataContext' +import { installFakeEventSource } from '../lib/test-event-source' +import { LoadDataAbstract } from './LoadDataAbstract' + +let mockIsActive = true +jest.mock('../lib/usePageActivity', () => ({ + usePageActivity: () => ({ isActive: mockIsActive, deadline: null, pageInUse: true }), +})) + +jest.mock('../resources/utils', () => ({ + getBackendUrl: () => '', +})) + +function createTestContext(overrides: Partial = {}): PluginData { + return { + ...defaultContext, + loadStarted: true, + loadCompleted: true, + startLoading: true, + mounted: true, + ...overrides, + } +} + +function Wrapper({ ctx, children }: { ctx: PluginData; children: ReactElement }) { + return createElement( + PluginDataContext.Provider, + { value: ctx }, + createElement( + RecoilRoot, + { + initializeState: (snapshot: MutableSnapshot) => { + snapshot.set(settingsState, { EVENT_STREAM_IDLE_TIMEOUT: '1', EVENT_STREAM_IDLE_GRACE_PERIOD: '0' }) + }, + } as ComponentProps, + children + ) + ) +} + +describe('LoadDataAbstract', () => { + let fake: ReturnType + + beforeEach(() => { + mockIsActive = true + fake = installFakeEventSource() + }) + + afterEach(() => { + fake.restore() + }) + + it('does not drive overlay flags by default', async () => { + const setIsStreamIdle = jest.fn() + const setIsReconnecting = jest.fn() + const ctx = createTestContext({ setIsStreamIdle, setIsReconnecting }) + const { rerender } = render( + + + + ) + await waitFor(() => expect(fake.sources.length).toBe(1)) + + mockIsActive = false + rerender( + + + + ) + + expect(setIsStreamIdle).not.toHaveBeenCalled() + expect(setIsReconnecting).not.toHaveBeenCalled() + expect(fake.sources[0].close).toHaveBeenCalled() + }) + + it('drives overlay flags when driveAppLifecycle is set', async () => { + const setIsStreamIdle = jest.fn() + const ctx = createTestContext({ setIsStreamIdle }) + const { rerender } = render( + + + + ) + await waitFor(() => expect(fake.sources.length).toBe(1)) + + mockIsActive = false + rerender( + + + + ) + + expect(setIsStreamIdle).toHaveBeenCalledWith(true) + }) + + it('applies resources[] watch events into the Recoil setter', async () => { + const setState = jest.fn() + const ctx = createTestContext() + render( + + + + ) + await waitFor(() => expect(fake.sources.length).toBe(1)) + + const object = { + kind: 'ClusterRole', + apiVersion: 'rbac.authorization.k8s.io/v1', + metadata: { name: 'kubevirt.io:admin', uid: 'uid-1' }, + } + act(() => { + fake.sources[0].emit({ type: 'ADDED', object }) + fake.sources[0].emit({ type: 'EOP' }) + }) + + expect(setState).toHaveBeenCalledWith([object]) + }) +}) diff --git a/frontend/src/components/LoadDataAbstract.tsx b/frontend/src/components/LoadDataAbstract.tsx new file mode 100644 index 00000000000..4974b4e65f8 --- /dev/null +++ b/frontend/src/components/LoadDataAbstract.tsx @@ -0,0 +1,176 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { SetterOrUpdater } from 'recoil' +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { useEventStreamIdleGracePeriod, useEventStreamIdleTimeout, WatchEvent } from '../atoms' +import { applyWatchEventsToCache, groupWatchEventsByKind } from '../hooks/applyWatchEventsToCache' +import { useWatchEventStream } from '../hooks/useWatchEventStream' +import { PluginDataContext } from '../lib/PluginDataContext' +import { usePageActivity } from '../lib/usePageActivity' +import type { IResource } from '../resources' + +export interface StreamResource { + apiVersion: string + kind: string + setState: SetterOrUpdater +} + +export interface LoadedContext { + isReconnecting: boolean +} + +export interface LoadDataAbstractProps { + path: string + /** Recoil atom contract for simple single-kind (or few-kind) streams. */ + resources?: StreamResource[] + /** Escape hatch for streams that need custom caches (mappers, reconnect flush). */ + applyWatchEvents?: (events: WatchEvent[]) => void + reset?: () => void + onSettings?: (settings: Record) => void + onEndOfPacket?: () => void + onLoaded?: (ctx: LoadedContext) => void + /** When true, drive PluginDataContext idle/reconnect overlay. Default false. */ + driveAppLifecycle?: boolean + children?: ReactNode +} + +function resourceCacheKey(apiVersion: string, kind: string): string { + return `${apiVersion.split('/')[0]}/${kind}` +} + +export function LoadDataAbstract(props: LoadDataAbstractProps) { + const { mounted, setIsStreamIdle, setIsReconnecting } = useContext(PluginDataContext) + const idleTimeoutMs = useEventStreamIdleTimeout() + const gracePeriodMs = useEventStreamIdleGracePeriod() + const { isActive } = usePageActivity(idleTimeoutMs, mounted) + const wasActiveRef = useRef(true) + const isReconnectingRef = useRef(false) + const streamStoppedRef = useRef(false) + const graceTimerRef = useRef>() + const eventSourceRef = useRef() + const processIntervalRef = useRef>() + const [restartKey, setRestartKey] = useState(0) + const cachesRef = useRef>>({}) + + const resourcesRef = useRef(props.resources) + resourcesRef.current = props.resources + const applyWatchEventsRef = useRef(props.applyWatchEvents) + applyWatchEventsRef.current = props.applyWatchEvents + const resetRef = useRef(props.reset) + resetRef.current = props.reset + const onLoadedRef = useRef(props.onLoaded) + onLoadedRef.current = props.onLoaded + const driveAppLifecycle = props.driveAppLifecycle ?? false + + const applyFromResources = useCallback((events: WatchEvent[]) => { + const resources = resourcesRef.current + if (!resources?.length) return + const grouped = groupWatchEventsByKind(events) + for (const resource of resources) { + const groupVersion = resource.apiVersion.split('/')[0] + const watchEvents = grouped[groupVersion]?.[resource.kind] + if (!watchEvents) continue + const cacheKey = resourceCacheKey(resource.apiVersion, resource.kind) + if (!cachesRef.current[cacheKey]) cachesRef.current[cacheKey] = {} + applyWatchEventsToCache(cachesRef.current[cacheKey], watchEvents) + resource.setState(Object.values(cachesRef.current[cacheKey])) + } + }, []) + + const resetFromResources = useCallback(() => { + const resources = resourcesRef.current + if (!resources?.length) return + for (const resource of resources) { + const cacheKey = resourceCacheKey(resource.apiVersion, resource.kind) + cachesRef.current[cacheKey] = {} + resource.setState([]) + } + }, []) + + const applyWatchEvents = useCallback( + (events: WatchEvent[]) => { + if (applyWatchEventsRef.current) { + applyWatchEventsRef.current(events) + return + } + applyFromResources(events) + }, + [applyFromResources] + ) + + const handleReset = useCallback(() => { + if (resetRef.current) { + resetRef.current() + return + } + resetFromResources() + }, [resetFromResources]) + + const stopStream = useCallback(() => { + streamStoppedRef.current = true + eventSourceRef.current?.close() + eventSourceRef.current = undefined + if (processIntervalRef.current) { + clearInterval(processIntervalRef.current) + processIntervalRef.current = undefined + } + }, []) + + useEffect(() => { + if (!isActive && wasActiveRef.current) { + wasActiveRef.current = false + if (driveAppLifecycle) { + setIsStreamIdle(true) + } + if (gracePeriodMs <= 0) { + stopStream() + } else { + graceTimerRef.current = setTimeout(stopStream, gracePeriodMs) + } + } else if (isActive && !wasActiveRef.current) { + wasActiveRef.current = true + if (graceTimerRef.current) { + clearTimeout(graceTimerRef.current) + graceTimerRef.current = undefined + } + if (streamStoppedRef.current) { + streamStoppedRef.current = false + isReconnectingRef.current = true + if (driveAppLifecycle) { + setIsStreamIdle(false) + setIsReconnecting(true) + } + handleReset() + setRestartKey((k) => k + 1) + } else if (driveAppLifecycle) { + setIsStreamIdle(false) + } + } + }, [driveAppLifecycle, gracePeriodMs, handleReset, isActive, setIsReconnecting, setIsStreamIdle, stopStream]) + + const onLoaded = useCallback(() => { + const isReconnecting = isReconnectingRef.current + onLoadedRef.current?.({ isReconnecting }) + if (isReconnecting) { + isReconnectingRef.current = false + if (driveAppLifecycle) { + setIsReconnecting(false) + } + } + }, [driveAppLifecycle, setIsReconnecting]) + + useWatchEventStream({ + path: props.path, + restartKey, + streamStoppedRef, + eventSourceRef, + processIntervalRef, + applyWatchEvents, + onSettings: props.onSettings, + onEndOfPacket: props.onEndOfPacket, + onLoaded, + }) + + return useMemo(() => (props.children ? <>{props.children} : null), [props.children]) +} diff --git a/frontend/src/components/LoadEventsData.tsx b/frontend/src/components/LoadEventsData.tsx new file mode 100644 index 00000000000..f0130a5b5c9 --- /dev/null +++ b/frontend/src/components/LoadEventsData.tsx @@ -0,0 +1,696 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import get from 'lodash/get' +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil' +import { tokenExpired } from '../logout' +import { + AgentClusterInstallApiVersion, + AgentClusterInstallKind, + AgentKind, + AgentKindVersion, + AgentMachineApiVersion, + AgentMachineKind, + AgentServiceConfigKind, + AgentServiceConfigKindVersion, + AnsibleJobApiVersion, + AnsibleJobKind, + AnsibleWorkflowKind, + ApplicationApiVersion, + ApplicationKind, + BareMetalHostApiVersion, + BareMetalHostKind, + CertificateSigningRequestApiVersion, + CertificateSigningRequestKind, + ChannelApiVersion, + ChannelKind, + ClusterClaimApiVersion, + ClusterClaimKind, + ClusterCuratorApiVersion, + ClusterCuratorKind, + ClusterDeploymentApiVersion, + ClusterDeploymentKind, + ClusterImageSetApiVersion, + ClusterImageSetKind, + ClusterManagementAddOnApiVersion, + ClusterManagementAddOnKind, + ClusterPoolApiVersion, + ClusterPoolKind, + ClusterProvisionApiVersion, + ClusterProvisionKind, + ClusterVersionApiVersion, + ClusterVersionKind, + ConfigMapApiVersion, + ConfigMapKind, + DiscoveredClusterApiVersion, + DiscoveredClusterKind, + DiscoveryConfigApiVersion, + DiscoveryConfigKind, + GitOpsClusterApiVersion, + GitOpsClusterKind, + GroupKind, + HelmReleaseApiVersion, + HelmReleaseKind, + HostedClusterApiVersion, + HostedClusterKind, + InfraEnvApiVersion, + InfraEnvKind, + InfrastructureApiVersion, + InfrastructureKind, + IResource, + MachinePoolApiVersion, + MachinePoolKind, + ManagedClusterAddOnApiVersion, + ManagedClusterAddOnKind, + ManagedClusterApiVersion, + ManagedClusterInfoApiVersion, + ManagedClusterInfoKind, + ManagedClusterKind, + ManagedClusterSetApiVersion, + ManagedClusterSetBindingApiVersion, + ManagedClusterSetBindingKind, + ManagedClusterSetKind, + MulticlusterRoleAssignmentApiVersion, + MulticlusterRoleAssignmentKind, + MultiClusterEngineApiVersion, + MultiClusterEngineKind, + NamespaceApiVersion, + NamespaceKind, + NMStateConfigApiVersion, + NMStateConfigKind, + NodePoolApiVersion, + NodePoolKind, + PlacementApiVersionAlpha, + PlacementBindingApiVersion, + PlacementBindingKind, + PlacementDecisionApiVersion, + PlacementDecisionKind, + PlacementKind, + PolicyApiVersion, + PolicyAutomationApiVersion, + PolicyAutomationKind, + PolicyKind, + PolicyReportApiVersion, + PolicyReportKind, + PolicySetApiVersion, + PolicySetKind, + SearchOperatorApiVersion, + SearchOperatorKind, + SecretApiVersion, + SecretKind, + StorageClassApiVersion, + StorageClassKind, + SubmarinerConfigApiVersion, + SubmarinerConfigKind, + SubscriptionApiVersion, + SubscriptionKind, + SubscriptionOperatorApiVersion, + SubscriptionOperatorKind, + ClusterExtensionApiVersion, + ClusterExtensionKind, + SubscriptionReportApiVersion, + SubscriptionReportKind, + UserApiVersion, + UserKind, + ServiceApiVersion, + ServiceKind, +} from '../resources' +import { getBackendUrl, getRequest } from '../resources/utils' +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { + agentClusterInstallsState, + agentMachinesState, + agentServiceConfigsState, + agentsState, + ansibleJobState, + ansibleWorkflowState, + applicationsState, + argoCDsState, + bareMetalHostsState, + certificateSigningRequestsState, + channelsState, + claimMappingsState, + clusterClaimsState, + clusterCuratorsState, + clusterDeploymentsState, + clusterImageSetsState, + clusterManagementAddonsState, + clusterPoolsState, + clusterProvisionsState, + clusterVersionState, + configMapsState, + discoveredClusterState, + discoveryConfigState, + gitOpsClustersState, + groupsState, + helmReleaseState, + hostedClustersState, + infraEnvironmentsState, + infrastructuresState, + isDirectAuthenticationEnabledState, + isFineGrainedRbacEnabledState, + isGlobalHubState, + isHubSelfManagedState, + localHubNameState, + machinePoolsState, + managedClusterAddonsState, + managedClusterInfosState, + managedClusterSetBindingsState, + managedClusterSetsState, + managedClustersState, + multiClusterEnginesState, + multiclusterRoleAssignmentState, + namespacesState, + nmStateConfigsState, + nodePoolsState, + placementBindingsState, + placementDecisionsState, + placementsState, + policiesState, + policyAutomationState, + policyreportState, + policySetsState, + searchOperatorState, + secretsState, + settingsState, + servicesState, + storageClassState, + submarinerConfigsState, + subscriptionOperatorsState, + clusterExtensionsState, + subscriptionReportsState, + subscriptionsState, + usersState, + WatchEvent, +} from '../atoms' +import { applyWatchEventsToCache, groupWatchEventsByKind } from '../hooks/applyWatchEventsToCache' +import { PluginDataContext } from '../lib/PluginDataContext' +import { useQuery } from '../lib/useQuery' +import { MultiClusterHubComponent } from '../resources/multi-cluster-hub-component' +import { ClaimMappings } from '~/resources/authentication' +import { LoadDataAbstract } from './LoadDataAbstract' + +export function LoadEventsData() { + const { loadCompleted, setLoadStarted, setLoadCompleted } = useContext(PluginDataContext) + const [eventsLoaded, setEventsLoaded] = useState(false) + const isReconnectingRef = useRef(false) + + const setAgentClusterInstalls = useSetRecoilState(agentClusterInstallsState) + const setAgentMachinesState = useSetRecoilState(agentMachinesState) + const setAgents = useSetRecoilState(agentsState) + const setAgentServiceConfigs = useSetRecoilState(agentServiceConfigsState) + const setAnsibleJobs = useSetRecoilState(ansibleJobState) + const setAnsibleWorkflows = useSetRecoilState(ansibleWorkflowState) + const setApplicationsState = useSetRecoilState(applicationsState) + const setArgoCDsState = useSetRecoilState(argoCDsState) + const setBareMetalHosts = useSetRecoilState(bareMetalHostsState) + const setCertificateSigningRequests = useSetRecoilState(certificateSigningRequestsState) + const setChannelsState = useSetRecoilState(channelsState) + const setClusterClaims = useSetRecoilState(clusterClaimsState) + const setClusterCurators = useSetRecoilState(clusterCuratorsState) + const setClusterDeployments = useSetRecoilState(clusterDeploymentsState) + const setClusterImageSets = useSetRecoilState(clusterImageSetsState) + const setClusterManagementAddons = useSetRecoilState(clusterManagementAddonsState) + const setClusterPools = useSetRecoilState(clusterPoolsState) + const setClusterProvisions = useSetRecoilState(clusterProvisionsState) + const setClusterVerions = useSetRecoilState(clusterVersionState) + const setConfigMaps = useSetRecoilState(configMapsState) + const setDiscoveredClusters = useSetRecoilState(discoveredClusterState) + const setDiscoveryConfigs = useSetRecoilState(discoveryConfigState) + const setGitOpsClustersState = useSetRecoilState(gitOpsClustersState) + const setGroups = useSetRecoilState(groupsState) + const setHelmReleases = useSetRecoilState(helmReleaseState) + const setHostedClustersState = useSetRecoilState(hostedClustersState) + const setInfraEnvironments = useSetRecoilState(infraEnvironmentsState) + const setInfrastructure = useSetRecoilState(infrastructuresState) + const setClaimMappings = useSetRecoilState(claimMappingsState) + const setIsDirectAuthenticationEnabled = useSetRecoilState(isDirectAuthenticationEnabledState) + const setIsFineGrainedRbacEnabled = useSetRecoilState(isFineGrainedRbacEnabledState) + const setIsGlobalHub = useSetRecoilState(isGlobalHubState) + const setIsHubSelfManaged = useSetRecoilState(isHubSelfManagedState) + const setlocalHubName = useSetRecoilState(localHubNameState) + const setMachinePools = useSetRecoilState(machinePoolsState) + const setManagedClusterAddons = useSetRecoilState(managedClusterAddonsState) + const setManagedClusterInfos = useSetRecoilState(managedClusterInfosState) + const setManagedClusterSetBindings = useSetRecoilState(managedClusterSetBindingsState) + const setManagedClusterSets = useSetRecoilState(managedClusterSetsState) + const setManagedClusters = useSetRecoilState(managedClustersState) + const setMultiClusterEngines = useSetRecoilState(multiClusterEnginesState) + const setMulticlusterRoleAssignments = useSetRecoilState(multiclusterRoleAssignmentState) + const setNamespaces = useSetRecoilState(namespacesState) + const setNMStateConfigs = useSetRecoilState(nmStateConfigsState) + const setNodePoolsState = useSetRecoilState(nodePoolsState) + const setPlacementBindingsState = useSetRecoilState(placementBindingsState) + const setPlacementDecisionsState = useSetRecoilState(placementDecisionsState) + const setPlacementsState = useSetRecoilState(placementsState) + const setPoliciesState = useSetRecoilState(policiesState) + const setPolicyAutomationState = useSetRecoilState(policyAutomationState) + const setPolicyReports = useSetRecoilState(policyreportState) + const setPolicySetsState = useSetRecoilState(policySetsState) + const setSearchOperator = useSetRecoilState(searchOperatorState) + const setSecrets = useSetRecoilState(secretsState) + const setSettings = useSetRecoilState(settingsState) + const setServices = useSetRecoilState(servicesState) + const setStorageClassState = useSetRecoilState(storageClassState) + const setSubmarinerConfigs = useSetRecoilState(submarinerConfigsState) + const setSubscriptionOperatorsState = useSetRecoilState(subscriptionOperatorsState) + const setClusterExtensionsState = useSetRecoilState(clusterExtensionsState) + const setSubscriptionReportsState = useSetRecoilState(subscriptionReportsState) + const setSubscriptionsState = useSetRecoilState(subscriptionsState) + const setUsers = useSetRecoilState(usersState) + + const { setters, mappers, caches } = useMemo(() => { + const setters: Record>> = {} + + const mappers: Record< + string, + Record< + string, + { + setter: SetterOrUpdater> + mcaches: Record>> + keyBy: string[] + } + > + > = {} + const caches: Record>> = {} + const mcaches: Record>> = {} + function addSetter(apiVersion: string, kind: string, setter: SetterOrUpdater) { + const groupVersion = apiVersion.split('/')[0] + if (!setters[groupVersion]) setters[groupVersion] = {} + setters[groupVersion][kind] = setter + if (!caches[groupVersion]) caches[groupVersion] = {} + caches[groupVersion][kind] = {} + } + function addMapper( + apiVersion: string, + kind: string, + setter: SetterOrUpdater>, + keyBy: string[] + ) { + const groupVersion = apiVersion.split('/')[0] + if (!mappers[groupVersion]) mappers[groupVersion] = {} + if (!mcaches[groupVersion]) mcaches[groupVersion] = {} + mcaches[groupVersion][kind] = {} + mappers[groupVersion][kind] = { setter, mcaches, keyBy } + } + + // mappers (key=>[values]) + addMapper(ManagedClusterAddOnApiVersion, ManagedClusterAddOnKind, setManagedClusterAddons, ['metadata.namespace']) + + // setters + addSetter('argoproj.io/v1alpha1', 'ArgoCD', setArgoCDsState) + addSetter(AgentClusterInstallApiVersion, AgentClusterInstallKind, setAgentClusterInstalls) + addSetter(AgentKindVersion, AgentKind, setAgents) + addSetter(AgentMachineApiVersion, AgentMachineKind, setAgentMachinesState) + addSetter(AgentServiceConfigKindVersion, AgentServiceConfigKind, setAgentServiceConfigs) + addSetter(AnsibleJobApiVersion, AnsibleJobKind, setAnsibleJobs) + addSetter(AnsibleJobApiVersion, AnsibleWorkflowKind, setAnsibleWorkflows) + addSetter(ApplicationApiVersion, ApplicationKind, setApplicationsState) + addSetter(BareMetalHostApiVersion, BareMetalHostKind, setBareMetalHosts) + addSetter(CertificateSigningRequestApiVersion, CertificateSigningRequestKind, setCertificateSigningRequests) + addSetter(ChannelApiVersion, ChannelKind, setChannelsState) + addSetter(ClusterClaimApiVersion, ClusterClaimKind, setClusterClaims) + addSetter(ClusterCuratorApiVersion, ClusterCuratorKind, setClusterCurators) + addSetter(ClusterDeploymentApiVersion, ClusterDeploymentKind, setClusterDeployments) + addSetter(ClusterImageSetApiVersion, ClusterImageSetKind, setClusterImageSets) + addSetter(ClusterManagementAddOnApiVersion, ClusterManagementAddOnKind, setClusterManagementAddons) + addSetter(ClusterPoolApiVersion, ClusterPoolKind, setClusterPools) + addSetter(ClusterProvisionApiVersion, ClusterProvisionKind, setClusterProvisions) + addSetter(ClusterVersionApiVersion, ClusterVersionKind, setClusterVerions) + addSetter(ConfigMapApiVersion, ConfigMapKind, setConfigMaps) + addSetter(DiscoveredClusterApiVersion, DiscoveredClusterKind, setDiscoveredClusters) + addSetter(DiscoveryConfigApiVersion, DiscoveryConfigKind, setDiscoveryConfigs) + addSetter(GitOpsClusterApiVersion, GitOpsClusterKind, setGitOpsClustersState) + addSetter(HelmReleaseApiVersion, HelmReleaseKind, setHelmReleases) + addSetter(HostedClusterApiVersion, HostedClusterKind, setHostedClustersState) + addSetter(InfraEnvApiVersion, InfraEnvKind, setInfraEnvironments) + addSetter(InfrastructureApiVersion, InfrastructureKind, setInfrastructure) + addSetter(MachinePoolApiVersion, MachinePoolKind, setMachinePools) + addSetter(ManagedClusterApiVersion, ManagedClusterKind, setManagedClusters) + addSetter(ManagedClusterInfoApiVersion, ManagedClusterInfoKind, setManagedClusterInfos) + addSetter(ManagedClusterSetApiVersion, ManagedClusterSetKind, setManagedClusterSets) + addSetter(ManagedClusterSetBindingApiVersion, ManagedClusterSetBindingKind, setManagedClusterSetBindings) + addSetter(MulticlusterRoleAssignmentApiVersion, MulticlusterRoleAssignmentKind, setMulticlusterRoleAssignments) + addSetter(MultiClusterEngineApiVersion, MultiClusterEngineKind, setMultiClusterEngines) + addSetter(NamespaceApiVersion, NamespaceKind, setNamespaces) + addSetter(NMStateConfigApiVersion, NMStateConfigKind, setNMStateConfigs) + addSetter(NodePoolApiVersion, NodePoolKind, setNodePoolsState) + addSetter(PlacementApiVersionAlpha, PlacementKind, setPlacementsState) + addSetter(PlacementBindingApiVersion, PlacementBindingKind, setPlacementBindingsState) + addSetter(PlacementDecisionApiVersion, PlacementDecisionKind, setPlacementDecisionsState) + addSetter(PolicyApiVersion, PolicyKind, setPoliciesState) + addSetter(PolicyAutomationApiVersion, PolicyAutomationKind, setPolicyAutomationState) + addSetter(PolicyReportApiVersion, PolicyReportKind, setPolicyReports) + addSetter(PolicySetApiVersion, PolicySetKind, setPolicySetsState) + addSetter(SearchOperatorApiVersion, SearchOperatorKind, setSearchOperator) + addSetter(SecretApiVersion, SecretKind, setSecrets) + addSetter(ServiceApiVersion, ServiceKind, setServices) + addSetter(StorageClassApiVersion, StorageClassKind, setStorageClassState) + addSetter(SubmarinerConfigApiVersion, SubmarinerConfigKind, setSubmarinerConfigs) + addSetter(SubscriptionApiVersion, SubscriptionKind, setSubscriptionsState) + addSetter(SubscriptionOperatorApiVersion, SubscriptionOperatorKind, setSubscriptionOperatorsState) + addSetter(ClusterExtensionApiVersion, ClusterExtensionKind, setClusterExtensionsState) + addSetter(SubscriptionReportApiVersion, SubscriptionReportKind, setSubscriptionReportsState) + addSetter(UserApiVersion, GroupKind, setGroups) + addSetter(UserApiVersion, UserKind, setUsers) + + return { setters, mappers, caches } + }, [ + setAgentClusterInstalls, + setAgentMachinesState, + setAgents, + setAgentServiceConfigs, + setAnsibleJobs, + setAnsibleWorkflows, + setApplicationsState, + setArgoCDsState, + setBareMetalHosts, + setCertificateSigningRequests, + setChannelsState, + setClusterClaims, + setClusterCurators, + setClusterDeployments, + setClusterImageSets, + setClusterManagementAddons, + setClusterPools, + setClusterProvisions, + setClusterVerions, + setConfigMaps, + setDiscoveredClusters, + setDiscoveryConfigs, + setGitOpsClustersState, + setGroups, + setHelmReleases, + setHostedClustersState, + setInfraEnvironments, + setInfrastructure, + setMachinePools, + setManagedClusterAddons, + setManagedClusterInfos, + setManagedClusterSetBindings, + setManagedClusterSets, + setManagedClusters, + setMultiClusterEngines, + setMulticlusterRoleAssignments, + setNamespaces, + setNMStateConfigs, + setNodePoolsState, + setPlacementBindingsState, + setPlacementDecisionsState, + setPlacementsState, + setPoliciesState, + setPolicyAutomationState, + setPolicyReports, + setPolicySetsState, + setSearchOperator, + setSecrets, + setServices, + setStorageClassState, + setSubmarinerConfigs, + setSubscriptionOperatorsState, + setClusterExtensionsState, + setSubscriptionReportsState, + setSubscriptionsState, + setUsers, + ]) + + const applyWatchEvents = useCallback( + (watchEvents: WatchEvent[]) => { + const resourceTypeMap = groupWatchEventsByKind(watchEvents) + for (const groupVersion in resourceTypeMap) { + for (const kind in resourceTypeMap[groupVersion]) { + const kindEvents = resourceTypeMap[groupVersion]?.[kind] + if (!kindEvents) continue + const setter = setters[groupVersion]?.[kind] + if (setter) { + const cache = caches[groupVersion]?.[kind] + if (cache) { + applyWatchEventsToCache(cache, kindEvents) + if (!isReconnectingRef.current) { + setter(Object.values(cache)) + } + } + } else { + const mapper = mappers[groupVersion]?.[kind] + if (mapper) { + updateMapperCache(mapper, groupVersion, kind, kindEvents) + if (!isReconnectingRef.current) { + mapper.setter({ ...mapper.mcaches[groupVersion]?.[kind] }) + } + } + } + } + } + }, + [caches, mappers, setters] + ) + + const reset = useCallback(() => { + isReconnectingRef.current = true + resetCaches(caches) + resetMapperCaches(mappers) + setEventsLoaded(false) + }, [caches, mappers]) + + const flushCachesToRecoil = useCallback(() => { + for (const groupVersion in setters) { + for (const kind in setters[groupVersion]) { + setters[groupVersion][kind](Object.values(caches[groupVersion]?.[kind])) + } + } + for (const groupVersion in mappers) { + for (const kind in mappers[groupVersion]) { + const { setter, mcaches } = mappers[groupVersion][kind] + setter({ ...mcaches[groupVersion]?.[kind] }) + } + } + }, [caches, mappers, setters]) + + const onEndOfPacket = useCallback(() => { + if (!isReconnectingRef.current) { + setLoadStarted(true) + } + }, [setLoadStarted]) + + const onLoaded = useCallback( + ({ isReconnecting }: { isReconnecting: boolean }) => { + if (isReconnecting) { + flushCachesToRecoil() + isReconnectingRef.current = false + } + setEventsLoaded(true) + }, + [flushCachesToRecoil] + ) + + const onSettings = useCallback( + (settings: Record) => { + setSettings(settings) + }, + [setSettings] + ) + + const { + data: globalHubRes, + loading: globalHubLoading, + startPolling: globalHubStartPoll, + stopPolling: globalHubStopPoll, + } = useQuery( + globalHubQueryFn, + [ + { + isGlobalHub: false, + localHubName: 'local-cluster', + isHubSelfManaged: undefined, + authentication: { isDirectAuthenticationEnabled: false }, + }, + ], + { + pollInterval: 30, + } + ) + + // Start all Polls for Global values here + useEffect(() => { + globalHubStartPoll() + return () => { + // Stop polls on dismount + globalHubStopPoll() + } + }, [globalHubStartPoll, globalHubStopPoll]) + + // Update global value setters when data has finished + const isGlobalHub = useRecoilValue(isGlobalHubState) + if (globalHubRes && !globalHubLoading && !isGlobalHub) { + setIsGlobalHub(globalHubRes[0]?.isGlobalHub) + setlocalHubName(globalHubRes[0]?.localHubName) + setIsHubSelfManaged(globalHubRes[0]?.isHubSelfManaged) + setIsDirectAuthenticationEnabled(globalHubRes[0]?.authentication?.isDirectAuthenticationEnabled ?? false) + setClaimMappings(globalHubRes[0]?.authentication?.claimMappings) + } + + const { + data: mchResponse, + loading: mchLoading, + startPolling: startMCHPoll, + stopPolling: stopMCHPoll, + } = useQuery(mchQueryFn, [], { + pollInterval: 30, + }) + + // Start all Polls for MCH resource + useEffect(() => { + startMCHPoll() + return () => { + // Stop polls on dismount + stopMCHPoll() + } + }, [startMCHPoll, stopMCHPoll]) + + // Update fine-grained RBAC state from mch response + const isFineGrainedRbacEnabled = useRecoilValue(isFineGrainedRbacEnabledState) + if (mchResponse && !mchLoading && !isFineGrainedRbacEnabled) { + setIsFineGrainedRbacEnabled(mchResponse?.find((e) => e?.name === 'fine-grained-rbac')?.enabled ?? false) + } + + // If all data not loaded (!loaded) & events data is loaded (eventsLoaded) && global hub value is loaded (!globalHubLoading) -> set loaded to true + if (!loadCompleted && eventsLoaded && !globalHubLoading) { + setLoadCompleted(true) + } + + useEffect(() => { + function checkLoggedIn() { + fetch(`${getBackendUrl()}/authenticated`, { + credentials: 'include', + headers: { accept: 'application/json' }, + }) + .then((res) => { + switch (res.status) { + case 200: + break + default: + /* istanbul ignore if */ + if (process.env.NODE_ENV === 'development' && res.status === 504) { + window.location.reload() + } else { + tokenExpired() + } + break + } + }) + .catch(() => { + tokenExpired() + }) + .finally(() => { + setTimeout(checkLoggedIn, 30 * 1000) + }) + } + + if (process.env.MODE !== 'plugin') { + checkLoggedIn() + } + }, []) + + return ( + + ) +} + +function resetCaches(caches: Record>>) { + for (const groupVersion in caches) { + for (const kind in caches[groupVersion]) { + caches[groupVersion][kind] = {} + } + } +} + +function resetMapperCaches( + mappers: Record< + string, + Record< + string, + { + setter: SetterOrUpdater> + mcaches: Record>> + keyBy: string[] + } + > + > +) { + for (const groupVersion in mappers) { + for (const kind in mappers[groupVersion]) { + const { mcaches } = mappers[groupVersion][kind] + for (const gv in mcaches) { + for (const k in mcaches[gv]) { + mcaches[gv][k] = {} + } + } + } + } +} + +function updateMapperCache( + mapper: { + setter: SetterOrUpdater> + mcaches: Record>> + keyBy: string[] + }, + groupVersion: string, + kind: string, + watchEvents: WatchEvent[] +) { + const { mcaches, keyBy } = mapper + const map = mcaches[groupVersion]?.[kind] + for (const watchEvent of watchEvents) { + const key = keyBy + .reduce((keys, partKey) => { + keys.push(get(watchEvent.object, partKey)) + return keys + }, [] as string[]) + .join('/') + map[key] = [...(map[key] || [])] + const arr = map[key] + const index = arr.findIndex( + (resource) => + resource.metadata?.name === watchEvent.object.metadata.name && + resource.metadata?.namespace === watchEvent.object.metadata.namespace + ) + switch (watchEvent.type) { + case 'ADDED': + case 'MODIFIED': + if (index !== -1) arr[index] = watchEvent.object + else arr.push(watchEvent.object) + break + case 'DELETED': + if (index !== -1) arr.splice(index, 1) + break + } + } +} + +// Query for GlobalHub check and name +const globalHubQueryFn = () => { + return getRequest<{ + isGlobalHub: boolean + localHubName: string + isHubSelfManaged: boolean | undefined + authentication: { + isDirectAuthenticationEnabled: boolean + claimMappings?: ClaimMappings + } + }>(getBackendUrl() + '/hub') +} + +// Query for GlobalHub check and name +const mchQueryFn = () => { + return getRequest(getBackendUrl() + '/multiclusterhub/components') +} diff --git a/frontend/src/components/LoadRbacData.test.tsx b/frontend/src/components/LoadRbacData.test.tsx new file mode 100644 index 00000000000..2aff32dc46c --- /dev/null +++ b/frontend/src/components/LoadRbacData.test.tsx @@ -0,0 +1,142 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import { act, render, waitFor } from '@testing-library/react' +import { createElement, ReactElement, type ComponentProps } from 'react' +import { MutableSnapshot, RecoilRoot, useRecoilValue } from 'recoil' +import { settingsState, vmClusterRolesState } from '../atoms' +import { PluginDataContext, defaultContext, PluginData } from '../lib/PluginDataContext' +import { installFakeEventSource } from '../lib/test-event-source' +import { ClusterRole } from '../resources' +import { LoadRbacData } from './LoadRbacData' + +let mockIsActive = true +jest.mock('../lib/usePageActivity', () => ({ + usePageActivity: () => ({ isActive: mockIsActive, deadline: null, pageInUse: true }), +})) + +jest.mock('../resources/utils', () => ({ + getBackendUrl: () => '', +})) + +function createTestContext(overrides: Partial = {}): PluginData { + return { + ...defaultContext, + loadStarted: true, + loadCompleted: true, + startLoading: true, + mounted: true, + ...overrides, + } +} + +function RolesProbe() { + const roles = useRecoilValue(vmClusterRolesState) + return createElement('div', { id: 'roles' }, String(roles.length)) +} + +function Wrapper({ ctx, children }: { ctx: PluginData; children: ReactElement }) { + return createElement( + PluginDataContext.Provider, + { value: ctx }, + createElement( + RecoilRoot, + { + initializeState: (snapshot: MutableSnapshot) => { + snapshot.set(settingsState, { EVENT_STREAM_IDLE_TIMEOUT: '1', EVENT_STREAM_IDLE_GRACE_PERIOD: '0' }) + }, + } as ComponentProps, + children + ) + ) +} + +const sampleRole: ClusterRole = { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRole', + metadata: { name: 'kubevirt.io:admin', uid: 'uid-1' }, + rules: [], +} + +describe('LoadRbacData', () => { + let fake: ReturnType + + beforeEach(() => { + mockIsActive = true + fake = installFakeEventSource() + }) + + afterEach(() => { + fake.restore() + }) + + it('opens /events/rbac with credentials and applies ADDED into the atom', async () => { + const ctx = createTestContext() + render( + + <> + + + + + ) + + await waitFor(() => expect(fake.sources.length).toBe(1)) + expect(fake.sources[0].url).toBe('/events/rbac') + expect(fake.sources[0].withCredentials).toBe(true) + + act(() => { + fake.sources[0].emit({ type: 'START' }) + fake.sources[0].emit({ type: 'ADDED', object: sampleRole }) + fake.sources[0].emit({ type: 'EOP' }) + fake.sources[0].emit({ type: 'LOADED' }) + }) + + await waitFor(() => { + expect(document.getElementById('roles')?.textContent).toBe('1') + }) + }) + + it('removes DELETED roles from the atom', async () => { + const ctx = createTestContext() + render( + + <> + + + + + ) + await waitFor(() => expect(fake.sources.length).toBe(1)) + act(() => { + fake.sources[0].emit({ type: 'ADDED', object: sampleRole }) + fake.sources[0].emit({ type: 'EOP' }) + }) + await waitFor(() => expect(document.getElementById('roles')?.textContent).toBe('1')) + act(() => { + fake.sources[0].emit({ type: 'DELETED', object: sampleRole }) + fake.sources[0].emit({ type: 'EOP' }) + }) + await waitFor(() => expect(document.getElementById('roles')?.textContent).toBe('0')) + }) + + it('closes the stream when idle with no grace period and does not drive overlay flags', async () => { + const setIsStreamIdle = jest.fn() + const setIsReconnecting = jest.fn() + const ctx = createTestContext({ setIsStreamIdle, setIsReconnecting }) + const { rerender } = render( + + + + ) + await waitFor(() => expect(fake.sources.length).toBe(1)) + mockIsActive = false + rerender( + + + + ) + expect(fake.sources[0].close).toHaveBeenCalled() + expect(setIsStreamIdle).not.toHaveBeenCalled() + expect(setIsReconnecting).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/LoadRbacData.tsx b/frontend/src/components/LoadRbacData.tsx new file mode 100644 index 00000000000..7a8ec650aff --- /dev/null +++ b/frontend/src/components/LoadRbacData.tsx @@ -0,0 +1,17 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { useMemo } from 'react' +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { useSetRecoilState } from 'recoil' +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { vmClusterRolesState } from '../atoms' +import { ClusterRoleKind, RbacApiVersion } from '../resources' +import { LoadDataAbstract } from './LoadDataAbstract' + +export function LoadRbacData() { + const setVMClusterRoles = useSetRecoilState(vmClusterRolesState) + const resources = useMemo( + () => [{ apiVersion: RbacApiVersion, kind: ClusterRoleKind, setState: setVMClusterRoles }], + [setVMClusterRoles] + ) + return +} diff --git a/frontend/src/hooks/applyWatchEventsToCache.test.ts b/frontend/src/hooks/applyWatchEventsToCache.test.ts new file mode 100644 index 00000000000..c675bc4f1f9 --- /dev/null +++ b/frontend/src/hooks/applyWatchEventsToCache.test.ts @@ -0,0 +1,66 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import type { WatchEvent } from '../atoms' +import type { IResource } from '../resources' +import { applyWatchEventsToCache, groupWatchEventsByKind, resourceKey } from './applyWatchEventsToCache' + +const added: WatchEvent = { + type: 'ADDED', + object: { + kind: 'ClusterRole', + apiVersion: 'rbac.authorization.k8s.io/v1', + metadata: { name: 'admin', namespace: undefined as unknown as string, resourceVersion: '1' }, + }, +} + +const modified: WatchEvent = { + type: 'MODIFIED', + object: { + kind: 'ClusterRole', + apiVersion: 'rbac.authorization.k8s.io/v1', + metadata: { name: 'admin', namespace: undefined as unknown as string, resourceVersion: '2' }, + }, +} + +const deleted: WatchEvent = { + type: 'DELETED', + object: { + kind: 'ClusterRole', + apiVersion: 'rbac.authorization.k8s.io/v1', + metadata: { name: 'admin', namespace: undefined as unknown as string, resourceVersion: '2' }, + }, +} + +const pod: WatchEvent = { + type: 'ADDED', + object: { + kind: 'Pod', + apiVersion: 'v1', + metadata: { name: 'nginx', namespace: 'default', resourceVersion: '1' }, + }, +} + +describe('resourceKey', () => { + it('joins namespace and name', () => { + expect(resourceKey(pod.object)).toBe('default/nginx') + }) +}) + +describe('applyWatchEventsToCache', () => { + it('adds, updates, and deletes by namespace/name', () => { + const cache: Record = {} + applyWatchEventsToCache(cache, [added]) + expect(Object.keys(cache)).toEqual(['undefined/admin']) + applyWatchEventsToCache(cache, [modified]) + expect(cache['undefined/admin'].metadata?.resourceVersion).toBe('2') + applyWatchEventsToCache(cache, [deleted]) + expect(cache).toEqual({}) + }) +}) + +describe('groupWatchEventsByKind', () => { + it('groups by apiVersion group and kind', () => { + const grouped = groupWatchEventsByKind([added, pod]) + expect(grouped['rbac.authorization.k8s.io'].ClusterRole).toEqual([added]) + expect(grouped.v1.Pod).toEqual([pod]) + }) +}) diff --git a/frontend/src/hooks/applyWatchEventsToCache.ts b/frontend/src/hooks/applyWatchEventsToCache.ts new file mode 100644 index 00000000000..4db48bf5f3e --- /dev/null +++ b/frontend/src/hooks/applyWatchEventsToCache.ts @@ -0,0 +1,37 @@ +/* Copyright Contributors to the Open Cluster Management project */ +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import type { WatchEvent } from '../atoms' +import type { IResource } from '../resources' + +export function resourceKey(object: WatchEvent['object']): string { + return `${object.metadata.namespace}/${object.metadata.name}` +} + +export function applyWatchEventsToCache(cache: Record, watchEvents: WatchEvent[]): void { + for (const watchEvent of watchEvents) { + const key = resourceKey(watchEvent.object) + switch (watchEvent.type) { + case 'ADDED': + case 'MODIFIED': + cache[key] = watchEvent.object + break + case 'DELETED': + delete cache[key] + break + } + } +} + +export function groupWatchEventsByKind(watchEvents: WatchEvent[]): Record> { + return watchEvents.reduce( + (resourceTypeMap, eventData) => { + const groupVersion = eventData.object.apiVersion.split('/')[0] + const kind = eventData.object.kind + if (!resourceTypeMap[groupVersion]) resourceTypeMap[groupVersion] = {} + if (!resourceTypeMap[groupVersion][kind]) resourceTypeMap[groupVersion][kind] = [] + resourceTypeMap[groupVersion][kind].push(eventData) + return resourceTypeMap + }, + {} as Record> + ) +} diff --git a/frontend/src/hooks/useWatchEventStream.test.ts b/frontend/src/hooks/useWatchEventStream.test.ts new file mode 100644 index 00000000000..d69b7e3222c --- /dev/null +++ b/frontend/src/hooks/useWatchEventStream.test.ts @@ -0,0 +1,128 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { act, renderHook } from '@testing-library/react-hooks' +import { useRef } from 'react' +import { installFakeEventSource } from '../lib/test-event-source' +import { useWatchEventStream } from './useWatchEventStream' + +jest.mock('../resources/utils', () => ({ + getBackendUrl: () => '', +})) + +describe('useWatchEventStream', () => { + let fake: ReturnType + + beforeEach(() => { + fake = installFakeEventSource() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + fake.restore() + }) + + function renderStream(path = '/events/rbac', streamStopped = false) { + const applyWatchEvents = jest.fn() + const onEndOfPacket = jest.fn() + const onLoaded = jest.fn() + const onSettings = jest.fn() + const { result, rerender, unmount } = renderHook( + (props: { path: string; restartKey: number }) => { + const streamStoppedRef = useRef(streamStopped) + const eventSourceRef = useRef() + const processIntervalRef = useRef>() + streamStoppedRef.current = streamStopped + useWatchEventStream({ + path: props.path, + restartKey: props.restartKey, + streamStoppedRef, + eventSourceRef, + processIntervalRef, + applyWatchEvents, + onEndOfPacket, + onLoaded, + onSettings, + }) + return { eventSourceRef, streamStoppedRef } + }, + { initialProps: { path, restartKey: 0 } } + ) + return { applyWatchEvents, onEndOfPacket, onLoaded, onSettings, result, rerender, unmount } + } + + it('opens the path with credentials', () => { + renderStream('/events/rbac') + expect(fake.sources).toHaveLength(1) + expect(fake.sources[0].url).toBe('/events/rbac') + expect(fake.sources[0].withCredentials).toBe(true) + }) + + it('applies ADDED on EOP and calls onEndOfPacket', () => { + const { applyWatchEvents, onEndOfPacket } = renderStream() + const object = { + kind: 'ClusterRole', + apiVersion: 'rbac.authorization.k8s.io/v1', + metadata: { name: 'admin', namespace: '', resourceVersion: '1' }, + } + act(() => { + fake.sources[0].emit({ type: 'START' }) + fake.sources[0].emit({ type: 'ADDED', object }) + fake.sources[0].emit({ type: 'EOP' }) + }) + expect(applyWatchEvents).toHaveBeenCalledWith([expect.objectContaining({ type: 'ADDED', object })]) + expect(onEndOfPacket).toHaveBeenCalledTimes(1) + }) + + it('drops queued events on START', () => { + const { applyWatchEvents } = renderStream() + const object = { + kind: 'ClusterRole', + apiVersion: 'rbac.authorization.k8s.io/v1', + metadata: { name: 'admin', namespace: '', resourceVersion: '1' }, + } + act(() => { + fake.sources[0].emit({ type: 'ADDED', object }) + fake.sources[0].emit({ type: 'START' }) + fake.sources[0].emit({ type: 'EOP' }) + }) + expect(applyWatchEvents).not.toHaveBeenCalled() + }) + + it('calls onLoaded after flushing the queue', () => { + const { applyWatchEvents, onLoaded } = renderStream() + act(() => { + fake.sources[0].emit({ type: 'LOADED' }) + }) + expect(applyWatchEvents).not.toHaveBeenCalled() + expect(onLoaded).toHaveBeenCalledTimes(1) + }) + + it('forwards SETTINGS', () => { + const { onSettings } = renderStream() + act(() => { + fake.sources[0].emit({ type: 'SETTINGS', settings: { FOO: 'bar' } }) + }) + expect(onSettings).toHaveBeenCalledWith({ FOO: 'bar' }) + }) + + it('reconnects after CLOSED unless the stream was stopped', () => { + renderStream('/events', false) + act(() => { + fake.sources[0].triggerError() + }) + expect(fake.sources).toHaveLength(1) + act(() => { + jest.advanceTimersByTime(1000) + }) + expect(fake.sources).toHaveLength(2) + }) + + it('does not reconnect when the stream was stopped for idle', () => { + renderStream('/events', true) + act(() => { + fake.sources[0].triggerError() + jest.advanceTimersByTime(1000) + }) + expect(fake.sources).toHaveLength(1) + }) +}) diff --git a/frontend/src/hooks/useWatchEventStream.ts b/frontend/src/hooks/useWatchEventStream.ts new file mode 100644 index 00000000000..7999e122d06 --- /dev/null +++ b/frontend/src/hooks/useWatchEventStream.ts @@ -0,0 +1,110 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { MutableRefObject, useEffect, useRef } from 'react' +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { ServerSideEventData, THROTTLE_EVENTS_DELAY, WatchEvent } from '../atoms' +import { getBackendUrl } from '../resources/utils' + +export interface WatchEventStreamHandlers { + applyWatchEvents: (events: WatchEvent[]) => void + onSettings?: (settings: Record) => void + onEndOfPacket?: () => void + onLoaded?: () => void +} + +export interface UseWatchEventStreamOptions extends WatchEventStreamHandlers { + path: string + restartKey: number + streamStoppedRef: MutableRefObject + eventSourceRef: MutableRefObject + processIntervalRef: MutableRefObject | undefined> +} + +export function useWatchEventStream({ + path, + restartKey, + streamStoppedRef, + eventSourceRef, + processIntervalRef, + applyWatchEvents, + onSettings, + onEndOfPacket, + onLoaded, +}: UseWatchEventStreamOptions): void { + const applyWatchEventsRef = useRef(applyWatchEvents) + applyWatchEventsRef.current = applyWatchEvents + const onSettingsRef = useRef(onSettings) + onSettingsRef.current = onSettings + const onEndOfPacketRef = useRef(onEndOfPacket) + onEndOfPacketRef.current = onEndOfPacket + const onLoadedRef = useRef(onLoaded) + onLoadedRef.current = onLoaded + + useEffect(() => { + const eventQueue: WatchEvent[] = [] + + function processEventQueue() { + if (eventQueue.length === 0) return + const watchEvents = eventQueue.splice(0) + applyWatchEventsRef.current(watchEvents) + } + + function processMessage(event: MessageEvent) { + if (!event.data) return + try { + const data = JSON.parse(event.data) as ServerSideEventData + switch (data.type) { + case 'ADDED': + case 'MODIFIED': + case 'DELETED': + eventQueue.push(data) + break + case 'START': + eventQueue.length = 0 + break + case 'EOP': + processEventQueue() + onEndOfPacketRef.current?.() + break + case 'LOADED': + processEventQueue() + onLoadedRef.current?.() + break + case 'SETTINGS': + onSettingsRef.current?.(data.settings) + break + } + } catch (err) { + console.error(err) + } + } + + let evtSource: EventSource | undefined + let reconnectTimer: ReturnType | undefined + + function startWatch() { + evtSource = new EventSource(`${getBackendUrl()}${path}`, { withCredentials: true }) + eventSourceRef.current = evtSource + evtSource.onmessage = processMessage + evtSource.onerror = function () { + console.log('EventSource', 'error', 'readyState', evtSource?.readyState) + if (streamStoppedRef.current) return + if (evtSource?.readyState === EventSource.CLOSED) { + reconnectTimer = setTimeout(() => { + startWatch() + }, 1000) + } + } + } + startWatch() + + const timeout = setInterval(processEventQueue, THROTTLE_EVENTS_DELAY) + processIntervalRef.current = timeout + return () => { + clearInterval(timeout) + if (reconnectTimer) clearTimeout(reconnectTimer) + if (evtSource) evtSource.close() + eventSourceRef.current = undefined + processIntervalRef.current = undefined + } + }, [eventSourceRef, path, processIntervalRef, restartKey, streamStoppedRef]) +} diff --git a/frontend/src/lib/test-event-source.ts b/frontend/src/lib/test-event-source.ts new file mode 100644 index 00000000000..309be17bc97 --- /dev/null +++ b/frontend/src/lib/test-event-source.ts @@ -0,0 +1,48 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +export type FakeEventSource = { + url: string + withCredentials: boolean + readyState: number + onmessage: ((ev: MessageEvent) => void) | null + onerror: (() => void) | null + close: jest.Mock + emit: (data: unknown) => void + triggerError: (readyState?: number) => void +} + +export function installFakeEventSource(): { sources: FakeEventSource[]; restore: () => void } { + const sources: FakeEventSource[] = [] + const OriginalEventSource = global.EventSource + global.EventSource = class { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSED = 2 + url: string + withCredentials: boolean + readyState = 1 + onmessage: ((ev: MessageEvent) => void) | null = null + onerror: (() => void) | null = null + close = jest.fn() + constructor(url: string | URL, init?: EventSourceInit) { + this.url = url.toString() + this.withCredentials = !!init?.withCredentials + const self = this as unknown as FakeEventSource + self.emit = (data: unknown) => { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent) + } + self.triggerError = (readyState = EventSource.CLOSED) => { + this.readyState = readyState + this.onerror?.() + } + sources.push(self) + } + } as unknown as typeof EventSource + + return { + sources, + restore: () => { + global.EventSource = OriginalEventSource + }, + } +} diff --git a/frontend/src/resources/utils/resource-request.ts b/frontend/src/resources/utils/resource-request.ts index bdc37f73ca4..cf7aed1ad3e 100644 --- a/frontend/src/resources/utils/resource-request.ts +++ b/frontend/src/resources/utils/resource-request.ts @@ -12,7 +12,7 @@ import { getResourceApiPath, getResourceName, getResourceNameApiPath, IResource, import { Status, StatusKind } from '../status' import { AnsibleTowerInventory, AnsibleTowerInventoryList } from '../ansible-inventory' -// must match ansiblePaths in backend/src/routes/ansibletower.ts +// must match ansiblePaths in backend-node/src/routes/ansibletower.ts const ansibleControllerPaths = ['/api/v2/job_templates/', '/api/v2/workflow_job_templates/'] // Ansible Automation Platform Operator v2.5 and later only supports the Gateway URL. // For Gateway URLs, use the following path prefixes: diff --git a/frontend/webpack.config.ts b/frontend/webpack.config.ts index 23362be1775..7e0468de102 100644 --- a/frontend/webpack.config.ts +++ b/frontend/webpack.config.ts @@ -179,6 +179,7 @@ module.exports = function (env: any, argv: { hot?: boolean; mode: string | undef '/multicloud/configure', '/multicloud/console-links', '/multicloud/events', + '/multicloud/events/rbac', '/multicloud/hub', '/multicloud/upgrade-risks-prediction', '/multicloud/login', diff --git a/lint-staged.config.js b/lint-staged.config.js index 5598a35d778..b885ee31f26 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -2,7 +2,8 @@ // lint-staged.config.js module.exports = { '*': 'npm run copyright:fix --', - 'backend/**/*.ts': 'npm run lint:fix:backend --', + 'backend-node/**/*.ts': 'npm run lint:fix:backend-node --', + 'backend/**/*.go': 'npm run lint:fix:backend --', 'frontend/**/*.{ts,tsx}|frontend/src/**/*.{js,jsx}': (staged) => { const files = staged.join(' ') return [ diff --git a/package.json b/package.json index 5174f0b8c58..ebb98dd8949 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,14 @@ "scripts": { "postinstall": "concurrently npm:ci:* -c green,blue", "ci:frontend": "cd frontend && npm ci", - "ci:backend": "cd backend && npm ci", + "ci:backend": "if command -v go >/dev/null 2>&1; then cd backend && go mod download; else echo 'Go not installed; skipping go mod download'; fi", + "ci:backend-node": "cd backend-node && npm ci", "start": "concurrently npm:start:backend npm:start:frontend -c green,blue", "start:hot": "concurrently npm:start:backend npm:start:frontend:hot -c green,blue", "launch": "concurrently npm:start:backend npm:start:frontend:launch -c green,blue", - "start:backend": "cd backend && npm start", + "start:backend": "concurrently -n go,sidecar -c green,yellow npm:start:backend:go npm:start:backend:sidecar", + "start:backend:go": ". ./port-defaults.sh && PORT=$BACKEND_PORT NODE_BACKEND_URL=https://127.0.0.1:$NODE_BACKEND_PORT ./scripts/air-backend.sh", + "start:backend:sidecar": ". ./port-defaults.sh && cd backend-node && PORT=$NODE_BACKEND_PORT ENV_FILE=../backend/.env CONFIG_DIR=../backend/config CERTS_DIR=../backend/certs npm start", "start:frontend": "cd frontend && npm start", "start:frontend:hot": "cd frontend && npm run start:hot", "start:frontend:launch": "cd frontend && npm run launch", @@ -22,47 +25,56 @@ "watch:multicluster-sdk": "cd frontend && npm run watch -w @stolostron/multicluster-sdk", "watch:react-form-wizard": "cd frontend && npm run watch -w @patternfly-labs/react-form-wizard", "check": "concurrently --kill-others-on-fail npm:copyright:check \"npm:check:*(!fix)\" -c green,blue,magenta", - "check:backend": "cd backend && npm run check", + "check:backend": "cd backend && go test ./... && ../scripts/golangci-lint-backend.sh", + "check:backend-node": "cd backend-node && npm run check", "check:frontend": "cd frontend && npm run check", "check:fix": "concurrently --kill-others-on-fail npm:copyright:fix npm:check:fix:* -c green,blue,magenta", - "check:fix:backend": "cd backend && npm run check:fix", + "check:fix:backend": "cd backend && gofmt -w . && go test ./... && ../scripts/golangci-lint-backend.sh", + "check:fix:backend-node": "cd backend-node && npm run check:fix", "check:fix:frontend": "cd frontend && npm run check:fix", "lint-staged": "npx lint-staged --no-stash", "lint": "concurrently --kill-others-on-fail \"npm:lint:*(!fix)\" -c green,blue", - "lint:backend": "cd backend && npm run lint", + "lint:backend": "./scripts/golangci-lint-backend.sh", + "lint:backend-node": "cd backend-node && npm run lint", "lint:frontend": "cd frontend && npm run lint", "lint:fix": "concurrently --kill-others-on-fail npm:lint:fix:* -c green,blue", - "lint:fix:backend": "cd backend && npm run lint:fix", + "lint:fix:backend": "cd backend && gofmt -w . && ../scripts/golangci-lint-backend.sh --fix", + "lint:fix:backend-node": "cd backend-node && npm run lint:fix", "lint:fix:frontend": "cd frontend && npm run lint:fix", - "test": "concurrently -P --kill-others-on-fail \"npm:test:* -- {@}\" -c green,blue --", - "test:backend": "cd backend && npm test --", + "test": "concurrently --kill-others-on-fail npm:test:backend npm:test:backend-node npm:test:frontend -c green,blue", + "test:backend": "cd backend && go test ./...", + "test:backend-node": "cd backend-node && npm test --", "test:frontend": "cd frontend && npm test --", "i18n": "concurrently --kill-others-on-fail \"npm:i18n:*(!fix)\" -c green,blue,magenta", "i18n:frontend": "cd frontend && npm run i18n --", "i18n:fix": "concurrently --kill-others-on-fail npm:i18n:fix:* -c green,blue,magenta", "i18n:fix:frontend": "cd frontend && npm run i18n:fix --", "build": "concurrently npm:build:* -c green,blue,magenta", - "build:backend": "cd backend && npm run build", + "build:backend": "cd backend && go build -o bin/console ./cmd/console", + "build:backend-node": "cd backend-node && npm run build", "build:frontend": "cd frontend && npm run build", "clean": "concurrently npm:clean:* -c green,blue", - "clean:backend": "cd backend && npm run clean", + "clean:backend": "rm -rf backend/bin backend/coverage backend/tmp", + "clean:backend-node": "cd backend-node && npm run clean", "clean:frontend": "cd frontend && npm run clean", - "update": "npx npm-check-updates --upgrade && npm install && npm run backend:update && npm run frontend:update", - "backend:update": "cd backend && npm run update", + "update": "npx npm-check-updates --upgrade && npm install && npm run backend-node:update && npm run frontend:update", + "backend-node:update": "cd backend-node && npm run update", "frontend:update": "cd frontend && npm run update", "copyright:check": "ts-node --skip-project scripts/copyright-check", "copyright:fix": "ts-node --skip-project scripts/copyright-fix", "docker:build": "docker build --file Containerfile.acm --tag console .", "docker:build:mce": "docker build --file Containerfile.mce --tag console-mce .", - "docker:run": "npm run docker:build && docker run --rm --name console -p 3000:3000 -e PORT=3000 -v $PWD/backend/certs:/app/certs -v $PWD/backend/config:/app/config --env-file=backend/.env console | ./backend/node_modules/.bin/pino-zen -i time && docker rm -f console", + "docker:run": "npm run docker:build && docker run --rm --name console -p 3000:3000 -e PORT=3000 -v $PWD/backend/certs:/app/certs -v $PWD/backend/config:/app/config --env-file=backend/.env console | ./backend-node/node_modules/.bin/pino-zen -i time && docker rm -f console", "docker:deploy": "npm run docker:build && docker tag console quay.io/$USER/console:latest && docker push quay.io/$USER/console:latest && ./scripts/patch-deployment.sh latest quay.io/$USER/console", "podman:build": "podman build --arch amd64 --file Containerfile.acm --tag console .", "podman:build:mce": "podman build --arch amd64 --file Containerfile.mce --tag console-mce .", - "podman:run": "npm run podman:build && podman run --arch amd64 --rm --name console -p 3000:3000 -e PORT=3000 -v $PWD/backend/certs:/app/certs -v $PWD/backend/config:/app/config --env-file=backend/.env console | ./backend/node_modules/.bin/pino-zen -i time && podman rm -f console", + "podman:run": "npm run podman:build && podman run --arch amd64 --rm --name console -p 3000:3000 -e PORT=3000 -v $PWD/backend/certs:/app/certs -v $PWD/backend/config:/app/config --env-file=backend/.env console | ./backend-node/node_modules/.bin/pino-zen -i time && podman rm -f console", "podman:deploy": "npm run podman:build && podman tag console quay.io/$USER/console:latest && podman push quay.io/$USER/console:latest && ./scripts/patch-deployment.sh latest quay.io/$USER/console", "podman:deploy:mce": "npm run podman:build:mce && podman tag console-mce quay.io/$USER/console-mce:latest && podman push quay.io/$USER/console-mce:latest && ./scripts/patch-deployment.sh latest quay.io/$USER/console-mce", "playwright:sanity": "npx playwright test --config e2e-template/playwright-sanity.config.ts", + "generate-certs": "mkdir -p backend/certs && openssl req -subj '/C=US' -new -newkey rsa:2048 -sha256 -days 365 -nodes -x509 -keyout backend/certs/tls.key -out backend/certs/tls.crt", "setup": "./setup.sh", + "setup:hub": "rm -rf backend/.env backend/certs && npm run setup && npm run generate-certs", "prepare": "husky install" }, "devDependencies": { diff --git a/port-defaults.sh b/port-defaults.sh index 8acc3d19a98..584516ee987 100644 --- a/port-defaults.sh +++ b/port-defaults.sh @@ -8,3 +8,4 @@ export FRONTEND_PORT=${FRONTEND_PORT:=3000} export MCE_PORT=${MCE_PORT:=3001} export ACM_PORT=${ACM_PORT:=3002} export BACKEND_PORT=${BACKEND_PORT:=4000} +export NODE_BACKEND_PORT=${NODE_BACKEND_PORT:=4001} diff --git a/scripts/air-backend.sh b/scripts/air-backend.sh new file mode 100755 index 00000000000..af7d70474bf --- /dev/null +++ b/scripts/air-backend.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Copyright Contributors to the Open Cluster Management project + +set -euo pipefail + +readonly AIR_VERSION=v1.67.4 +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v go >/dev/null 2>&1; then + echo "Go is not installed; the console backend requires Go 1.26+" >&2 + exit 1 +fi + +export PATH="$(go env GOPATH)/bin:${PATH}" + +if ! command -v air >/dev/null 2>&1; then + go install "github.com/air-verse/air@${AIR_VERSION}" +fi + +cd "${ROOT_DIR}/backend" +exec air "$@" diff --git a/scripts/copyright-fix.ts b/scripts/copyright-fix.ts index f0ea89a80d9..7ea7f24ed4d 100644 --- a/scripts/copyright-fix.ts +++ b/scripts/copyright-fix.ts @@ -6,7 +6,10 @@ export async function fixCopyright(path: string): Promise { try { const file = await readFile(path) if (!file.toString().includes('Copyright Contributors to the Open Cluster Management project')) { - const fixed = '/* Copyright Contributors to the Open Cluster Management project */\n' + file.toString() + const header = path.endsWith('.go') + ? '// Copyright Contributors to the Open Cluster Management project\n\n' + : '/* Copyright Contributors to the Open Cluster Management project */\n' + const fixed = header + file.toString() console.log('fixed:', path) void writeFile(path, fixed) } diff --git a/scripts/copyright.ts b/scripts/copyright.ts index edd60200738..62f50cef34d 100644 --- a/scripts/copyright.ts +++ b/scripts/copyright.ts @@ -2,8 +2,8 @@ import { lstat, readdir, readFile, writeFile } from 'fs/promises' import { join } from 'path' -const ignoreDirectories = ['.git', 'node_modules', 'coverage', 'build', 'dist', 'lib'] -const extensions = ['.ts', '.tsx', '.js'] +const ignoreDirectories = ['.git', 'node_modules', 'coverage', 'build', 'dist', 'lib', 'bin'] +const extensions = ['.ts', '.tsx', '.js', '.go'] export type CopyrightAction = (path: string) => Promise diff --git a/scripts/golangci-lint-backend.sh b/scripts/golangci-lint-backend.sh new file mode 100755 index 00000000000..41220b9dce6 --- /dev/null +++ b/scripts/golangci-lint-backend.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Copyright Contributors to the Open Cluster Management project + +set -euo pipefail + +readonly GOLANGCI_LINT_VERSION=v1.64.8 +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v go >/dev/null 2>&1; then + echo "Go is not installed; skipping backend lint" >&2 + exit 0 +fi + +if ! command -v golangci-lint >/dev/null 2>&1; then + go install "github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION}" +fi + +export PATH="$(go env GOPATH)/bin:${PATH}" +cd "${ROOT_DIR}/backend" +golangci-lint run "$@" diff --git a/setup.sh b/setup.sh index 9c2f090ff40..0c468be2edd 100755 --- a/setup.sh +++ b/setup.sh @@ -9,6 +9,8 @@ source ./oauth-client-name.sh echo > ./backend/.env echo PORT="${BACKEND_PORT}" >> ./backend/.env +echo NODE_BACKEND_PORT="${NODE_BACKEND_PORT}" >> ./backend/.env +echo NODE_BACKEND_URL="https://127.0.0.1:${NODE_BACKEND_PORT}" >> ./backend/.env echo NODE_ENV=development >> ./backend/.env CLUSTER_API_URL=`oc get infrastructure cluster -o jsonpath={.status.apiServerURL}` diff --git a/sonar-project.properties b/sonar-project.properties index 540f1eea40c..34129de67c1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,10 +1,10 @@ sonar.projectKey=open-cluster-management_console sonar.projectName=console sonar.organization=open-cluster-management -sonar.sources=frontend/src,frontend/packages/multicluster-sdk/src,backend/src +sonar.sources=frontend/src,frontend/packages/multicluster-sdk/src,backend-node/src sonar.exclusions=node_modules/**/*,frontend/node_modules/**/*,frontend/src/atoms.tsx,frontend/src/lib/nock-util.ts,frontend/src/**/*.stories.tsx,frontend/src/**/*.fixtures.ts,frontend/src/routes/Search/search-sdk/search-sdk.ts sonar.coverage.exclusions=**/*.sharedmocks.tsx,**/*.sharedmocks.ts,**/test-shots.ts,**/setupTests.ts sonar.tests=frontend/src -sonar.test.inclusions=frontend/**/*.test.tsx,frontend/**/*.test.ts,frontend/**/*.test.js,backend/test/**/*.spec.ts -sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info,backend/coverage/lcov.info -sonar.testExecutionReportPaths=frontend/test-report.xml,backend/test-report.xml +sonar.test.inclusions=frontend/**/*.test.tsx,frontend/**/*.test.ts,frontend/**/*.test.js,backend-node/test/**/*.spec.ts +sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info,backend-node/coverage/lcov.info +sonar.testExecutionReportPaths=frontend/test-report.xml,backend-node/test-report.xml From a6ddcebdcd31cb8a99fa05fad3f41c9453440cdb Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Mon, 31 Aug 2026 19:59:39 +0200 Subject: [PATCH 02/16] ACM-42592: Migrate hub kube-apiserver proxy routes to Go (#48) * ACM-42589: Migrate hub kube-apiserver proxy routes to Go Move /api, /apis, and /version passthrough from the Node sidecar to a new backend/internal/k8sproxy package in the Go public listener. The proxy uses the user's Bearer or cookie token, strips the /multicloud prefix for route matching, forwards an allowlist of request/response headers, and falls back to 502 Bad Gateway when the upstream cluster API is unreachable. Remove the corresponding Node proxy route and tests, and update AGENTS.md and ARCHITECTURE.md to reflect the migrated routes. Signed-off-by: Enrique Mingorance Cano * .editorconfig Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- .editorconfig | 21 ++ backend-node/src/app.ts | 7 - backend-node/src/routes/proxy.ts | 73 ----- backend-node/test/routes/proxy.test.ts | 294 --------------------- backend/AGENTS.md | 5 +- backend/cmd/console/main.go | 10 +- backend/internal/k8sproxy/k8sproxy.go | 100 +++++++ backend/internal/k8sproxy/k8sproxy_test.go | 275 +++++++++++++++++++ backend/internal/server/server.go | 28 ++ backend/internal/server/server_test.go | 92 +++++++ docs/ARCHITECTURE.md | 4 +- 11 files changed, 531 insertions(+), 378 deletions(-) create mode 100644 .editorconfig delete mode 100644 backend-node/src/routes/proxy.ts delete mode 100644 backend-node/test/routes/proxy.test.ts create mode 100644 backend/internal/k8sproxy/k8sproxy.go create mode 100644 backend/internal/k8sproxy/k8sproxy_test.go diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000000..de7f0cd0125 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# Copyright Contributors to the Open Cluster Management project + +# https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{js,jsx,ts,tsx,mjs,cjs,json,jsonc,yml,yaml,css,scss,graphql}] +indent_style = space +indent_size = 2 + +[*.go] +indent_style = tab +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 50d0994ac87..3dafa977314 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -22,7 +22,6 @@ import { observabilityProxy, prometheusProxy } from './routes/metricsProxy' import { multiClusterHubComponents } from './routes/multiClusterHubComponents' import { login, loginCallback, logout } from './routes/oauth' import { operatorCheck } from './routes/operatorCheck' -import { proxy } from './routes/proxy' import { readiness } from './routes/readiness' import { search } from './routes/search' import { placementDebug } from './routes/placementDebug' @@ -62,13 +61,7 @@ export const router = Router({ maxParamLength: 500 }) router.get('/readinessProbe', readiness) router.get('/livenessProbe', liveness) router.get('/ping', respondOK) -router.all('/api', proxy) -router.all('/api/*', proxy) -router.all('/apis', proxy) -router.all('/apis/*', proxy) router.get('/apiPaths', apiPaths) -router.get('/version', proxy) -router.get('/version/', proxy) router.post('/operatorCheck', operatorCheck) router.get('/observability/*', observabilityProxy) router.get('/prometheus/*', prometheusProxy) diff --git a/backend-node/src/routes/proxy.ts b/backend-node/src/routes/proxy.ts deleted file mode 100644 index 70fac4fef32..00000000000 --- a/backend-node/src/routes/proxy.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse, OutgoingHttpHeaders } from 'node:http2' -import { constants } from 'node:http2' -import type { RequestOptions } from 'node:https' -import { request } from 'node:https' -import { pipeline } from 'node:stream' -import { URL } from 'node:url' -import { logger } from '../lib/logger' -import { notFound, unauthorized } from '../lib/respond' -import { getToken } from '../lib/token' -import { getDefaultAgent } from '../lib/agent' - -const proxyHeaders = [ - constants.HTTP2_HEADER_ACCEPT, - constants.HTTP2_HEADER_ACCEPT_ENCODING, - constants.HTTP2_HEADER_CONTENT_ENCODING, - constants.HTTP2_HEADER_CONTENT_LENGTH, - constants.HTTP2_HEADER_CONTENT_TYPE, -] -const proxyResponseHeaders = [ - constants.HTTP2_HEADER_CACHE_CONTROL, - constants.HTTP2_HEADER_CONTENT_TYPE, - constants.HTTP2_HEADER_CONTENT_LENGTH, - constants.HTTP2_HEADER_CONTENT_ENCODING, - constants.HTTP2_HEADER_ETAG, -] - -// Cache cluster URL to avoid parsing on every request -let clusterUrl: URL -function getClusterUrl(): URL { - if (!clusterUrl) { - clusterUrl = new URL(process.env.CLUSTER_API_URL) - } - return clusterUrl -} - -export function proxy(req: Http2ServerRequest, res: Http2ServerResponse): void { - const token = getToken(req) - if (!token) return unauthorized(req, res) - - const url = req.url - - const headers: OutgoingHttpHeaders = { authorization: `Bearer ${token}` } - for (const header of proxyHeaders) { - if (req.headers[header]) headers[header] = req.headers[header] - } - - const cluster = getClusterUrl() - const options: RequestOptions = { - protocol: cluster.protocol, - hostname: cluster.hostname, - port: cluster.port, - path: url, - method: req.method, - headers, - agent: getDefaultAgent(), - } - pipeline( - req, - request(options, (response) => { - if (!response) return notFound(req, res) - const responseHeaders: OutgoingHttpHeaders = {} - for (const header of proxyResponseHeaders) { - if (response.headers[header]) responseHeaders[header] = response.headers[header] - } - res.writeHead(response.statusCode ?? 500, responseHeaders) - pipeline(response, res as unknown as NodeJS.WritableStream, () => logger.error) - }), - (err) => { - if (err) logger.error(err) - } - ) -} diff --git a/backend-node/test/routes/proxy.test.ts b/backend-node/test/routes/proxy.test.ts deleted file mode 100644 index f60cf0e316d..00000000000 --- a/backend-node/test/routes/proxy.test.ts +++ /dev/null @@ -1,294 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { constants } from 'node:http2' -import { jest } from '@jest/globals' -import { pipeline } from 'node:stream' -import { request } from 'node:https' -import { proxy } from '../../src/routes/proxy' -import { getToken } from '../../src/lib/token' -import { getDefaultAgent } from '../../src/lib/agent' -// import { logger } from '../../src/lib/logger' -import { unauthorized } from '../../src/lib/respond' - -// Mock dependencies -jest.mock('../../src/lib/token', () => ({ - getToken: jest.fn(), -})) - -jest.mock('../../src/lib/agent', () => ({ - getDefaultAgent: jest.fn(() => ({})), -})) - -jest.mock('../../src/lib/logger', () => ({ - logger: { - error: jest.fn(), - }, -})) - -jest.mock('../../src/lib/respond', () => ({ - notFound: jest.fn(), - unauthorized: jest.fn(), -})) - -jest.mock('node:https', () => ({ - request: jest.fn((_options: unknown, callback: unknown) => { - const mockStream = {} as NodeJS.ReadableStream - const mockResponse = { - headers: {}, - statusCode: 200, - } - if (callback && typeof callback === 'function') { - process.nextTick(() => (callback as (response: unknown) => void)(mockResponse)) - } - return mockStream - }), -})) - -jest.mock('node:stream', () => ({ - pipeline: jest.fn((_req: unknown, _request: unknown, callback: unknown) => { - if (callback && typeof callback === 'function') { - process.nextTick(() => (callback as (error: Error | null) => void)(null)) - } - }), -})) - -// Import mocked modules -const mockGetToken = getToken as jest.MockedFunction -const mockGetDefaultAgent = getDefaultAgent as jest.MockedFunction -const mockUnauthorized = unauthorized as jest.MockedFunction -const mockPipeline = jest.mocked(pipeline) -const mockRequest = jest.mocked(request) - -describe('Proxy Route Tests', () => { - let mockReq: Partial - let mockRes: Partial - let originalClusterUrl: string | undefined - - beforeEach(() => { - // Store original environment variable - originalClusterUrl = process.env.CLUSTER_API_URL - - // Set up test environment - process.env.CLUSTER_API_URL = 'https://test-cluster.example.com:6443' - - // Reset all mocks - jest.clearAllMocks() - - // Create mock request and response objects - mockReq = { - url: '/api/v1/namespaces', - method: 'GET', - headers: { - [constants.HTTP2_HEADER_ACCEPT]: 'application/json', - [constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json', - [constants.HTTP2_HEADER_AUTHORIZATION]: 'Bearer test-token', - }, - } as Partial - - mockRes = { - writeHead: jest.fn(), - } as Partial - - // Don't reset modules to avoid issues with mocks - }) - - afterEach(() => { - // Restore original environment variable - if (originalClusterUrl !== undefined) { - process.env.CLUSTER_API_URL = originalClusterUrl - } else { - delete process.env.CLUSTER_API_URL - } - }) - - describe('getClusterUrl function', () => { - it('should parse and cache cluster URL from environment variable', async () => { - // Re-import the module to test getClusterUrl - const { proxy: proxyFunction } = await import('../../src/routes/proxy') - - // Call proxy to trigger getClusterUrl - mockGetToken.mockReturnValue('test-token') - - proxyFunction(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - // Verify that the URL was parsed correctly - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - protocol: 'https:', - hostname: 'test-cluster.example.com', - port: '6443', - }), - expect.any(Function) - ) - }) - - it('should reuse cached URL on subsequent calls', async () => { - const { proxy: proxyFunction } = await import('../../src/routes/proxy') - - mockGetToken.mockReturnValue('test-token') - - // Call proxy multiple times - proxyFunction(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - proxyFunction(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - // Should only parse URL once (cached) - expect(mockRequest).toHaveBeenCalledTimes(2) - }) - }) - - describe('proxy function', () => { - it('should return unauthorized when no token is provided', () => { - mockGetToken.mockReturnValue(null) - - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockUnauthorized).toHaveBeenCalledWith(mockReq, mockRes) - expect(mockRequest).not.toHaveBeenCalled() - }) - - it('should return unauthorized when token is empty string', () => { - mockGetToken.mockReturnValue('') - - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockUnauthorized).toHaveBeenCalledWith(mockReq, mockRes) - expect(mockRequest).not.toHaveBeenCalled() - }) - - it('should make HTTPS request with correct options when token is provided', () => { - mockGetToken.mockReturnValue('test-token') - - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - protocol: 'https:', - hostname: 'test-cluster.example.com', - port: '6443', - path: '/api/v1/namespaces', - method: 'GET', - headers: expect.objectContaining({ - authorization: 'Bearer test-token', - accept: 'application/json', - 'content-type': 'application/json', - }) as Record, - agent: {}, - }), - expect.any(Function) - ) - }) - - it('should forward proxy headers from request', () => { - mockGetToken.mockReturnValue('test-token') - - // Add more headers to test forwarding - Object.assign( - mockReq as Record, - { - headers: { - [constants.HTTP2_HEADER_ACCEPT]: 'application/json', - [constants.HTTP2_HEADER_ACCEPT_ENCODING]: 'gzip', - [constants.HTTP2_HEADER_CONTENT_ENCODING]: 'gzip', - [constants.HTTP2_HEADER_CONTENT_LENGTH]: '100', - [constants.HTTP2_HEADER_CONTENT_TYPE]: 'application/json', - [constants.HTTP2_HEADER_AUTHORIZATION]: 'Bearer test-token', - // This header should not be forwarded - 'x-custom-header': 'should-not-be-forwarded', - }, - } as Partial - ) - - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: 'Bearer test-token', - accept: 'application/json', - 'accept-encoding': 'gzip', - 'content-encoding': 'gzip', - 'content-length': '100', - 'content-type': 'application/json', - }) as Record, - }), - expect.any(Function) - ) - - // Verify custom header is not forwarded - const callArgs = mockRequest.mock.calls[0]?.[0] as unknown as { headers: Record } - expect(callArgs?.headers).not.toHaveProperty('x-custom-header') - }) - - it('should use getDefaultAgent for HTTPS requests', () => { - const mockAgent = { name: 'test-agent' } as unknown as import('https').Agent - mockGetDefaultAgent.mockReturnValue(mockAgent) - mockGetToken.mockReturnValue('test-token') - - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockGetDefaultAgent).toHaveBeenCalled() - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - agent: mockAgent, - }), - expect.any(Function) - ) - }) - - it('should handle different HTTP methods', () => { - mockGetToken.mockReturnValue('test-token') - - const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] - - methods.forEach((method) => { - Object.assign(mockReq as Record, { method } as Record) - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: method, - }), - expect.any(Function) - ) - }) - }) - - it('should handle different URL paths', () => { - mockGetToken.mockReturnValue('test-token') - - const paths = ['/api/v1/namespaces', '/apis/apps/v1/deployments', '/api/v1/pods'] - - paths.forEach((path) => { - Object.assign(mockReq as Record, { url: path } as Record) - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - path: path, - }), - expect.any(Function) - ) - }) - }) - - it('should call pipeline with correct arguments', () => { - mockGetToken.mockReturnValue('test-token') - - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockPipeline).toHaveBeenCalledWith( - mockReq, - expect.any(Object), // The result of request(options, callback) - expect.any(Function) // Error callback - ) - }) - - it('should handle pipeline errors', () => { - mockGetToken.mockReturnValue('test-token') - - // Test that pipeline is called with correct arguments - proxy(mockReq as import('http2').Http2ServerRequest, mockRes as import('http2').Http2ServerResponse) - - expect(mockPipeline).toHaveBeenCalled() - }) - }) -}) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index b88e3fed620..ee18a66d6b5 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -18,6 +18,7 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `cmd/console` | Process entry: load config, require SA token, listen, SIGINT/SIGTERM | | `internal/server` | TLS listener, chi mux, `/multicloud` probe aliases | | `internal/proxy` | Reverse proxy to `NODE_BACKEND_URL` (original path, including `/multicloud`) | +| `internal/k8sproxy` | Hub kube-apiserver passthrough for `/api`, `/apis`, `/version` (user Bearer token) | | `internal/health` | `/ping`, `/livenessProbe` (Go only), `/readinessProbe` (Go + sidecar `/ping`) | | `internal/config` | `.env` + `config/` directory (filename = key) | | `internal/auth` | Cookie/Bearer, SA token/CA, TokenReview helper | @@ -49,10 +50,12 @@ Go backend :4000 (TLS / HTTP/2) ├─ GET /livenessProbe, /readinessProbe, /ping │ (also /multicloud/…) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) + ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) + │ (also /multicloud/…) └─ everything else (original URL) ──HTTP/1.1──► Node sidecar :4001 │ ▼ - Hub cluster API + Hub cluster API (unmigrated routes) ``` `/multicloud` is stripped only when matching Go-owned routes. The proxy forwards the original path so Node can keep stripping it. diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index dc87ee88017..dcb007536ac 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -6,6 +6,7 @@ import ( "context" "errors" "log/slog" + "net/url" "os" "os/signal" "syscall" @@ -13,6 +14,7 @@ import ( "github.com/stolostron/console/backend/internal/auth" "github.com/stolostron/console/backend/internal/config" rbacevents "github.com/stolostron/console/backend/internal/events/rbac" + "github.com/stolostron/console/backend/internal/k8sproxy" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/server" "k8s.io/client-go/kubernetes" @@ -60,7 +62,13 @@ func run() error { } rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacevents.NewSSARAccess(restCfg)) - handler, err := server.Handler(cfg, server.WithRBACEvents(rbacHandler)) + clusterURL, err := url.Parse(cfg.ClusterAPIURL) + if err != nil { + return err + } + k8sHandler := k8sproxy.New(clusterURL, k8sproxy.TLSConfigFromCA(sa.CACert)) + + handler, err := server.Handler(cfg, server.WithRBACEvents(rbacHandler), server.WithK8sProxy(k8sHandler)) if err != nil { return err } diff --git a/backend/internal/k8sproxy/k8sproxy.go b/backend/internal/k8sproxy/k8sproxy.go new file mode 100644 index 00000000000..590d3023335 --- /dev/null +++ b/backend/internal/k8sproxy/k8sproxy.go @@ -0,0 +1,100 @@ +// Copyright Contributors to the Open Cluster Management project + +package k8sproxy + +import ( + "crypto/tls" + "crypto/x509" + "net/http" + "net/http/httputil" + "net/url" + "time" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/server" +) + +var requestHeaders = []string{ + "Accept", + "Accept-Encoding", + "Content-Encoding", + "Content-Length", + "Content-Type", +} + +var responseHeaders = []string{ + "Cache-Control", + "Content-Type", + "Content-Length", + "Content-Encoding", + "Etag", +} + +// TLSConfigFromCA builds TLS config matching Node getDefaultAgent (cluster CA + system roots). +func TLSConfigFromCA(caCert []byte) *tls.Config { + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } + if len(caCert) > 0 { + pool.AppendCertsFromPEM(caCert) + } else { + tlsCfg.InsecureSkipVerify = true //nolint:gosec // matches auth.RESTConfig when CA missing + } + tlsCfg.RootCAs = pool + return tlsCfg +} + +// New returns a handler that proxies hub K8s API requests (/api, /apis, /version) with the user's token. +func New(clusterURL *url.URL, tlsConfig *tls.Config) http.Handler { + transport := &http.Transport{ + TLSClientConfig: tlsConfig, + ForceAttemptHTTP2: true, + ResponseHeaderTimeout: 0, + } + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + token := auth.TokenFromRequest(pr.In) + stripped := server.StripMulticloud(pr.In.URL.Path) + pr.SetURL(clusterURL) + pr.Out.URL.Path = stripped + pr.Out.URL.RawQuery = pr.In.URL.RawQuery + pr.Out.Host = clusterURL.Host + + pr.Out.Header = http.Header{} + for _, name := range requestHeaders { + if v := pr.In.Header.Get(name); v != "" { + pr.Out.Header.Set(name, v) + } + } + pr.Out.Header.Set("Authorization", "Bearer "+token) + }, + ModifyResponse: filterResponseHeaders, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) { + w.WriteHeader(http.StatusBadGateway) + }, + Transport: transport, + FlushInterval: -1 * time.Millisecond, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if auth.TokenFromRequest(r) == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + rp.ServeHTTP(w, r) + }) +} + +func filterResponseHeaders(resp *http.Response) error { + filtered := http.Header{} + for _, name := range responseHeaders { + for _, v := range resp.Header.Values(name) { + filtered.Add(name, v) + } + } + resp.Header = filtered + return nil +} diff --git a/backend/internal/k8sproxy/k8sproxy_test.go b/backend/internal/k8sproxy/k8sproxy_test.go new file mode 100644 index 00000000000..f8a0257af95 --- /dev/null +++ b/backend/internal/k8sproxy/k8sproxy_test.go @@ -0,0 +1,275 @@ +// Copyright Contributors to the Open Cluster Management project + +package k8sproxy_test + +import ( + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/k8sproxy" +) + +func newTestHandler(t *testing.T, upstream http.Handler) (*httptest.Server, http.Handler) { + t.Helper() + up := httptest.NewServer(upstream) + t.Cleanup(up.Close) + clusterURL, err := url.Parse(up.URL) + if err != nil { + t.Fatal(err) + } + tlsCfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test server + return up, k8sproxy.New(clusterURL, tlsCfg) +} + +func TestUnauthorizedWithoutToken(t *testing.T) { + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("upstream should not be called") + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + resp, err := ts.Client().Get(ts.URL + "/api/v1/namespaces") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if len(body) != 0 { + t.Fatalf("expected empty body, got %q", body) + } +} + +func TestForwardsBearerToken(t *testing.T) { + var capturedAuth string + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/namespaces", nil) + req.Header.Set("Authorization", "Bearer user-token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedAuth != "Bearer user-token" { + t.Fatalf("Authorization %q", capturedAuth) + } +} + +func TestCookieTokenWinsOverBearer(t *testing.T) { + var capturedAuth string + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/namespaces", nil) + req.AddCookie(&http.Cookie{Name: auth.AccessTokenCookie, Value: "cookie-token"}) + req.Header.Set("Authorization", "Bearer header-token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedAuth != "Bearer cookie-token" { + t.Fatalf("Authorization %q", capturedAuth) + } +} + +func TestStripsMulticloudPrefix(t *testing.T) { + var capturedPath, capturedQuery string + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/api/v1/namespaces?foo=bar", nil) + req.Header.Set("Authorization", "Bearer token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedPath != "/api/v1/namespaces" { + t.Fatalf("path %q", capturedPath) + } + if capturedQuery != "foo=bar" { + t.Fatalf("query %q", capturedQuery) + } +} + +func TestRequestHeaderAllowlist(t *testing.T) { + var captured http.Header + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/namespaces", strings.NewReader(`{"kind":"Namespace"}`)) + req.Header.Set("Authorization", "Bearer token") + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Length", "20") + req.Header.Set("X-Custom-Header", "drop-me") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + if captured.Get("Authorization") != "Bearer token" { + t.Fatalf("Authorization %q", captured.Get("Authorization")) + } + if captured.Get("Accept") != "application/json" { + t.Fatal("missing Accept") + } + if captured.Get("X-Custom-Header") != "" { + t.Fatal("custom header should not be forwarded") + } + if captured.Get("X-Forwarded-For") != "" { + t.Fatal("X-Forwarded-For should not be set") + } +} + +func TestResponseHeaderAllowlist(t *testing.T) { + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Audit-Id", "secret") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/namespaces", nil) + req.Header.Set("Authorization", "Bearer token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.Header.Get("Content-Type") != "application/json" { + t.Fatal("missing Content-Type") + } + if resp.Header.Get("Cache-Control") != "no-cache" { + t.Fatal("missing Cache-Control") + } + if resp.Header.Get("Audit-Id") != "" { + t.Fatal("Audit-Id should be filtered") + } +} + +func TestPassesThroughStatusCodes(t *testing.T) { + cases := []int{http.StatusOK, http.StatusUnauthorized, http.StatusForbidden} + for _, want := range cases { + t.Run(http.StatusText(want), func(t *testing.T) { + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(want) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/namespaces", nil) + req.Header.Set("Authorization", "Bearer token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != want { + t.Fatalf("status %d want %d", resp.StatusCode, want) + } + }) + } +} + +func TestStreamsRequestBody(t *testing.T) { + var capturedBody string + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + capturedBody = string(b) + w.WriteHeader(http.StatusCreated) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + body := `{"kind":"Namespace"}` + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/apis/apps/v1/namespaces/default/deployments", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer token") + req.Header.Set("Content-Type", "application/json") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status %d", resp.StatusCode) + } + if capturedBody != body { + t.Fatalf("body %q", capturedBody) + } +} + +func TestBadGatewayWhenUpstreamUnreachable(t *testing.T) { + clusterURL, err := url.Parse("https://127.0.0.1:1") + if err != nil { + t.Fatal(err) + } + h := k8sproxy.New(clusterURL, k8sproxy.TLSConfigFromCA(nil)) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/namespaces", nil) + req.Header.Set("Authorization", "Bearer token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestVersionPath(t *testing.T) { + var capturedPath string + _, h := newTestHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/version/", nil) + req.Header.Set("Authorization", "Bearer token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedPath != "/version/" { + t.Fatalf("path %q", capturedPath) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index ca10357305f..e81d5b7b6e8 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -27,6 +27,7 @@ const multicloudPrefix = "/multicloud" type handlerOptions struct { rbacEvents http.Handler + k8sProxy http.Handler } // Option configures Handler. @@ -39,6 +40,13 @@ func WithRBACEvents(h http.Handler) Option { } } +// WithK8sProxy registers /api, /apis, and /version passthrough to the hub kube-apiserver. +func WithK8sProxy(h http.Handler) Option { + return func(o *handlerOptions) { + o.k8sProxy = h + } +} + // StripMulticloud returns the path used for Go-owned route matching. func StripMulticloud(path string) string { if path == multicloudPrefix { @@ -66,6 +74,23 @@ func isEventStream(path string) bool { return path == "/events/rbac" } +func registerK8sProxyRoutes(r chi.Router, h http.Handler) { + for _, pattern := range []string{ + "/api", "/api/*", + "/apis", "/apis/*", + multicloudPrefix + "/api", multicloudPrefix + "/api/*", + multicloudPrefix + "/apis", multicloudPrefix + "/apis/*", + } { + r.Handle(pattern, h) + } + for _, pattern := range []string{ + "/version", "/version/", + multicloudPrefix + "/version", multicloudPrefix + "/version/", + } { + r.Get(pattern, h.ServeHTTP) + } +} + // TLSConfigForSidecar is for the loopback Node sidecar. Local generate-certs // writes a self-signed cert with no SAN, so hostname verification cannot succeed. func TLSConfigForSidecar(_ *config.Config) *tls.Config { @@ -101,6 +126,9 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { r.Get("/events/rbac", o.rbacEvents.ServeHTTP) r.Get(multicloudPrefix+"/events/rbac", o.rbacEvents.ServeHTTP) } + if o.k8sProxy != nil { + registerK8sProxyRoutes(r, o.k8sProxy) + } r.NotFound(sidecar.ServeHTTP) r.MethodNotAllowed(sidecar.ServeHTTP) return r, nil diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 4561858bca5..19ffeca20eb 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -200,3 +200,95 @@ func TestRBACEventsNotProxied(t *testing.T) { } } } + +func TestK8sProxyNotProxiedToSidecar(t *testing.T) { + var sidecarPaths []string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarPaths = append(sidecarPaths, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + var k8sPaths []string + k8s := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + k8sPaths = append(k8sPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + }) + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithK8sProxy(k8s)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + paths := []string{ + "/api/v1/namespaces", + "/apis/apps/v1/deployments", + "/version", + "/multicloud/api/v1/pods", + "/multicloud/apis/rbac.authorization.k8s.io/v1/clusterroles", + "/multicloud/version/", + } + for _, path := range paths { + sidecarPaths = nil + k8sPaths = nil + req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) + req.Header.Set("Authorization", "Bearer token") + resp, getErr := ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if len(sidecarPaths) != 0 { + t.Fatalf("%s was proxied to sidecar: %v", path, sidecarPaths) + } + if len(k8sPaths) != 1 || k8sPaths[0] != path { + t.Fatalf("%s k8s paths %v", path, k8sPaths) + } + } +} + +func TestApiPathsStillProxiedToSidecar(t *testing.T) { + var capturedPath string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`["/api/v1"]`)) + })) + defer sidecar.Close() + + k8s := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("k8s proxy should not handle /apiPaths") + }) + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithK8sProxy(k8s)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/apiPaths", "/multicloud/apiPaths"} { + resp, getErr := ts.Client().Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if capturedPath != path { + t.Fatalf("%s sidecar path %q", path, capturedPath) + } + } + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/hub", nil) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedPath != "/multicloud/hub" { + t.Fatalf("hub sidecar path %q", capturedPath) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2cfe2e5344e..3520cfbef27 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,12 +29,12 @@ The frontend has two builds. One for the stand alone version and one for the dyn ## Console Backend -The public listener is a Go process (`backend/`). Routes that have not been migrated yet are reverse-proxied to the Node sidecar (`backend-node/`). The plugin and browser keep talking to the same Service and paths. +The public listener is a Go process (`backend/`). Hub kube-apiserver passthrough routes (`/api`, `/apis`, `/version`) and other migrated routes are served natively in Go. Routes that have not been migrated yet are reverse-proxied to the Node sidecar (`backend-node/`). The plugin and browser keep talking to the same Service and paths. The console backend uses a service account to `list` and `watch` kubernetes cluster resources. Resource events are streamed to the console frontend. RBAC is enforced using the token passed from the console frontend. All resources are checked for access using `SubjectAccessReview` calls to the cluster. -The console backend proxies the cluster apiserver `/api` and `/apis` apiserver REST routes. +The console backend proxies the cluster apiserver `/api` and `/apis` apiserver REST routes from the Go public listener (`backend/internal/k8sproxy`). All REST calls use the token passed from the console frontend. From f2114958af474039caa4bcd4fd06f28954a8bfef Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Tue, 1 Sep 2026 11:11:40 +0200 Subject: [PATCH 03/16] ACM-42594 Serve static plugin and SPA assets from the Go backend (#50) * Serve static plugin and SPA assets from the Go backend Move static file serving out of the Node sidecar into a new Go internal/static package. The Go listener now handles plugin assets, hashed JS/CSS, locales, and index.html with the same cache headers, CSP, and brotli/gzip negotiation previously provided by backend-node. - Add PUBLIC_FOLDER config and default to /app/public in images - Build the Go console binary in Containerfile.acm and Containerfile.mce - Remove backend-node/src/routes/serve.ts and its tests - Add scripts/console-entrypoint.sh to launch Go + Node sidecar Signed-off-by: Enrique Mingorance Cano * .editorconfig Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --- Containerfile.acm | 14 +- Containerfile.mce | 14 +- backend-node/AGENTS.md | 3 +- backend-node/src/app.ts | 2 - backend-node/src/routes/serve.ts | 177 ------------- backend-node/test/jest-setup.ts | 1 - backend-node/test/routes/serve.test.ts | 88 ------- backend/AGENTS.md | 6 +- backend/cmd/console/main.go | 15 +- backend/internal/config/config.go | 2 + backend/internal/config/config_test.go | 10 + backend/internal/server/server.go | 24 +- backend/internal/server/server_test.go | 51 ++++ backend/internal/static/public/README | 3 + backend/internal/static/static.go | 239 ++++++++++++++++++ backend/internal/static/static_test.go | 332 +++++++++++++++++++++++++ docs/ARCHITECTURE.md | 2 + scripts/console-entrypoint.sh | 11 + 18 files changed, 719 insertions(+), 275 deletions(-) delete mode 100644 backend-node/src/routes/serve.ts delete mode 100644 backend-node/test/routes/serve.test.ts create mode 100644 backend/internal/static/public/README create mode 100644 backend/internal/static/static.go create mode 100644 backend/internal/static/static_test.go create mode 100755 scripts/console-entrypoint.sh diff --git a/Containerfile.acm b/Containerfile.acm index 72320176014..39a1076937d 100644 --- a/Containerfile.acm +++ b/Containerfile.acm @@ -24,6 +24,14 @@ RUN npm ci --omit=optional COPY ./backend-node . RUN npm run build +ARG GO_BASE=golang:1.26 +FROM ${GO_BASE} as go-backend +WORKDIR /src +COPY ./backend/go.mod ./backend/go.sum ./ +RUN go mod download +COPY ./backend . +RUN CGO_ENABLED=0 GOOS=linux go build -o /console ./cmd/console + FROM build-base as production WORKDIR /app/backend-node COPY ./backend-node/package-lock.json ./backend-node/package.json ./ @@ -33,11 +41,15 @@ FROM ${NODE_BASE} COPY --from=crypto-policy /etc/crypto-policies /etc/crypto-policies WORKDIR /app ENV NODE_ENV production +ENV PUBLIC_FOLDER=/app/public COPY --from=production /app/backend-node/node_modules ./node_modules COPY --from=backend /app/backend-node/backend.mjs ./ +COPY --from=go-backend /console ./console COPY --from=dynamic-plugin /app/frontend/plugins/acm/dist ./public/plugin +COPY ./scripts/console-entrypoint.sh ./console-entrypoint.sh +RUN chmod 755 /app/console-entrypoint.sh /app/console USER 1001 -CMD ["node", "backend.mjs"] +CMD ["/app/console-entrypoint.sh"] LABEL com.redhat.component="console-container" \ cpe="cpe:/a:redhat:acm:5.1::el9" \ diff --git a/Containerfile.mce b/Containerfile.mce index ddf12ade504..e5d6e5167fa 100644 --- a/Containerfile.mce +++ b/Containerfile.mce @@ -24,6 +24,14 @@ RUN npm ci --omit=optional COPY ./backend-node . RUN npm run build +ARG GO_BASE=golang:1.26 +FROM ${GO_BASE} as go-backend +WORKDIR /src +COPY ./backend/go.mod ./backend/go.sum ./ +RUN go mod download +COPY ./backend . +RUN CGO_ENABLED=0 GOOS=linux go build -o /console ./cmd/console + FROM build-base as production WORKDIR /app/backend-node COPY ./backend-node/package-lock.json ./backend-node/package.json ./ @@ -33,11 +41,15 @@ FROM ${NODE_BASE} COPY --from=crypto-policy /etc/crypto-policies /etc/crypto-policies WORKDIR /app ENV NODE_ENV production +ENV PUBLIC_FOLDER=/app/public COPY --from=production /app/backend-node/node_modules ./node_modules COPY --from=backend /app/backend-node/backend.mjs ./ +COPY --from=go-backend /console ./console COPY --from=dynamic-plugin /app/frontend/plugins/mce/dist ./public/plugin +COPY ./scripts/console-entrypoint.sh ./console-entrypoint.sh +RUN chmod 755 /app/console-entrypoint.sh /app/console USER 1001 -CMD ["node", "backend.mjs"] +CMD ["/app/console-entrypoint.sh"] LABEL com.redhat.component="multicluster-engine-console-mce-container" \ cpe="cpe:/a:redhat:multicluster_engine:5.1::el9" \ diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md index a390b170d07..39aee0e8457 100644 --- a/backend-node/AGENTS.md +++ b/backend-node/AGENTS.md @@ -17,7 +17,7 @@ Node.js ESM proxy server. Sits between the browser and the hub cluster API serve | Directory | Purpose | |-----------|---------| | `src/lib/` | Core server: `main.ts` entry, `server.ts`, auth, cookies, CORS, proxy, search, SSE, logging, config | -| `src/routes/` | HTTP route handlers: proxy, OAuth, search, events, hub, serve, metrics, managed cluster proxy, etc. | +| `src/routes/` | HTTP route handlers: proxy, OAuth, search, events, hub, metrics, managed cluster proxy, etc. | | `src/resources/` | Backend resource watchers and handlers | | `test/` | Jest test files | | `config/` | Runtime configuration lives in `../backend/config` (Go backend) | @@ -98,7 +98,6 @@ Optional development/debug variables (not in `.env` by default): | `MOCK_CLUSTERS` | Number of mock clusters to generate for testing | | `DISABLE_EVENTS` | Set to `true` to disable SSE event streams | | `DISABLE_STREAM_COMPRESSION` | Set to `true` to disable SSE compression | -| `PUBLIC_FOLDER` | Override static file serving path (default `./public`) | ### Settings (`../backend/config/` directory) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 3dafa977314..4b507a71f1c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -25,7 +25,6 @@ import { operatorCheck } from './routes/operatorCheck' import { readiness } from './routes/readiness' import { search } from './routes/search' import { placementDebug } from './routes/placementDebug' -import { serveHandler } from './routes/serve' import { upgradeRiskPredictions } from './routes/upgrade-risks-prediction' import { username } from './routes/username' import { userpreference } from './routes/userpreference' @@ -109,7 +108,6 @@ router.post('/sts-ocm-role', getOCMRoleARN) router.post('/sts-user-role', getUserRole) router.post('/openshift-versions', getWizardVersions) router.post('/machine-types', getWizardMachineTypes) -router.get('/*', serveHandler) export async function requestHandler(req: Http2ServerRequest, res: Http2ServerResponse): Promise { if (!isProduction) { diff --git a/backend-node/src/routes/serve.ts b/backend-node/src/routes/serve.ts deleted file mode 100644 index b1bac067fb2..00000000000 --- a/backend-node/src/routes/serve.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -/* Copyright Contributors to the Open Cluster Management project */ -import type { Stats } from 'node:fs' -import { createReadStream } from 'node:fs' - -import { extname } from 'node:path' -import { logger } from '../lib/logger' -import { pipeline } from 'node:stream' -import { stat } from 'node:fs/promises' -import { catchInternalServerError } from '../lib/respond' - -const cacheControl = process.env.NODE_ENV === 'production' ? 'public, max-age=604800' : 'no-store' -const localesCacheControl = process.env.NODE_ENV === 'production' ? 'public, max-age=3600' : 'no-store' -const publicFolder = process.env.PUBLIC_FOLDER || './public' - -export async function serve(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - try { - let url = req.url.split('?')[0] - - let ext = extname(url) - if (ext === '') { - ext = '.html' - url = '/index.html' - } - - // Security headers - if (url === '/index.html') { - res.setHeader('Cache-Control', 'no-cache') - res.setHeader('X-Frame-Options', 'deny') - res.setHeader('X-XSS-Protection', '1; mode=block') - res.setHeader('X-Content-Type-Options', 'nosniff') - res.setHeader('X-Permitted-Cross-Domain-Policies', 'none') - res.setHeader('Referrer-Policy', 'no-referrer') - res.setHeader('X-DNS-Prefetch-Control', 'off') - res.setHeader('Expect-CT', 'enforce, max-age=30') - res.setHeader( - 'Content-Security-Policy', - [ - "default-src 'self'", - "connect-src 'self' https://api.github.com", - "base-uri 'self'", - 'block-all-mixed-content', - "font-src 'self' https: data:", - "frame-ancestors 'self'", - "img-src 'self' data:", - "object-src 'none'", - "script-src 'self' 'unsafe-eval'", - "script-src-attr 'none'", - "style-src 'self' https: 'unsafe-inline'", - 'upgrade-insecure-requests', - ].join(';') - ) - } else if (url === '/plugin/plugin-entry.js' || url === '/plugin/plugin-manifest.json') { - res.setHeader('Cache-Control', 'no-cache') - } else if (url.includes('/locales/')) { - res.setHeader('Cache-Control', localesCacheControl) - } else { - res.setHeader('Cache-Control', cacheControl) - } - - const acceptEncoding = (req.headers[constants.HTTP2_HEADER_ACCEPT_ENCODING] as string) ?? '' - const contentType = contentTypes[ext] - if (contentType === undefined) { - logger.debug('unknown content type', `ext=${ext}`) - res.writeHead(404).end() - return - } - - const filePath = `${publicFolder}${url}` - let stats: Stats - try { - stats = await stat(filePath) - } catch { - res.writeHead(404).end() - return - } - - const modificationTime = stats.mtime.toUTCString() - res.setHeader(constants.HTTP2_HEADER_LAST_MODIFIED, modificationTime) - // Don't send content for cache revalidation - if (req.headers['if-modified-since'] === modificationTime) { - res.writeHead(constants.HTTP_STATUS_NOT_MODIFIED).end() - return - } - - if (/\bbr\b/.test(acceptEncoding)) { - try { - const brFilePath = `${filePath}.br` - const brStats = await stat(brFilePath) - const readStream = createReadStream(brFilePath, { autoClose: true }) - readStream - .on('open', () => { - res.writeHead(200, { - [constants.HTTP2_HEADER_CONTENT_ENCODING]: 'br', - [constants.HTTP2_HEADER_CONTENT_TYPE]: contentType, - [constants.HTTP2_HEADER_CONTENT_LENGTH]: brStats.size.toString(), - }) - }) - .on('error', (err) => { - logger.error(err) - res.writeHead(404).end() - }) - pipeline(readStream, res as unknown as NodeJS.WritableStream, (err) => { - if (err) logger.error(err) - }) - return - } catch { - // Do nothing - } - } - - if (/\bgzip\b/.test(acceptEncoding)) { - try { - const gzFilePath = `${filePath}.gz` - const gzStats = await stat(gzFilePath) - const readStream = createReadStream(gzFilePath, { autoClose: true }) - readStream - .on('open', () => { - res.writeHead(200, { - [constants.HTTP2_HEADER_CONTENT_ENCODING]: 'gzip', - [constants.HTTP2_HEADER_CONTENT_TYPE]: contentType, - [constants.HTTP2_HEADER_CONTENT_LENGTH]: gzStats.size.toString(), - }) - }) - .on('error', (err) => { - logger.error(err) - res.writeHead(404).end() - }) - pipeline(readStream, res as unknown as NodeJS.WritableStream, (err) => { - if (err) logger.error(err) - }) - return - } catch { - // Do nothing - } - } - - const readStream = createReadStream(`${publicFolder}${url}`, { autoClose: true }) - readStream - .on('open', () => { - res.writeHead(200, { - [constants.HTTP2_HEADER_CONTENT_TYPE]: contentType, - [constants.HTTP2_HEADER_CONTENT_LENGTH]: stats.size.toString(), - }) - }) - .on('error', (err) => { - logger.error(err) - res.writeHead(404).end() - }) - pipeline(readStream, res as unknown as NodeJS.WritableStream, (err) => { - if (err) logger.error(err) - }) - } catch (err) { - logger.error(err) - res.writeHead(404).end() - return - } -} - -export function serveHandler(req: Http2ServerRequest, res: Http2ServerResponse): void { - serve(req, res).catch(catchInternalServerError(res)) -} - -const contentTypes: Record = { - '.html': 'text/html; charset=utf-8', - '.css': 'text/css; charset=UTF-8', - '.js': 'application/javascript; charset=UTF-8', - '.map': 'application/json; charset=utf-8', - '.jpg': 'image/jpeg', - '.json': 'application/json; charset=utf-8', - '.svg': 'image/svg+xml', - '.png': 'image/png', - '.ttf': 'font/ttf', - '.woff': 'font/woff', - '.woff2': 'font/woff2', -} diff --git a/backend-node/test/jest-setup.ts b/backend-node/test/jest-setup.ts index 75b3e82838c..0e77ac85c63 100644 --- a/backend-node/test/jest-setup.ts +++ b/backend-node/test/jest-setup.ts @@ -5,7 +5,6 @@ process.env.NODE_ENV = 'test' process.env.LOG_LEVEL = 'silent' process.env.CLUSTER_API_URL = 'https://example.com' process.env.TOKEN = 'sa-token' -process.env.PUBLIC_FOLDER = '../frontend/public' process.env.ENV_FILE = '../backend/.env' process.env.CONFIG_DIR = '../backend/config' process.env.CERTS_DIR = '../backend/certs' diff --git a/backend-node/test/routes/serve.test.ts b/backend-node/test/routes/serve.test.ts deleted file mode 100644 index 8b99d5c0b6c..00000000000 --- a/backend-node/test/routes/serve.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createBrotliCompress, createGzip } from 'node:zlib' -import { createReadStream, createWriteStream } from 'node:fs' - -/* Copyright Contributors to the Open Cluster Management project */ -import { constants } from 'node:http2' -import { getDecodeStream } from '../../src/lib/compression' -import { pipeline } from 'node:stream' -import rawBody from 'raw-body' -import { request } from '../mock-request' -import { unlink } from 'node:fs/promises' - -describe(`serve Route`, function () { - it(`serves index.html with correct headers`, async function () { - const res = await request('GET', '/') - expect(res.statusCode).toEqual(200) - const expectedHeaders = ['cache-control', 'content-security-policy', 'last-modified'] - expectedHeaders.every((header) => expect(res.hasHeader(header))) - const bodyString = await rawBody(getDecodeStream(res.stream), { - limit: 1 * 1024 * 1024, - encoding: true, - }) - expect(bodyString).toContain('') - }) - - it(`serves index.html with br compression`, async function () { - const indexPath = `${process.env.PUBLIC_FOLDER}/index.html` - const indexPathCompressed = `${indexPath}.br` - try { - // Temporarily create .br version - const index = createReadStream(indexPath) - const indexCompressed = createWriteStream(indexPathCompressed) - const br = createBrotliCompress() - await new Promise((resolve, reject) => { - pipeline(index, br, indexCompressed, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - - const res = await request('GET', '/', null, { - [constants.HTTP2_HEADER_ACCEPT_ENCODING]: ['br'], - }) - expect(res.statusCode).toEqual(200) - const bodyString = await rawBody(getDecodeStream(res.stream, 'br'), { - limit: 1 * 1024 * 1024, - encoding: true, - }) - expect(bodyString).toContain('') - } finally { - await unlink(indexPathCompressed) - } - }) - - it(`serves index.html with gzip compression`, async function () { - const indexPath = `${process.env.PUBLIC_FOLDER}/index.html` - const indexPathCompressed = `${indexPath}.gz` - try { - // Temporarily create .gz version - const index = createReadStream(indexPath) - const indexCompressed = createWriteStream(indexPathCompressed) - const gz = createGzip() - await new Promise((resolve, reject) => { - pipeline(index, gz, indexCompressed, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - - const res = await request('GET', '/', null, { - [constants.HTTP2_HEADER_ACCEPT_ENCODING]: ['gzip'], - }) - expect(res.statusCode).toEqual(200) - const bodyString = await rawBody(getDecodeStream(res.stream, 'gzip'), { - limit: 1 * 1024 * 1024, - encoding: true, - }) - expect(bodyString).toContain('') - } finally { - await unlink(indexPathCompressed) - } - }) -}) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index ee18a66d6b5..0bd9e503b46 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -5,7 +5,7 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns ## Key Technologies - **Runtime**: Go 1.26+ (`net/http`; TLS enables HTTP/2 automatically) -- **Router**: `chi` — probes registered natively; everything else is `NotFound` → reverse proxy +- **Router**: `chi` — probes and migrated routes registered natively; static GET assets; everything else is `NotFound` → reverse proxy - **Proxy**: `httputil.ReverseProxy` (HTTP/1.1 to the sidecar so WebSocket upgrades work; `FlushInterval: -1` for SSE) - **Logging**: `log/slog` JSON (`method`, `path`, `status`, `duration`) - **Config watch**: `fsnotify` on `config/` (1s debounce) @@ -23,6 +23,7 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/config` | `.env` + `config/` directory (filename = key) | | `internal/auth` | Cookie/Bearer, SA token/CA, TokenReview helper | | `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | +| `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | | `config/` | Runtime settings shared with the Node sidecar | | `certs/` | TLS material (`npm run generate-certs` at repo root) | @@ -52,6 +53,7 @@ Go backend :4000 (TLS / HTTP/2) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) │ (also /multicloud/…) + ├─ GET static assets (/plugin/*, hashed JS/CSS, locales, index.html) └─ everything else (original URL) ──HTTP/1.1──► Node sidecar :4001 │ ▼ @@ -65,3 +67,5 @@ Go backend :4000 (TLS / HTTP/2) `npm run setup` writes `backend/.env`. The sidecar loads the same file via `ENV_FILE` / `CONFIG_DIR` / `CERTS_DIR`. `godotenv` does not override `PORT`, so the sidecar can listen on `NODE_BACKEND_PORT` while `.env` still has `PORT=4000` for Go. Go exits 1 at startup if the service-account token is missing (`TOKEN` or `/var/run/secrets/kubernetes.io/serviceaccount/token`). + +`PUBLIC_FOLDER` (default `public`) is the on-disk plugin/SPA tree. Production images copy `frontend/plugins/{acm|mce}/dist` to `/app/public/plugin`. diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index dcb007536ac..17f2aa191b2 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -17,6 +17,7 @@ import ( "github.com/stolostron/console/backend/internal/k8sproxy" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/server" + "github.com/stolostron/console/backend/internal/static" "k8s.io/client-go/kubernetes" ) @@ -62,13 +63,24 @@ func run() error { } rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacevents.NewSSARAccess(restCfg)) + var opts []server.Option + opts = append(opts, server.WithRBACEvents(rbacHandler)) clusterURL, err := url.Parse(cfg.ClusterAPIURL) if err != nil { return err } k8sHandler := k8sproxy.New(clusterURL, k8sproxy.TLSConfigFromCA(sa.CACert)) + opts = append(opts, server.WithK8sProxy(k8sHandler)) + fsys, ok := static.OpenFS(cfg.PublicFolder) + if !ok { + fsys = static.BundledFS() + } + opts = append(opts, server.WithStatic(static.New(static.Options{ + FS: fsys, + Production: os.Getenv("NODE_ENV") == "production", + }))) - handler, err := server.Handler(cfg, server.WithRBACEvents(rbacHandler), server.WithK8sProxy(k8sHandler)) + handler, err := server.Handler(cfg, opts...) if err != nil { return err } @@ -77,6 +89,7 @@ func run() error { "PORT", cfg.Port, "NODE_BACKEND_URL", cfg.NodeBackendURL, slog.String("CONFIG_DIR", cfg.ConfigDir), + slog.String("PUBLIC_FOLDER", cfg.PublicFolder), ) return server.ListenAndServe(ctx, cfg, handler) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a39661fd372..90d656ec28a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { CACert string ServiceCACert string LogLevel string + PublicFolder string mu sync.RWMutex settings map[string]string @@ -56,6 +57,7 @@ func Load() *Config { CACert: os.Getenv("CA_CERT"), ServiceCACert: os.Getenv("SERVICE_CA_CERT"), LogLevel: envOr("LOG_LEVEL", "debug"), + PublicFolder: envOr("PUBLIC_FOLDER", "public"), settings: map[string]string{}, } _ = cfg.ReloadSettings() diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index d64f0c677c2..7024efbc62d 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -72,6 +72,16 @@ func TestLoad_FromEnvFile(t *testing.T) { } } +func TestLoad_PublicFolder(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) + t.Setenv("PUBLIC_FOLDER", "/app/public") + cfg := config.Load() + if cfg.PublicFolder != "/app/public" { + t.Fatalf("PublicFolder=%q", cfg.PublicFolder) + } +} + func TestWatch_ReloadsOnChange(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "LOG_LEVEL") diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index e81d5b7b6e8..45d65c8ec97 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -21,6 +21,7 @@ import ( "github.com/stolostron/console/backend/internal/health" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/proxy" + "github.com/stolostron/console/backend/internal/static" ) const multicloudPrefix = "/multicloud" @@ -28,6 +29,7 @@ const multicloudPrefix = "/multicloud" type handlerOptions struct { rbacEvents http.Handler k8sProxy http.Handler + staticH http.Handler } // Option configures Handler. @@ -47,6 +49,13 @@ func WithK8sProxy(h http.Handler) Option { } } +// WithStatic serves plugin and SPA files for GET requests with known static extensions. +func WithStatic(h http.Handler) Option { + return func(o *handlerOptions) { + o.staticH = h + } +} + // StripMulticloud returns the path used for Go-owned route matching. func StripMulticloud(path string) string { if path == multicloudPrefix { @@ -129,11 +138,24 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { if o.k8sProxy != nil { registerK8sProxyRoutes(r, o.k8sProxy) } - r.NotFound(sidecar.ServeHTTP) + r.NotFound(notFoundHandler(o.staticH, sidecar)) r.MethodNotAllowed(sidecar.ServeHTTP) return r, nil } +func notFoundHandler(staticH, sidecar http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + stripped := StripMulticloud(r.URL.Path) + if staticH != nil && r.Method == http.MethodGet && static.IsStaticPath(stripped) { + r2 := r.Clone(r.Context()) + r2.URL.Path = stripped + staticH.ServeHTTP(w, r2) + return + } + sidecar.ServeHTTP(w, r) + } +} + func requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { stripped := StripMulticloud(r.URL.Path) diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 19ffeca20eb..698945d80b6 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -201,6 +201,57 @@ func TestRBACEventsNotProxied(t *testing.T) { } } +func TestStaticNotProxiedToSidecar(t *testing.T) { + var sidecarPaths []string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarPaths = append(sidecarPaths, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + staticH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Static", r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("plugin")) + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithStatic(staticH)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/plugin/plugin-manifest.json", "/multicloud/plugin/plugin-entry.js", "/index.html", "/"} { + sidecarPaths = nil + resp, getErr := ts.Client().Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if len(sidecarPaths) != 0 { + t.Fatalf("%s proxied to sidecar: %v", path, sidecarPaths) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + if string(body) != "plugin" { + t.Fatalf("%s body %s", path, body) + } + } + + sidecarPaths = nil + resp, err := ts.Client().Get(ts.URL + "/hub") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if len(sidecarPaths) != 1 || sidecarPaths[0] != "/hub" { + t.Fatalf("hub sidecar paths %v", sidecarPaths) + } +} + func TestK8sProxyNotProxiedToSidecar(t *testing.T) { var sidecarPaths []string sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/static/public/README b/backend/internal/static/public/README new file mode 100644 index 00000000000..f3c1b441ef2 --- /dev/null +++ b/backend/internal/static/public/README @@ -0,0 +1,3 @@ +Plugin and SPA files are copied here at image build +(`frontend/plugins/{acm|mce}/dist` → `public/plugin`). +Local development serves plugins from webpack; set PUBLIC_FOLDER to override. diff --git a/backend/internal/static/static.go b/backend/internal/static/static.go new file mode 100644 index 00000000000..107deecc87d --- /dev/null +++ b/backend/internal/static/static.go @@ -0,0 +1,239 @@ +// Copyright Contributors to the Open Cluster Management project + +package static + +import ( + "embed" + "io" + "io/fs" + "net/http" + "os" + "path" + "strconv" + "strings" + + applog "github.com/stolostron/console/backend/internal/log" +) + +const ( + cspHeader = "default-src 'self';connect-src 'self' https://api.github.com;base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self' 'unsafe-eval';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" + cacheProd = "public, max-age=604800" + cacheLoc = "public, max-age=3600" + noCache = "no-cache" + noStore = "no-store" +) + +//go:embed all:public +var embeddedPublic embed.FS + +var contentTypes = map[string]string{ + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=UTF-8", + ".js": "application/javascript; charset=UTF-8", + ".map": "application/json; charset=utf-8", + ".jpg": "image/jpeg", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + +// Options configure static asset serving. +type Options struct { + FS fs.FS + Production bool +} + +// Handler serves plugin and SPA files with Node-compatible headers and compression. +type Handler struct { + fsys fs.FS + production bool +} + +// New builds a static file handler. FS is typically os.DirFS(PUBLIC_FOLDER). +func New(opts Options) *Handler { + return &Handler{fsys: opts.FS, production: opts.Production} +} + +// OpenFS returns a filesystem for PUBLIC_FOLDER when the directory exists. +func OpenFS(publicFolder string) (fs.FS, bool) { + if publicFolder == "" { + return nil, false + } + st, err := os.Stat(publicFolder) + if err != nil || !st.IsDir() { + return nil, false + } + return os.DirFS(publicFolder), true +} + +// BundledFS is plugin/SPA files compiled into the binary (overridden by PUBLIC_FOLDER). +func BundledFS() fs.FS { + sub, err := fs.Sub(embeddedPublic, "public") + if err != nil { + return embeddedPublic + } + return sub +} + +// IsStaticPath reports whether a path (already stripped of /multicloud) should be +// served as a static file rather than reverse-proxied to the Node sidecar. +// Bare paths other than / are not treated as SPA fallback so API routes like /hub +// still reach the sidecar. +func IsStaticPath(stripped string) bool { + urlPath := strings.TrimSuffix(stripped, "/") + if urlPath == "" || urlPath == "/" || urlPath == "/index.html" { + return true + } + _, ok := contentTypes[path.Ext(urlPath)] + return ok +} + +func requestFileURL(stripped string) string { + urlPath := stripped + if i := strings.Index(urlPath, "?"); i >= 0 { + urlPath = urlPath[:i] + } + if urlPath == "" || urlPath == "/" { + return "/index.html" + } + return urlPath +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if h.fsys == nil { + w.WriteHeader(http.StatusNotFound) + return + } + urlPath := requestFileURL(r.URL.Path) + ext := path.Ext(urlPath) + setCacheHeaders(w, urlPath, h.production) + + contentType, ok := contentTypes[ext] + if !ok { + applog.Logger().Debug("unknown content type", "ext", ext) + w.WriteHeader(http.StatusNotFound) + return + } + + rel := strings.TrimPrefix(path.Clean(urlPath), "/") + if rel == "" || !fs.ValidPath(rel) { + w.WriteHeader(http.StatusNotFound) + return + } + + info, err := statFile(h.fsys, rel) + if err != nil || info.IsDir() { + w.WriteHeader(http.StatusNotFound) + return + } + + mod := info.ModTime().UTC().Format(http.TimeFormat) + w.Header().Set("Last-Modified", mod) + if r.Header.Get("If-Modified-Since") == mod { + w.WriteHeader(http.StatusNotModified) + return + } + + accept := r.Header.Get("Accept-Encoding") + if serveCompressed(w, h.fsys, rel, contentType, accept, "br", ".br") { + return + } + if serveCompressed(w, h.fsys, rel, contentType, accept, "gzip", ".gz") { + return + } + + f, err := h.fsys.Open(rel) + if err != nil { + applog.Logger().Error("static open", "error", err) + w.WriteHeader(http.StatusNotFound) + return + } + defer f.Close() + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + w.WriteHeader(http.StatusOK) + if _, copyErr := io.Copy(w, f); copyErr != nil { + applog.Logger().Error("static copy", "error", copyErr) + } +} + +func setCacheHeaders(w http.ResponseWriter, urlPath string, production bool) { + switch { + case urlPath == "/index.html": + w.Header().Set("Cache-Control", noCache) + w.Header().Set("X-Frame-Options", "deny") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Permitted-Cross-Domain-Policies", "none") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-DNS-Prefetch-Control", "off") + w.Header().Set("Expect-CT", "enforce, max-age=30") + w.Header().Set("Content-Security-Policy", cspHeader) + case urlPath == "/plugin/plugin-entry.js" || urlPath == "/plugin/plugin-manifest.json": + w.Header().Set("Cache-Control", noCache) + case strings.Contains(urlPath, "/locales/"): + if production { + w.Header().Set("Cache-Control", cacheLoc) + } else { + w.Header().Set("Cache-Control", noStore) + } + default: + if production { + w.Header().Set("Cache-Control", cacheProd) + } else { + w.Header().Set("Cache-Control", noStore) + } + } +} + +func serveCompressed(w http.ResponseWriter, fsys fs.FS, rel, contentType, accept, token, suffix string) bool { + if !acceptsEncoding(accept, token) { + return false + } + name := rel + suffix + info, err := statFile(fsys, name) + if err != nil || info.IsDir() { + return false + } + f, err := fsys.Open(name) + if err != nil { + return false + } + defer f.Close() + w.Header().Set("Content-Encoding", token) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + w.WriteHeader(http.StatusOK) + if _, copyErr := io.Copy(w, f); copyErr != nil { + applog.Logger().Error("static copy", "error", copyErr) + } + return true +} + +func acceptsEncoding(header, token string) bool { + for _, part := range strings.Split(header, ",") { + enc := strings.TrimSpace(part) + if i := strings.Index(enc, ";"); i >= 0 { + enc = strings.TrimSpace(enc[:i]) + } + if enc == token { + return true + } + } + return false +} + +func statFile(fsys fs.FS, name string) (fs.FileInfo, error) { + if sf, ok := fsys.(fs.StatFS); ok { + return sf.Stat(name) + } + f, err := fsys.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + return f.Stat() +} diff --git a/backend/internal/static/static_test.go b/backend/internal/static/static_test.go new file mode 100644 index 00000000000..6765823d122 --- /dev/null +++ b/backend/internal/static/static_test.go @@ -0,0 +1,332 @@ +// Copyright Contributors to the Open Cluster Management project + +package static_test + +import ( + "compress/gzip" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stolostron/console/backend/internal/static" +) + +const wantCSP = "default-src 'self';connect-src 'self' https://api.github.com;base-uri 'self';block-all-mixed-content;font-src 'self' https: data:;frame-ancestors 'self';img-src 'self' data:;object-src 'none';script-src 'self' 'unsafe-eval';script-src-attr 'none';style-src 'self' https: 'unsafe-inline';upgrade-insecure-requests" + +func writeTree(t *testing.T) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "index.html": "console", + "plugin/plugin-entry.js": "console.log('entry')", + "plugin/plugin-manifest.json": `{"name":"acm"}`, + "locales/en/translation.json": `{"hello":"world"}`, + "assets/app.abc123.js": "window.app=1", + "logo.png": "png-bytes", + } + for name, body := range files { + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + gzPath := filepath.Join(dir, "assets", "app.abc123.js.gz") + gz, err := os.Create(gzPath) + if err != nil { + t.Fatal(err) + } + w := gzip.NewWriter(gz) + if _, err := w.Write([]byte("window.app=1")); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "assets", "app.abc123.js.br"), []byte("fake-brotli"), 0o644); err != nil { + t.Fatal(err) + } + return dir +} + +func get(t *testing.T, h http.Handler, path string, hdr http.Header) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + for k, vs := range hdr { + for _, v := range vs { + req.Header.Add(k, v) + } + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Result() +} + +func TestIsStaticPath(t *testing.T) { + yes := []string{"/", "/index.html", "/plugin/plugin-entry.js", "/plugin/plugin-manifest.json", + "/locales/en/translation.json", "/assets/app.js", "/logo.png", "/a.woff2"} + for _, p := range yes { + if !static.IsStaticPath(p) { + t.Fatalf("%s should be static", p) + } + } + no := []string{"/hub", "/events", "/username", "/api/v1/pods", "/secret.txt", "/plugin"} + for _, p := range no { + if static.IsStaticPath(p) { + t.Fatalf("%s should not be static", p) + } + } +} + +func TestIndexHTMLHeaders(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t))}) + for _, path := range []string{"/", "/index.html"} { + resp := get(t, h, path, nil) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + if !strings.Contains(string(body), "") { + t.Fatalf("%s body %s", path, body) + } + if resp.Header.Get("Cache-Control") != "no-cache" { + t.Fatalf("cache %q", resp.Header.Get("Cache-Control")) + } + if resp.Header.Get("Content-Security-Policy") != wantCSP { + t.Fatalf("csp %q", resp.Header.Get("Content-Security-Policy")) + } + if resp.Header.Get("X-Frame-Options") != "deny" { + t.Fatal("missing frame options") + } + if resp.Header.Get("Content-Type") != "text/html; charset=utf-8" { + t.Fatalf("ct %q", resp.Header.Get("Content-Type")) + } + if resp.Header.Get("Last-Modified") == "" { + t.Fatal("missing last-modified") + } + } +} + +func TestPluginManifestNoCache(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t)), Production: true}) + resp := get(t, h, "/plugin/plugin-manifest.json", nil) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + if string(body) != `{"name":"acm"}` { + t.Fatalf("body %s", body) + } + if resp.Header.Get("Cache-Control") != "no-cache" { + t.Fatalf("cache %q", resp.Header.Get("Cache-Control")) + } + if resp.Header.Get("Content-Type") != "application/json; charset=utf-8" { + t.Fatalf("ct %q", resp.Header.Get("Content-Type")) + } +} + +func TestPluginEntryNoCache(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t)), Production: true}) + resp := get(t, h, "/plugin/plugin-entry.js", nil) + resp.Body.Close() + if resp.Header.Get("Cache-Control") != "no-cache" { + t.Fatalf("cache %q", resp.Header.Get("Cache-Control")) + } + if resp.Header.Get("Content-Type") != "application/javascript; charset=UTF-8" { + t.Fatalf("ct %q", resp.Header.Get("Content-Type")) + } +} + +func TestHashedAssetCacheProduction(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t)), Production: true}) + resp := get(t, h, "/assets/app.abc123.js", nil) + resp.Body.Close() + if resp.Header.Get("Cache-Control") != "public, max-age=604800" { + t.Fatalf("cache %q", resp.Header.Get("Cache-Control")) + } +} + +func TestHashedAssetCacheDevelopment(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t)), Production: false}) + resp := get(t, h, "/assets/app.abc123.js", nil) + resp.Body.Close() + if resp.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("cache %q", resp.Header.Get("Cache-Control")) + } +} + +func TestLocalesCache(t *testing.T) { + dir := writeTree(t) + prod := static.New(static.Options{FS: os.DirFS(dir), Production: true}) + resp := get(t, prod, "/locales/en/translation.json", nil) + resp.Body.Close() + if resp.Header.Get("Cache-Control") != "public, max-age=3600" { + t.Fatalf("prod cache %q", resp.Header.Get("Cache-Control")) + } + dev := static.New(static.Options{FS: os.DirFS(dir), Production: false}) + resp = get(t, dev, "/locales/en/translation.json", nil) + resp.Body.Close() + if resp.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("dev cache %q", resp.Header.Get("Cache-Control")) + } +} + +func TestBrotliNegotiation(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t))}) + resp := get(t, h, "/assets/app.abc123.js", http.Header{"Accept-Encoding": []string{"gzip, br"}}) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.Header.Get("Content-Encoding") != "br" { + t.Fatalf("encoding %q", resp.Header.Get("Content-Encoding")) + } + if string(body) != "fake-brotli" { + t.Fatalf("body %q", body) + } + if resp.Header.Get("Content-Type") != "application/javascript; charset=UTF-8" { + t.Fatalf("ct %q", resp.Header.Get("Content-Type")) + } +} + +func TestGzipFallbackWhenNoBrotliAccepted(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t))}) + resp := get(t, h, "/assets/app.abc123.js", http.Header{"Accept-Encoding": []string{"gzip"}}) + defer resp.Body.Close() + if resp.Header.Get("Content-Encoding") != "gzip" { + t.Fatalf("encoding %q", resp.Header.Get("Content-Encoding")) + } + zr, err := gzip.NewReader(resp.Body) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + body, _ := io.ReadAll(zr) + if string(body) != "window.app=1" { + t.Fatalf("body %q", body) + } +} + +func TestUncompressedWhenNoAcceptEncoding(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t))}) + resp := get(t, h, "/assets/app.abc123.js", nil) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.Header.Get("Content-Encoding") != "" { + t.Fatalf("encoding %q", resp.Header.Get("Content-Encoding")) + } + if string(body) != "window.app=1" { + t.Fatalf("body %q", body) + } +} + +func TestIfModifiedSince(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t))}) + first := get(t, h, "/logo.png", nil) + first.Body.Close() + mod := first.Header.Get("Last-Modified") + if mod == "" { + t.Fatal("missing last-modified") + } + resp := get(t, h, "/logo.png", http.Header{"If-Modified-Since": []string{mod}}) + resp.Body.Close() + if resp.StatusCode != http.StatusNotModified { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestNotFound(t *testing.T) { + h := static.New(static.Options{FS: os.DirFS(writeTree(t))}) + resp := get(t, h, "/plugin/missing.js", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestUnknownExtension(t *testing.T) { + dir := writeTree(t) + if err := os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("nope"), 0o644); err != nil { + t.Fatal(err) + } + h := static.New(static.Options{FS: os.DirFS(dir)}) + resp := get(t, h, "/secret.txt", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestNilFS(t *testing.T) { + h := static.New(static.Options{}) + resp := get(t, h, "/index.html", nil) + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestOpenFS(t *testing.T) { + dir := t.TempDir() + fsys, ok := static.OpenFS(dir) + if !ok || fsys == nil { + t.Fatal("expected dir fs") + } + if _, ok := static.OpenFS(filepath.Join(dir, "missing")); ok { + t.Fatal("missing dir should fail") + } + if _, ok := static.OpenFS(""); ok { + t.Fatal("empty should fail") + } +} + +func TestBundledFS(t *testing.T) { + fsys := static.BundledFS() + if fsys == nil { + t.Fatal("expected bundled fs") + } + f, err := fsys.Open("README") + if err != nil { + t.Fatal(err) + } + _ = f.Close() +} + +func TestContentTypes(t *testing.T) { + dir := writeTree(t) + if err := os.WriteFile(filepath.Join(dir, "a.css"), []byte("a{}"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "a.svg"), []byte(""), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "a.woff2"), []byte("w"), 0o644); err != nil { + t.Fatal(err) + } + h := static.New(static.Options{FS: os.DirFS(dir)}) + cases := map[string]string{ + "/a.css": "text/css; charset=UTF-8", + "/a.svg": "image/svg+xml", + "/a.woff2": "font/woff2", + "/logo.png": "image/png", + } + for p, want := range cases { + resp := get(t, h, p, nil) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", p, resp.StatusCode) + } + if resp.Header.Get("Content-Type") != want { + t.Fatalf("%s ct %q want %q", p, resp.Header.Get("Content-Type"), want) + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3520cfbef27..ef4516f6662 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,3 +38,5 @@ All resources are checked for access using `SubjectAccessReview` calls to the cl The console backend proxies the cluster apiserver `/api` and `/apis` apiserver REST routes from the Go public listener (`backend/internal/k8sproxy`). All REST calls use the token passed from the console frontend. + +Static plugin assets (`plugin-manifest.json`, `plugin-entry.js`, hashed JS/CSS, locales) are served by the Go listener with the same cache headers, CSP, and brotli/gzip content negotiation as the former Node `serve` route. diff --git a/scripts/console-entrypoint.sh b/scripts/console-entrypoint.sh new file mode 100755 index 00000000000..554d0ce1385 --- /dev/null +++ b/scripts/console-entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Copyright Contributors to the Open Cluster Management project +# Public listener is Go; Node sidecar handles unmigrated routes. +set -eu +NODE_BACKEND_PORT="${NODE_BACKEND_PORT:-4001}" +export NODE_BACKEND_URL="${NODE_BACKEND_URL:-https://127.0.0.1:${NODE_BACKEND_PORT}}" +export PUBLIC_FOLDER="${PUBLIC_FOLDER:-/app/public}" +export CERTS_DIR="${CERTS_DIR:-/app/certs}" +export CONFIG_DIR="${CONFIG_DIR:-/app/config}" +PORT="${NODE_BACKEND_PORT}" node /app/backend.mjs & +exec /app/console From 4bec84a47bd5bfb1c28de68abc86c7f209317412 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Tue, 1 Sep 2026 15:45:09 +0200 Subject: [PATCH 04/16] ACM-42593 Migrate managed cluster, metrics, and VM proxy routes to Go backend (#49) * Migrate managed cluster, metrics, and VM proxy routes to Go backend Add Go handlers for /managedclusterproxy/*, /prometheus/*, /observability/*, and the /virtualmachines* family, moving them from the Node sidecar to the Go listener. Introduce internal/clusterproxy resolver, metricsproxy, mcproxy, and vmproxy packages, plus auth helpers for service CA TLS and request token validation. Update server routing, config env vars, and AGENTS.md to register and document the migrated stateless proxies. Signed-off-by: Enrique Mingorance Cano * .editorconfig Signed-off-by: Enrique Mingorance Cano * Add hubresources package and use dynamic client for MCE/MCH lookups Introduce backend/internal/hubresources with MCETargetNamespace and MCHFineGrainedRBAC helpers. Update clusterproxy.Resolver and vmproxy to use the Kubernetes dynamic client instead of manual HTTP/JSON requests when reading MultiClusterEngine and MultiClusterHub resources, and adjust tests to use the fake dynamic client. Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --- backend-node/AGENTS.md | 10 +- backend-node/src/app.ts | 13 - backend-node/src/lib/server.ts | 5 - backend-node/src/lib/virtual-machine.ts | 267 ------ .../src/routes/managedClusterProxy.ts | 68 -- backend-node/src/routes/metricsProxy.ts | 81 -- .../src/routes/virtualMachineProxy.ts | 481 ----------- .../test/routes/managedClusterProxy.test.ts | 45 -- backend-node/test/routes/metricsProxy.test.ts | 20 - .../test/routes/placementDebug.test.ts | 2 +- .../test/routes/virtualMachineProxy.test.ts | 761 ------------------ backend/AGENTS.md | 10 + backend/cmd/console/main.go | 37 +- backend/internal/auth/auth.go | 65 +- backend/internal/auth/auth_test.go | 15 +- backend/internal/auth/tls.go | 41 + backend/internal/auth/tls_test.go | 65 ++ backend/internal/clusterproxy/resolver.go | 163 ++++ .../internal/clusterproxy/resolver_test.go | 92 +++ backend/internal/config/config.go | 54 +- backend/internal/config/config_test.go | 79 ++ backend/internal/hubresources/hubresources.go | 84 ++ .../hubresources/hubresources_test.go | 101 +++ backend/internal/mcproxy/mcproxy.go | 102 +++ backend/internal/mcproxy/mcproxy_test.go | 182 +++++ backend/internal/metricsproxy/metricsproxy.go | 94 +++ .../metricsproxy/metricsproxy_test.go | 177 ++++ backend/internal/server/server.go | 93 ++- backend/internal/server/server_test.go | 84 +- backend/internal/vmproxy/handler.go | 258 ++++++ backend/internal/vmproxy/handler_test.go | 358 ++++++++ backend/internal/vmproxy/hub.go | 92 +++ backend/internal/vmproxy/hub_test.go | 118 +++ backend/internal/vmproxy/kubevirt.go | 32 + backend/internal/vmproxy/kubevirt_test.go | 59 ++ backend/internal/vmproxy/units.go | 121 +++ backend/internal/vmproxy/units_test.go | 59 ++ backend/internal/vmproxy/usage.go | 235 ++++++ backend/internal/vmproxy/usage_test.go | 157 ++++ docs/ARCHITECTURE.md | 2 +- 40 files changed, 2985 insertions(+), 1797 deletions(-) delete mode 100644 backend-node/src/lib/virtual-machine.ts delete mode 100644 backend-node/src/routes/managedClusterProxy.ts delete mode 100644 backend-node/src/routes/metricsProxy.ts delete mode 100644 backend-node/src/routes/virtualMachineProxy.ts delete mode 100644 backend-node/test/routes/managedClusterProxy.test.ts delete mode 100644 backend-node/test/routes/metricsProxy.test.ts delete mode 100644 backend-node/test/routes/virtualMachineProxy.test.ts create mode 100644 backend/internal/auth/tls.go create mode 100644 backend/internal/auth/tls_test.go create mode 100644 backend/internal/clusterproxy/resolver.go create mode 100644 backend/internal/clusterproxy/resolver_test.go create mode 100644 backend/internal/hubresources/hubresources.go create mode 100644 backend/internal/hubresources/hubresources_test.go create mode 100644 backend/internal/mcproxy/mcproxy.go create mode 100644 backend/internal/mcproxy/mcproxy_test.go create mode 100644 backend/internal/metricsproxy/metricsproxy.go create mode 100644 backend/internal/metricsproxy/metricsproxy_test.go create mode 100644 backend/internal/vmproxy/handler.go create mode 100644 backend/internal/vmproxy/handler_test.go create mode 100644 backend/internal/vmproxy/hub.go create mode 100644 backend/internal/vmproxy/hub_test.go create mode 100644 backend/internal/vmproxy/kubevirt.go create mode 100644 backend/internal/vmproxy/kubevirt_test.go create mode 100644 backend/internal/vmproxy/units.go create mode 100644 backend/internal/vmproxy/units_test.go create mode 100644 backend/internal/vmproxy/usage.go create mode 100644 backend/internal/vmproxy/usage_test.go diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md index 39aee0e8457..7ee492f18b9 100644 --- a/backend-node/AGENTS.md +++ b/backend-node/AGENTS.md @@ -6,18 +6,17 @@ Node.js ESM proxy server. Sits between the browser and the hub cluster API serve - **Runtime**: Node.js with native ESM (`"type": "module"`) - **Router**: `find-my-way` for HTTP/2 route matching -- **Proxy**: `node:https` + `pipeline` for main API proxy; `http2-proxy` for managed cluster proxy +- **Proxy**: `node:https` + `pipeline` for main API proxy - **Logging**: Pino with structured JSON output (use `pino-zen` for dev formatting) -- **Metrics**: Prometheus metrics proxied via `metricsProxy` route - **HTTP Client**: `got` for outbound requests -- **WebSocket**: upgrade handler routes to search (bidirectional relay via `ws` with token injection) and managed cluster proxy (via `http2-proxy`) +- **WebSocket**: upgrade handler routes to search (bidirectional relay via `ws` with token injection) ## Source Layout | Directory | Purpose | |-----------|---------| | `src/lib/` | Core server: `main.ts` entry, `server.ts`, auth, cookies, CORS, proxy, search, SSE, logging, config | -| `src/routes/` | HTTP route handlers: proxy, OAuth, search, events, hub, metrics, managed cluster proxy, etc. | +| `src/routes/` | HTTP route handlers: proxy, OAuth, search, events, hub, etc. | | `src/resources/` | Backend resource watchers and handlers | | `test/` | Jest test files | | `config/` | Runtime configuration lives in `../backend/config` (Go backend) | @@ -85,9 +84,6 @@ Generated by `npm run setup` from the repo root into **`../backend/.env`**. The | `FRONTEND_URL` | Frontend URL for post-login redirect | | `SEARCH_API_URL` | Search API route URL | | `PLACEMENT_DEBUG_URL` | Placement debug service route URL | -| `CLUSTER_PROXY_ADDON_USER_HOST` / `CLUSTER_PROXY_ADDON_USER_ROUTE` | Managed cluster proxy endpoint | -| `OBSERVABILITY_ROUTE` | Observability query proxy route (requires ACM Observability) | -| `PROMETHEUS_ROUTE` | Prometheus route for metrics proxy | Optional development/debug variables (not in `.env` by default): diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 4b507a71f1c..cbe7bd11062 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -18,7 +18,6 @@ import { configure } from './routes/configure' import { events, startWatching, stopWatching } from './routes/events' import { hub } from './routes/hub' import { liveness } from './routes/liveness' -import { observabilityProxy, prometheusProxy } from './routes/metricsProxy' import { multiClusterHubComponents } from './routes/multiClusterHubComponents' import { login, loginCallback, logout } from './routes/oauth' import { operatorCheck } from './routes/operatorCheck' @@ -28,8 +27,6 @@ import { placementDebug } from './routes/placementDebug' import { upgradeRiskPredictions } from './routes/upgrade-risks-prediction' import { username } from './routes/username' import { userpreference } from './routes/userpreference' -import { virtualMachineGETProxy, virtualMachineProxy, vmResourceUsageProxy } from './routes/virtualMachineProxy' -import { managedClusterProxy } from './routes/managedClusterProxy' import { hypershiftStatus } from './routes/hypershift-status' import { clusterVersion } from './routes/clusterVersion' import { watchTLSSecurityProfile } from './lib/tlsProfileWatch' @@ -62,8 +59,6 @@ router.get('/livenessProbe', liveness) router.get('/ping', respondOK) router.get('/apiPaths', apiPaths) router.post('/operatorCheck', operatorCheck) -router.get('/observability/*', observabilityProxy) -router.get('/prometheus/*', prometheusProxy) if (!isProduction) { router.get('/configure', configure) router.get('/login', login) @@ -85,16 +80,8 @@ router.get('/hypershift-status', hypershiftStatus) router.get('/cluster-version', clusterVersion) router.post('/upgrade-risks-prediction', upgradeRiskPredictions) router.post('/aggregate/*', aggregate) -router.get('/virtualmachines/get/*', virtualMachineGETProxy) -router.all('/virtualmachines/*', virtualMachineProxy) -router.all('/virtualmachineinstances/*', virtualMachineProxy) -router.get('/virtualmachinesnapshots/get/*', virtualMachineGETProxy) -router.all('/virtualmachinesnapshots/*', virtualMachineProxy) -router.all('/virtualmachinerestores', virtualMachineProxy) -router.get('/vmResourceUsage/cluster/:cluster/namespace/:namespace', vmResourceUsageProxy) router.get('/multiclusterhub/components', multiClusterHubComponents) router.get('/multiclusterengine/components', multiClusterEngineComponents) -router.all('/managedclusterproxy/*', managedClusterProxy) // rosa wizard routes router.post('/aws-account-ids', getAwsAccountIds) diff --git a/backend-node/src/lib/server.ts b/backend-node/src/lib/server.ts index 5698da035b2..fc78b06a7ec 100644 --- a/backend-node/src/lib/server.ts +++ b/backend-node/src/lib/server.ts @@ -6,7 +6,6 @@ import { constants, createSecureServer, createServer } from 'node:http2' import type { Socket } from 'node:net' import type { TLSSocket } from 'node:tls' import { logger } from './logger' -import { managedClusterProxy } from '../routes/managedClusterProxy' import { readFileSync } from 'node:fs' import { searchWebSocket } from '../routes/search' import { certFile } from './paths' @@ -79,10 +78,6 @@ export function startServer(options: ServerOptions): Promise { if (isStopping) { diff --git a/backend-node/src/lib/virtual-machine.ts b/backend-node/src/lib/virtual-machine.ts deleted file mode 100644 index d239aeac550..00000000000 --- a/backend-node/src/lib/virtual-machine.ts +++ /dev/null @@ -1,267 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -export type PodMetricsList = { - kind: 'PodMetricsList' - apiVersion: 'metrics.k8s.io/v1beta1' - metadata: Record - items: PodMetric[] -} - -export type PodMetric = { - metadata: { - name: string - namespace: string - creationTimestamp: string // ISO timestamp string - labels: Record - } - timestamp: string // ISO timestamp string - window: string // duration string e.g. "17.953s" - containers: ContainerMetric[] -} - -type ContainerMetric = { - name: string - usage: { - cpu: string // e.g. "6894867n", "0" - memory: string // e.g. "23940Ki" - } -} - -export type PodListType = { - kind: 'PodList' - apiVersion: 'v1' - metadata: { - resourceVersion: string - } - items: PodType[] -} - -export type PodType = { - metadata: { - name: string - } - spec: { - containers: { - resources: { - requests: { - cpu: string // "100m", - memory: string // "2294Mi" - } - } - }[] - } -} - -type usageMetrics = { - requested: number // unit is millicores - usage: number // unit is MiB - usagePercent: number // unit is % -} - -export type VmiUsageType = { - podName: string - vmiName: string - clusterName: string - namespace: string - cpu: usageMetrics - memory: usageMetrics - storage: usageMetrics -} - -export type FilesystemType = { - items: { - diskName: string - fileSystemType: string - mountPoint: string - totalBytes: number - usedBytes: number - }[] - metadata: Record -} - -/** - * Converts a Kubernetes CPU value from nanocores to millicores. - * @param {string} nanocoreString - The CPU usage string, e.g., "5124125n". - * @returns {number} The equivalent value in millicores. - */ -export function convertNanocoresToMillicores(nanocoreString: string): number { - // Use parseInt to extract the numeric part of the string. - // It will automatically stop at the non-numeric character 'n'. - const nanocores = Number.parseInt(nanocoreString, 10) - - // Check if the parsing was successful and it's a valid number. - if (Number.isNaN(nanocores)) { - return 0 // Or throw an error, depending on desired behavior - } - - // Divide by 1,000,000 to convert from nanocores to millicores. - const millicores = nanocores / 1000000 - - return millicores -} - -/** - * Parses a Kubernetes memory string (e.g., "20480Ki", "256Mi", "1.5Gi") - * and returns the value in Mebibytes (Mi). - * @param {string} memoryString - The memory usage string. - * @returns {number} The equivalent value in Mebibytes. - */ -export function convertKibibytesToMebibytes(kibibyteString: string) { - // Use parseInt to extract the numeric part of the string. - const kibibytes = Number.parseInt(kibibyteString, 10) - - // Check if the parsing was successful. - if (Number.isNaN(kibibytes)) { - return 0 // Or throw an error - } - - // Divide by 1024 to convert from Kibibytes to Mebibytes. - const mebibytes = kibibytes / 1024 - - return mebibytes -} - -/** - * Converts a storage value from bytes to gibibytes (GiB). - * @param {number} bytes - The storage size in bytes. - * @returns {number} The equivalent value in gibibytes (GiB). - */ -export function convertBytesToGibibytes(bytes: number) { - if (typeof bytes !== 'number' || Number.isNaN(bytes)) { - return 0 - } - - // The conversion factor for bytes to gibibytes (1024*1024*1024) - const bytesInAGibibyte = 1073741824 - - const gibibytes = bytes / bytesInAGibibyte - - return gibibytes -} - -/** - * Converts a Kubernetes CPU resource string into its integer value in millicores. - * - * @param {string | null | undefined} cpuRequest - The CPU resource string from a Kubernetes spec (e.g., "500m", "1", "0.5"). - * @returns {number} The equivalent value in millicores. - * @throws {Error} If the input is invalid, not a string, or in an unrecognizable format. - */ -export function toMillicores(cpuRequest: string): number { - // 1. Validate the input - if (cpuRequest === null || cpuRequest === undefined || typeof cpuRequest !== 'string' || cpuRequest.trim() === '') { - throw new Error('Invalid input: cpuRequest must be a non-empty string.') - } - - const trimmedCpu = cpuRequest.trim() - - // 2. Handle values already in millicores (ending with "m") - if (trimmedCpu.endsWith('m')) { - const numericPart = trimmedCpu.slice(0, -1) - const millicores = Number.parseInt(numericPart, 10) - - // Ensure the part before "m" was a valid integer - if (Number.isNaN(millicores) || String(millicores) !== numericPart) { - throw new Error(`Invalid millicore value: "${cpuRequest}". The part before "m" must be an integer.`) - } - return millicores - } - - // 3. Handle values in full/fractional cores - const coreValue = Number.parseFloat(trimmedCpu) - - // Ensure the input was a valid number - if (Number.isNaN(coreValue)) { - throw new Error(`Invalid core value: "${cpuRequest}". Must be a number or end with 'm'.`) - } - - return coreValue * 1000 -} - -/** - * Converts a Kubernetes memory resource string into its value in Mebibytes (Mi). - * - * @param {string | null | undefined} memoryRequest - The memory resource string from a Kubernetes spec (e.g., "128Mi", "1Gi", "500M"). - * @returns {number} The equivalent value in Mebibytes (Mi). - * @throws {Error} If the input is invalid, not a string, or in an unrecognizable format. - */ -export function toMebibytes(memoryRequest: string): number { - // 1. Validate the input - if ( - memoryRequest === null || - memoryRequest === undefined || - typeof memoryRequest !== 'string' || - memoryRequest.trim() === '' - ) { - throw new Error('Invalid input: memoryRequest must be a non-empty string.') - } - - // 2. Define conversion factors to bytes - const BYTES_IN_A_MEBIBYTE = 1024 * 1024 - const multipliers: Record = { - // Binary units (power of 2) - Ki: 1024, - Mi: BYTES_IN_A_MEBIBYTE, - Gi: 1024 * 1024 * 1024, - Ti: 1024 * 1024 * 1024 * 1024, - Pi: 1024 * 1024 * 1024 * 1024 * 1024, - Ei: 1024 * 1024 * 1024 * 1024 * 1024 * 1024, - // Decimal units (power of 10) - k: 1000, - M: 1000 * 1000, - G: 1000 * 1000 * 1000, - T: 1000 * 1000 * 1000 * 1000, - P: 1000 * 1000 * 1000 * 1000 * 1000, - E: 1000 * 1000 * 1000 * 1000 * 1000 * 1000, - } - - // 3. Parse the input string - const regex = /^(\d+(\.\d+)?)\s*([A-Za-z]+)?$/ - const match = memoryRequest.trim().match(regex) - - if (!match) { - throw new Error(`Invalid memory format: "${memoryRequest}". Expected a number followed by an optional unit.`) - } - - const numericValue = Number.parseFloat(match[1]) - const unit = match[3] || '' // Default to empty string if no unit is present - - // 4. Calculate the value in bytes - let bytes - if (unit === '') { - // If there's no unit, Kubernetes treats the value as bytes - bytes = numericValue - } else if (multipliers[unit]) { - bytes = numericValue * multipliers[unit] - } else { - throw new Error(`Invalid memory unit: "${unit}".`) - } - - // 5. Convert bytes to Mebibytes and return - return bytes / BYTES_IN_A_MEBIBYTE -} - -/** - * Calculates the usage percentage as a whole number (integer). - * - * @param {number} usage - The amount of a resource that is currently being used. - * @param {number} requested - The total amount of the resource that was requested. - * @returns {number} The usage as a rounded, integer percentage. Returns 0 if the requested amount is 0 or invalid. - */ -export function calUsagePercent(usage: number, requested: number) { - // --- Input Validation --- - if (typeof usage !== 'number' || typeof requested !== 'number' || usage < 0 || requested < 0) { - console.error("Invalid input: 'usage' and 'requested' must be non-negative numbers.") - return 0 - } - - // --- Edge Case: Division by Zero --- - if (requested === 0) { - return 0 - } - - // --- Calculation and Rounding --- - const percentage = (usage / requested) * 100 - - // Round the result to the nearest whole number - return Math.round(percentage) -} diff --git a/backend-node/src/routes/managedClusterProxy.ts b/backend-node/src/routes/managedClusterProxy.ts deleted file mode 100644 index f6bf2ae86f5..00000000000 --- a/backend-node/src/routes/managedClusterProxy.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import { logger } from '../lib/logger' -import { respondInternalServerError } from '../lib/respond' -import { getAuthenticatedToken, isHttp2ServerResponse } from '../lib/token' -import { getMultiClusterEngine } from '../lib/multi-cluster-engine' -import proxy from 'http2-proxy' -import type { TLSSocket } from 'node:tls' -import { getServiceCACertificate } from '../lib/serviceAccountToken' - -export async function managedClusterProxy(req: Http2ServerRequest, res: Http2ServerResponse): Promise -export async function managedClusterProxy(req: Http2ServerRequest, socket: TLSSocket, head: Buffer): Promise -export async function managedClusterProxy( - req: Http2ServerRequest, - resOrSocket: Http2ServerResponse | TLSSocket, - head?: Buffer -): Promise { - const token = await getAuthenticatedToken(req, resOrSocket) - if (!token) return - - // expected path is /managedclusterproxy// - const path = req.url - const splitPath = path.split('/') - const managedCluster = splitPath[2] - const apiPath = splitPath.slice(3).join('/') - - try { - const mce = await getMultiClusterEngine() - const proxyService = `cluster-proxy-addon-user.${mce?.spec?.targetNamespace || 'multicluster-engine'}.svc.cluster.local` - const proxyHost = process.env.CLUSTER_PROXY_ADDON_USER_HOST || proxyService - const proxyPort = process.env.CLUSTER_PROXY_ADDON_USER_HOST ? 443 : 9092 - - req.url = `/${managedCluster}/${apiPath}` - - req.headers[constants.HTTP2_HEADER_AUTHORIZATION] = `Bearer ${token}` - req.headers[constants.HTTP2_HEADER_HOST] = proxyHost - req.headers['origin'] = `https://${proxyHost}` - - const proxyOptions = { - protocol: 'https', - hostname: proxyHost, - port: proxyPort, - // DO NOT use 'agent: getServiceAgent()' here; connection agent does not work with proxy - ca: getServiceCACertificate(), - } as const - - const proxyHandler = (err: Error) => { - if (err) { - logger.error(err) - throw err - } - } - - if (isHttp2ServerResponse(resOrSocket)) { - await proxy.web(req, resOrSocket, proxyOptions, proxyHandler) - } else { - await proxy.ws(req, resOrSocket, head, proxyOptions, proxyHandler) - } - } catch (err) { - logger.error(err) - if (isHttp2ServerResponse(resOrSocket)) { - respondInternalServerError(req, resOrSocket) - } else { - resOrSocket.destroy() - } - } -} diff --git a/backend-node/src/routes/metricsProxy.ts b/backend-node/src/routes/metricsProxy.ts deleted file mode 100644 index 90e025d51c0..00000000000 --- a/backend-node/src/routes/metricsProxy.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse, OutgoingHttpHeaders } from 'node:http2' -import { constants } from 'node:http2' -import type { RequestOptions } from 'node:https' -import { request } from 'node:https' -import { pipeline } from 'node:stream' -import { URL } from 'node:url' -import { getServiceAgent } from '../lib/agent' -import { logger } from '../lib/logger' -import { notFound, respondInternalServerError, unauthorized } from '../lib/respond' -import { getToken } from '../lib/token' - -const proxyHeaders = [ - constants.HTTP2_HEADER_ACCEPT, - constants.HTTP2_HEADER_ACCEPT_ENCODING, - constants.HTTP2_HEADER_CONTENT_ENCODING, - constants.HTTP2_HEADER_CONTENT_LENGTH, - constants.HTTP2_HEADER_CONTENT_TYPE, -] -const proxyResponseHeaders = [ - constants.HTTP2_HEADER_CACHE_CONTROL, - constants.HTTP2_HEADER_CONTENT_TYPE, - constants.HTTP2_HEADER_CONTENT_LENGTH, - constants.HTTP2_HEADER_CONTENT_ENCODING, - constants.HTTP2_HEADER_ETAG, -] - -export function prometheusProxy(req: Http2ServerRequest, res: Http2ServerResponse) { - const token = getToken(req) - if (!token) unauthorized(req, res) - - const prometheusProxyService = 'https://prometheus-k8s.openshift-monitoring.svc.cluster.local:9091' - const promURL = process.env.PROMETHEUS_ROUTE || prometheusProxyService - - metricsProxy(req, res, token, promURL) -} - -export function observabilityProxy(req: Http2ServerRequest, res: Http2ServerResponse) { - const token = getToken(req) - if (!token) unauthorized(req, res) - - const obsProxyService = 'https://rbac-query-proxy.open-cluster-management-observability.svc.cluster.local:8443' - const obsURL = process.env.OBSERVABILITY_ROUTE || obsProxyService - - metricsProxy(req, res, token, obsURL) -} - -function metricsProxy(req: Http2ServerRequest, res: Http2ServerResponse, token: string, route: string): void { - const path = req.url.replace('/observability', '/api/v1').replace('/prometheus', '/api/v1') - const headers: OutgoingHttpHeaders = { authorization: `Bearer ${token}` } - for (const header of proxyHeaders) { - if (req.headers[header]) headers[header] = req.headers[header] - } - - if (!route) return respondInternalServerError(req, res) - const rbacQueryProxyUrl = new URL(route) - const options: RequestOptions = { - protocol: rbacQueryProxyUrl.protocol, - hostname: rbacQueryProxyUrl.hostname, - port: rbacQueryProxyUrl.port, - path, - method: req.method, - headers, - agent: getServiceAgent(), - } - pipeline( - req, - request(options, (response) => { - if (!response) return notFound(req, res) - const responseHeaders: OutgoingHttpHeaders = {} - for (const header of proxyResponseHeaders) { - if (response.headers[header]) responseHeaders[header] = response.headers[header] - } - res.writeHead(response.statusCode ?? 500, responseHeaders) - pipeline(response, res as unknown as NodeJS.WritableStream, () => logger.error) - }), - (err) => { - if (err) logger.error(err) - } - ) -} diff --git a/backend-node/src/routes/virtualMachineProxy.ts b/backend-node/src/routes/virtualMachineProxy.ts deleted file mode 100644 index 4fac5f8e36b..00000000000 --- a/backend-node/src/routes/virtualMachineProxy.ts +++ /dev/null @@ -1,481 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import type { HeadersInit } from 'node-fetch' -import { getServiceAgent } from '../lib/agent' -import { fetchRetry } from '../lib/fetch-retry' -import { jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { getMultiClusterEngine } from '../lib/multi-cluster-engine' -import { getMultiClusterHub } from '../lib/multi-cluster-hub' -import { respondInternalServerError } from '../lib/respond' -import { getServiceAccountToken } from '../lib/serviceAccountToken' -import { getAuthenticatedToken } from '../lib/token' -import { - calUsagePercent, - convertBytesToGibibytes, - convertKibibytesToMebibytes, - convertNanocoresToMillicores, - type FilesystemType, - type PodListType, - type PodMetric, - type PodMetricsList, - type PodType, - toMebibytes, - toMillicores, - type VmiUsageType, -} from '../lib/virtual-machine' -import type { ResourceList } from '../resources/resource-list' -import type { Secret } from '../resources/secret' -import { canAccess } from './events' - -const { - HTTP2_HEADER_CONTENT_TYPE, - HTTP2_HEADER_AUTHORIZATION, - HTTP2_HEADER_ACCEPT, - HTTP_STATUS_INTERNAL_SERVER_ERROR, -} = constants - -interface ActionBody { - managedCluster: string - vmName: string - vmNamespace: string - reqBody?: object -} - -const getKubeVirtAPI = (url: string, name: string, namespace: string, action?: string) => { - let path = '' - switch (url) { - case '/virtualmachines/update': - case '/virtualmachines/delete': - path = `${path}/apis/kubevirt.io/v1/namespaces/${namespace}/virtualmachines/${name}` - break - case '/virtualmachines/start': - case '/virtualmachines/stop': - case '/virtualmachines/restart': - path = `${path}/apis/subresources.kubevirt.io/v1/namespaces/${namespace}/virtualmachines/${name}/${action}` - break - case '/virtualmachineinstances/pause': - case '/virtualmachineinstances/unpause': - path = `${path}/apis/subresources.kubevirt.io/v1/namespaces/${namespace}/virtualmachineinstances/${name}/${action}` - break - case '/virtualmachinesnapshots/create': - path = `${path}/apis/snapshot.kubevirt.io/v1beta1/namespaces/${namespace}/virtualmachinesnapshots` - break - case '/virtualmachinesnapshots/update': - case '/virtualmachinesnapshots/delete': - path = `${path}/apis/snapshot.kubevirt.io/v1beta1/namespaces/${namespace}/virtualmachinesnapshots/${name}` - break - case '/virtualmachinerestores': - path = `${path}/apis/snapshot.kubevirt.io/v1beta1/namespaces/${namespace}/virtualmachinerestores` - break - } - return path -} - -export async function virtualMachineGETProxy(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - const proxyURL = await getProxyUrl() - const urlSplit = req.url.split('/') - // vm get requests have url /virtualmachines/get/// - const managedCluster = urlSplit[3] - const vmName = urlSplit[4] - const vmNamespace = urlSplit[5] - let path = `${proxyURL}/${managedCluster}` - if (req.url.startsWith('/virtualmachines/get')) { - path = `${path}/apis/kubevirt.io/v1/namespaces/${vmNamespace}/virtualmachines/${vmName}` - } else if (req.url.startsWith('/virtualmachinesnapshots/get')) { - path = `${path}/apis/snapshot.kubevirt.io/v1beta1/namespaces/${vmNamespace}/virtualmachinesnapshots/${vmName}` - } - const getResponse = await fetchRetry(path, { - method: 'GET', - headers: { - [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${token}`, - }, - agent: getServiceAgent(), - compress: true, - }) - .then((response) => response.json() as unknown) - .catch((err: Error): undefined => { - logger.error({ msg: 'Error getting VM resource (fine grained RBAC)', error: err.message }) - return undefined - }) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(getResponse)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function virtualMachineProxy(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - let token = await getAuthenticatedToken(req, res) - if (token) { - try { - const mch = await getMultiClusterHub() - const isFineGrainedRbacEnabled = - mch?.spec?.overrides?.components?.find( - (e: { enabled: boolean; name: string }) => e.name === 'fine-grained-rbac' - )?.enabled ?? false - const proxyURL = await getProxyUrl() - - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - req.on('end', async () => { - let body = {} as ActionBody - try { - body = JSON.parse(chucks.join('')) as ActionBody - } catch (err) { - logger.error(err) - } - const action = req.url.split('/')[2] - const path = `${proxyURL}/${body.managedCluster}${getKubeVirtAPI(req.url, body.vmName, body.vmNamespace, action)}` - const reqBody = JSON.stringify(body.reqBody) - - if (!isFineGrainedRbacEnabled) { - // Fine grained RBAC not enabled - need to get managed cluster vm-actor token for proxy - const serviceAccountToken = getServiceAccountToken() - // If user is not able to create an MCA in the managed cluster namespace -> they aren't authorized to trigger actions. - const hasAuth = await canAccess( - { - kind: 'ManagedClusterAction', - apiVersion: 'action.open-cluster-management.io/v1beta1', - metadata: { namespace: body.managedCluster }, - }, - 'create', - token - ).then((allowed) => allowed) - - if (hasAuth) { - // console-mce ClusterRole does not allow for GET on secrets. Have to list in a namespace - const secretPath = process.env.CLUSTER_API_URL + `/api/v1/namespaces/${body.managedCluster}/secrets` - token = await jsonRequest(secretPath, serviceAccountToken) - .then((response: ResourceList) => { - const secret = response.items.find((secret) => secret.metadata.name === 'vm-actor') - const proxyToken = secret.data?.token ?? '' - return Buffer.from(proxyToken, 'base64').toString('ascii') - }) - .catch((err: Error): undefined => { - logger.error({ msg: `Error getting secret in namespace ${body.managedCluster}`, error: err.message }) - return undefined - }) - } - } - - let headers: HeadersInit = {} - switch (req.url) { - // start, stop, restart, pause, unpause all require */* for accept and content-type headers - case '/virtualmachines/start': - case '/virtualmachines/stop': - case '/virtualmachines/restart': - case '/virtualmachineinstances/pause': - case '/virtualmachineinstances/unpause': - headers = { - [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${token}`, - } - break - default: - headers = { - [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${token}`, - [HTTP2_HEADER_ACCEPT]: 'application/json', - [HTTP2_HEADER_CONTENT_TYPE]: 'application/json', - } - } - - await fetchRetry(path, { - method: req.method, - headers, - agent: getServiceAgent(), - body: reqBody, - compress: true, - }) - .then(async (response) => { - let responseBody = undefined - const responseContentType = response.headers.get('content-type') - if (responseContentType && responseContentType.includes('application/json')) { - responseBody = (await response.json()) as unknown - } else { - responseBody = await response.text() - } - - const contentType = typeof responseBody === 'string' ? 'text/plain' : 'application/json' - res.setHeader('Content-Type', contentType) - res.writeHead(response.status ?? HTTP_STATUS_INTERNAL_SERVER_ERROR) - res.end(JSON.stringify(responseBody)) - }) - .catch((err: Error): undefined => { - logger.error({ - msg: 'Error in VirtualMachine action request (fine grained RBAC)', - error: err.message, - }) - respondInternalServerError(req, res) - return undefined - }) - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -/** - * Handles a request to retrieve aggregated CPU, memory, and storage usage - * for all KubeVirt virtual machines (VMs) in a given namespace and cluster. - * - * This function: - * - Authenticates the request and retrieves the token. - * - Uses the token to proxy requests to the cluster via the Cluster Proxy Addon. - * - Fetches pod metrics (CPU and memory) from `metrics.k8s.io`. - * - Fetches filesystem usage data from the KubeVirt `filesystemlist` subresource. - * - Aggregates usage data across all matched pods (virt-launchers). - * - Returns the total CPU (millicores), memory (MiB), and storage (GiB) usage in the namespace. - - * - * @param req - The incoming HTTP/2 server request. - * @param res - The HTTP/2 server response to write usage results or errors. - * @param params - A record containing URL parameters; expects `cluster` and `namespace`. -* On success, it sends a 200 OK with a JSON body like: - * ```json - * { - * "cpu": number, - * "memory": number, - * "storage": number, - * "vmisUsage": [{ - * "cpu": { - * "requested": number, / millicores - * "usage": number, // millicores - * "usagePercent": number // ex: 20 - * }, - * "memory": { - * "requested": 0, // MiB - * "usage": 0, // MiB - * "usagePercent": 0 // ex: 20 - * }, - * "storage": { - * "requested": 0, // GiB - * "usage": 0, // GiB - * "usagePercent": 0 ex: 20 - * }, - * "podName": string, - * "vmiName": string, - * "clusterName": string, - * "namespace": string - * }] - * } - * ``` - * On failure, it sends an error response (e.g., 500 Internal Server Error). - * @throws Responds with 500 if token acquisition or any data fetch fails. - */ -export async function vmResourceUsageProxy( - req: Http2ServerRequest, - res: Http2ServerResponse, - params: Record -): Promise { - const token = await getAuthenticatedToken(req, res) - if (!token) { - respondInternalServerError(req, res) // Or a 401 Unauthorized error - return - } - - const { cluster: clusterName, namespace } = params - if (!clusterName || !namespace) { - res.writeHead(400, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Cluster name and namespace are required' })) - return - } - - try { - const proxyURL = await getProxyUrl() - const { podMetricsList, podList } = await fetchVmData(proxyURL, clusterName, namespace, token) - - const usageData = await calculateAllVmiUsage(podMetricsList, podList, { - proxyURL, - clusterName, - namespace, - token, - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(usageData)) - } catch (err: unknown) { - // A single catch block for any failure during the process - logger.error({ msg: 'Failed to get aggregated VM usage', error: err }) - respondInternalServerError(req, res) - } -} - -/** - * @description Determines the correct proxy URL for making API requests. - * It prefers the environment variable and falls back to a constructed SVC URL. - * @returns {Promise} The resolved proxy URL. - */ -async function getProxyUrl(): Promise { - if (process.env.CLUSTER_PROXY_ADDON_USER_ROUTE) { - return process.env.CLUSTER_PROXY_ADDON_USER_ROUTE - } - const mce = await getMultiClusterEngine() - const targetNamespace = mce?.spec?.targetNamespace || 'multicluster-engine' - return `https://cluster-proxy-addon-user.${targetNamespace}.svc.cluster.local:9092` -} - -/** - * @description Fetches the initial Pod and PodMetrics lists from the proxied cluster. - * @param {string} proxyURL The base URL of the cluster proxy. - * @param {string} clusterName The name of the target cluster. - * @param {string} namespace The namespace to query within the target cluster. - * @param {string} token The authentication token. - * @returns {Promise<{podMetricsList: PodMetricsList, podList: PodListType}>} The fetched data. - */ -async function fetchVmData(proxyURL: string, clusterName: string, namespace: string, token: string) { - const labelSelector = 'kubevirt.io=virt-launcher' - const podMetricsListUrl = `${proxyURL}/${clusterName}/apis/metrics.k8s.io/v1beta1/namespaces/${namespace}/pods?labelSelector=${labelSelector}` - const podListUrl = `${proxyURL}/${clusterName}/api/v1/namespaces/${namespace}/pods?labelSelector=${labelSelector}` - - // Fetch initial data in parallel - const [podMetricsList, podList] = await Promise.all([ - jsonRequest(podMetricsListUrl, token), - jsonRequest(podListUrl, token), - ]) - - return { podMetricsList, podList } -} - -/** - * @description Processes raw pod and metrics data to calculate detailed usage for each VM - * and aggregates the totals for the entire namespace. - * - * @param {PodMetricsList} podMetricsList The list of pod metrics. - * @param {PodListType} podList The list of pod specs. - * @param {object} context Context object containing necessary parameters for sub-requests. - * @returns {Promise} An object containing aggregated totals and a list of per-VMI usage. - */ -async function calculateAllVmiUsage( - podMetricsList: PodMetricsList, - podList: PodListType, - context: { - proxyURL: string - clusterName: string - namespace: string - token: string - } -) { - // Use a Map for efficient O(1) lookups of pods by their name. - const podMap = new Map(podList.items.map((pod) => [pod.metadata.name, pod])) - - // Map each pod metric to a promise that resolves with its full usage details. - const usagePromises = podMetricsList.items.map((metric) => - calculateSingleVmiUsage(metric, podMap.get(metric.metadata.name), context) - ) - - const results = await Promise.allSettled(usagePromises) - - // --- Aggregate the results --- - const vmisUsage: VmiUsageType[] = [] - let sumCpuUsageInNs = 0 // in millicores - let sumMemoryUsageInNs = 0 // in MiB - let sumStorageUsageInNs = 0 // in GiB (Note: your original was in GiB) - - for (const result of results) { - if (result.status === 'fulfilled' && result.value) { - const vmiUsage = result.value - vmisUsage.push(vmiUsage) - sumCpuUsageInNs += vmiUsage.cpu.usage - sumMemoryUsageInNs += vmiUsage.memory.usage - sumStorageUsageInNs += vmiUsage.storage.usage - } else if (result.status === 'rejected') { - logger.error({ msg: 'Failed to process a VM metric', error: result.reason as unknown }) - } - } - - return { - cpu: sumCpuUsageInNs, - memory: sumMemoryUsageInNs, - storage: sumStorageUsageInNs, - vmisUsage, - } -} - -/** - * @description Calculates the resource usage for a single Virtual Machine Instance. - * This function is designed to be called concurrently. - * @param {PodMetrics} metric The metrics for a single virt-launcher pod. - * @param {Pod | undefined} pod The full pod spec, if found. - * @param {object} context Context object for making further API calls. - * @returns {Promise} A promise that resolves to the VMI's usage data, or null if it cannot be processed. - */ -async function calculateSingleVmiUsage( - metric: PodMetric, - pod: PodType | undefined, - context: { - proxyURL: string - clusterName: string - namespace: string - token: string - } -): Promise { - const vmiName = metric?.metadata?.labels['vm.kubevirt.io/name'] - // If the pod spec is missing or it's not a VM pod, skip it. - if (!pod || !vmiName) { - return null - } - - // --- Calculate CPU and Memory Usage --- - let podRequestedCPU = 0 - let podRequestedMemory = 0 - for (const c of pod.spec.containers) { - // Assuming these helper functions handle undefined/null inputs gracefully (e.g., return 0) - podRequestedCPU += toMillicores(c.resources?.requests?.cpu) - podRequestedMemory += toMebibytes(c.resources?.requests?.memory) - } - - let podCpuUsage = 0 - let podMemoryUsage = 0 - for (const container of metric.containers) { - podCpuUsage += convertNanocoresToMillicores(container.usage.cpu) - podMemoryUsage += convertKibibytesToMebibytes(container.usage.memory) - } - - // --- Fetch and Calculate Storage Usage --- - const { proxyURL, clusterName, namespace, token } = context - const filesystemUrl = `${proxyURL}/${clusterName}/apis/subresources.kubevirt.io/v1/namespaces/${namespace}/virtualmachineinstances/${vmiName}/filesystemlist` - const filesystem = await jsonRequest(filesystemUrl, token) - - let podStorageUsage = 0 - let podStorageTotal = 0 - if (filesystem?.items) { - for (const item of filesystem.items) { - // Assuming these helpers convert bytes to GiB as in the original code - podStorageUsage += convertBytesToGibibytes(item.usedBytes) - podStorageTotal += convertBytesToGibibytes(item.totalBytes) - } - } - - // --- Assemble final VMI usage object --- - return { - podName: pod.metadata.name, - vmiName: vmiName, - clusterName, - namespace, - cpu: { - requested: Math.round(podRequestedCPU), - usage: Math.round(podCpuUsage), - usagePercent: calUsagePercent(podCpuUsage, podRequestedCPU), - }, - memory: { - requested: Math.round(podRequestedMemory), - usage: Math.round(podMemoryUsage), - usagePercent: calUsagePercent(podMemoryUsage, podRequestedMemory), - }, - storage: { - requested: Math.round(podStorageTotal), - usage: Math.round(podStorageUsage), - usagePercent: calUsagePercent(podStorageUsage, podStorageTotal), - }, - } -} diff --git a/backend-node/test/routes/managedClusterProxy.test.ts b/backend-node/test/routes/managedClusterProxy.test.ts deleted file mode 100644 index acbe5840b2f..00000000000 --- a/backend-node/test/routes/managedClusterProxy.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { managedClusterProxy } from '../../src/routes/managedClusterProxy' -import proxy from 'http2-proxy' -import { jest } from '@jest/globals' -import { TLSSocket } from 'node:tls' -let isHttp2Response: boolean = true -const proxyWeb = proxy.web as jest.Mock -const proxyWs = proxy.ws as jest.Mock -jest.mock('http2-proxy', () => ({ - web: jest.fn((_req, _resOrSocket, _proxyOptions, proxyHandler: (err: Error | null) => void) => - process.nextTick(() => proxyHandler(null)) - ), - ws: jest.fn((_req, _resOrSocket, _head, _proxyOptions, proxyHandler: (err: Error | null) => void) => - process.nextTick(() => proxyHandler(null)) - ), -})) -jest.mock('../../src/lib/token', () => ({ - isHttp2ServerResponse: jest.fn(() => isHttp2Response), - getAuthenticatedToken: jest.fn(() => { - return 'testtoken' - }), -})) -describe('ManagedClusterProxy tests', () => { - const req = { url: '/managedclusterproxy/testcluster/testapi/', headers: {} } as Http2ServerRequest - const res = { destroy: jest.fn() } as unknown as Http2ServerResponse - const socket = { destroy: jest.fn() } as unknown as TLSSocket - const head = Buffer.from('') - beforeEach(() => { - jest.clearAllMocks() - isHttp2Response = true - }) - it('test proxy call to web server', async () => { - isHttp2Response = true - await managedClusterProxy(req, res) - expect(proxyWeb).toHaveBeenCalled() - expect(proxyWs).not.toHaveBeenCalled() - }) - it('test proxy call to socket', async () => { - isHttp2Response = false - await managedClusterProxy(req, socket, head) - expect(proxyWeb).not.toHaveBeenCalled() - expect(proxyWs).toHaveBeenCalled() - }) -}) diff --git a/backend-node/test/routes/metricsProxy.test.ts b/backend-node/test/routes/metricsProxy.test.ts deleted file mode 100644 index 6539faee920..00000000000 --- a/backend-node/test/routes/metricsProxy.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import nock from 'nock' -import { request } from '../mock-request' - -describe('metrics proxy route', function () { - it('Successfully calls prometheus endpoint', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - const res = await request('GET', '/prometheus/query') - expect(res.statusCode).toEqual(200) - }) - it(`Successfully calls observability endpoint`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - const res = await request('GET', '/observability/query') - expect(res.statusCode).toEqual(200) - }) -}) diff --git a/backend-node/test/routes/placementDebug.test.ts b/backend-node/test/routes/placementDebug.test.ts index ce35f3d7049..c8f586d26a6 100644 --- a/backend-node/test/routes/placementDebug.test.ts +++ b/backend-node/test/routes/placementDebug.test.ts @@ -65,5 +65,5 @@ describe(`placementDebug Route`, function () { // Connection errors are handled by the pipeline error callback in placementDebug.ts. // The mock-request test infrastructure doesn't reliably capture pipeline-level errors - // (same limitation as proxy.ts and metricsProxy.ts, which also omit connection error tests). + // (same limitation as proxy.ts, which also omits connection error tests). }) diff --git a/backend-node/test/routes/virtualMachineProxy.test.ts b/backend-node/test/routes/virtualMachineProxy.test.ts deleted file mode 100644 index 98f96d3b254..00000000000 --- a/backend-node/test/routes/virtualMachineProxy.test.ts +++ /dev/null @@ -1,761 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import nock from 'nock' -import { request } from '../mock-request' -import { parseResponseJsonBody } from '../../src/lib/body-parser' -import type { VmiUsageType } from '../../src/lib/virtual-machine' - -describe('Virtual Machine actions', function () { - afterEach(() => { - nock.cleanAll() - }) - - it('should successfully call start action', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .put('/testCluster/apis/subresources.kubevirt.io/v1/namespaces/vmNamespace/virtualmachines/vmName/start') - .reply(200, { - statusCode: 200, - }) - const res = await request('PUT', '/virtualmachines/start', { - managedCluster: 'testCluster', - vmName: 'vmName', - vmNamespace: 'vmNamespace', - }) - expect(res.statusCode).toEqual(200) - }) - - it('should successfully call pause action', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .put('/testCluster/apis/subresources.kubevirt.io/v1/namespaces/vmNamespace/virtualmachineinstances/vmName/pause') - .reply(200, { - statusCode: 200, - }) - const res = await request('PUT', '/virtualmachineinstances/pause', { - managedCluster: 'testCluster', - vmName: 'vmName', - vmNamespace: 'vmNamespace', - }) - expect(res.statusCode).toEqual(200) - }) - - it('should successfully take snapshot action', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .post('/testCluster/apis/snapshot.kubevirt.io/v1beta1/namespaces/vmNamespace/virtualmachinesnapshots') - .reply(200, { - statusCode: 200, - }) - const res = await request('POST', '/virtualmachinesnapshots/create', { - managedCluster: 'testCluster', - vmName: 'vmName', - vmNamespace: 'vmNamespace', - reqBody: { - apiVersion: 'snapshot.kubevirt.io/v1beta1', - kind: 'VirtualMachineSnapshot', - metadata: { - name: 'test-snapshot', - namespace: 'vmNamespace', - ownerReferences: [ - { - apiVersion: 'kubevirt.io/v1', - blockOwnerDeletion: false, - kind: 'VirtualMachine', - name: 'test-vm', - uid: '1234-abcd', - }, - ], - }, - spec: { - source: { - apiGroup: 'kubevirt.io', - kind: 'VirtualMachine', - name: 'test-vm', - }, - }, - }, - }) - expect(res.statusCode).toEqual(200) - }) - - it('should error on start action request', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .put('/testCluster/apis/subresources.kubevirt.io/v1/namespaces/vmNamespace/virtualmachines/vmName/start') - .reply(500) - const res = await request('PUT', '/virtualmachines/start', { - managedCluster: 'testCluster', - vmName: 'vmName', - vmNamespace: 'vmNamespace', - }) - expect(res.statusCode).toEqual(500) - }) - - it('should fail with invalid route and secret', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL).get('/api/v1/namespaces/testCluster/secrets').reply(400, { - statusCode: 400, - apiVersion: 'v1', - kind: 'SecretList', - items: [], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .put('/testCluster/apis/subresources.kubevirt.io/v1/namespaces/vmNamespace/virtualmachines/vmName/start') - .reply(500, { - name: 'Error', - message: 'error testing...', - }) - const res = await request('PUT', '/virtualmachines/start', { - managedCluster: 'testCluster', - vmName: 'vmName', - vmNamespace: 'vmNamespace', - }) - expect(res.statusCode).toEqual(500) - }) - - it('should successfully restore a snapshot', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .post('/testCluster/apis/snapshot.kubevirt.io/v1beta1/namespaces/vmNamespace/virtualmachinerestores') - .reply(200, { - statusCode: 200, - }) - const res = await request('POST', '/virtualmachinerestores', { - managedCluster: 'testCluster', - vmName: 'vmName', - vmNamespace: 'vmNamespace', - reqBody: { - apiVersion: 'snapshot.kubevirt.io/v1beta1', - kind: 'VirtualMachineRestore', - metadata: { - name: 'test-snapshot', - namespace: 'vmNamespace', - ownerReferences: [ - { - apiVersion: 'kubevirt.io/v1', - blockOwnerDeletion: false, - kind: 'VirtualMachine', - name: 'test-vm', - uid: '1234-abcd', - }, - ], - }, - spec: { - target: { - apiGroup: 'kubevirt.io', - kind: 'VirtualMachine', - name: 'test-vm', - }, - }, - }, - }) - expect(res.statusCode).toEqual(200) - }) - - it('should successfully get VM', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .get('/testCluster/apis/kubevirt.io/v1/namespaces/vmNamespace/virtualmachines/vmName') - .reply(200, { - statusCode: 200, - }) - const res = await request('GET', '/virtualmachines/get/testCluster/vmName/vmNamespace') - expect(res.statusCode).toEqual(200) - }) - - it('should successfully get VM snapshot', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .get('/testCluster/apis/snapshot.kubevirt.io/v1beta1/namespaces/vmNamespace/virtualmachinesnapshots/vmName') - .reply(200, { - statusCode: 200, - }) - const res = await request('GET', '/virtualmachinesnapshots/get/testCluster/vmName/vmNamespace') - expect(res.statusCode).toEqual(200) - }) - - it('should successfully delete VM', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .delete('/testCluster/apis/kubevirt.io/v1/namespaces/vmNamespace/virtualmachines/vmName') - .reply(200, { - statusCode: 200, - }) - const res = await request('DELETE', '/virtualmachines/delete', { - managedCluster: 'testCluster', - vmName: 'vmName', - vmNamespace: 'vmNamespace', - reqBody: {}, - }) - expect(res.statusCode).toEqual(200) - }) - - it('should successfully delete VM Snapshot', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/testCluster/secrets') - .reply(200, { - statusCode: 200, - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'vm-actor', - namespace: 'testCluster', - }, - data: { - // test-vm-token - token: 'dGVzdC12bS10b2tlbg==', // notsecret - }, - type: 'Opaque', - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .delete('/testCluster/apis/snapshot.kubevirt.io/v1beta1/namespaces/vmNamespace/virtualmachinesnapshots/vmName') - .reply(200, { - statusCode: 200, - }) - const res = await request('DELETE', '/virtualmachinesnapshots/delete', { - managedCluster: 'testCluster', - vmName: 'vmName', - vmNamespace: 'vmNamespace', - reqBody: {}, - }) - expect(res.statusCode).toEqual(200) - }) -}) - -describe('vmResourceUsageProxy', () => { - beforeEach(() => { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - }) - afterEach(() => { - nock.cleanAll() - }) - it('returns 400 if cluster or namespace param is missing', async () => { - const clusterName = '' - const namespace = 'vmNamespace' - const res = await request('GET', `/vmResourceUsage/cluster/${clusterName}/namespace/${namespace}`) - expect(res.statusCode).toEqual(400) - }) - it('aggregates cpu, memory, storage and returns 200', async () => { - const clusterName = 'testCluster' - const namespace = 'vmNamespace' - const vmiName = 'centos' - const podName = 'centos-launcher' - const vmiName2 = 'fedora' - const podName2 = 'fedora-launcher' - - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .get( - `/${clusterName}/apis/metrics.k8s.io/v1beta1/namespaces/${namespace}/pods?labelSelector=kubevirt.io=virt-launcher` - ) - .reply(200, { - kind: 'PodMetricsList', - apiVersion: 'metrics.k8s.io/v1beta1', - metadata: {}, - items: [ - { - metadata: { - name: podName, - namespace: 'default', - labels: { - 'kubevirt.io': 'virt-launcher', - 'kubevirt.io/created-by': '113966b8-3b80-48cc-92da-71631d06a03f', - 'kubevirt.io/nodeName': 'worker-0-2', - 'network.kubevirt.io/headlessService': 'headless', - 'vm.kubevirt.io/name': vmiName, - }, - }, - timestamp: '2025-06-05T15:57:41Z', - window: '12.508s', - containers: [ - { - name: 'compute', - usage: { - cpu: '6894867n', - memory: '908492Ki', - }, - }, - { - name: 'compute', - usage: { - cpu: '6894867n', - memory: '908492Ki', - }, - }, - ], - }, - { - metadata: { - name: podName2, - namespace: 'default', - labels: { - 'kubevirt.io': 'virt-launcher', - 'kubevirt.io/created-by': '113966b8-3b80-48cc-92da-71631d06a03f', - 'kubevirt.io/nodeName': 'worker-0-2', - 'network.kubevirt.io/headlessService': 'headless', - 'vm.kubevirt.io/name': vmiName2, - }, - }, - timestamp: '2025-06-05T15:57:41Z', - window: '12.508s', - containers: [ - { - name: 'compute', - usage: { - cpu: '6894867n', - memory: '908492Ki', - }, - }, - { - name: 'compute', - usage: { - cpu: '6894867n', - memory: '908492Ki', - }, - }, - ], - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .get(`/${clusterName}/api/v1/namespaces/${namespace}/pods?labelSelector=kubevirt.io=virt-launcher`) - .reply(200, { - kind: 'PodList', - apiVersion: 'v1', - metadata: { - resourceVersion: '56375871', - }, - items: [ - { - metadata: { - name: podName, - }, - spec: { - containers: [ - { - resources: { - requests: { - cpu: '100m', - memory: '2294Mi', - }, - }, - }, - ], - }, - }, - { - metadata: { - name: podName2, - }, - spec: { - containers: [ - { - resources: { - requests: { - cpu: '100m', - memory: '2294Mi', - }, - }, - }, - { - resources: { - requests: { - cpu: '100m', - memory: '2294Mi', - }, - }, - }, - ], - }, - }, - ], - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .get( - `/${clusterName}/apis/subresources.kubevirt.io/v1/namespaces/${namespace}/virtualmachineinstances/${vmiName}/filesystemlist` - ) - .reply(200, { - items: [ - { - diskName: 'vda1', - fileSystemType: 'ext4', - mountPoint: '/', - totalBytes: 32212254720, // 30Gib - usedBytes: 1029201920, // 1Gib - }, - ], - metadata: {}, - }) - nock('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092') - .get( - `/${clusterName}/apis/subresources.kubevirt.io/v1/namespaces/${namespace}/virtualmachineinstances/${vmiName2}/filesystemlist` - ) - .reply(200, { - items: [ - { - diskName: 'vda1', - fileSystemType: 'ext4', - mountPoint: '/', - totalBytes: 42949672960, //40Gib - usedBytes: 5368709120, // 5Gib - }, - ], - metadata: {}, - }) - - const response = { - cpu: 28, // millicores - memory: 3548, // MiB - storage: 6, // GiB - vmisUsage: [ - { - cpu: { - requested: 100, - usage: 14, - usagePercent: 14, - }, - memory: { - requested: 2294, - usage: 1774, - usagePercent: 77, - }, - storage: { - requested: 30, - usage: 1, - usagePercent: 3, - }, - podName: podName, - vmiName: vmiName, - namespace, - clusterName, - }, - { - cpu: { - requested: 200, - usage: 14, - usagePercent: 7, - }, - memory: { - requested: 4588, - usage: 1774, - usagePercent: 39, - }, - storage: { - requested: 40, - usage: 5, - usagePercent: 13, - }, - podName: podName2, - vmiName: vmiName2, - namespace, - clusterName, - }, - ], - } - - const res = await request('GET', `/vmResourceUsage/cluster/${clusterName}/namespace/${namespace}`) - expect(res.statusCode).toEqual(200) - const result = await parseResponseJsonBody(res) - expect(result.cpu).toEqual(response.cpu) - expect(result.memory).toEqual(response.memory) - expect(result.storage).toEqual(response.storage) - - const centosUsage = (result['vmisUsage'] as VmiUsageType[]).find((vu) => vu.vmiName === vmiName) - expect(centosUsage.cpu.requested).toEqual(response.vmisUsage[0].cpu.requested) - expect(centosUsage.cpu.usage).toEqual(response.vmisUsage[0].cpu.usage) - expect(centosUsage.cpu.usagePercent).toEqual(response.vmisUsage[0].cpu.usagePercent) - expect(centosUsage.memory.usagePercent).toEqual(response.vmisUsage[0].memory.usagePercent) - expect(centosUsage.storage.usagePercent).toEqual(response.vmisUsage[0].storage.usagePercent) - expect(centosUsage.namespace).toEqual(response.vmisUsage[0].namespace) - expect(centosUsage.clusterName).toEqual(response.vmisUsage[0].clusterName) - - const fedoraUsage = (result['vmisUsage'] as VmiUsageType[]).find((vu) => vu.vmiName === vmiName2) - expect(fedoraUsage.cpu.requested).toEqual(response.vmisUsage[1].cpu.requested) - expect(fedoraUsage.cpu.usage).toEqual(response.vmisUsage[1].cpu.usage) - expect(fedoraUsage.cpu.usagePercent).toEqual(response.vmisUsage[1].cpu.usagePercent) - expect(fedoraUsage.memory.usagePercent).toEqual(response.vmisUsage[1].memory.usagePercent) - expect(fedoraUsage.storage.usagePercent).toEqual(response.vmisUsage[1].storage.usagePercent) - }) -}) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 0bd9e503b46..272e38d5281 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -19,6 +19,10 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/server` | TLS listener, chi mux, `/multicloud` probe aliases | | `internal/proxy` | Reverse proxy to `NODE_BACKEND_URL` (original path, including `/multicloud`) | | `internal/k8sproxy` | Hub kube-apiserver passthrough for `/api`, `/apis`, `/version` (user Bearer token) | +| `internal/clusterproxy` | cluster-proxy-addon-user URL discovery (MCE target namespace / env overrides) | +| `internal/mcproxy` | Managed-cluster reverse proxy (`/managedclusterproxy/*`, including WebSocket) | +| `internal/metricsproxy` | Prometheus and observability query reverse proxies | +| `internal/vmproxy` | VirtualMachine GET helpers, actions, and resource-usage aggregation | | `internal/health` | `/ping`, `/livenessProbe` (Go only), `/readinessProbe` (Go + sidecar `/ping`) | | `internal/config` | `.env` + `config/` directory (filename = key) | | `internal/auth` | Cookie/Bearer, SA token/CA, TokenReview helper | @@ -53,6 +57,10 @@ Go backend :4000 (TLS / HTTP/2) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) │ (also /multicloud/…) + ├─ ALL /managedclusterproxy/* → cluster-proxy addon (user token; WebSocket) + ├─ GET /prometheus/*, /observability/* → metrics backends (user token) + ├─ /virtualmachines/*, /virtualmachineinstances/*, /virtualmachinesnapshots/*, + │ /virtualmachinerestores, GET /vmResourceUsage/* → managed cluster via addon ├─ GET static assets (/plugin/*, hashed JS/CSS, locales, index.html) └─ everything else (original URL) ──HTTP/1.1──► Node sidecar :4001 │ @@ -68,4 +76,6 @@ Go backend :4000 (TLS / HTTP/2) Go exits 1 at startup if the service-account token is missing (`TOKEN` or `/var/run/secrets/kubernetes.io/serviceaccount/token`). +Migrated proxy routes also read `CLUSTER_PROXY_ADDON_USER_HOST` / `CLUSTER_PROXY_ADDON_USER_ROUTE`, `PROMETHEUS_ROUTE`, `OBSERVABILITY_ROUTE`, and `SERVICE_CA_CERT` from the same `.env`. + `PUBLIC_FOLDER` (default `public`) is the on-disk plugin/SPA tree. Production images copy `frontend/plugins/{acm|mce}/dist` to `/app/public/plugin`. diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index 17f2aa191b2..cc3b4c25fe4 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -11,14 +11,19 @@ import ( "os/signal" "syscall" + "k8s.io/client-go/kubernetes" + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/clusterproxy" "github.com/stolostron/console/backend/internal/config" rbacevents "github.com/stolostron/console/backend/internal/events/rbac" "github.com/stolostron/console/backend/internal/k8sproxy" applog "github.com/stolostron/console/backend/internal/log" + "github.com/stolostron/console/backend/internal/mcproxy" + "github.com/stolostron/console/backend/internal/metricsproxy" "github.com/stolostron/console/backend/internal/server" "github.com/stolostron/console/backend/internal/static" - "k8s.io/client-go/kubernetes" + "github.com/stolostron/console/backend/internal/vmproxy" ) func main() { @@ -80,6 +85,36 @@ func run() error { Production: os.Getenv("NODE_ENV") == "production", }))) + serviceTLS := auth.ServiceTLSConfig(sa) + addonResolver := &clusterproxy.Resolver{ + HostOverride: cfg.ClusterProxyAddonUserHost, + RouteOverride: cfg.ClusterProxyAddonUserRoute, + Hub: restCfg, + } + promURL, err := metricsproxy.ParseTarget(cfg.PrometheusRoute, metricsproxy.DefaultPrometheusURL) + if err != nil { + return err + } + obsURL, err := metricsproxy.ParseTarget(cfg.ObservabilityRoute, metricsproxy.DefaultObservabilityURL) + if err != nil { + return err + } + opts = append(opts, + server.WithManagedClusterProxy(mcproxy.New(mcproxy.Options{ + Resolver: addonResolver, + TLSConfig: serviceTLS, + RESTConfig: restCfg, + })), + server.WithPrometheusProxy(metricsproxy.New(promURL, serviceTLS, "/prometheus")), + server.WithObservabilityProxy(metricsproxy.New(obsURL, serviceTLS, "/observability")), + server.WithVMProxy(vmproxy.New(vmproxy.Options{ + Resolver: addonResolver, + TLSConfig: serviceTLS, + RESTConfig: restCfg, + SAToken: sa.Token, + })), + ) + handler, err := server.Handler(cfg, opts...) if err != nil { return err diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 3cd39e16634..5973a0bcd41 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -39,8 +39,21 @@ func SetServiceAccountDir(dir string) func() { // ServiceAccount holds the in-cluster (or env-fallback) credentials. type ServiceAccount struct { - Token string - CACert []byte + Token string + CACert []byte + ServiceCACert []byte +} + +// StatusError is an HTTP status from hub token validation (GET /api). +type StatusError struct { + Status int +} + +func (e *StatusError) Error() string { + if e == nil { + return "status error" + } + return fmt.Sprintf("token validation status %d", e.Status) } // LoadServiceAccount reads the projected SA files, falling back to TOKEN / CA_CERT. @@ -57,10 +70,26 @@ func LoadServiceAccount(cfg *config.Config) (ServiceAccount, bool) { } } } + serviceCA := readCertFileOrEnv(filepath.Join(serviceAccountBaseDir, "service-ca.crt"), cfg.ServiceCACert) if strings.TrimSpace(token) == "" { return ServiceAccount{}, false } - return ServiceAccount{Token: token, CACert: ca}, true + return ServiceAccount{Token: token, CACert: ca, ServiceCACert: serviceCA}, true +} + +func readCertFileOrEnv(path, base64Env string) []byte { + data, err := os.ReadFile(path) + if err == nil && len(data) > 0 { + return data + } + if base64Env == "" { + return nil + } + decoded, decErr := base64.StdEncoding.DecodeString(base64Env) + if decErr != nil { + return nil + } + return decoded } func readFileOrDefault(path, fallback string) string { @@ -144,11 +173,39 @@ func ValidateUserToken(ctx context.Context, base *rest.Config, token string) err } defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("token validation status %d", resp.StatusCode) + return &StatusError{Status: resp.StatusCode} } return nil } +// RequireToken writes 401 with an empty body when the request has no user token. +func RequireToken(w http.ResponseWriter, r *http.Request) (string, bool) { + token := TokenFromRequest(r) + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + return "", false + } + return token, true +} + +// AuthenticateRequest extracts the user token and validates it with GET /api (Node getAuthenticatedToken). +func AuthenticateRequest(ctx context.Context, base *rest.Config, w http.ResponseWriter, r *http.Request) (string, bool) { + token, ok := RequireToken(w, r) + if !ok { + return "", false + } + if err := ValidateUserToken(ctx, base, token); err != nil { + var se *StatusError + if errors.As(err, &se) && se.Status != 0 { + w.WriteHeader(se.Status) + return "", false + } + w.WriteHeader(http.StatusInternalServerError) + return "", false + } + return token, true +} + // NewTokenReviewer builds a TokenReview client using the service account. func NewTokenReviewer(cfg *config.Config, sa ServiceAccount) (TokenReviewer, error) { restCfg, err := RESTConfig(cfg, sa) diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go index 40b3213f1a1..357e31be42b 100644 --- a/backend/internal/auth/auth_test.go +++ b/backend/internal/auth/auth_test.go @@ -85,7 +85,11 @@ func TestLoadServiceAccount_EnvFallback(t *testing.T) { restore := auth.SetServiceAccountDir(dir) defer restore() - cfg := &config.Config{Token: "env-token", CACert: base64.StdEncoding.EncodeToString([]byte("ca-bytes"))} + cfg := &config.Config{ + Token: "env-token", + CACert: base64.StdEncoding.EncodeToString([]byte("ca-bytes")), + ServiceCACert: base64.StdEncoding.EncodeToString([]byte("svc-ca")), + } sa, ok := auth.LoadServiceAccount(cfg) if !ok { t.Fatal("expected token from env") @@ -96,6 +100,9 @@ func TestLoadServiceAccount_EnvFallback(t *testing.T) { if string(sa.CACert) != "ca-bytes" { t.Fatalf("ca %q", sa.CACert) } + if string(sa.ServiceCACert) != "svc-ca" { + t.Fatalf("service ca %q", sa.ServiceCACert) + } } func TestLoadServiceAccount_FromFiles(t *testing.T) { @@ -106,6 +113,9 @@ func TestLoadServiceAccount_FromFiles(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "ca.crt"), []byte("file-ca"), 0o600); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(dir, "service-ca.crt"), []byte("file-svc-ca"), 0o600); err != nil { + t.Fatal(err) + } restore := auth.SetServiceAccountDir(dir) defer restore() @@ -120,6 +130,9 @@ func TestLoadServiceAccount_FromFiles(t *testing.T) { if string(sa.CACert) != "file-ca" { t.Fatalf("ca %q", sa.CACert) } + if string(sa.ServiceCACert) != "file-svc-ca" { + t.Fatalf("service ca %q", sa.ServiceCACert) + } } func TestLoadServiceAccount_Missing(t *testing.T) { diff --git a/backend/internal/auth/tls.go b/backend/internal/auth/tls.go new file mode 100644 index 00000000000..efd013f97a1 --- /dev/null +++ b/backend/internal/auth/tls.go @@ -0,0 +1,41 @@ +// Copyright Contributors to the Open Cluster Management project + +package auth + +import ( + "crypto/tls" + "crypto/x509" + "os" +) + +// TLSConfigFromCA builds a TLS config from a PEM CA bundle. +// When includeSystemRoots is true, the system pool is used as well (Node non-production service agent). +func TLSConfigFromCA(caCert []byte, includeSystemRoots bool) *tls.Config { + tlsCfg := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + var pool *x509.CertPool + if includeSystemRoots { + system, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } else { + pool = system + } + } else { + pool = x509.NewCertPool() + } + if len(caCert) > 0 { + pool.AppendCertsFromPEM(caCert) + } else if !includeSystemRoots { + tlsCfg.InsecureSkipVerify = true //nolint:gosec // matches RESTConfig when CA missing + } + tlsCfg.RootCAs = pool + return tlsCfg +} + +// ServiceTLSConfig trusts SERVICE_CA_CERT / service-ca.crt. Local development also trusts system roots +// so OpenShift Routes verify, matching Node getServiceAgent(). +func ServiceTLSConfig(sa ServiceAccount) *tls.Config { + return TLSConfigFromCA(sa.ServiceCACert, os.Getenv("NODE_ENV") != "production") +} diff --git a/backend/internal/auth/tls_test.go b/backend/internal/auth/tls_test.go new file mode 100644 index 00000000000..a74fd1e0eee --- /dev/null +++ b/backend/internal/auth/tls_test.go @@ -0,0 +1,65 @@ +// Copyright Contributors to the Open Cluster Management project + +package auth_test + +import ( + "crypto/tls" + "testing" + + "github.com/stolostron/console/backend/internal/auth" +) + +func TestTLSConfigFromCA_NoCAInsecureWithoutSystemRoots(t *testing.T) { + cfg := auth.TLSConfigFromCA(nil, false) + if !cfg.InsecureSkipVerify { + t.Fatal("expected InsecureSkipVerify without CA or system roots") + } + if cfg.MinVersion != tls.VersionTLS12 { + t.Fatalf("MinVersion=%#x", cfg.MinVersion) + } + if cfg.RootCAs == nil { + t.Fatal("expected non-nil cert pool") + } +} + +func TestTLSConfigFromCA_WithCADoesNotSkipVerify(t *testing.T) { + // Invalid PEM still counts as "CA provided" and must not enable skip-verify. + cfg := auth.TLSConfigFromCA([]byte("not-a-pem-bundle"), false) + if cfg.InsecureSkipVerify { + t.Fatal("expected verify when CA bytes are present") + } +} + +func TestTLSConfigFromCA_SystemRootsWithoutExplicitCA(t *testing.T) { + cfg := auth.TLSConfigFromCA(nil, true) + if cfg.InsecureSkipVerify { + t.Fatal("expected system roots instead of skip-verify") + } + if cfg.RootCAs == nil { + t.Fatal("expected cert pool with system roots") + } +} + +func TestServiceTLSConfig_DevelopmentUsesSystemRoots(t *testing.T) { + t.Setenv("NODE_ENV", "development") + cfg := auth.ServiceTLSConfig(auth.ServiceAccount{}) + if cfg.InsecureSkipVerify { + t.Fatal("development should trust system roots when service CA is empty") + } +} + +func TestServiceTLSConfig_ProductionWithoutCAInsecure(t *testing.T) { + t.Setenv("NODE_ENV", "production") + cfg := auth.ServiceTLSConfig(auth.ServiceAccount{}) + if !cfg.InsecureSkipVerify { + t.Fatal("production without service CA should skip verify") + } +} + +func TestServiceTLSConfig_ProductionWithServiceCA(t *testing.T) { + t.Setenv("NODE_ENV", "production") + cfg := auth.ServiceTLSConfig(auth.ServiceAccount{ServiceCACert: []byte("service-ca-pem")}) + if cfg.InsecureSkipVerify { + t.Fatal("production with service CA should verify") + } +} diff --git a/backend/internal/clusterproxy/resolver.go b/backend/internal/clusterproxy/resolver.go new file mode 100644 index 00000000000..55ed80b1466 --- /dev/null +++ b/backend/internal/clusterproxy/resolver.go @@ -0,0 +1,163 @@ +// Copyright Contributors to the Open Cluster Management project + +package clusterproxy + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/hubresources" + applog "github.com/stolostron/console/backend/internal/log" +) + +const ( + DefaultNamespace = "multicluster-engine" + inClusterPort = "9092" + routePort = "443" + servicePrefix = "cluster-proxy-addon-user" +) + +// Resolver finds the cluster-proxy-addon-user endpoint (env override or MCE target namespace). +type Resolver struct { + HostOverride string + RouteOverride string + // Target, when set, is the full addon URL (tests). + Target *url.URL + Hub *rest.Config + // Dynamic, when set, is used instead of building a client from Hub (tests). + Dynamic dynamic.Interface + // Client is used with Hub for dynamic.NewForConfigAndClient (tests). + Client *http.Client + + mu sync.Mutex + cachedNS string + haveCache bool +} + +// ServiceHost is the in-cluster DNS name for the addon user proxy. +func ServiceHost(namespace string) string { + if namespace == "" { + namespace = DefaultNamespace + } + return servicePrefix + "." + namespace + ".svc.cluster.local" +} + +// HostPort is used by the managed-cluster reverse proxy (CLUSTER_PROXY_ADDON_USER_HOST or svc:9092). +func (r *Resolver) HostPort(ctx context.Context) (host, port string) { + if r != nil && r.Target != nil { + return hostnamePort(r.Target) + } + if r != nil && r.HostOverride != "" { + return r.HostOverride, routePort + } + return ServiceHost(r.namespace(ctx)), inClusterPort +} + +// ProxyURL is the addon base URL for ReverseProxy (scheme + host + port). +func (r *Resolver) ProxyURL(ctx context.Context) (*url.URL, error) { + if r != nil && r.Target != nil { + return r.Target, nil + } + host, port := r.HostPort(ctx) + return TargetURL(host, port) +} + +// URL is used by VM helpers (CLUSTER_PROXY_ADDON_USER_ROUTE or https://svc:9092). +func (r *Resolver) URL(ctx context.Context) (*url.URL, error) { + if r != nil && r.Target != nil { + return r.Target, nil + } + if r != nil && r.RouteOverride != "" { + return url.Parse(r.RouteOverride) + } + host, port := r.HostPortWithoutHostOverride(ctx) + return url.Parse("https://" + host + ":" + port) +} + +// HostPortWithoutHostOverride ignores CLUSTER_PROXY_ADDON_USER_HOST so VM URL matching Node +// still uses the in-cluster service when only the Route env is unset. +func (r *Resolver) HostPortWithoutHostOverride(ctx context.Context) (host, port string) { + return ServiceHost(r.namespace(ctx)), inClusterPort +} + +func (r *Resolver) namespace(ctx context.Context) string { + if r == nil { + return DefaultNamespace + } + r.mu.Lock() + defer r.mu.Unlock() + if r.haveCache { + return r.cachedNS + } + ns := r.fetchNamespace(ctx) + r.cachedNS = ns + r.haveCache = true + return ns +} + +func (r *Resolver) fetchNamespace(ctx context.Context) string { + dc, err := r.dynamicClient() + if err != nil { + applog.Logger().Error("mce dynamic client", "error", err) + return DefaultNamespace + } + ns, err := hubresources.MCETargetNamespace(ctx, dc) + if err != nil { + applog.Logger().Error("Error getting MultiClusterEngine", "error", err) + return DefaultNamespace + } + if ns == "" { + return DefaultNamespace + } + return ns +} + +func (r *Resolver) dynamicClient() (dynamic.Interface, error) { + if r != nil && r.Dynamic != nil { + return r.Dynamic, nil + } + if r == nil || r.Hub == nil { + return nil, fmt.Errorf("hub rest config is required") + } + if r.Client != nil { + return dynamic.NewForConfigAndClient(r.Hub, r.Client) + } + return dynamic.NewForConfig(r.Hub) +} + +// TargetURL builds https://host:port for ReverseProxy SetURL. +func TargetURL(host, port string) (*url.URL, error) { + if host == "" { + return nil, fmt.Errorf("cluster proxy host is empty") + } + if port == "" { + port = inClusterPort + } + return url.Parse("https://" + netJoinHostPort(host, port)) +} + +func netJoinHostPort(host, port string) string { + if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") { + return "[" + host + "]:" + port + } + return host + ":" + port +} + +func hostnamePort(u *url.URL) (host, port string) { + host = u.Hostname() + port = u.Port() + if port != "" { + return host, port + } + if u.Scheme == "http" { + return host, "80" + } + return host, "443" +} diff --git a/backend/internal/clusterproxy/resolver_test.go b/backend/internal/clusterproxy/resolver_test.go new file mode 100644 index 00000000000..4ec7df37b29 --- /dev/null +++ b/backend/internal/clusterproxy/resolver_test.go @@ -0,0 +1,92 @@ +// Copyright Contributors to the Open Cluster Management project + +package clusterproxy_test + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + + "github.com/stolostron/console/backend/internal/clusterproxy" +) + +func mceObject(targetNamespace string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "multicluster.openshift.io", + Version: "v1", + Kind: "MultiClusterEngine", + }) + obj.SetName("engine") + if err := unstructured.SetNestedField(obj.Object, targetNamespace, "spec", "targetNamespace"); err != nil { + panic(err) + } + return obj +} + +func TestServiceHost(t *testing.T) { + if got := clusterproxy.ServiceHost(""); got != "cluster-proxy-addon-user.multicluster-engine.svc.cluster.local" { + t.Fatalf("empty ns: %s", got) + } + if got := clusterproxy.ServiceHost("mce"); got != "cluster-proxy-addon-user.mce.svc.cluster.local" { + t.Fatalf("mce ns: %s", got) + } +} + +func TestHostPortOverride(t *testing.T) { + r := &clusterproxy.Resolver{HostOverride: "addon.example.com"} + host, port := r.HostPort(context.Background()) + if host != "addon.example.com" || port != "443" { + t.Fatalf("got %s:%s", host, port) + } +} + +func TestURLRouteOverride(t *testing.T) { + r := &clusterproxy.Resolver{RouteOverride: "https://addon.example.com"} + u, err := r.URL(context.Background()) + if err != nil { + t.Fatal(err) + } + if u.String() != "https://addon.example.com" { + t.Fatalf("url %s", u) + } +} + +func TestNamespaceFromMCE(t *testing.T) { + dc := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"}: "MultiClusterEngineList", + }, mceObject("custom-mce")) + + r := &clusterproxy.Resolver{Dynamic: dc} + host, port := r.HostPort(context.Background()) + if host != "cluster-proxy-addon-user.custom-mce.svc.cluster.local" || port != "9092" { + t.Fatalf("got %s:%s", host, port) + } + // cached + host2, _ := r.HostPort(context.Background()) + if host2 != host { + t.Fatal("expected cache") + } +} + +func TestNamespaceFallbackOnError(t *testing.T) { + r := &clusterproxy.Resolver{} + host, port := r.HostPort(context.Background()) + if host != clusterproxy.ServiceHost(clusterproxy.DefaultNamespace) || port != "9092" { + t.Fatalf("got %s:%s", host, port) + } +} + +func TestTargetURL(t *testing.T) { + u, err := clusterproxy.TargetURL("addon.example.com", "443") + if err != nil { + t.Fatal(err) + } + if u.Scheme != "https" || u.Host != "addon.example.com:443" { + t.Fatalf("url %s", u) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 90d656ec28a..b64915f0b1d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -18,17 +18,21 @@ const debounce = time.Second // Config is process configuration loaded from env, .env, and the config/ directory. type Config struct { - Port string - NodeBackendURL string - ConfigDir string - CertsDir string - EnvFile string - ClusterAPIURL string - Token string - CACert string - ServiceCACert string - LogLevel string - PublicFolder string + Port string + NodeBackendURL string + ConfigDir string + CertsDir string + EnvFile string + ClusterAPIURL string + Token string + CACert string + ServiceCACert string + LogLevel string + PrometheusRoute string + ObservabilityRoute string + ClusterProxyAddonUserHost string + ClusterProxyAddonUserRoute string + PublicFolder string mu sync.RWMutex settings map[string]string @@ -47,18 +51,22 @@ func Load() *Config { _ = godotenv.Load(envFile) cfg := &Config{ - Port: envOr("PORT", "4000"), - NodeBackendURL: envOr("NODE_BACKEND_URL", "https://127.0.0.1:4001"), - ConfigDir: envOr("CONFIG_DIR", "config"), - CertsDir: envOr("CERTS_DIR", "certs"), - EnvFile: envFile, - ClusterAPIURL: os.Getenv("CLUSTER_API_URL"), - Token: os.Getenv("TOKEN"), - CACert: os.Getenv("CA_CERT"), - ServiceCACert: os.Getenv("SERVICE_CA_CERT"), - LogLevel: envOr("LOG_LEVEL", "debug"), - PublicFolder: envOr("PUBLIC_FOLDER", "public"), - settings: map[string]string{}, + Port: envOr("PORT", "4000"), + NodeBackendURL: envOr("NODE_BACKEND_URL", "https://127.0.0.1:4001"), + ConfigDir: envOr("CONFIG_DIR", "config"), + CertsDir: envOr("CERTS_DIR", "certs"), + EnvFile: envFile, + ClusterAPIURL: os.Getenv("CLUSTER_API_URL"), + Token: os.Getenv("TOKEN"), + CACert: os.Getenv("CA_CERT"), + ServiceCACert: os.Getenv("SERVICE_CA_CERT"), + LogLevel: envOr("LOG_LEVEL", "debug"), + PrometheusRoute: os.Getenv("PROMETHEUS_ROUTE"), + ObservabilityRoute: os.Getenv("OBSERVABILITY_ROUTE"), + ClusterProxyAddonUserHost: os.Getenv("CLUSTER_PROXY_ADDON_USER_HOST"), + ClusterProxyAddonUserRoute: os.Getenv("CLUSTER_PROXY_ADDON_USER_ROUTE"), + PublicFolder: envOr("PUBLIC_FOLDER", "public"), + settings: map[string]string{}, } _ = cfg.ReloadSettings() return cfg diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 7024efbc62d..6ac9e44973d 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -72,6 +72,37 @@ func TestLoad_FromEnvFile(t *testing.T) { } } +func TestLoad_ProxyEnvVars(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) + t.Setenv("PORT", "4100") + t.Setenv("NODE_BACKEND_URL", "https://127.0.0.1:4101") + t.Setenv("PROMETHEUS_ROUTE", "https://prom.example") + t.Setenv("OBSERVABILITY_ROUTE", "https://obs.example") + t.Setenv("CLUSTER_PROXY_ADDON_USER_HOST", "proxy.example") + t.Setenv("CLUSTER_PROXY_ADDON_USER_ROUTE", "https://proxy.example") + + cfg := config.Load() + if cfg.Port != "4100" { + t.Fatalf("Port=%q", cfg.Port) + } + if cfg.NodeBackendURL != "https://127.0.0.1:4101" { + t.Fatalf("NodeBackendURL=%q", cfg.NodeBackendURL) + } + if cfg.PrometheusRoute != "https://prom.example" { + t.Fatalf("PrometheusRoute=%q", cfg.PrometheusRoute) + } + if cfg.ObservabilityRoute != "https://obs.example" { + t.Fatalf("ObservabilityRoute=%q", cfg.ObservabilityRoute) + } + if cfg.ClusterProxyAddonUserHost != "proxy.example" { + t.Fatalf("ClusterProxyAddonUserHost=%q", cfg.ClusterProxyAddonUserHost) + } + if cfg.ClusterProxyAddonUserRoute != "https://proxy.example" { + t.Fatalf("ClusterProxyAddonUserRoute=%q", cfg.ClusterProxyAddonUserRoute) + } +} + func TestLoad_PublicFolder(t *testing.T) { dir := t.TempDir() t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) @@ -82,6 +113,54 @@ func TestLoad_PublicFolder(t *testing.T) { } } +func TestReloadSettings_MissingDir(t *testing.T) { + cfg := &config.Config{ConfigDir: filepath.Join(t.TempDir(), "missing")} + if err := cfg.ReloadSettings(); err != nil { + t.Fatal(err) + } +} + +func TestReloadSettings_UnsetsRemovedKeys(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "LOG_LEVEL") + if err := os.WriteFile(path, []byte("debug"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.Config{ConfigDir: dir} + if err := cfg.ReloadSettings(); err != nil { + t.Fatal(err) + } + if os.Getenv("LOG_LEVEL") != "debug" { + t.Fatalf("LOG_LEVEL=%q", os.Getenv("LOG_LEVEL")) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := cfg.ReloadSettings(); err != nil { + t.Fatal(err) + } + if os.Getenv("LOG_LEVEL") != "" { + t.Fatalf("LOG_LEVEL=%q want unset", os.Getenv("LOG_LEVEL")) + } +} + +func TestSettings_ReturnsCopy(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "feature-flag") + if err := os.WriteFile(path, []byte("on"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.Config{ConfigDir: dir} + if err := cfg.ReloadSettings(); err != nil { + t.Fatal(err) + } + got := cfg.Settings() + got["feature-flag"] = "off" + if cfg.Settings()["feature-flag"] != "on" { + t.Fatalf("settings mutated: %q", cfg.Settings()["feature-flag"]) + } +} + func TestWatch_ReloadsOnChange(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "LOG_LEVEL") diff --git a/backend/internal/hubresources/hubresources.go b/backend/internal/hubresources/hubresources.go new file mode 100644 index 00000000000..7c95121f638 --- /dev/null +++ b/backend/internal/hubresources/hubresources.go @@ -0,0 +1,84 @@ +// Copyright Contributors to the Open Cluster Management project + +package hubresources + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +var ( + mceGVR = schema.GroupVersionResource{ + Group: "multicluster.openshift.io", + Version: "v1", + Resource: "multiclusterengines", + } + mchGVR = schema.GroupVersionResource{ + Group: "operator.open-cluster-management.io", + Version: "v1", + Resource: "multiclusterhubs", + } +) + +const fineGrainedRBACComponent = "fine-grained-rbac" + +// MCETargetNamespace returns spec.targetNamespace from the first MultiClusterEngine. +func MCETargetNamespace(ctx context.Context, client dynamic.Interface) (string, error) { + if client == nil { + return "", fmt.Errorf("kubernetes dynamic client is required") + } + list, err := client.Resource(mceGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return "", err + } + if len(list.Items) == 0 { + return "", nil + } + ns, found, err := unstructured.NestedString(list.Items[0].Object, "spec", "targetNamespace") + if err != nil { + return "", err + } + if !found { + return "", nil + } + return ns, nil +} + +// MCHFineGrainedRBAC reports whether the first MulticlusterHub enables fine-grained-rbac. +func MCHFineGrainedRBAC(ctx context.Context, client dynamic.Interface) (bool, error) { + if client == nil { + return false, fmt.Errorf("kubernetes dynamic client is required") + } + list, err := client.Resource(mchGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return false, err + } + if len(list.Items) == 0 { + return false, nil + } + components, found, err := unstructured.NestedSlice(list.Items[0].Object, "spec", "overrides", "components") + if err != nil { + return false, err + } + if !found { + return false, nil + } + for _, raw := range components { + component, ok := raw.(map[string]interface{}) + if !ok { + continue + } + name, _ := component["name"].(string) + if name != fineGrainedRBACComponent { + continue + } + enabled, _ := component["enabled"].(bool) + return enabled, nil + } + return false, nil +} diff --git a/backend/internal/hubresources/hubresources_test.go b/backend/internal/hubresources/hubresources_test.go new file mode 100644 index 00000000000..4c358b5fbcf --- /dev/null +++ b/backend/internal/hubresources/hubresources_test.go @@ -0,0 +1,101 @@ +// Copyright Contributors to the Open Cluster Management project + +package hubresources_test + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + + "github.com/stolostron/console/backend/internal/hubresources" +) + +func mceObject(targetNamespace string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "multicluster.openshift.io", + Version: "v1", + Kind: "MultiClusterEngine", + }) + obj.SetName("engine") + if err := unstructured.SetNestedField(obj.Object, targetNamespace, "spec", "targetNamespace"); err != nil { + panic(err) + } + return obj +} + +func mchObject(fineGrainedEnabled bool) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "operator.open-cluster-management.io", + Version: "v1", + Kind: "MultiClusterHub", + }) + obj.SetName("hub") + components := []interface{}{ + map[string]interface{}{ + "name": "fine-grained-rbac", + "enabled": fineGrainedEnabled, + }, + } + if err := unstructured.SetNestedSlice(obj.Object, components, "spec", "overrides", "components"); err != nil { + panic(err) + } + return obj +} + +func TestMCETargetNamespace(t *testing.T) { + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"}: "MultiClusterEngineList", + }, mceObject("custom-mce")) + ns, err := hubresources.MCETargetNamespace(context.Background(), client) + if err != nil { + t.Fatal(err) + } + if ns != "custom-mce" { + t.Fatalf("namespace %q", ns) + } +} + +func TestMCETargetNamespace_EmptyList(t *testing.T) { + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"}: "MultiClusterEngineList", + }) + ns, err := hubresources.MCETargetNamespace(context.Background(), client) + if err != nil { + t.Fatal(err) + } + if ns != "" { + t.Fatalf("namespace %q", ns) + } +} + +func TestMCHFineGrainedRBAC_Enabled(t *testing.T) { + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "operator.open-cluster-management.io", Version: "v1", Resource: "multiclusterhubs"}: "MultiClusterHubList", + }, mchObject(true)) + ok, err := hubresources.MCHFineGrainedRBAC(context.Background(), client) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected enabled") + } +} + +func TestMCHFineGrainedRBAC_Disabled(t *testing.T) { + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "operator.open-cluster-management.io", Version: "v1", Resource: "multiclusterhubs"}: "MultiClusterHubList", + }, mchObject(false)) + ok, err := hubresources.MCHFineGrainedRBAC(context.Background(), client) + if err != nil { + t.Fatal(err) + } + if ok { + t.Fatal("expected disabled") + } +} diff --git a/backend/internal/mcproxy/mcproxy.go b/backend/internal/mcproxy/mcproxy.go new file mode 100644 index 00000000000..0e50b1e3dbd --- /dev/null +++ b/backend/internal/mcproxy/mcproxy.go @@ -0,0 +1,102 @@ +// Copyright Contributors to the Open Cluster Management project + +package mcproxy + +import ( + "context" + "crypto/tls" + "errors" + "net/http" + "net/http/httputil" + "strings" + "time" + + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/clusterproxy" + applog "github.com/stolostron/console/backend/internal/log" + "github.com/stolostron/console/backend/internal/server" +) + +// Options configure the managed-cluster reverse proxy. +type Options struct { + Resolver *clusterproxy.Resolver + TLSConfig *tls.Config + RESTConfig *rest.Config + // Validate, if set, replaces GET /api token validation (tests). + Validate func(ctx context.Context, token string) error +} + +// New proxies /managedclusterproxy// to the cluster-proxy addon. +func New(opts Options) http.Handler { + transport := &http.Transport{ + TLSClientConfig: opts.TLSConfig, + ForceAttemptHTTP2: false, // HTTP/1.1 so WebSocket upgrades work + ResponseHeaderTimeout: 0, + } + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + target, err := opts.Resolver.ProxyURL(pr.In.Context()) + if err != nil { + return + } + stripped := server.StripMulticloud(pr.In.URL.Path) + pr.SetURL(target) + pr.Out.URL.Path = rewritePath(stripped) + pr.Out.URL.RawQuery = pr.In.URL.RawQuery + host := target.Hostname() + pr.Out.Host = host + token := auth.TokenFromRequest(pr.In) + pr.Out.Header.Set("Authorization", "Bearer "+token) + pr.Out.Header.Set("Origin", "https://"+host) + }, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + applog.Logger().Error("managed cluster proxy", "error", err) + w.WriteHeader(http.StatusInternalServerError) + }, + Transport: transport, + FlushInterval: -1 * time.Millisecond, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !authorize(opts, w, r) { + return + } + rp.ServeHTTP(w, r) + }) +} + +func authorize(opts Options, w http.ResponseWriter, r *http.Request) bool { + if opts.Validate != nil { + token, ok := auth.RequireToken(w, r) + if !ok { + return false + } + if err := opts.Validate(r.Context(), token); err != nil { + var se *auth.StatusError + if errors.As(err, &se) && se.Status != 0 { + w.WriteHeader(se.Status) + return false + } + w.WriteHeader(http.StatusInternalServerError) + return false + } + return true + } + _, ok := auth.AuthenticateRequest(r.Context(), opts.RESTConfig, w, r) + return ok +} + +func rewritePath(stripped string) string { + trimmed := strings.TrimPrefix(stripped, "/") + parts := strings.Split(trimmed, "/") + if len(parts) < 2 { + return "/" + } + cluster := parts[1] + apiPath := strings.Join(parts[2:], "/") + if apiPath == "" { + return "/" + cluster + "/" + } + return "/" + cluster + "/" + apiPath +} diff --git a/backend/internal/mcproxy/mcproxy_test.go b/backend/internal/mcproxy/mcproxy_test.go new file mode 100644 index 00000000000..ce1d30bc133 --- /dev/null +++ b/backend/internal/mcproxy/mcproxy_test.go @@ -0,0 +1,182 @@ +// Copyright Contributors to the Open Cluster Management project + +package mcproxy_test + +import ( + "context" + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/clusterproxy" + "github.com/stolostron/console/backend/internal/mcproxy" +) + +func newHandler(t *testing.T, upstream http.Handler) http.Handler { + t.Helper() + up := httptest.NewTLSServer(upstream) + t.Cleanup(up.Close) + target, err := url.Parse(up.URL) + if err != nil { + t.Fatal(err) + } + return mcproxy.New(mcproxy.Options{ + Resolver: &clusterproxy.Resolver{Target: target}, + TLSConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test server + Validate: func(context.Context, string) error { return nil }, + }) +} + +func TestUnauthorizedWithoutToken(t *testing.T) { + h := newHandler(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("upstream should not be called") + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + resp, err := ts.Client().Get(ts.URL + "/managedclusterproxy/c1/api/v1/pods") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestRewritesPathAndSetsHeaders(t *testing.T) { + var capturedPath, capturedQuery, capturedAuth, capturedHost, capturedOrigin, capturedMethod string + h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.RawQuery + capturedAuth = r.Header.Get("Authorization") + capturedHost = r.Host + capturedOrigin = r.Header.Get("Origin") + capturedMethod = r.Method + w.Header().Set("X-Upstream", "yes") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/managedclusterproxy/testcluster/api/v1/pods?watch=true", nil) + req.Header.Set("Authorization", "Bearer user-token") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d body %s", resp.StatusCode, body) + } + if capturedPath != "/testcluster/api/v1/pods" { + t.Fatalf("path %q", capturedPath) + } + if capturedQuery != "watch=true" { + t.Fatalf("query %q", capturedQuery) + } + if capturedAuth != "Bearer user-token" { + t.Fatalf("auth %q", capturedAuth) + } + if capturedMethod != http.MethodGet { + t.Fatalf("method %s", capturedMethod) + } + if capturedOrigin == "" || capturedHost == "" { + t.Fatalf("host %q origin %q", capturedHost, capturedOrigin) + } + if string(body) != `{"ok":true}` { + t.Fatalf("body %s", body) + } + if resp.Header.Get("X-Upstream") != "yes" { + t.Fatal("upstream header not forwarded") + } +} + +func TestCookieToken(t *testing.T) { + var capturedAuth string + h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/managedclusterproxy/c1/api", nil) + req.AddCookie(&http.Cookie{Name: auth.AccessTokenCookie, Value: "cookie-token"}) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedAuth != "Bearer cookie-token" { + t.Fatalf("auth %q", capturedAuth) + } +} + +func TestPassesThroughStatus(t *testing.T) { + h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/managedclusterproxy/c1/api", nil) + req.Header.Set("Authorization", "Bearer t") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestValidateFailureStatus(t *testing.T) { + h := mcproxy.New(mcproxy.Options{ + Resolver: &clusterproxy.Resolver{HostOverride: "127.0.0.1"}, + Validate: func(context.Context, string) error { + return &auth.StatusError{Status: http.StatusUnauthorized} + }, + }) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/managedclusterproxy/c1/api", nil) + req.Header.Set("Authorization", "Bearer bad") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestUnreachableUpstream(t *testing.T) { + h := mcproxy.New(mcproxy.Options{ + Resolver: &clusterproxy.Resolver{HostOverride: "127.0.0.1"}, + TLSConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test + Validate: func(context.Context, string) error { return nil }, + }) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/managedclusterproxy/c1/api", nil) + req.Header.Set("Authorization", "Bearer t") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status %d", resp.StatusCode) + } +} diff --git a/backend/internal/metricsproxy/metricsproxy.go b/backend/internal/metricsproxy/metricsproxy.go new file mode 100644 index 00000000000..91f97e89c55 --- /dev/null +++ b/backend/internal/metricsproxy/metricsproxy.go @@ -0,0 +1,94 @@ +// Copyright Contributors to the Open Cluster Management project + +package metricsproxy + +import ( + "crypto/tls" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/server" +) + +var requestHeaders = []string{ + "Accept", + "Accept-Encoding", + "Content-Encoding", + "Content-Length", + "Content-Type", +} + +var responseHeaders = []string{ + "Cache-Control", + "Content-Type", + "Content-Length", + "Content-Encoding", + "Etag", +} + +const ( + DefaultPrometheusURL = "https://prometheus-k8s.openshift-monitoring.svc.cluster.local:9091" + DefaultObservabilityURL = "https://rbac-query-proxy.open-cluster-management-observability.svc.cluster.local:8443" +) + +// New returns a ReverseProxy that rewrites /prometheus or /observability to /api/v1 on target. +func New(target *url.URL, tlsConfig *tls.Config, prefix string) http.Handler { + transport := &http.Transport{ + TLSClientConfig: tlsConfig, + ForceAttemptHTTP2: true, + ResponseHeaderTimeout: 0, + } + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + token := auth.TokenFromRequest(pr.In) + stripped := server.StripMulticloud(pr.In.URL.Path) + stripped = strings.ReplaceAll(stripped, prefix, "/api/v1") + pr.SetURL(target) + pr.Out.URL.Path = stripped + pr.Out.URL.RawQuery = pr.In.URL.RawQuery + pr.Out.Host = target.Host + pr.Out.Header = http.Header{} + for _, name := range requestHeaders { + if v := pr.In.Header.Get(name); v != "" { + pr.Out.Header.Set(name, v) + } + } + pr.Out.Header.Set("Authorization", "Bearer "+token) + }, + ModifyResponse: filterResponseHeaders, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) { + w.WriteHeader(http.StatusBadGateway) + }, + Transport: transport, + FlushInterval: -1 * time.Millisecond, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.RequireToken(w, r); !ok { + return + } + rp.ServeHTTP(w, r) + }) +} + +func filterResponseHeaders(resp *http.Response) error { + filtered := http.Header{} + for _, name := range responseHeaders { + for _, v := range resp.Header.Values(name) { + filtered.Add(name, v) + } + } + resp.Header = filtered + return nil +} + +// ParseTarget uses override when set, otherwise the in-cluster default. +func ParseTarget(override, fallback string) (*url.URL, error) { + if override == "" { + override = fallback + } + return url.Parse(override) +} diff --git a/backend/internal/metricsproxy/metricsproxy_test.go b/backend/internal/metricsproxy/metricsproxy_test.go new file mode 100644 index 00000000000..77f2e0d1948 --- /dev/null +++ b/backend/internal/metricsproxy/metricsproxy_test.go @@ -0,0 +1,177 @@ +// Copyright Contributors to the Open Cluster Management project + +package metricsproxy_test + +import ( + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/metricsproxy" +) + +func newTestHandler(t *testing.T, prefix string, upstream http.Handler) http.Handler { + t.Helper() + up := httptest.NewServer(upstream) + t.Cleanup(up.Close) + target, err := url.Parse(up.URL) + if err != nil { + t.Fatal(err) + } + tlsCfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // test + return metricsproxy.New(target, tlsCfg, prefix) +} + +func TestUnauthorizedWithoutToken(t *testing.T) { + h := newTestHandler(t, "/prometheus", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("upstream should not be called") + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + resp, err := ts.Client().Get(ts.URL + "/prometheus/query") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestPrometheusRewritesToAPIV1(t *testing.T) { + var capturedPath, capturedQuery, capturedAuth string + h := newTestHandler(t, "/prometheus", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.RawQuery + capturedAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Audit-Id", "drop") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"success"}`)) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/prometheus/query?query=ALERTS", nil) + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Custom", "drop") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + if capturedPath != "/api/v1/query" { + t.Fatalf("path %q", capturedPath) + } + if capturedQuery != "query=ALERTS" { + t.Fatalf("query %q", capturedQuery) + } + if capturedAuth != "Bearer user-token" { + t.Fatalf("auth %q", capturedAuth) + } + if string(body) != `{"status":"success"}` { + t.Fatalf("body %s", body) + } + if resp.Header.Get("Content-Type") != "application/json" { + t.Fatal("missing Content-Type") + } + if resp.Header.Get("Audit-Id") != "" { + t.Fatal("Audit-Id should be filtered") + } +} + +func TestObservabilityRewritesToAPIV1(t *testing.T) { + var capturedPath string + h := newTestHandler(t, "/observability", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/observability/query_range", nil) + req.Header.Set("Authorization", "Bearer t") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedPath != "/api/v1/query_range" { + t.Fatalf("path %q", capturedPath) + } +} + +func TestCookieToken(t *testing.T) { + var capturedAuth string + h := newTestHandler(t, "/prometheus", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/prometheus/query", nil) + req.AddCookie(&http.Cookie{Name: auth.AccessTokenCookie, Value: "cookie-token"}) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedAuth != "Bearer cookie-token" { + t.Fatalf("auth %q", capturedAuth) + } +} + +func TestRequestHeaderAllowlist(t *testing.T) { + var captured http.Header + h := newTestHandler(t, "/prometheus", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/prometheus/query", strings.NewReader(`{"q":"up"}`)) + req.Header.Set("Authorization", "Bearer t") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Custom-Header", "drop-me") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if captured.Get("X-Custom-Header") != "" { + t.Fatal("custom header should not be forwarded") + } + if captured.Get("Content-Type") != "application/json" { + t.Fatal("missing Content-Type") + } +} + +func TestParseTarget(t *testing.T) { + u, err := metricsproxy.ParseTarget("", metricsproxy.DefaultPrometheusURL) + if err != nil { + t.Fatal(err) + } + if u.String() != metricsproxy.DefaultPrometheusURL { + t.Fatalf("got %s", u) + } + u, err = metricsproxy.ParseTarget("https://prom.example.com", metricsproxy.DefaultPrometheusURL) + if err != nil { + t.Fatal(err) + } + if u.Host != "prom.example.com" { + t.Fatalf("host %s", u.Host) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 45d65c8ec97..798607a314b 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -27,9 +27,13 @@ import ( const multicloudPrefix = "/multicloud" type handlerOptions struct { - rbacEvents http.Handler - k8sProxy http.Handler - staticH http.Handler + rbacEvents http.Handler + k8sProxy http.Handler + mcProxy http.Handler + prometheus http.Handler + observability http.Handler + vmProxy http.Handler + staticH http.Handler } // Option configures Handler. @@ -49,6 +53,35 @@ func WithK8sProxy(h http.Handler) Option { } } + +// WithManagedClusterProxy registers /managedclusterproxy/* (HTTP and WebSocket). +func WithManagedClusterProxy(h http.Handler) Option { + return func(o *handlerOptions) { + o.mcProxy = h + } +} + +// WithPrometheusProxy registers GET /prometheus/*. +func WithPrometheusProxy(h http.Handler) Option { + return func(o *handlerOptions) { + o.prometheus = h + } +} + +// WithObservabilityProxy registers GET /observability/*. +func WithObservabilityProxy(h http.Handler) Option { + return func(o *handlerOptions) { + o.observability = h + } +} + +// WithVMProxy registers VirtualMachine GET helpers, actions, and usage. +func WithVMProxy(h http.Handler) Option { + return func(o *handlerOptions) { + o.vmProxy = h + } +} + // WithStatic serves plugin and SPA files for GET requests with known static extensions. func WithStatic(h http.Handler) Option { return func(o *handlerOptions) { @@ -80,7 +113,55 @@ func isProbe(path string) bool { } func isEventStream(path string) bool { - return path == "/events/rbac" + switch path { + case "/events", "/events/rbac": + return true + default: + return false + } +} + +func isWebSocket(r *http.Request) bool { + return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") +} + +func registerAliased(r chi.Router, h http.Handler, patterns ...string) { + for _, pattern := range patterns { + r.Handle(pattern, h) + r.Handle(multicloudPrefix+pattern, h) + } +} + +func registerAliasedGet(r chi.Router, h http.Handler, patterns ...string) { + for _, pattern := range patterns { + r.Get(pattern, h.ServeHTTP) + r.Get(multicloudPrefix+pattern, h.ServeHTTP) + } +} + +func registerStatelessProxies(r chi.Router, o *handlerOptions) { + if o.mcProxy != nil { + registerAliased(r, o.mcProxy, "/managedclusterproxy/*") + } + if o.prometheus != nil { + registerAliasedGet(r, o.prometheus, "/prometheus/*") + } + if o.observability != nil { + registerAliasedGet(r, o.observability, "/observability/*") + } + if o.vmProxy != nil { + registerAliasedGet(r, o.vmProxy, + "/virtualmachines/get/*", + "/virtualmachinesnapshots/get/*", + "/vmResourceUsage/*", + ) + registerAliased(r, o.vmProxy, + "/virtualmachines/*", + "/virtualmachineinstances/*", + "/virtualmachinesnapshots/*", + "/virtualmachinerestores", + ) + } } func registerK8sProxyRoutes(r chi.Router, h http.Handler) { @@ -138,6 +219,7 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { if o.k8sProxy != nil { registerK8sProxyRoutes(r, o.k8sProxy) } + registerStatelessProxies(r, o) r.NotFound(notFoundHandler(o.staticH, sidecar)) r.MethodNotAllowed(sidecar.ServeHTTP) return r, nil @@ -160,7 +242,8 @@ func requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { stripped := StripMulticloud(r.URL.Path) // Do not wrap SSE: the wrapper can prevent HTTP/2 from flushing events to EventSource. - if isProbe(stripped) || isEventStream(stripped) { + // Do not wrap WebSocket: ReverseProxy needs the raw Hijacker. + if isProbe(stripped) || isEventStream(stripped) || isWebSocket(r) { next.ServeHTTP(w, r) return } diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 698945d80b6..3f580e0b1a4 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -201,6 +201,66 @@ func TestRBACEventsNotProxied(t *testing.T) { } } +func TestStatelessProxiesNotProxiedToSidecar(t *testing.T) { + var sidecarPaths []string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarPaths = append(sidecarPaths, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Go", r.URL.Path) + w.WriteHeader(http.StatusOK) + }) + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, + server.WithManagedClusterProxy(ok), + server.WithPrometheusProxy(ok), + server.WithObservabilityProxy(ok), + server.WithVMProxy(ok), + ) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + paths := []string{ + "/managedclusterproxy/c1/api/v1/pods", + "/multicloud/managedclusterproxy/c1/api", + "/prometheus/query", + "/multicloud/prometheus/query", + "/observability/query", + "/multicloud/observability/query", + "/virtualmachines/get/c/n/ns", + "/multicloud/virtualmachines/start", + "/virtualmachineinstances/pause", + "/virtualmachinesnapshots/get/c/n/ns", + "/virtualmachinerestores", + "/vmResourceUsage/cluster/c/namespace/ns", + } + for _, path := range paths { + sidecarPaths = nil + req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) + resp, getErr := ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if len(sidecarPaths) != 0 { + t.Fatalf("%s was proxied to sidecar: %v", path, sidecarPaths) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + if resp.Header.Get("X-Go") != path { + t.Fatalf("%s X-Go %q", path, resp.Header.Get("X-Go")) + } + } +} + func TestStaticNotProxiedToSidecar(t *testing.T) { var sidecarPaths []string sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -301,7 +361,7 @@ func TestK8sProxyNotProxiedToSidecar(t *testing.T) { } } -func TestApiPathsStillProxiedToSidecar(t *testing.T) { +func TestUnmigratedRoutesStillProxied(t *testing.T) { var capturedPath string sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path @@ -310,19 +370,27 @@ func TestApiPathsStillProxiedToSidecar(t *testing.T) { })) defer sidecar.Close() + ok := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("go handler should not run") + }) k8s := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("k8s proxy should not handle /apiPaths") }) cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithK8sProxy(k8s)) + h, err := server.Handler(cfg, + server.WithK8sProxy(k8s), + server.WithPrometheusProxy(ok), + server.WithManagedClusterProxy(ok), + server.WithVMProxy(ok), + ) if err != nil { t.Fatal(err) } ts := httptest.NewServer(h) defer ts.Close() - for _, path := range []string{"/apiPaths", "/multicloud/apiPaths"} { + for _, path := range []string{"/hub", "/multicloud/search", "/apiPaths", "/multicloud/apiPaths"} { resp, getErr := ts.Client().Get(ts.URL + path) if getErr != nil { t.Fatal(getErr) @@ -332,14 +400,4 @@ func TestApiPathsStillProxiedToSidecar(t *testing.T) { t.Fatalf("%s sidecar path %q", path, capturedPath) } } - - req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/hub", nil) - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if capturedPath != "/multicloud/hub" { - t.Fatalf("hub sidecar path %q", capturedPath) - } } diff --git a/backend/internal/vmproxy/handler.go b/backend/internal/vmproxy/handler.go new file mode 100644 index 00000000000..43ee4372061 --- /dev/null +++ b/backend/internal/vmproxy/handler.go @@ -0,0 +1,258 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "io" + "net/http" + "strings" + + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/clusterproxy" + applog "github.com/stolostron/console/backend/internal/log" + "github.com/stolostron/console/backend/internal/server" +) + +// Options configure VirtualMachine proxy handlers. +type Options struct { + Resolver *clusterproxy.Resolver + TLSConfig *tls.Config + RESTConfig *rest.Config + SAToken string + Kube kubernetes.Interface + HubDynamic dynamic.Interface + UserKube func(token string) (kubernetes.Interface, error) + Validate func(ctx context.Context, token string) error + FineGrained func(ctx context.Context) (bool, error) +} + +// Handler serves VM GET helpers, actions, and resource-usage aggregation. +type Handler struct { + opts Options + saKube kubernetes.Interface + addonClient *http.Client +} + +// New builds a VM proxy handler. +func New(opts Options) *Handler { + h := &Handler{opts: opts, saKube: opts.Kube} + if h.saKube == nil && opts.RESTConfig != nil { + if kube, err := kubernetes.NewForConfig(opts.RESTConfig); err == nil { + h.saKube = kube + } + } + h.addonClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: opts.TLSConfig, + ForceAttemptHTTP2: false, + }, + } + return h +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + token, ok := h.authenticate(w, r) + if !ok { + return + } + path := server.StripMulticloud(r.URL.Path) + switch { + case strings.HasPrefix(path, "/vmResourceUsage/"): + h.usage(w, r, token, path) + case strings.HasPrefix(path, "/virtualmachines/get/") || strings.HasPrefix(path, "/virtualmachinesnapshots/get/"): + h.get(w, r, token, path) + default: + h.action(w, r, token, path) + } +} + +func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { + if h.opts.Validate != nil { + token, ok := auth.RequireToken(w, r) + if !ok { + return "", false + } + if err := h.opts.Validate(r.Context(), token); err != nil { + w.WriteHeader(http.StatusUnauthorized) + return "", false + } + return token, true + } + return auth.AuthenticateRequest(r.Context(), h.opts.RESTConfig, w, r) +} + +func (h *Handler) proxyBase(ctx context.Context) (string, error) { + u, err := h.opts.Resolver.URL(ctx) + if err != nil { + return "", err + } + return strings.TrimRight(u.String(), "/"), nil +} + +type actionBody struct { + ManagedCluster string `json:"managedCluster"` + VMName string `json:"vmName"` + VMNamespace string `json:"vmNamespace"` + ReqBody json.RawMessage `json:"reqBody"` +} + +func (h *Handler) action(w http.ResponseWriter, r *http.Request, token, path string) { + raw, err := io.ReadAll(r.Body) + if err != nil { + applog.Logger().Error("vm proxy", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + var body actionBody + if len(raw) > 0 { + if unmarshalErr := json.Unmarshal(raw, &body); unmarshalErr != nil { + applog.Logger().Error("vm proxy", "error", unmarshalErr) + } + } + parts := strings.Split(path, "/") + action := "" + if len(parts) > 2 { + action = parts[2] + } + base, err := h.proxyBase(r.Context()) + if err != nil { + applog.Logger().Error("vm proxy", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + addonPath := kubeVirtAPI(path, body.VMName, body.VMNamespace, action) + url := base + "/" + body.ManagedCluster + addonPath + + if !h.fineGrainedRBAC(r.Context()) { + if h.canCreateMCA(r.Context(), token, body.ManagedCluster) { + if actor, ok := h.vmActorToken(r.Context(), body.ManagedCluster); ok { + token = actor + } else { + token = "" + } + } + } + + var reqBody io.Reader + if len(body.ReqBody) > 0 && string(body.ReqBody) != "null" { + reqBody = bytes.NewReader(body.ReqBody) + } + req, err := http.NewRequestWithContext(r.Context(), r.Method, url, reqBody) + if err != nil { + applog.Logger().Error("vm proxy", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if !isSubresourceAction(path) { + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + } + resp, err := h.addonClient.Do(req) + if err != nil { + applog.Logger().Error("Error in VirtualMachine action request (fine grained RBAC)", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer resp.Body.Close() + writeProxiedBody(w, resp) +} + +func (h *Handler) get(w http.ResponseWriter, r *http.Request, token, path string) { + base, err := h.proxyBase(r.Context()) + if err != nil { + applog.Logger().Error("vm proxy", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + parts := strings.Split(path, "/") + // /virtualmachines/get/// + if len(parts) < 6 { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("null")) + return + } + cluster, name, namespace := parts[3], parts[4], parts[5] + var api string + if strings.HasPrefix(path, "/virtualmachines/get/") { + api = "/apis/kubevirt.io/v1/namespaces/" + namespace + "/virtualmachines/" + name + } else { + api = "/apis/snapshot.kubevirt.io/v1beta1/namespaces/" + namespace + "/virtualmachinesnapshots/" + name + } + url := base + "/" + cluster + api + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, url, nil) + if err != nil { + applog.Logger().Error("vm proxy", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := h.addonClient.Do(req) + if err != nil { + applog.Logger().Error("Error getting VM resource (fine grained RBAC)", "error", err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(nil) + return + } + defer resp.Body.Close() + decoded, err := decodeJSONBody(resp) + if err != nil { + applog.Logger().Error("Error getting VM resource (fine grained RBAC)", "error", err) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(nil) + return + } + w.Header().Set("Content-Type", "application/json") + enc, _ := json.Marshal(decoded) + _, _ = w.Write(enc) +} + +func writeProxiedBody(w http.ResponseWriter, resp *http.Response) { + body, _ := io.ReadAll(resp.Body) + ct := resp.Header.Get("Content-Type") + var payload any + if strings.Contains(ct, "application/json") { + if err := json.Unmarshal(body, &payload); err != nil { + payload = string(body) + } + } else { + payload = string(body) + } + encoded, _ := json.Marshal(payload) + if _, isString := payload.(string); isString { + w.Header().Set("Content-Type", "text/plain") + } else { + w.Header().Set("Content-Type", "application/json") + } + status := resp.StatusCode + if status == 0 { + status = http.StatusInternalServerError + } + w.WriteHeader(status) + _, _ = w.Write(encoded) +} + +func decodeJSONBody(resp *http.Response) (any, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if len(body) == 0 { + return nil, nil + } + var v any + if err := json.Unmarshal(body, &v); err != nil { + return nil, err + } + return v, nil +} diff --git a/backend/internal/vmproxy/handler_test.go b/backend/internal/vmproxy/handler_test.go new file mode 100644 index 00000000000..773a0a7e669 --- /dev/null +++ b/backend/internal/vmproxy/handler_test.go @@ -0,0 +1,358 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + + authzv1 "k8s.io/api/authorization/v1" + + "github.com/stolostron/console/backend/internal/clusterproxy" + "github.com/stolostron/console/backend/internal/vmproxy" +) + +func allowMCA(client *fake.Clientset, allowed bool) { + client.PrependReactor("create", "selfsubjectaccessreviews", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: allowed}, + }, nil + }) +} + +func vmActorSecret() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "vm-actor", Namespace: "testCluster"}, + Data: map[string][]byte{"token": []byte("test-vm-token")}, + } +} + +func newVMHandler(t *testing.T, addon http.Handler, kube kubernetes.Interface) http.Handler { + t.Helper() + up := httptest.NewServer(addon) + t.Cleanup(up.Close) + target, err := url.Parse(up.URL) + if err != nil { + t.Fatal(err) + } + if kube == nil { + fc := fake.NewSimpleClientset(vmActorSecret()) + allowMCA(fc, true) + kube = fc + } + return vmproxy.New(vmproxy.Options{ + Resolver: &clusterproxy.Resolver{Target: target}, + Kube: kube, + UserKube: func(string) (kubernetes.Interface, error) { return kube, nil }, + Validate: func(context.Context, string) error { return nil }, + FineGrained: func(context.Context) (bool, error) { return false, nil }, + }) +} + +func doJSON(t *testing.T, h http.Handler, method, path string, body any) *http.Response { + t.Helper() + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + rdr = strings.NewReader(string(b)) + } + req, err := http.NewRequest(method, ts.URL+path, rdr) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer user-token") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func TestUnauthorized(t *testing.T) { + h := newVMHandler(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("upstream") + }), nil) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + resp, err := ts.Client().Get(ts.URL + "/virtualmachines/get/c/n/ns") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestStartAction(t *testing.T) { + var capturedPath, capturedAuth, capturedMethod string + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedAuth = r.Header.Get("Authorization") + capturedMethod = r.Method + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"statusCode":200}`)) + }), nil) + resp := doJSON(t, h, http.MethodPut, "/virtualmachines/start", map[string]string{ + "managedCluster": "testCluster", + "vmName": "vmName", + "vmNamespace": "vmNamespace", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + if capturedPath != "/testCluster/apis/subresources.kubevirt.io/v1/namespaces/vmNamespace/virtualmachines/vmName/start" { + t.Fatalf("path %s", capturedPath) + } + if capturedAuth != "Bearer test-vm-token" { + t.Fatalf("auth %s", capturedAuth) + } + if capturedMethod != http.MethodPut { + t.Fatalf("method %s", capturedMethod) + } +} + +func TestPauseAction(t *testing.T) { + var capturedPath string + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }), nil) + resp := doJSON(t, h, http.MethodPut, "/virtualmachineinstances/pause", map[string]string{ + "managedCluster": "testCluster", + "vmName": "vmName", + "vmNamespace": "vmNamespace", + }) + resp.Body.Close() + if capturedPath != "/testCluster/apis/subresources.kubevirt.io/v1/namespaces/vmNamespace/virtualmachineinstances/vmName/pause" { + t.Fatalf("path %s", capturedPath) + } +} + +func TestSnapshotCreate(t *testing.T) { + var capturedPath, capturedBody string + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + b, _ := io.ReadAll(r.Body) + capturedBody = string(b) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }), nil) + reqBody := map[string]any{"kind": "VirtualMachineSnapshot", "metadata": map[string]any{"name": "test-snapshot"}} + resp := doJSON(t, h, http.MethodPost, "/virtualmachinesnapshots/create", map[string]any{ + "managedCluster": "testCluster", + "vmName": "vmName", + "vmNamespace": "vmNamespace", + "reqBody": reqBody, + }) + resp.Body.Close() + if capturedPath != "/testCluster/apis/snapshot.kubevirt.io/v1beta1/namespaces/vmNamespace/virtualmachinesnapshots" { + t.Fatalf("path %s", capturedPath) + } + if !strings.Contains(capturedBody, "VirtualMachineSnapshot") { + t.Fatalf("body %s", capturedBody) + } +} + +func TestActionUpstreamError(t *testing.T) { + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }), nil) + resp := doJSON(t, h, http.MethodPut, "/virtualmachines/start", map[string]string{ + "managedCluster": "testCluster", + "vmName": "vmName", + "vmNamespace": "vmNamespace", + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestGetVM(t *testing.T) { + var capturedPath string + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"kind":"VirtualMachine"}`)) + }), nil) + resp := doJSON(t, h, http.MethodGet, "/virtualmachines/get/testCluster/vmName/vmNamespace", nil) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + if capturedPath != "/testCluster/apis/kubevirt.io/v1/namespaces/vmNamespace/virtualmachines/vmName" { + t.Fatalf("path %s", capturedPath) + } + if !strings.Contains(string(body), "VirtualMachine") { + t.Fatalf("body %s", body) + } +} + +func TestGetVMSnapshot(t *testing.T) { + var capturedPath string + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"kind":"VirtualMachineSnapshot"}`)) + }), nil) + resp := doJSON(t, h, http.MethodGet, "/multicloud/virtualmachinesnapshots/get/testCluster/vmName/vmNamespace", nil) + resp.Body.Close() + if capturedPath != "/testCluster/apis/snapshot.kubevirt.io/v1beta1/namespaces/vmNamespace/virtualmachinesnapshots/vmName" { + t.Fatalf("path %s", capturedPath) + } +} + +func TestDeleteVM(t *testing.T) { + var capturedPath, capturedMethod string + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedMethod = r.Method + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }), nil) + resp := doJSON(t, h, http.MethodDelete, "/virtualmachines/delete", map[string]any{ + "managedCluster": "testCluster", + "vmName": "vmName", + "vmNamespace": "vmNamespace", + "reqBody": map[string]any{}, + }) + resp.Body.Close() + if capturedMethod != http.MethodDelete { + t.Fatalf("method %s", capturedMethod) + } + if capturedPath != "/testCluster/apis/kubevirt.io/v1/namespaces/vmNamespace/virtualmachines/vmName" { + t.Fatalf("path %s", capturedPath) + } +} + +func TestRestoreSnapshot(t *testing.T) { + var capturedPath string + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }), nil) + resp := doJSON(t, h, http.MethodPost, "/virtualmachinerestores", map[string]any{ + "managedCluster": "testCluster", + "vmName": "vmName", + "vmNamespace": "vmNamespace", + "reqBody": map[string]any{"kind": "VirtualMachineRestore"}, + }) + resp.Body.Close() + if capturedPath != "/testCluster/apis/snapshot.kubevirt.io/v1beta1/namespaces/vmNamespace/virtualmachinerestores" { + t.Fatalf("path %s", capturedPath) + } +} + +func TestFineGrainedUsesUserToken(t *testing.T) { + var capturedAuth string + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(up.Close) + target, _ := url.Parse(up.URL) + h := vmproxy.New(vmproxy.Options{ + Resolver: &clusterproxy.Resolver{Target: target}, + Validate: func(context.Context, string) error { return nil }, + FineGrained: func(context.Context) (bool, error) { return true, nil }, + }) + resp := doJSON(t, h, http.MethodPut, "/virtualmachines/start", map[string]string{ + "managedCluster": "testCluster", + "vmName": "vmName", + "vmNamespace": "vmNamespace", + }) + resp.Body.Close() + if capturedAuth != "Bearer user-token" { + t.Fatalf("auth %s", capturedAuth) + } +} + +func TestUsageMissingParams(t *testing.T) { + h := newVMHandler(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), nil) + resp := doJSON(t, h, http.MethodGet, "/vmResourceUsage/cluster//namespace/vmNamespace", nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestUsageAggregate(t *testing.T) { + h := newVMHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(r.URL.Path, "/apis/metrics.k8s.io/"): + _, _ = w.Write([]byte(`{"items":[ + {"metadata":{"name":"centos-launcher","labels":{"vm.kubevirt.io/name":"centos"}},"containers":[ + {"usage":{"cpu":"6894867n","memory":"908492Ki"}}, + {"usage":{"cpu":"6894867n","memory":"908492Ki"}} + ]}, + {"metadata":{"name":"fedora-launcher","labels":{"vm.kubevirt.io/name":"fedora"}},"containers":[ + {"usage":{"cpu":"6894867n","memory":"908492Ki"}}, + {"usage":{"cpu":"6894867n","memory":"908492Ki"}} + ]} + ]}`)) + case strings.Contains(r.URL.Path, "/api/v1/namespaces/") && strings.Contains(r.URL.RawQuery, "labelSelector"): + _, _ = w.Write([]byte(`{"items":[ + {"metadata":{"name":"centos-launcher"},"spec":{"containers":[{"resources":{"requests":{"cpu":"100m","memory":"2294Mi"}}}]}}, + {"metadata":{"name":"fedora-launcher"},"spec":{"containers":[{"resources":{"requests":{"cpu":"100m","memory":"2294Mi"}}},{"resources":{"requests":{"cpu":"100m","memory":"2294Mi"}}}]}} + ]}`)) + case strings.Contains(r.URL.Path, "/virtualmachineinstances/centos/filesystemlist"): + _, _ = w.Write([]byte(`{"items":[{"totalBytes":32212254720,"usedBytes":1029201920}]}`)) + case strings.Contains(r.URL.Path, "/virtualmachineinstances/fedora/filesystemlist"): + _, _ = w.Write([]byte(`{"items":[{"totalBytes":42949672960,"usedBytes":5368709120}]}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + }), nil) + resp := doJSON(t, h, http.MethodGet, "/vmResourceUsage/cluster/testCluster/namespace/vmNamespace", nil) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if int(got["cpu"].(float64)) != 28 { + t.Fatalf("cpu %v", got["cpu"]) + } + if int(got["memory"].(float64)) != 3548 { + t.Fatalf("memory %v", got["memory"]) + } + if int(got["storage"].(float64)) != 6 { + t.Fatalf("storage %v", got["storage"]) + } +} diff --git a/backend/internal/vmproxy/hub.go b/backend/internal/vmproxy/hub.go new file mode 100644 index 00000000000..7f3e9b64980 --- /dev/null +++ b/backend/internal/vmproxy/hub.go @@ -0,0 +1,92 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +import ( + "context" + + authzv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/hubresources" + applog "github.com/stolostron/console/backend/internal/log" +) + +func (h *Handler) fineGrainedRBAC(ctx context.Context) bool { + if h.opts.FineGrained != nil { + ok, err := h.opts.FineGrained(ctx) + return err == nil && ok + } + dc, err := h.hubDynamic() + if err != nil { + applog.Logger().Error("mch dynamic client", "error", err) + return false + } + ok, err := hubresources.MCHFineGrainedRBAC(ctx, dc) + if err != nil { + applog.Logger().Error("Error getting MultiClusterHub", "error", err) + return false + } + return ok +} + +func (h *Handler) hubDynamic() (dynamic.Interface, error) { + if h.opts.HubDynamic != nil { + return h.opts.HubDynamic, nil + } + if h.opts.RESTConfig == nil { + return nil, rest.ErrNotInCluster + } + return dynamic.NewForConfig(h.opts.RESTConfig) +} + +func (h *Handler) canCreateMCA(ctx context.Context, userToken, namespace string) bool { + client, err := h.userKube(userToken) + if err != nil { + applog.Logger().Error("vm ssar client", "error", err) + return false + } + review, err := client.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Group: "action.open-cluster-management.io", + Namespace: namespace, + Resource: "managedclusteractions", + Verb: "create", + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + applog.Logger().Error("vm ssar", "error", err) + return false + } + return review.Status.Allowed +} + +func (h *Handler) vmActorToken(ctx context.Context, namespace string) (string, bool) { + if h.saKube == nil { + return "", false + } + list, err := h.saKube.CoreV1().Secrets(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + applog.Logger().Error("Error getting secret in namespace "+namespace, "error", err) + return "", false + } + for i := range list.Items { + if list.Items[i].Name == "vm-actor" { + return string(list.Items[i].Data["token"]), true + } + } + return "", false +} + +func (h *Handler) userKube(token string) (kubernetes.Interface, error) { + if h.opts.UserKube != nil { + return h.opts.UserKube(token) + } + return kubernetes.NewForConfig(auth.UserRESTConfig(h.opts.RESTConfig, token)) +} diff --git a/backend/internal/vmproxy/hub_test.go b/backend/internal/vmproxy/hub_test.go new file mode 100644 index 00000000000..a74e96232f2 --- /dev/null +++ b/backend/internal/vmproxy/hub_test.go @@ -0,0 +1,118 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +import ( + "context" + "testing" + + authzv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes" + kubefake "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" +) + +func mchObject(fineGrainedEnabled bool) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "operator.open-cluster-management.io", + Version: "v1", + Kind: "MultiClusterHub", + }) + obj.SetName("hub") + components := []interface{}{ + map[string]interface{}{ + "name": "fine-grained-rbac", + "enabled": fineGrainedEnabled, + }, + } + if err := unstructured.SetNestedSlice(obj.Object, components, "spec", "overrides", "components"); err != nil { + panic(err) + } + return obj +} + +func mchDynamicClient(enabled bool) *fake.FakeDynamicClient { + return fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "operator.open-cluster-management.io", Version: "v1", Resource: "multiclusterhubs"}: "MultiClusterHubList", + }, mchObject(enabled)) +} + +func TestFineGrainedRBAC_OptionOverride(t *testing.T) { + h := &Handler{opts: Options{FineGrained: func(context.Context) (bool, error) { return true, nil }}} + if !h.fineGrainedRBAC(context.Background()) { + t.Fatal("expected override to enable fine-grained RBAC") + } +} + +func TestFineGrainedRBAC_FromHub(t *testing.T) { + h := &Handler{opts: Options{HubDynamic: mchDynamicClient(true)}} + if !h.fineGrainedRBAC(context.Background()) { + t.Fatal("expected fine-grained RBAC enabled from hub") + } +} + +func TestFineGrainedRBAC_DisabledComponent(t *testing.T) { + h := &Handler{opts: Options{HubDynamic: mchDynamicClient(false)}} + if h.fineGrainedRBAC(context.Background()) { + t.Fatal("expected fine-grained RBAC disabled") + } +} + +func TestFineGrainedRBAC_MissingClient(t *testing.T) { + h := &Handler{} + if h.fineGrainedRBAC(context.Background()) { + t.Fatal("expected false without hub client") + } +} + +func TestCanCreateMCA_Allowed(t *testing.T) { + client := kubefake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: true}, + }, nil + }) + h := &Handler{opts: Options{UserKube: func(string) (kubernetes.Interface, error) { return client, nil }}} + if !h.canCreateMCA(context.Background(), "user-token", "ns") { + t.Fatal("expected MCA create allowed") + } +} + +func TestCanCreateMCA_Denied(t *testing.T) { + client := kubefake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: false}, + }, nil + }) + h := &Handler{opts: Options{UserKube: func(string) (kubernetes.Interface, error) { return client, nil }}} + if h.canCreateMCA(context.Background(), "user-token", "ns") { + t.Fatal("expected MCA create denied") + } +} + +func TestVMActorToken_Found(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "vm-actor", Namespace: "cluster-ns"}, + Data: map[string][]byte{"token": []byte("vm-actor-token")}, + } + h := &Handler{saKube: kubefake.NewSimpleClientset(secret)} + token, ok := h.vmActorToken(context.Background(), "cluster-ns") + if !ok || token != "vm-actor-token" { + t.Fatalf("token=%q ok=%v", token, ok) + } +} + +func TestVMActorToken_NotFound(t *testing.T) { + h := &Handler{saKube: kubefake.NewSimpleClientset()} + if token, ok := h.vmActorToken(context.Background(), "cluster-ns"); ok || token != "" { + t.Fatalf("token=%q ok=%v", token, ok) + } +} diff --git a/backend/internal/vmproxy/kubevirt.go b/backend/internal/vmproxy/kubevirt.go new file mode 100644 index 00000000000..e0a86e26eaa --- /dev/null +++ b/backend/internal/vmproxy/kubevirt.go @@ -0,0 +1,32 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +func kubeVirtAPI(urlPath, name, namespace, action string) string { + switch urlPath { + case "/virtualmachines/update", "/virtualmachines/delete": + return "/apis/kubevirt.io/v1/namespaces/" + namespace + "/virtualmachines/" + name + case "/virtualmachines/start", "/virtualmachines/stop", "/virtualmachines/restart": + return "/apis/subresources.kubevirt.io/v1/namespaces/" + namespace + "/virtualmachines/" + name + "/" + action + case "/virtualmachineinstances/pause", "/virtualmachineinstances/unpause": + return "/apis/subresources.kubevirt.io/v1/namespaces/" + namespace + "/virtualmachineinstances/" + name + "/" + action + case "/virtualmachinesnapshots/create": + return "/apis/snapshot.kubevirt.io/v1beta1/namespaces/" + namespace + "/virtualmachinesnapshots" + case "/virtualmachinesnapshots/update", "/virtualmachinesnapshots/delete": + return "/apis/snapshot.kubevirt.io/v1beta1/namespaces/" + namespace + "/virtualmachinesnapshots/" + name + case "/virtualmachinerestores": + return "/apis/snapshot.kubevirt.io/v1beta1/namespaces/" + namespace + "/virtualmachinerestores" + default: + return "" + } +} + +func isSubresourceAction(urlPath string) bool { + switch urlPath { + case "/virtualmachines/start", "/virtualmachines/stop", "/virtualmachines/restart", + "/virtualmachineinstances/pause", "/virtualmachineinstances/unpause": + return true + default: + return false + } +} diff --git a/backend/internal/vmproxy/kubevirt_test.go b/backend/internal/vmproxy/kubevirt_test.go new file mode 100644 index 00000000000..89934a77f72 --- /dev/null +++ b/backend/internal/vmproxy/kubevirt_test.go @@ -0,0 +1,59 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +import "testing" + +func TestKubeVirtAPI(t *testing.T) { + cases := []struct { + path string + action string + want string + }{ + {"/virtualmachines/update", "", "/apis/kubevirt.io/v1/namespaces/ns/virtualmachines/vm"}, + {"/virtualmachines/delete", "", "/apis/kubevirt.io/v1/namespaces/ns/virtualmachines/vm"}, + {"/virtualmachines/start", "start", "/apis/subresources.kubevirt.io/v1/namespaces/ns/virtualmachines/vm/start"}, + {"/virtualmachines/stop", "stop", "/apis/subresources.kubevirt.io/v1/namespaces/ns/virtualmachines/vm/stop"}, + {"/virtualmachines/restart", "restart", "/apis/subresources.kubevirt.io/v1/namespaces/ns/virtualmachines/vm/restart"}, + {"/virtualmachineinstances/pause", "pause", "/apis/subresources.kubevirt.io/v1/namespaces/ns/virtualmachineinstances/vm/pause"}, + {"/virtualmachineinstances/unpause", "unpause", "/apis/subresources.kubevirt.io/v1/namespaces/ns/virtualmachineinstances/vm/unpause"}, + {"/virtualmachinesnapshots/create", "", "/apis/snapshot.kubevirt.io/v1beta1/namespaces/ns/virtualmachinesnapshots"}, + {"/virtualmachinesnapshots/update", "", "/apis/snapshot.kubevirt.io/v1beta1/namespaces/ns/virtualmachinesnapshots/vm"}, + {"/virtualmachinesnapshots/delete", "", "/apis/snapshot.kubevirt.io/v1beta1/namespaces/ns/virtualmachinesnapshots/vm"}, + {"/virtualmachinerestores", "", "/apis/snapshot.kubevirt.io/v1beta1/namespaces/ns/virtualmachinerestores"}, + } + for _, tc := range cases { + if got := kubeVirtAPI(tc.path, "vm", "ns", tc.action); got != tc.want { + t.Fatalf("%s: got %q want %q", tc.path, got, tc.want) + } + } + if got := kubeVirtAPI("/unknown", "vm", "ns", "start"); got != "" { + t.Fatalf("unknown path: got %q", got) + } +} + +func TestIsSubresourceAction(t *testing.T) { + subresource := []string{ + "/virtualmachines/start", + "/virtualmachines/stop", + "/virtualmachines/restart", + "/virtualmachineinstances/pause", + "/virtualmachineinstances/unpause", + } + for _, path := range subresource { + if !isSubresourceAction(path) { + t.Fatalf("%s should be subresource action", path) + } + } + nonSubresource := []string{ + "/virtualmachines/update", + "/virtualmachines/delete", + "/virtualmachinesnapshots/create", + "/virtualmachinerestores", + } + for _, path := range nonSubresource { + if isSubresourceAction(path) { + t.Fatalf("%s should not be subresource action", path) + } + } +} diff --git a/backend/internal/vmproxy/units.go b/backend/internal/vmproxy/units.go new file mode 100644 index 00000000000..69ed876a8b0 --- /dev/null +++ b/backend/internal/vmproxy/units.go @@ -0,0 +1,121 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" +) + +func parseLeadingInt(s string) (int, bool) { + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == 0 { + return 0, false + } + n, err := strconv.Atoi(s[:i]) + if err != nil { + return 0, false + } + return n, true +} + +func convertNanocoresToMillicores(nanocoreString string) float64 { + n, ok := parseLeadingInt(nanocoreString) + if !ok { + return 0 + } + return float64(n) / 1_000_000 +} + +func convertKibibytesToMebibytes(kibibyteString string) float64 { + n, ok := parseLeadingInt(kibibyteString) + if !ok { + return 0 + } + return float64(n) / 1024 +} + +func convertBytesToGibibytes(bytes float64) float64 { + if math.IsNaN(bytes) { + return 0 + } + return bytes / 1_073_741_824 +} + +func toMillicores(cpuRequest string) (float64, error) { + trimmed := strings.TrimSpace(cpuRequest) + if trimmed == "" { + return 0, fmt.Errorf("Invalid input: cpuRequest must be a non-empty string.") + } + if strings.HasSuffix(trimmed, "m") { + numericPart := trimmed[:len(trimmed)-1] + millicores, err := strconv.Atoi(numericPart) + if err != nil || strconv.Itoa(millicores) != numericPart { + return 0, fmt.Errorf("Invalid millicore value: %q. The part before \"m\" must be an integer.", cpuRequest) + } + return float64(millicores), nil + } + coreValue, err := strconv.ParseFloat(trimmed, 64) + if err != nil { + return 0, fmt.Errorf("Invalid core value: %q. Must be a number or end with 'm'.", cpuRequest) + } + return coreValue * 1000, nil +} + +var memoryRE = regexp.MustCompile(`^(\d+(\.\d+)?)\s*([A-Za-z]+)?$`) + +func toMebibytes(memoryRequest string) (float64, error) { + trimmed := strings.TrimSpace(memoryRequest) + if trimmed == "" { + return 0, fmt.Errorf("Invalid input: memoryRequest must be a non-empty string.") + } + match := memoryRE.FindStringSubmatch(trimmed) + if match == nil { + return 0, fmt.Errorf("Invalid memory format: %q. Expected a number followed by an optional unit.", memoryRequest) + } + numericValue, err := strconv.ParseFloat(match[1], 64) + if err != nil { + return 0, err + } + unit := match[3] + multipliers := map[string]float64{ + "Ki": 1024, + "Mi": 1024 * 1024, + "Gi": 1024 * 1024 * 1024, + "Ti": 1024 * 1024 * 1024 * 1024, + "Pi": 1024 * 1024 * 1024 * 1024 * 1024, + "Ei": 1024 * 1024 * 1024 * 1024 * 1024 * 1024, + "k": 1000, + "M": 1000 * 1000, + "G": 1000 * 1000 * 1000, + "T": 1000 * 1000 * 1000 * 1000, + "P": 1000 * 1000 * 1000 * 1000 * 1000, + "E": 1000 * 1000 * 1000 * 1000 * 1000 * 1000, + } + var bytes float64 + switch { + case unit == "": + bytes = numericValue + case multipliers[unit] != 0: + bytes = numericValue * multipliers[unit] + default: + return 0, fmt.Errorf("Invalid memory unit: %q.", unit) + } + return bytes / (1024 * 1024), nil +} + +func calUsagePercent(usage, requested float64) int { + if usage < 0 || requested < 0 || math.IsNaN(usage) || math.IsNaN(requested) { + return 0 + } + if requested == 0 { + return 0 + } + return int(math.Round((usage / requested) * 100)) +} diff --git a/backend/internal/vmproxy/units_test.go b/backend/internal/vmproxy/units_test.go new file mode 100644 index 00000000000..32e61ee4bfe --- /dev/null +++ b/backend/internal/vmproxy/units_test.go @@ -0,0 +1,59 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +import "testing" + +func TestConvertNanocoresToMillicores(t *testing.T) { + if got := convertNanocoresToMillicores("6894867n"); got != 6.894867 { + t.Fatalf("got %v", got) + } + if got := convertNanocoresToMillicores("not-a-number"); got != 0 { + t.Fatalf("got %v", got) + } +} + +func TestConvertKibibytesToMebibytes(t *testing.T) { + if got := convertKibibytesToMebibytes("908492Ki"); got != 908492.0/1024 { + t.Fatalf("got %v", got) + } +} + +func TestConvertBytesToGibibytes(t *testing.T) { + if got := convertBytesToGibibytes(32212254720); got != 30 { + t.Fatalf("got %v", got) + } +} + +func TestToMillicores(t *testing.T) { + got, err := toMillicores("100m") + if err != nil || got != 100 { + t.Fatalf("got %v %v", got, err) + } + got, err = toMillicores("1") + if err != nil || got != 1000 { + t.Fatalf("got %v %v", got, err) + } + if _, err := toMillicores(""); err == nil { + t.Fatal("expected error") + } +} + +func TestToMebibytes(t *testing.T) { + got, err := toMebibytes("2294Mi") + if err != nil || got != 2294 { + t.Fatalf("got %v %v", got, err) + } + if _, err := toMebibytes(""); err == nil { + t.Fatal("expected error") + } +} + +func TestCalUsagePercent(t *testing.T) { + if got := calUsagePercent(14, 100); got != 14 { + t.Fatalf("got %d", got) + } + if got := calUsagePercent(1, 0); got != 0 { + t.Fatalf("got %d", got) + } +} diff --git a/backend/internal/vmproxy/usage.go b/backend/internal/vmproxy/usage.go new file mode 100644 index 00000000000..267467f3d62 --- /dev/null +++ b/backend/internal/vmproxy/usage.go @@ -0,0 +1,235 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +import ( + "context" + "encoding/json" + "io" + "math" + "net/http" + "strings" + + applog "github.com/stolostron/console/backend/internal/log" +) + +type usageMetrics struct { + Requested int `json:"requested"` + Usage int `json:"usage"` + UsagePercent int `json:"usagePercent"` +} + +type vmiUsage struct { + PodName string `json:"podName"` + VmiName string `json:"vmiName"` + ClusterName string `json:"clusterName"` + Namespace string `json:"namespace"` + CPU usageMetrics `json:"cpu"` + Memory usageMetrics `json:"memory"` + Storage usageMetrics `json:"storage"` +} + +type usageResponse struct { + CPU int `json:"cpu"` + Memory int `json:"memory"` + Storage int `json:"storage"` + VmisUsage []vmiUsage `json:"vmisUsage"` +} + +type podMetricsList struct { + Items []podMetric `json:"items"` +} + +type podMetric struct { + Metadata struct { + Name string `json:"name"` + Labels map[string]string `json:"labels"` + } `json:"metadata"` + Containers []struct { + Usage struct { + CPU string `json:"cpu"` + Memory string `json:"memory"` + } `json:"usage"` + } `json:"containers"` +} + +type podListType struct { + Items []podType `json:"items"` +} + +type podType struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + Spec struct { + Containers []struct { + Resources struct { + Requests struct { + CPU string `json:"cpu"` + Memory string `json:"memory"` + } `json:"requests"` + } `json:"resources"` + } `json:"containers"` + } `json:"spec"` +} + +type filesystemType struct { + Items []struct { + TotalBytes float64 `json:"totalBytes"` + UsedBytes float64 `json:"usedBytes"` + } `json:"items"` +} + +func (h *Handler) usage(w http.ResponseWriter, r *http.Request, token, path string) { + cluster, namespace, ok := parseUsagePath(path) + if !ok || cluster == "" || namespace == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"Cluster name and namespace are required"}`)) + return + } + base, err := h.proxyBase(r.Context()) + if err != nil { + applog.Logger().Error("Failed to get aggregated VM usage", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + result, err := h.aggregateUsage(r.Context(), base, cluster, namespace, token) + if err != nil { + applog.Logger().Error("Failed to get aggregated VM usage", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + enc, _ := json.Marshal(result) + _, _ = w.Write(enc) +} + +func parseUsagePath(path string) (cluster, namespace string, ok bool) { + const prefix = "/vmResourceUsage/cluster/" + if !strings.HasPrefix(path, prefix) { + return "", "", false + } + rest := strings.TrimPrefix(path, prefix) + nsIdx := strings.Index(rest, "/namespace/") + if nsIdx < 0 { + return "", "", false + } + cluster = rest[:nsIdx] + namespace = strings.Trim(rest[nsIdx+len("/namespace/"):], "/") + return cluster, namespace, true +} + +func (h *Handler) aggregateUsage(ctx context.Context, base, cluster, namespace, token string) (*usageResponse, error) { + label := "kubevirt.io=virt-launcher" + metricsURL := base + "/" + cluster + "/apis/metrics.k8s.io/v1beta1/namespaces/" + namespace + "/pods?labelSelector=" + label + podsURL := base + "/" + cluster + "/api/v1/namespaces/" + namespace + "/pods?labelSelector=" + label + + var metrics podMetricsList + var pods podListType + if err := h.getJSON(ctx, metricsURL, token, &metrics); err != nil { + return nil, err + } + if err := h.getJSON(ctx, podsURL, token, &pods); err != nil { + return nil, err + } + + podMap := map[string]podType{} + for _, p := range pods.Items { + podMap[p.Metadata.Name] = p + } + + out := &usageResponse{VmisUsage: []vmiUsage{}} + for _, metric := range metrics.Items { + pod, found := podMap[metric.Metadata.Name] + u, err := h.singleVmiUsage(ctx, base, cluster, namespace, token, metric, pod, found) + if err != nil { + applog.Logger().Error("Failed to process a VM metric", "error", err) + continue + } + if u == nil { + continue + } + out.VmisUsage = append(out.VmisUsage, *u) + out.CPU += u.CPU.Usage + out.Memory += u.Memory.Usage + out.Storage += u.Storage.Usage + } + return out, nil +} + +func (h *Handler) singleVmiUsage(ctx context.Context, base, cluster, namespace, token string, metric podMetric, pod podType, found bool) (*vmiUsage, error) { + vmiName := metric.Metadata.Labels["vm.kubevirt.io/name"] + if !found || vmiName == "" { + return nil, nil + } + var podRequestedCPU, podRequestedMemory float64 + for _, c := range pod.Spec.Containers { + cpu, err := toMillicores(c.Resources.Requests.CPU) + if err != nil { + return nil, err + } + mem, err := toMebibytes(c.Resources.Requests.Memory) + if err != nil { + return nil, err + } + podRequestedCPU += cpu + podRequestedMemory += mem + } + var podCPU, podMem float64 + for _, c := range metric.Containers { + podCPU += convertNanocoresToMillicores(c.Usage.CPU) + podMem += convertKibibytesToMebibytes(c.Usage.Memory) + } + + fsURL := base + "/" + cluster + "/apis/subresources.kubevirt.io/v1/namespaces/" + namespace + "/virtualmachineinstances/" + vmiName + "/filesystemlist" + var fs filesystemType + if err := h.getJSON(ctx, fsURL, token, &fs); err != nil { + return nil, err + } + var storageUsed, storageTotal float64 + for _, item := range fs.Items { + storageUsed += convertBytesToGibibytes(item.UsedBytes) + storageTotal += convertBytesToGibibytes(item.TotalBytes) + } + return &vmiUsage{ + PodName: pod.Metadata.Name, + VmiName: vmiName, + ClusterName: cluster, + Namespace: namespace, + CPU: usageMetrics{ + Requested: int(math.Round(podRequestedCPU)), + Usage: int(math.Round(podCPU)), + UsagePercent: calUsagePercent(podCPU, podRequestedCPU), + }, + Memory: usageMetrics{ + Requested: int(math.Round(podRequestedMemory)), + Usage: int(math.Round(podMem)), + UsagePercent: calUsagePercent(podMem, podRequestedMemory), + }, + Storage: usageMetrics{ + Requested: int(math.Round(storageTotal)), + Usage: int(math.Round(storageUsed)), + UsagePercent: calUsagePercent(storageUsed, storageTotal), + }, + }, nil +} + +func (h *Handler) getJSON(ctx context.Context, rawURL, token string, dest any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + resp, err := h.addonClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(body, dest) +} diff --git a/backend/internal/vmproxy/usage_test.go b/backend/internal/vmproxy/usage_test.go new file mode 100644 index 00000000000..e25c45f2437 --- /dev/null +++ b/backend/internal/vmproxy/usage_test.go @@ -0,0 +1,157 @@ +// Copyright Contributors to the Open Cluster Management project + +package vmproxy + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stolostron/console/backend/internal/clusterproxy" +) + +func TestParseUsagePath(t *testing.T) { + cases := []struct { + path string + cluster string + namespace string + ok bool + }{ + {"/vmResourceUsage/cluster/c1/namespace/ns1", "c1", "ns1", true}, + {"/vmResourceUsage/cluster/c1/namespace/ns1/", "c1", "ns1", true}, + {"/vmResourceUsage/cluster/", "", "", false}, + {"/vmResourceUsage/cluster/c1/namespace/", "c1", "", true}, + {"/other/path", "", "", false}, + {"", "", "", false}, + } + for _, tc := range cases { + cluster, namespace, ok := parseUsagePath(tc.path) + if ok != tc.ok || cluster != tc.cluster || namespace != tc.namespace { + t.Fatalf("%q: got cluster=%q namespace=%q ok=%v want cluster=%q namespace=%q ok=%v", + tc.path, cluster, namespace, ok, tc.cluster, tc.namespace, tc.ok) + } + } +} + +func TestAggregateUsage_SkipsUnmatchedPods(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case strings.Contains(r.URL.Path, "/apis/metrics.k8s.io/"): + _, _ = w.Write([]byte(`{"items":[ + {"metadata":{"name":"orphan-launcher","labels":{}},"containers":[{"usage":{"cpu":"1000000n","memory":"100Ki"}}]}, + {"metadata":{"name":"centos-launcher","labels":{"vm.kubevirt.io/name":"centos"}},"containers":[{"usage":{"cpu":"2000000n","memory":"200Ki"}}]} + ]}`)) + case strings.Contains(r.URL.Path, "/api/v1/namespaces/"): + _, _ = w.Write([]byte(`{"items":[ + {"metadata":{"name":"centos-launcher"},"spec":{"containers":[{"resources":{"requests":{"cpu":"100m","memory":"128Mi"}}}]}} + ]}`)) + case strings.Contains(r.URL.Path, "/virtualmachineinstances/centos/filesystemlist"): + _, _ = w.Write([]byte(`{"items":[{"totalBytes":1073741824,"usedBytes":536870912}]}`)) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + t.Cleanup(ts.Close) + + target, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + h := New(Options{Resolver: &clusterproxy.Resolver{Target: target}}) + + got, err := h.aggregateUsage(context.Background(), ts.URL, "cluster", "ns", "token") + if err != nil { + t.Fatal(err) + } + if len(got.VmisUsage) != 1 { + t.Fatalf("vmisUsage len=%d", len(got.VmisUsage)) + } + if got.VmisUsage[0].VmiName != "centos" { + t.Fatalf("vmi %q", got.VmisUsage[0].VmiName) + } +} + +func TestAggregateUsage_EmptyMetrics(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(r.URL.Path, "/apis/metrics.k8s.io/") { + _, _ = w.Write([]byte(`{"items":[]}`)) + return + } + if strings.Contains(r.URL.Path, "/api/v1/namespaces/") { + _, _ = w.Write([]byte(`{"items":[]}`)) + return + } + t.Fatalf("unexpected path %s", r.URL.Path) + })) + t.Cleanup(ts.Close) + + target, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + h := New(Options{Resolver: &clusterproxy.Resolver{Target: target}}) + + got, err := h.aggregateUsage(context.Background(), ts.URL, "cluster", "ns", "token") + if err != nil { + t.Fatal(err) + } + if len(got.VmisUsage) != 0 || got.CPU != 0 || got.Memory != 0 || got.Storage != 0 { + t.Fatalf("expected empty usage, got %#v", got) + } +} + +func TestGetJSON_DecodesResponse(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"items":[{"metadata":{"name":"pod-a"}}]}`)) + })) + t.Cleanup(ts.Close) + + h := New(Options{}) + h.addonClient = ts.Client() + + var list podListType + if err := h.getJSON(context.Background(), ts.URL, "test-token", &list); err != nil { + t.Fatal(err) + } + if len(list.Items) != 1 || list.Items[0].Metadata.Name != "pod-a" { + t.Fatalf("list %#v", list) + } +} + +func TestUsageResponseJSONShape(t *testing.T) { + resp := usageResponse{ + CPU: 10, + Memory: 20, + Storage: 30, + VmisUsage: []vmiUsage{{ + PodName: "pod", VmiName: "vm", ClusterName: "c", Namespace: "ns", + CPU: usageMetrics{Requested: 100, Usage: 50, UsagePercent: 50}, + }}, + } + b, err := json.Marshal(resp) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(b, &decoded); err != nil { + t.Fatal(err) + } + if decoded["cpu"].(float64) != 10 { + t.Fatalf("cpu %v", decoded["cpu"]) + } + vmis := decoded["vmisUsage"].([]any) + if len(vmis) != 1 { + t.Fatalf("vmisUsage len %d", len(vmis)) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ef4516f6662..074a300cdb6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,7 @@ The frontend has two builds. One for the stand alone version and one for the dyn ## Console Backend -The public listener is a Go process (`backend/`). Hub kube-apiserver passthrough routes (`/api`, `/apis`, `/version`) and other migrated routes are served natively in Go. Routes that have not been migrated yet are reverse-proxied to the Node sidecar (`backend-node/`). The plugin and browser keep talking to the same Service and paths. +The public listener is a Go process (`backend/`). Hub kube-apiserver passthrough routes (`/api`, `/apis`, `/version`), managed-cluster, metrics, and VirtualMachine proxy routes are served natively in Go. Routes that have not been migrated yet are reverse-proxied to the Node sidecar (`backend-node/`). The plugin and browser keep talking to the same Service and paths. The console backend uses a service account to `list` and `watch` kubernetes cluster resources. Resource events are streamed to the console frontend. From 6bacb5fcf60ca51c913c6dcdc015cc85c330fe4f Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Tue, 1 Sep 2026 19:08:15 +0200 Subject: [PATCH 05/16] ACM-42595: Migrate OAuth login, logout, and /configure discovery to Go backend (#51) * ACM-42589: Migrate OAuth login, logout, and /configure discovery to Go backend Move standalone OAuth/OpenShift OAuth/OIDC login flow and token-endpoint discovery from the Node sidecar into the Go public listener. Adds `internal/oauth` with `/configure`, `/login`, `/login/callback`, and `/logout` handlers, OCM SSO client-credentials exchange in `internal/auth`, shared TLS/HTTP client helpers, and new env vars (`OAUTH2_*`, `OIDC_ISSUER_URL`, `FRONTEND_URL`). The Go server registers these routes under `/` and `/multicloud` in non-production, while production keeps OpenShift Console auth. Removes the corresponding Node routes and tests, updates `AGENTS.md` and architecture docs, and adjusts a frontend test helper. Signed-off-by: Enrique Mingorance Cano * .editorconfig Signed-off-by: Enrique Mingorance Cano * use the dynamic client-go Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --- backend-node/AGENTS.md | 4 +- backend-node/src/app.ts | 9 - backend-node/src/routes/configure.ts | 13 - backend-node/src/routes/oauth.ts | 133 ------ backend-node/test/routes/apiPath.test.ts | 3 - backend-node/test/routes/configure.test.ts | 21 - backend/AGENTS.md | 5 +- backend/cmd/console/main.go | 17 +- backend/go.mod | 2 +- backend/internal/auth/ocm.go | 81 ++++ backend/internal/auth/ocm_test.go | 83 ++++ backend/internal/auth/tls.go | 16 + backend/internal/config/config.go | 13 + backend/internal/config/config_test.go | 24 ++ backend/internal/oauth/oauth.go | 360 ++++++++++++++++ backend/internal/oauth/oauth_test.go | 383 ++++++++++++++++++ backend/internal/oauth/revoke.go | 34 ++ backend/internal/oauth/revoke_test.go | 80 ++++ backend/internal/oauth/token.go | 20 + backend/internal/server/server.go | 31 ++ backend/internal/server/server_test.go | 148 +++++++ docs/ARCHITECTURE.md | 2 + .../src/components/LoadPluginData.test.tsx | 14 +- 23 files changed, 1310 insertions(+), 186 deletions(-) delete mode 100644 backend-node/src/routes/configure.ts delete mode 100644 backend-node/src/routes/oauth.ts delete mode 100644 backend-node/test/routes/configure.test.ts create mode 100644 backend/internal/auth/ocm.go create mode 100644 backend/internal/auth/ocm_test.go create mode 100644 backend/internal/oauth/oauth.go create mode 100644 backend/internal/oauth/oauth_test.go create mode 100644 backend/internal/oauth/revoke.go create mode 100644 backend/internal/oauth/revoke_test.go create mode 100644 backend/internal/oauth/token.go diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md index 7ee492f18b9..0c65b6553f7 100644 --- a/backend-node/AGENTS.md +++ b/backend-node/AGENTS.md @@ -16,7 +16,7 @@ Node.js ESM proxy server. Sits between the browser and the hub cluster API serve | Directory | Purpose | |-----------|---------| | `src/lib/` | Core server: `main.ts` entry, `server.ts`, auth, cookies, CORS, proxy, search, SSE, logging, config | -| `src/routes/` | HTTP route handlers: proxy, OAuth, search, events, hub, etc. | +| `src/routes/` | HTTP route handlers: proxy, search, events, hub, etc. | | `src/resources/` | Backend resource watchers and handlers | | `test/` | Jest test files | | `config/` | Runtime configuration lives in `../backend/config` (Go backend) | @@ -37,7 +37,7 @@ Run from the `backend-node/` directory, or use the `npm run *:backend-node` vari ## Architecture -The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated. +The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated. OAuth login, logout, and `/configure` discovery are served by Go. ```text Browser / plugin → Go :4000 → Node sidecar (this package) → Hub Cluster API Server diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index cbe7bd11062..a39c54bae8d 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -14,12 +14,10 @@ import { ServerSideEvents } from './lib/server-side-events' import { aggregate, startAggregating, stopAggregating } from './routes/aggregator' import { ansibleTower } from './routes/ansibletower' import { apiPaths } from './routes/apiPaths' -import { configure } from './routes/configure' import { events, startWatching, stopWatching } from './routes/events' import { hub } from './routes/hub' import { liveness } from './routes/liveness' import { multiClusterHubComponents } from './routes/multiClusterHubComponents' -import { login, loginCallback, logout } from './routes/oauth' import { operatorCheck } from './routes/operatorCheck' import { readiness } from './routes/readiness' import { search } from './routes/search' @@ -59,13 +57,6 @@ router.get('/livenessProbe', liveness) router.get('/ping', respondOK) router.get('/apiPaths', apiPaths) router.post('/operatorCheck', operatorCheck) -if (!isProduction) { - router.get('/configure', configure) - router.get('/login', login) - router.get('/login/callback', loginCallback) - router.get('/logout', logout) - router.get('/logout/', logout) -} if (eventsEnabled) { router.get('/events', events) } diff --git a/backend-node/src/routes/configure.ts b/backend-node/src/routes/configure.ts deleted file mode 100644 index 6cf9253f462..00000000000 --- a/backend-node/src/routes/configure.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { getOauthInfoPromise } from './oauth' - -export async function configure(_req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const oauthInfo = await getOauthInfoPromise() - const responsePayload = { - token_endpoint: oauthInfo.token_endpoint, - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(responsePayload)) -} diff --git a/backend-node/src/routes/oauth.ts b/backend-node/src/routes/oauth.ts deleted file mode 100644 index 7dab03c9e6b..00000000000 --- a/backend-node/src/routes/oauth.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { createHash } from 'node:crypto' -import got from 'got' -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { encode as stringifyQuery, parse as parseQueryString } from 'node:querystring' -import { deleteCookie } from '../lib/cookies' -import { fetchRetry } from '../lib/fetch-retry' -import { jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { redirect, respondInternalServerError, unauthorized } from '../lib/respond' -import { getToken } from '../lib/token' -import { setDead } from './liveness' -import { getCACertificate } from '../lib/serviceAccountToken' - -type OAuthInfo = { authorization_endpoint: string; token_endpoint: string } -let oauthInfoPromise: Promise - -export function getOauthInfoPromise() { - if (oauthInfoPromise === undefined) { - const handleError = (error: string) => { - logger.error({ msg: 'oauth-authorization-server error', error }) - setDead() - return { - authorization_endpoint: '', - token_endpoint: '', - } - } - const discoveryDocument = process.env.OIDC_ISSUER_URL - ? '.well-known/openid-configuration' - : '.well-known/oauth-authorization-server' - const oidcIssuerUrl = process.env.OIDC_ISSUER_URL ?? process.env.CLUSTER_API_URL - if (oidcIssuerUrl) { - const discoveryUrl = new URL( - discoveryDocument, - oidcIssuerUrl.endsWith('/') ? oidcIssuerUrl : `${oidcIssuerUrl}/` - ).toString() - oauthInfoPromise = jsonRequest(discoveryUrl).catch((err: Error) => { - return handleError(err.message) - }) - } else { - oauthInfoPromise = Promise.resolve(handleError('Missing OIDC_ISSUER_URL or CLUSTER_API_URL for OAuth discovery')) - } - } - return oauthInfoPromise -} - -export async function login(_req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const oauthInfo = await getOauthInfoPromise() - - const queryString = stringifyQuery({ - response_type: `code`, - client_id: process.env.OAUTH2_CLIENT_ID, - redirect_uri: process.env.OAUTH2_REDIRECT_URL, - scope: process.env.OIDC_ISSUER_URL ? 'openid' : 'user:full', - state: '', - }) - return redirect(res, `${oauthInfo.authorization_endpoint}?${queryString}`) -} - -export async function loginCallback(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const url = req.url - if (url.includes('?')) { - const oauthInfo = await getOauthInfoPromise() - const queryString = url.substring(url.indexOf('?') + 1) - const query = parseQueryString(queryString) - const code = query.code as string - const requestQuery: Record = { - grant_type: `authorization_code`, - code: code, - redirect_uri: process.env.OAUTH2_REDIRECT_URL, - client_id: process.env.OAUTH2_CLIENT_ID, - client_secret: process.env.OAUTH2_CLIENT_SECRET, - } - const response = await fetchRetry(oauthInfo.token_endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, - body: stringifyQuery(requestQuery), - }) - const body = (await response.json()) as { access_token?: string; id_token?: string } - const token = process.env.OIDC_ISSUER_URL ? body.id_token : body.access_token - if (token) { - const headers = { - 'Set-Cookie': `acm-access-token-cookie=${token};${ - process.env.NODE_ENV === 'production' ? ' Secure;' : '' - } HttpOnly; Path=/`, - location: process.env.FRONTEND_URL, - } - res.writeHead(302, headers).end() - return - } else { - return respondInternalServerError(req, res) - } - } else { - return respondInternalServerError(req, res) - } -} - -export function logout(req: Http2ServerRequest, res: Http2ServerResponse): void { - const token = getToken(req) - if (!token) return unauthorized(req, res) - - const gotOptions = { - headers: { Authorization: `Bearer ${token}` }, - https: { certificateAuthority: getCACertificate() }, - } - - let tokenName = token - const sha256Prefix = 'sha256~' - if (tokenName.startsWith(sha256Prefix)) { - tokenName = `sha256~${createHash('sha256') - .update(token.substring(sha256Prefix.length)) - .digest('base64') - .replaceAll('=', '') - .replaceAll('+', '-') - .replaceAll('/', '_')}` - } - - const url = - process.env.CLUSTER_API_URL + `/apis/oauth.openshift.io/v1/oauthaccesstokens/${tokenName}?gracePeriodSeconds=0` - got - .delete(url, gotOptions) - .then(() => { - const host = req.headers.host - - deleteCookie(res, { cookie: 'connect.sid' }) - deleteCookie(res, { cookie: 'acm-access-token-cookie' }) - deleteCookie(res, { cookie: '_oauth_proxy', domain: `.${host}` }) - res.writeHead(200).end() - }) - .catch((err) => { - logger.error(err) - }) -} diff --git a/backend-node/test/routes/apiPath.test.ts b/backend-node/test/routes/apiPath.test.ts index 2d24f1f7d36..982ee78b18c 100644 --- a/backend-node/test/routes/apiPath.test.ts +++ b/backend-node/test/routes/apiPath.test.ts @@ -5,9 +5,6 @@ import nock from 'nock' describe(`apiPath Route`, function () { it(`should serve resource names`, async function () { - nock(process.env.CLUSTER_API_URL).get('/.well-known/oauth-authorization-server').reply(200, { - token_endpoint: 'https://oauth-openshift.apps.cs-aws-411-d62fs.dev02.red-chesterfield.com/oauth/token', - }) nock(process.env.CLUSTER_API_URL).get('/').reply(200, { status: 200, paths, diff --git a/backend-node/test/routes/configure.test.ts b/backend-node/test/routes/configure.test.ts deleted file mode 100644 index 390b4192bb2..00000000000 --- a/backend-node/test/routes/configure.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import { parseResponseJsonBody } from '../../src/lib/body-parser' -import nock from 'nock' - -describe(`configure Route`, function () { - it(`should return the oauth token endpoint of the MultiClusterHub`, async function () { - nock(process.env.CLUSTER_API_URL).get('/.well-known').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL).get('/.well-known/oauth-authorization-server').reply(200, { - token_endpoint: 'https://oauth-openshift.apps.cs-aws-411-d62fs.dev02.red-chesterfield.com/oauth/token', - }) - const res = await request('GET', '/configure') - expect(res.statusCode).toEqual(200) - const { token_endpoint } = await parseResponseJsonBody(res) - expect(token_endpoint).toEqual( - 'https://oauth-openshift.apps.cs-aws-411-d62fs.dev02.red-chesterfield.com/oauth/token' - ) - }) -}) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 272e38d5281..7e4e94b6a04 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -25,7 +25,8 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/vmproxy` | VirtualMachine GET helpers, actions, and resource-usage aggregation | | `internal/health` | `/ping`, `/livenessProbe` (Go only), `/readinessProbe` (Go + sidecar `/ping`) | | `internal/config` | `.env` + `config/` directory (filename = key) | -| `internal/auth` | Cookie/Bearer, SA token/CA, TokenReview helper | +| `internal/auth` | Cookie/Bearer, SA token/CA, TokenReview helper, OCM SSO client-credentials token | +| `internal/oauth` | `/configure` discovery; standalone `/login` `/login/callback` `/logout` (OpenShift OAuth and OIDC) | | `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | | `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | @@ -57,6 +58,8 @@ Go backend :4000 (TLS / HTTP/2) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) │ (also /multicloud/…) + ├─ GET /configure (OAuth/OIDC token_endpoint discovery) + ├─ GET /login, /login/callback, /logout (standalone OAuth/OIDC; non-production) ├─ ALL /managedclusterproxy/* → cluster-proxy addon (user token; WebSocket) ├─ GET /prometheus/*, /observability/* → metrics backends (user token) ├─ /virtualmachines/*, /virtualmachineinstances/*, /virtualmachinesnapshots/*, diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index cc3b4c25fe4..2a748957d40 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -19,6 +19,7 @@ import ( rbacevents "github.com/stolostron/console/backend/internal/events/rbac" "github.com/stolostron/console/backend/internal/k8sproxy" applog "github.com/stolostron/console/backend/internal/log" + "github.com/stolostron/console/backend/internal/oauth" "github.com/stolostron/console/backend/internal/mcproxy" "github.com/stolostron/console/backend/internal/metricsproxy" "github.com/stolostron/console/backend/internal/server" @@ -68,8 +69,22 @@ func run() error { } rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacevents.NewSSARAccess(restCfg)) + oauthH := oauth.New(oauth.Options{ + ClientID: cfg.OAuth2ClientID, + ClientSecret: cfg.OAuth2ClientSecret, + RedirectURL: cfg.OAuth2RedirectURL, + FrontendURL: cfg.FrontendURL, + ClusterAPIURL: cfg.ClusterAPIURL, + OIDCIssuerURL: cfg.OIDCIssuerURL, + Production: cfg.Production, + Client: auth.HTTPClient(sa.CACert, 0), + RESTConfig: restCfg, + }) var opts []server.Option - opts = append(opts, server.WithRBACEvents(rbacHandler)) + opts = append(opts, server.WithRBACEvents(rbacHandler), server.WithOAuth(oauthH)) + if !cfg.Production { + opts = append(opts, server.WithOAuthLogin()) + } clusterURL, err := url.Parse(cfg.ClusterAPIURL) if err != nil { return err diff --git a/backend/go.mod b/backend/go.mod index 614231239c6..13ed34f4073 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,7 @@ require ( github.com/fsnotify/fsnotify v1.8.0 github.com/go-chi/chi/v5 v5.2.1 github.com/joho/godotenv v1.5.1 + golang.org/x/oauth2 v0.23.0 k8s.io/api v0.32.3 k8s.io/apimachinery v0.32.3 k8s.io/client-go v0.32.3 @@ -34,7 +35,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/x448/float16 v0.8.4 // indirect golang.org/x/net v0.30.0 // indirect - golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/sys v0.26.0 // indirect golang.org/x/term v0.25.0 // indirect golang.org/x/text v0.19.0 // indirect diff --git a/backend/internal/auth/ocm.go b/backend/internal/auth/ocm.go new file mode 100644 index 00000000000..ba954112c08 --- /dev/null +++ b/backend/internal/auth/ocm.go @@ -0,0 +1,81 @@ +// Copyright Contributors to the Open Cluster Management project + +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const defaultOCMTokenURL = "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token" + +var ocmTokenURL = defaultOCMTokenURL + +// SetOCMTokenURL overrides the Red Hat SSO token endpoint (tests). +func SetOCMTokenURL(raw string) func() { + prev := ocmTokenURL + ocmTokenURL = raw + return func() { ocmTokenURL = prev } +} + +// OCMServiceToken exchanges base64-encoded OCM client credentials for an SSO access token. +func OCMServiceToken(ctx context.Context, client *http.Client, clientID, clientSecret string) (string, error) { + if client == nil { + client = http.DefaultClient + } + id := base64DecodeASCII(clientID) + secret := base64DecodeASCII(clientSecret) + form := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {id}, + "client_secret": {secret}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ocmTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("token exchange failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var tok struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &tok); err != nil { + return "", err + } + if tok.AccessToken == "" { + return "", fmt.Errorf("token exchange failed (%d): missing access_token", resp.StatusCode) + } + return tok.AccessToken, nil +} + +func base64DecodeASCII(value string) string { + if value == "" { + return "" + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + decoded, err = base64.RawStdEncoding.DecodeString(value) + if err != nil { + return value + } + } + return string(decoded) +} diff --git a/backend/internal/auth/ocm_test.go b/backend/internal/auth/ocm_test.go new file mode 100644 index 00000000000..20708084cd0 --- /dev/null +++ b/backend/internal/auth/ocm_test.go @@ -0,0 +1,83 @@ +// Copyright Contributors to the Open Cluster Management project + +package auth_test + +import ( + "context" + "crypto/tls" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stolostron/console/backend/internal/auth" +) + +func TestOCMServiceToken(t *testing.T) { + var posted url.Values + sso := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/realms/redhat-external/protocol/openid-connect/token" { + http.NotFound(w, r) + return + } + b, _ := io.ReadAll(r.Body) + posted, _ = url.ParseQuery(string(b)) + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("content-type %q", r.Header.Get("Content-Type")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"mock-access-token"}`)) + })) + defer sso.Close() + restore := auth.SetOCMTokenURL(sso.URL + "/auth/realms/redhat-external/protocol/openid-connect/token") + defer restore() + + id := base64.StdEncoding.EncodeToString([]byte("my-client-id")) + secret := base64.StdEncoding.EncodeToString([]byte("my-client-secret")) + tok, err := auth.OCMServiceToken(context.Background(), sso.Client(), id, secret) + if err != nil { + t.Fatal(err) + } + if tok != "mock-access-token" { + t.Fatalf("token %q", tok) + } + if posted.Get("grant_type") != "client_credentials" { + t.Fatalf("grant %q", posted.Get("grant_type")) + } + if posted.Get("client_id") != "my-client-id" || posted.Get("client_secret") != "my-client-secret" { + t.Fatalf("decoded fields %v", posted) + } +} + +func TestOCMServiceToken_Error(t *testing.T) { + sso := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Invalid credentials")) + })) + defer sso.Close() + restore := auth.SetOCMTokenURL(sso.URL) + defer restore() + id := base64.StdEncoding.EncodeToString([]byte("id")) + secret := base64.StdEncoding.EncodeToString([]byte("secret")) + _, err := auth.OCMServiceToken(context.Background(), sso.Client(), id, secret) + if err == nil || !strings.Contains(err.Error(), "token exchange failed (401): Invalid credentials") { + t.Fatalf("err %v", err) + } +} + +func TestTLSConfigFromCA_AppendsPEM(t *testing.T) { + cfg := auth.TLSConfigFromCA([]byte("not-pem"), true) + if cfg.MinVersion != tls.VersionTLS12 { + t.Fatalf("min version %d", cfg.MinVersion) + } + if cfg.RootCAs == nil { + t.Fatal("expected root pool") + } + client := auth.HTTPClient(nil, 0) + if client.Timeout == 0 || client.Transport == nil { + t.Fatal("expected timeout and transport") + } +} diff --git a/backend/internal/auth/tls.go b/backend/internal/auth/tls.go index efd013f97a1..c41b0139963 100644 --- a/backend/internal/auth/tls.go +++ b/backend/internal/auth/tls.go @@ -5,7 +5,9 @@ package auth import ( "crypto/tls" "crypto/x509" + "net/http" "os" + "time" ) // TLSConfigFromCA builds a TLS config from a PEM CA bundle. @@ -34,6 +36,20 @@ func TLSConfigFromCA(caCert []byte, includeSystemRoots bool) *tls.Config { return tlsCfg } +// HTTPClient is an outbound client that trusts system roots and the service-account CA. +func HTTPClient(ca []byte, timeout time.Duration) *http.Client { + if timeout <= 0 { + timeout = 30 * time.Second + } + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + TLSClientConfig: TLSConfigFromCA(ca, true), + Proxy: http.ProxyFromEnvironment, + }, + } +} + // ServiceTLSConfig trusts SERVICE_CA_CERT / service-ca.crt. Local development also trusts system roots // so OpenShift Routes verify, matching Node getServiceAgent(). func ServiceTLSConfig(sa ServiceAccount) *tls.Config { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b64915f0b1d..5f10098db00 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -34,6 +34,13 @@ type Config struct { ClusterProxyAddonUserRoute string PublicFolder string + OAuth2ClientID string + OAuth2ClientSecret string + OAuth2RedirectURL string + OIDCIssuerURL string + FrontendURL string + Production bool + mu sync.RWMutex settings map[string]string } @@ -66,6 +73,12 @@ func Load() *Config { ClusterProxyAddonUserHost: os.Getenv("CLUSTER_PROXY_ADDON_USER_HOST"), ClusterProxyAddonUserRoute: os.Getenv("CLUSTER_PROXY_ADDON_USER_ROUTE"), PublicFolder: envOr("PUBLIC_FOLDER", "public"), + OAuth2ClientID: os.Getenv("OAUTH2_CLIENT_ID"), + OAuth2ClientSecret: os.Getenv("OAUTH2_CLIENT_SECRET"), + OAuth2RedirectURL: os.Getenv("OAUTH2_REDIRECT_URL"), + OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), + FrontendURL: os.Getenv("FRONTEND_URL"), + Production: os.Getenv("NODE_ENV") == "production", settings: map[string]string{}, } _ = cfg.ReloadSettings() diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 6ac9e44973d..b1f04443928 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -72,6 +72,30 @@ func TestLoad_FromEnvFile(t *testing.T) { } } +func TestLoad_OAuthEnv(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) + t.Setenv("OAUTH2_CLIENT_ID", "cid") + t.Setenv("OAUTH2_CLIENT_SECRET", "csecret") + t.Setenv("OAUTH2_REDIRECT_URL", "https://localhost:3000/multicloud/login/callback") + t.Setenv("OIDC_ISSUER_URL", "https://sso.example.com") + t.Setenv("FRONTEND_URL", "https://localhost:3000") + t.Setenv("NODE_ENV", "production") + cfg := config.Load() + if cfg.OAuth2ClientID != "cid" || cfg.OAuth2ClientSecret != "csecret" { + t.Fatalf("client %q %q", cfg.OAuth2ClientID, cfg.OAuth2ClientSecret) + } + if cfg.OAuth2RedirectURL != "https://localhost:3000/multicloud/login/callback" { + t.Fatalf("redirect %q", cfg.OAuth2RedirectURL) + } + if cfg.OIDCIssuerURL != "https://sso.example.com" || cfg.FrontendURL != "https://localhost:3000" { + t.Fatalf("oidc/frontend %q %q", cfg.OIDCIssuerURL, cfg.FrontendURL) + } + if !cfg.Production { + t.Fatal("expected Production") + } +} + func TestLoad_ProxyEnvVars(t *testing.T) { dir := t.TempDir() t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) diff --git a/backend/internal/oauth/oauth.go b/backend/internal/oauth/oauth.go new file mode 100644 index 00000000000..baf96031514 --- /dev/null +++ b/backend/internal/oauth/oauth.go @@ -0,0 +1,360 @@ +// Copyright Contributors to the Open Cluster Management project + +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + + "golang.org/x/oauth2" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + applog "github.com/stolostron/console/backend/internal/log" +) + +const ( + connectSIDCookie = "connect.sid" + oauthProxyCookie = "_oauth_proxy" +) + +// Info is the OAuth/OIDC discovery subset used by login and token exchange. +type Info struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` +} + +// Options configure the standalone OAuth/OIDC login handlers. +type Options struct { + ClientID string + ClientSecret string + RedirectURL string + FrontendURL string + ClusterAPIURL string + OIDCIssuerURL string + Production bool + Client *http.Client + RESTConfig *rest.Config + // UserDynamic is used with RESTConfig for per-user hub API calls (tests). + UserDynamic func(bearer string) (dynamic.Interface, error) + Discover func(ctx context.Context) (Info, error) + Revoke func(ctx context.Context, bearer, tokenName string) error +} + +// Handler serves GET /configure, /login, /login/callback, and /logout. +type Handler struct { + clientID string + clientSecret string + redirectURL string + frontendURL string + clusterAPIURL string + oidcIssuerURL string + production bool + client *http.Client + restConfig *rest.Config + userDynamic func(bearer string) (dynamic.Interface, error) + discover func(ctx context.Context) (Info, error) + revoke func(ctx context.Context, bearer, tokenName string) error + + mu sync.Mutex + info Info + ok bool +} + +// New builds an OAuth handler. Client should trust the hub CA (auth.HTTPClient). +func New(opts Options) *Handler { + c := opts.Client + if c == nil { + c = http.DefaultClient + } + h := &Handler{ + clientID: opts.ClientID, + clientSecret: opts.ClientSecret, + redirectURL: opts.RedirectURL, + frontendURL: opts.FrontendURL, + clusterAPIURL: strings.TrimRight(opts.ClusterAPIURL, "/"), + oidcIssuerURL: strings.TrimRight(opts.OIDCIssuerURL, "/"), + production: opts.Production, + client: withAcceptJSON(c), + restConfig: opts.RESTConfig, + userDynamic: opts.UserDynamic, + discover: opts.Discover, + revoke: opts.Revoke, + } + if h.discover == nil { + h.discover = h.discoverDefault + } + if h.revoke == nil { + h.revoke = h.revokeDefault + } + return h +} + +type acceptJSON struct { + base http.RoundTripper +} + +func (t acceptJSON) RoundTrip(req *http.Request) (*http.Response, error) { + r := req.Clone(req.Context()) + if r.Header.Get("Accept") == "" { + r.Header.Set("Accept", "application/json") + } + base := t.base + if base == nil { + base = http.DefaultTransport + } + return base.RoundTrip(r) +} + +func withAcceptJSON(c *http.Client) *http.Client { + cp := *c + cp.Transport = acceptJSON{base: c.Transport} + return &cp +} + +func (h *Handler) oauth2Config(info Info) *oauth2.Config { + scopes := []string{"user:full"} + if h.oidcIssuerURL != "" { + scopes = []string{"openid"} + } + return &oauth2.Config{ + ClientID: h.clientID, + ClientSecret: h.clientSecret, + RedirectURL: h.redirectURL, + Scopes: scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: info.AuthorizationEndpoint, + TokenURL: info.TokenEndpoint, + AuthStyle: oauth2.AuthStyleInParams, + }, + } +} + +func (h *Handler) endpoints(ctx context.Context) (Info, error) { + info, err := h.discoverCached(ctx) + if err != nil { + return Info{}, err + } + if info.AuthorizationEndpoint == "" { + return Info{}, fmt.Errorf("oauth discovery missing authorization_endpoint") + } + return info, nil +} + +// discoverCached is the Go equivalent of Node getOauthInfoPromise(): one well-known +// fetch, then reuse. Failures are not cached so a later request can retry. +func (h *Handler) discoverCached(ctx context.Context) (Info, error) { + h.mu.Lock() + defer h.mu.Unlock() + if h.ok { + return h.info, nil + } + info, err := h.discover(ctx) + if err != nil { + return Info{}, err + } + h.info = info + h.ok = true + return info, nil +} + +func (h *Handler) discoverDefault(ctx context.Context) (Info, error) { + raw, err := DiscoveryURL(h.clusterAPIURL, h.oidcIssuerURL) + if err != nil { + return Info{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) + if err != nil { + return Info{}, err + } + resp, err := h.client.Do(req) + if err != nil { + return Info{}, err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return Info{}, fmt.Errorf("oauth discovery status %d", resp.StatusCode) + } + var info Info + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return Info{}, err + } + return info, nil +} + +// DiscoveryURL is the well-known document Node uses (OIDC vs OpenShift OAuth). +func DiscoveryURL(clusterAPIURL, oidcIssuerURL string) (string, error) { + base := oidcIssuerURL + doc := ".well-known/oauth-authorization-server" + if oidcIssuerURL != "" { + doc = ".well-known/openid-configuration" + } else { + base = clusterAPIURL + } + if strings.TrimSpace(base) == "" { + return "", fmt.Errorf("missing OIDC_ISSUER_URL or CLUSTER_API_URL for OAuth discovery") + } + if !strings.HasSuffix(base, "/") { + base += "/" + } + u, err := url.Parse(base) + if err != nil { + return "", err + } + ref, err := url.Parse(doc) + if err != nil { + return "", err + } + return u.ResolveReference(ref).String(), nil +} + +// Configure is GET /configure: { token_endpoint } for frontend logout and Display Token. +func (h *Handler) Configure(w http.ResponseWriter, r *http.Request) { + token := "" + info, err := h.discoverCached(r.Context()) + if err != nil { + applog.Logger().Error("oauth configure discovery", "error", err) + } else { + token = info.TokenEndpoint + } + w.Header().Set("Content-Type", "application/json") + body, err := json.Marshal(struct { + TokenEndpoint string `json:"token_endpoint"` + }{TokenEndpoint: token}) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) +} + +// Login redirects to the IdP authorization endpoint. +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + info, err := h.endpoints(r.Context()) + if err != nil { + applog.Logger().Error("oauth login discovery", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + loc := h.oauth2Config(info).AuthCodeURL("", oauth2.SetAuthURLParam("state", "")) + http.Redirect(w, r, loc, http.StatusFound) +} + +// Callback exchanges the authorization code and sets acm-access-token-cookie. +func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery == "" { + w.WriteHeader(http.StatusInternalServerError) + return + } + code := r.URL.Query().Get("code") + info, err := h.endpoints(r.Context()) + if err != nil { + applog.Logger().Error("oauth callback discovery", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + token, err := h.exchange(r.Context(), info, code) + if err != nil || token == "" { + applog.Logger().Error("oauth token exchange", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Set-Cookie", accessCookie(token, h.production)) + w.Header().Set("Location", h.frontendURL) + w.WriteHeader(http.StatusFound) +} + +func accessCookie(token string, production bool) string { + secure := "" + if production { + secure = " Secure;" + } + return auth.AccessTokenCookie + "=" + token + ";" + secure + " HttpOnly; Path=/" +} + +func (h *Handler) exchange(ctx context.Context, info Info, code string) (string, error) { + ctx = context.WithValue(ctx, oauth2.HTTPClient, h.client) + tok, err := h.oauth2Config(info).Exchange(ctx, code) + if err != nil { + return "", err + } + if h.oidcIssuerURL != "" { + if id, ok := tok.Extra("id_token").(string); ok && id != "" { + return id, nil + } + return "", fmt.Errorf("missing id_token") + } + if tok.AccessToken == "" { + return "", fmt.Errorf("missing access_token") + } + return tok.AccessToken, nil +} + +// Refresh exchanges a refresh_token before access-token expiry (golang.org/x/oauth2 TokenSource). +func (h *Handler) Refresh(ctx context.Context, refreshToken string) (*oauth2.Token, error) { + if strings.TrimSpace(refreshToken) == "" { + return nil, fmt.Errorf("refresh_token is required") + } + info, err := h.endpoints(ctx) + if err != nil { + return nil, err + } + ctx = context.WithValue(ctx, oauth2.HTTPClient, h.client) + src := h.oauth2Config(info).TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}) + return src.Token() +} + +// Logout revokes the OpenShift OAuth access token and clears session cookies. +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + token := auth.TokenFromRequest(r) + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + if err := h.revoke(r.Context(), token, AccessTokenName(token)); err != nil { + applog.Logger().Error("oauth logout revoke", "error", err) + // Still clear cookies so OIDC / already-revoked tokens can sign out locally. + } + clearSessionCookies(w, r.Host) + w.WriteHeader(http.StatusOK) +} + +func clearSessionCookies(w http.ResponseWriter, host string) { + deleteCookie(w, connectSIDCookie, "") + deleteCookie(w, auth.AccessTokenCookie, "") + deleteCookie(w, oauthProxyCookie, "."+host) +} + +func deleteCookie(w http.ResponseWriter, name, domain string) { + s := name + "=; Secure; HttpOnly; Path=/; max-age=0" + if domain != "" { + s += "; Domain=" + domain + } + w.Header().Add("Set-Cookie", s) +} + +func (h *Handler) userDynamicClient(bearer string) (dynamic.Interface, error) { + if h.userDynamic != nil { + return h.userDynamic(bearer) + } + if h.restConfig == nil { + return nil, fmt.Errorf("kubernetes rest config is required") + } + return dynamic.NewForConfig(auth.UserRESTConfig(h.restConfig, bearer)) +} + +func (h *Handler) revokeDefault(ctx context.Context, bearer, tokenName string) error { + dc, err := h.userDynamicClient(bearer) + if err != nil { + return err + } + return RevokeOAuthAccessToken(ctx, dc, tokenName) +} diff --git a/backend/internal/oauth/oauth_test.go b/backend/internal/oauth/oauth_test.go new file mode 100644 index 00000000000..19da2022fa7 --- /dev/null +++ b/backend/internal/oauth/oauth_test.go @@ -0,0 +1,383 @@ +// Copyright Contributors to the Open Cluster Management project + +package oauth_test + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/oauth" +) + +func TestDiscoveryURL(t *testing.T) { + got, err := oauth.DiscoveryURL("https://api.example.com:6443", "") + if err != nil { + t.Fatal(err) + } + if got != "https://api.example.com:6443/.well-known/oauth-authorization-server" { + t.Fatalf("got %q", got) + } + got, err = oauth.DiscoveryURL("https://api.example.com:6443/", "https://sso.example.com/auth/realms/foo") + if err != nil { + t.Fatal(err) + } + if got != "https://sso.example.com/auth/realms/foo/.well-known/openid-configuration" { + t.Fatalf("oidc got %q", got) + } + if _, err := oauth.DiscoveryURL("", ""); err == nil { + t.Fatal("expected error") + } +} + +func TestAccessTokenName(t *testing.T) { + if got := oauth.AccessTokenName("plain-token"); got != "plain-token" { + t.Fatalf("got %q", got) + } + raw := "abcdefghijklmnopqrstuvwxyz012345" + token := "sha256~" + raw + sum := sha256.Sum256([]byte(raw)) + want := "sha256~" + base64.RawURLEncoding.EncodeToString(sum[:]) + if got := oauth.AccessTokenName(token); got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestLoginRedirect_OpenShift(t *testing.T) { + h := oauth.New(oauth.Options{ + ClientID: "console-dev", + RedirectURL: "https://localhost:3000/multicloud/login/callback", + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{ + AuthorizationEndpoint: "https://oauth.example.com/oauth/authorize", + TokenEndpoint: "https://oauth.example.com/oauth/token", + }, nil + }, + }) + rec := httptest.NewRecorder() + h.Login(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + if rec.Code != http.StatusFound { + t.Fatalf("status %d", rec.Code) + } + loc := rec.Header().Get("Location") + u, err := url.Parse(loc) + if err != nil { + t.Fatal(err) + } + if u.Scheme+"://"+u.Host+u.Path != "https://oauth.example.com/oauth/authorize" { + t.Fatalf("location host/path %q", loc) + } + q := u.Query() + if q.Get("response_type") != "code" || q.Get("client_id") != "console-dev" { + t.Fatalf("query %v", q) + } + if q.Get("redirect_uri") != "https://localhost:3000/multicloud/login/callback" { + t.Fatalf("redirect_uri %q", q.Get("redirect_uri")) + } + if q.Get("scope") != "user:full" { + t.Fatalf("scope %q", q.Get("scope")) + } + if _, ok := q["state"]; !ok { + t.Fatal("expected empty state param") + } +} + +func TestLoginRedirect_OIDCScope(t *testing.T) { + h := oauth.New(oauth.Options{ + ClientID: "oidc-client", + RedirectURL: "https://localhost:3000/multicloud/login/callback", + OIDCIssuerURL: "https://sso.example.com/auth/realms/foo", + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{ + AuthorizationEndpoint: "https://sso.example.com/auth", + TokenEndpoint: "https://sso.example.com/token", + }, nil + }, + }) + rec := httptest.NewRecorder() + h.Login(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + if rec.Code != http.StatusFound { + t.Fatalf("status %d", rec.Code) + } + u, _ := url.Parse(rec.Header().Get("Location")) + if u.Query().Get("scope") != "openid" { + t.Fatalf("scope %q", u.Query().Get("scope")) + } +} + +func TestCallback_SetsCookieAndRedirects(t *testing.T) { + var posted url.Values + idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/token" { + http.NotFound(w, r) + return + } + b, _ := io.ReadAll(r.Body) + posted, _ = url.ParseQuery(string(b)) + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("content-type %q", r.Header.Get("Content-Type")) + } + if r.Header.Get("Accept") != "application/json" { + t.Errorf("accept %q", r.Header.Get("Accept")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"sha256~user-token","token_type":"Bearer"}`)) + })) + defer idp.Close() + + h := oauth.New(oauth.Options{ + ClientID: "cid", + ClientSecret: "csecret", + RedirectURL: "https://localhost:3000/multicloud/login/callback", + FrontendURL: "https://localhost:3000", + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{AuthorizationEndpoint: idp.URL + "/auth", TokenEndpoint: idp.URL + "/token"}, nil + }, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/login/callback?code=the-code", nil) + h.Callback(rec, req) + if rec.Code != http.StatusFound { + t.Fatalf("status %d body %s", rec.Code, rec.Body.Bytes()) + } + if rec.Header().Get("Location") != "https://localhost:3000" { + t.Fatalf("location %q", rec.Header().Get("Location")) + } + cookie := rec.Header().Get("Set-Cookie") + if !strings.HasPrefix(cookie, auth.AccessTokenCookie+"=sha256~user-token;") { + t.Fatalf("cookie %q", cookie) + } + if strings.Contains(cookie, "Secure") { + t.Fatalf("dev cookie must not be Secure: %q", cookie) + } + if !strings.Contains(cookie, "HttpOnly") || !strings.Contains(cookie, "Path=/") { + t.Fatalf("cookie attrs %q", cookie) + } + if strings.Contains(strings.ToLower(cookie), "samesite") { + t.Fatalf("Node does not set SameSite: %q", cookie) + } + if posted.Get("grant_type") != "authorization_code" || posted.Get("code") != "the-code" { + t.Fatalf("form %v", posted) + } + if posted.Get("client_id") != "cid" || posted.Get("client_secret") != "csecret" { + t.Fatalf("client fields %v", posted) + } +} + +func TestCallback_ProductionSecureCookieAndIDToken(t *testing.T) { + idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"not-this","id_token":"header.payload.sig","token_type":"Bearer"}`)) + })) + defer idp.Close() + h := oauth.New(oauth.Options{ + ClientID: "cid", + ClientSecret: "sec", + RedirectURL: "https://localhost:3000/multicloud/login/callback", + FrontendURL: "https://localhost:3000", + OIDCIssuerURL: "https://sso.example.com", + Production: true, + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{AuthorizationEndpoint: idp.URL + "/a", TokenEndpoint: idp.URL + "/t"}, nil + }, + }) + rec := httptest.NewRecorder() + h.Callback(rec, httptest.NewRequest(http.MethodGet, "/login/callback?code=x", nil)) + if rec.Code != http.StatusFound { + t.Fatalf("status %d", rec.Code) + } + cookie := rec.Header().Get("Set-Cookie") + if !strings.Contains(cookie, auth.AccessTokenCookie+"=header.payload.sig;") { + t.Fatalf("cookie %q", cookie) + } + if !strings.Contains(cookie, " Secure;") { + t.Fatalf("production cookie must be Secure: %q", cookie) + } +} + +func TestCallback_Errors(t *testing.T) { + h := oauth.New(oauth.Options{ + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{AuthorizationEndpoint: "https://x/a", TokenEndpoint: "https://x/t"}, nil + }, + }) + rec := httptest.NewRecorder() + h.Callback(rec, httptest.NewRequest(http.MethodGet, "/login/callback", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("no query status %d", rec.Code) + } + + idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"token_type":"Bearer"}`)) + })) + defer idp.Close() + h = oauth.New(oauth.Options{ + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{AuthorizationEndpoint: idp.URL, TokenEndpoint: idp.URL}, nil + }, + }) + rec = httptest.NewRecorder() + h.Callback(rec, httptest.NewRequest(http.MethodGet, "/login/callback?code=z", nil)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("missing token status %d", rec.Code) + } +} + +func TestRefresh_UsesRefreshTokenGrant(t *testing.T) { + var posted url.Values + idp := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + posted, _ = url.ParseQuery(string(b)) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"rotated","token_type":"Bearer","expires_in":3600}`)) + })) + defer idp.Close() + h := oauth.New(oauth.Options{ + ClientID: "cid", + ClientSecret: "sec", + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{AuthorizationEndpoint: idp.URL + "/a", TokenEndpoint: idp.URL + "/t"}, nil + }, + }) + tok, err := h.Refresh(context.Background(), "rt-1") + if err != nil { + t.Fatal(err) + } + if tok.AccessToken != "rotated" { + t.Fatalf("access %q", tok.AccessToken) + } + if posted.Get("grant_type") != "refresh_token" || posted.Get("refresh_token") != "rt-1" { + t.Fatalf("form %v", posted) + } +} + +func TestLogout_RevokesAndClearsCookies(t *testing.T) { + raw := "abcdefghijklmnopqrstuvwxyz012345" + bearer := "sha256~" + raw + wantName := oauth.AccessTokenName(bearer) + client := oauthAccessTokenClient(oauthAccessTokenObject(wantName)) + + h := oauth.New(oauth.Options{ + UserDynamic: func(string) (dynamic.Interface, error) { return client, nil }, + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{AuthorizationEndpoint: "https://x/a", TokenEndpoint: "https://x/t"}, nil + }, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/logout", nil) + req.Host = "localhost:4000" + req.AddCookie(&http.Cookie{Name: auth.AccessTokenCookie, Value: bearer}) + h.Logout(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + _, err := client.Resource(oauthAccessTokenGVR).Get(context.Background(), wantName, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected token deleted, got %v", err) + } + cookies := rec.Header().Values("Set-Cookie") + joined := strings.Join(cookies, "\n") + for _, name := range []string{"connect.sid", auth.AccessTokenCookie, "_oauth_proxy"} { + if !strings.Contains(joined, name+"=") { + t.Fatalf("missing delete cookie %s in %v", name, cookies) + } + } + if !strings.Contains(joined, "Domain=.localhost:4000") { + t.Fatalf("oauth_proxy domain %v", cookies) + } + if !strings.Contains(joined, "max-age=0") { + t.Fatalf("expected max-age=0 %v", cookies) + } +} + +func TestLogout_UnauthorizedWithoutToken(t *testing.T) { + h := oauth.New(oauth.Options{}) + rec := httptest.NewRecorder() + h.Logout(rec, httptest.NewRequest(http.MethodGet, "/logout", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.Bytes()) + } +} + +func TestDiscover_FromWellKnown(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/oauth-authorization-server" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "authorization_endpoint": "https://oauth.example.com/auth", + "token_endpoint": "https://oauth.example.com/token", + }) + })) + defer api.Close() + h := oauth.New(oauth.Options{ClusterAPIURL: api.URL}) + rec := httptest.NewRecorder() + h.Login(rec, httptest.NewRequest(http.MethodGet, "/login", nil)) + if rec.Code != http.StatusFound { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Header().Get("Location"), "https://oauth.example.com/auth") { + t.Fatalf("location %q", rec.Header().Get("Location")) + } +} + +func TestConfigure_TokenEndpoint(t *testing.T) { + h := oauth.New(oauth.Options{ + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{ + TokenEndpoint: "https://oauth-openshift.apps.example.com/oauth/token", + }, nil + }, + }) + rec := httptest.NewRecorder() + h.Configure(rec, httptest.NewRequest(http.MethodGet, "/configure", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if rec.Header().Get("Content-Type") != "application/json" { + t.Fatalf("content-type %q", rec.Header().Get("Content-Type")) + } + var body struct { + TokenEndpoint string `json:"token_endpoint"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.TokenEndpoint != "https://oauth-openshift.apps.example.com/oauth/token" { + t.Fatalf("token_endpoint %q", body.TokenEndpoint) + } +} + +func TestConfigure_DiscoveryErrorEmptyEndpoint(t *testing.T) { + h := oauth.New(oauth.Options{ + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{}, fmt.Errorf("oauth-authorization-server error") + }, + }) + rec := httptest.NewRecorder() + h.Configure(rec, httptest.NewRequest(http.MethodGet, "/configure", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.String() != `{"token_endpoint":""}` { + t.Fatalf("body %s", rec.Body.Bytes()) + } +} diff --git a/backend/internal/oauth/revoke.go b/backend/internal/oauth/revoke.go new file mode 100644 index 00000000000..90b7c617be2 --- /dev/null +++ b/backend/internal/oauth/revoke.go @@ -0,0 +1,34 @@ +// Copyright Contributors to the Open Cluster Management project + +package oauth + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +var oauthAccessTokenGVR = schema.GroupVersionResource{ + Group: "oauth.openshift.io", + Version: "v1", + Resource: "oauthaccesstokens", +} + +// RevokeOAuthAccessToken deletes an OAuthAccessToken with gracePeriodSeconds=0. +func RevokeOAuthAccessToken(ctx context.Context, client dynamic.Interface, tokenName string) error { + if client == nil { + return fmt.Errorf("kubernetes dynamic client is required") + } + grace := int64(0) + err := client.Resource(oauthAccessTokenGVR).Delete(ctx, tokenName, metav1.DeleteOptions{ + GracePeriodSeconds: &grace, + }) + if apierrors.IsNotFound(err) { + return nil + } + return err +} diff --git a/backend/internal/oauth/revoke_test.go b/backend/internal/oauth/revoke_test.go new file mode 100644 index 00000000000..d4c6275da72 --- /dev/null +++ b/backend/internal/oauth/revoke_test.go @@ -0,0 +1,80 @@ +// Copyright Contributors to the Open Cluster Management project + +package oauth_test + +import ( + "context" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + + "github.com/stolostron/console/backend/internal/oauth" +) + +var oauthAccessTokenGVR = schema.GroupVersionResource{ + Group: "oauth.openshift.io", + Version: "v1", + Resource: "oauthaccesstokens", +} + +func oauthAccessTokenObject(name string) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "oauth.openshift.io", + Version: "v1", + Kind: "OAuthAccessToken", + }) + obj.SetName(name) + return obj +} + +func oauthAccessTokenClient(objects ...runtime.Object) *fake.FakeDynamicClient { + return fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + oauthAccessTokenGVR: "OAuthAccessTokenList", + }, objects...) +} + +func TestRevokeOAuthAccessToken_DeletesToken(t *testing.T) { + raw := "abcdefghijklmnopqrstuvwxyz012345" + bearer := "sha256~" + raw + wantName := oauth.AccessTokenName(bearer) + client := oauthAccessTokenClient(oauthAccessTokenObject(wantName)) + if err := oauth.RevokeOAuthAccessToken(context.Background(), client, wantName); err != nil { + t.Fatal(err) + } + _, err := client.Resource(oauthAccessTokenGVR).Get(context.Background(), wantName, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestRevokeOAuthAccessToken_PlainTokenName(t *testing.T) { + name := "plain-token" + client := oauthAccessTokenClient(oauthAccessTokenObject(name)) + if err := oauth.RevokeOAuthAccessToken(context.Background(), client, name); err != nil { + t.Fatal(err) + } + _, err := client.Resource(oauthAccessTokenGVR).Get(context.Background(), name, metav1.GetOptions{}) + if !apierrors.IsNotFound(err) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestRevokeOAuthAccessToken_NotFoundOK(t *testing.T) { + client := oauthAccessTokenClient() + if err := oauth.RevokeOAuthAccessToken(context.Background(), client, "missing"); err != nil { + t.Fatal(err) + } +} + +func TestRevokeOAuthAccessToken_NilClient(t *testing.T) { + err := oauth.RevokeOAuthAccessToken(context.Background(), nil, "token") + if err == nil { + t.Fatal("expected error") + } +} diff --git a/backend/internal/oauth/token.go b/backend/internal/oauth/token.go new file mode 100644 index 00000000000..cdec2f3abb3 --- /dev/null +++ b/backend/internal/oauth/token.go @@ -0,0 +1,20 @@ +// Copyright Contributors to the Open Cluster Management project + +package oauth + +import ( + "crypto/sha256" + "encoding/base64" + "strings" +) + +const sha256Prefix = "sha256~" + +// AccessTokenName is the OpenShift OAuthAccessToken object name for a bearer token. +func AccessTokenName(token string) string { + if !strings.HasPrefix(token, sha256Prefix) { + return token + } + sum := sha256.Sum256([]byte(token[len(sha256Prefix):])) + return sha256Prefix + base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 798607a314b..dc447cd3728 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -20,6 +20,7 @@ import ( "github.com/stolostron/console/backend/internal/config" "github.com/stolostron/console/backend/internal/health" applog "github.com/stolostron/console/backend/internal/log" + "github.com/stolostron/console/backend/internal/oauth" "github.com/stolostron/console/backend/internal/proxy" "github.com/stolostron/console/backend/internal/static" ) @@ -29,6 +30,8 @@ const multicloudPrefix = "/multicloud" type handlerOptions struct { rbacEvents http.Handler k8sProxy http.Handler + oauth *oauth.Handler + oauthLogin bool mcProxy http.Handler prometheus http.Handler observability http.Handler @@ -53,6 +56,19 @@ func WithK8sProxy(h http.Handler) Option { } } +// WithOAuth registers GET /configure (OAuth discovery for logout and Display Token). +func WithOAuth(h *oauth.Handler) Option { + return func(o *handlerOptions) { + o.oauth = h + } +} + +// WithOAuthLogin registers standalone /login, /login/callback, and /logout (non-production). +func WithOAuthLogin() Option { + return func(o *handlerOptions) { + o.oauthLogin = true + } +} // WithManagedClusterProxy registers /managedclusterproxy/* (HTTP and WebSocket). func WithManagedClusterProxy(h http.Handler) Option { @@ -219,12 +235,27 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { if o.k8sProxy != nil { registerK8sProxyRoutes(r, o.k8sProxy) } + if o.oauth != nil { + registerOAuth(r, "", o.oauth, o.oauthLogin) + registerOAuth(r, multicloudPrefix, o.oauth, o.oauthLogin) + } registerStatelessProxies(r, o) r.NotFound(notFoundHandler(o.staticH, sidecar)) r.MethodNotAllowed(sidecar.ServeHTTP) return r, nil } +func registerOAuth(r chi.Router, prefix string, h *oauth.Handler, login bool) { + r.Get(prefix+"/configure", h.Configure) + if !login { + return + } + r.Get(prefix+"/login", h.Login) + r.Get(prefix+"/login/callback", h.Callback) + r.Get(prefix+"/logout", h.Logout) + r.Get(prefix+"/logout/", h.Logout) +} + func notFoundHandler(staticH, sidecar http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { stripped := StripMulticloud(r.URL.Path) diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 3f580e0b1a4..50b0f7c3a0e 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -3,6 +3,7 @@ package server_test import ( + "context" "io" "net/http" "net/http/httptest" @@ -10,6 +11,7 @@ import ( "testing" "github.com/stolostron/console/backend/internal/config" + "github.com/stolostron/console/backend/internal/oauth" "github.com/stolostron/console/backend/internal/server" ) @@ -201,6 +203,81 @@ func TestRBACEventsNotProxied(t *testing.T) { } } +func TestOAuthNotProxiedToSidecar(t *testing.T) { + var sidecarPaths []string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarPaths = append(sidecarPaths, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + oa := oauth.New(oauth.Options{ + ClientID: "cid", + RedirectURL: "https://localhost:3000/multicloud/login/callback", + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{ + AuthorizationEndpoint: "https://oauth.example.com/oauth/authorize", + TokenEndpoint: "https://oauth.example.com/oauth/token", + }, nil + }, + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithOAuth(oa), server.WithOAuthLogin()) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + client := &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + for _, path := range []string{"/login", "/multicloud/login"} { + sidecarPaths = nil + resp, getErr := client.Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if len(sidecarPaths) != 0 { + t.Fatalf("%s proxied to sidecar: %v", path, sidecarPaths) + } + if resp.StatusCode != http.StatusFound { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + } + + sidecarPaths = nil + resp, err := ts.Client().Get(ts.URL + "/logout") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if len(sidecarPaths) != 0 { + t.Fatalf("logout proxied: %v", sidecarPaths) + } + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("logout status %d", resp.StatusCode) + } + + sidecarPaths = nil + resp, err = ts.Client().Get(ts.URL + "/configure") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if len(sidecarPaths) != 0 { + t.Fatalf("configure proxied: %v", sidecarPaths) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("configure status %d", resp.StatusCode) + } + if !strings.Contains(string(body), `"token_endpoint":"https://oauth.example.com/oauth/token"`) { + t.Fatalf("configure body %s", body) + } +} + func TestStatelessProxiesNotProxiedToSidecar(t *testing.T) { var sidecarPaths []string sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -312,6 +389,77 @@ func TestStaticNotProxiedToSidecar(t *testing.T) { } } +func TestOAuthAbsentProxiesToSidecar(t *testing.T) { + var sidecarPaths []string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarPaths = append(sidecarPaths, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + resp, err := ts.Client().Get(ts.URL + "/login") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if len(sidecarPaths) != 1 || sidecarPaths[0] != "/login" { + t.Fatalf("sidecar paths %v", sidecarPaths) + } +} + +func TestConfigureWithoutLoginNotProxied(t *testing.T) { + var sidecarPaths []string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarPaths = append(sidecarPaths, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + oa := oauth.New(oauth.Options{ + Discover: func(context.Context) (oauth.Info, error) { + return oauth.Info{TokenEndpoint: "https://oauth.example.com/oauth/token"}, nil + }, + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithOAuth(oa)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + resp, err := ts.Client().Get(ts.URL + "/multicloud/configure") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if len(sidecarPaths) != 0 { + t.Fatalf("configure proxied: %v", sidecarPaths) + } + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "oauth.example.com") { + t.Fatalf("status %d body %s", resp.StatusCode, body) + } + + sidecarPaths = nil + resp, err = ts.Client().Get(ts.URL + "/login") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if len(sidecarPaths) != 1 || sidecarPaths[0] != "/login" { + t.Fatalf("login should still proxy without WithOAuthLogin: %v", sidecarPaths) + } +} + func TestK8sProxyNotProxiedToSidecar(t *testing.T) { var sidecarPaths []string sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 074a300cdb6..ec9b6459620 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,4 +39,6 @@ All resources are checked for access using `SubjectAccessReview` calls to the cl The console backend proxies the cluster apiserver `/api` and `/apis` apiserver REST routes from the Go public listener (`backend/internal/k8sproxy`). All REST calls use the token passed from the console frontend. +Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. + Static plugin assets (`plugin-manifest.json`, `plugin-entry.js`, hashed JS/CSS, locales) are served by the Go listener with the same cache headers, CSP, and brotli/gzip content negotiation as the former Node `serve` route. diff --git a/frontend/src/components/LoadPluginData.test.tsx b/frontend/src/components/LoadPluginData.test.tsx index dec397513f9..be66f05d377 100644 --- a/frontend/src/components/LoadPluginData.test.tsx +++ b/frontend/src/components/LoadPluginData.test.tsx @@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' +import { NavigationPath } from '../NavigationPath' import { defaultContext, PluginData, PluginDataContext } from '../lib/PluginDataContext' import { PluginContext, defaultPlugin } from '../lib/PluginContext' import { LoadPluginData } from './LoadPluginData' @@ -12,10 +13,14 @@ jest.mock('../lib/acm-i18next', () => ({ }), })) -function renderWithContext(contextOverrides: Partial, children = 'Page Content') { +function renderWithContext( + contextOverrides: Partial, + children = 'Page Content', + initialPath = '/' +) { const ctx: PluginData = { ...defaultContext, ...contextOverrides } return render( - + {children} @@ -32,6 +37,11 @@ describe('LoadPluginData', () => { expect(screen.getByText('Loading')).toBeInTheDocument() }) + it('fast-loads /multicloud when loadStarted without waiting for loadCompleted', () => { + renderWithContext({ loadCompleted: false, loadStarted: true }, 'Page Content', NavigationPath.emptyPath + '/multicloud') + expect(screen.getByText('Page Content')).toBeInTheDocument() + }) + it('shows children when loadCompleted is true', () => { renderWithContext({ loadCompleted: true }) expect(screen.getByText('Page Content')).toBeInTheDocument() From f59dad42c2975528acc2543e4dde27ef80ab82b5 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Wed, 2 Sep 2026 15:53:13 +0200 Subject: [PATCH 06/16] cors fix (#53) Signed-off-by: Enrique Mingorance Cano --- backend/internal/cors/cors.go | 36 +++++++++ backend/internal/cors/cors_test.go | 102 +++++++++++++++++++++++++ backend/internal/server/server.go | 2 + backend/internal/server/server_test.go | 46 +++++++++++ 4 files changed, 186 insertions(+) create mode 100644 backend/internal/cors/cors.go create mode 100644 backend/internal/cors/cors_test.go diff --git a/backend/internal/cors/cors.go b/backend/internal/cors/cors.go new file mode 100644 index 00000000000..a745d828563 --- /dev/null +++ b/backend/internal/cors/cors.go @@ -0,0 +1,36 @@ +// Copyright Contributors to the Open Cluster Management project + +package cors + +import ( + "net/http" +) + +// Comment to be removed as a part of the backend-node decommissioning, see ACM-42603 +// Middleware mirrors backend-node/src/lib/cors.ts: reflect Origin and answer OPTIONS. +// with 200 in non-production so standalone dev (webpack on :3000/:3001/:3002) can call :4000. +func Middleware(production bool) func(http.Handler) http.Handler { + if production { + return func(next http.Handler) http.Handler { return next } + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin, Access-Control-Allow-Origin") + } + w.Header().Set("Access-Control-Allow-Credentials", "true") + if r.Method == http.MethodOptions { + if v := r.Header.Get("Access-Control-Request-Method"); v != "" { + w.Header().Set("Access-Control-Allow-Methods", v) + } + if v := r.Header.Get("Access-Control-Request-Headers"); v != "" { + w.Header().Set("Access-Control-Allow-Headers", v) + } + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/backend/internal/cors/cors_test.go b/backend/internal/cors/cors_test.go new file mode 100644 index 00000000000..dbf2b30dcba --- /dev/null +++ b/backend/internal/cors/cors_test.go @@ -0,0 +1,102 @@ +// Copyright Contributors to the Open Cluster Management project + +package cors_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stolostron/console/backend/internal/cors" +) + +func TestMiddleware_ProductionPassthrough(t *testing.T) { + var called bool + h := cors.Middleware(true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodOptions, ts.URL, nil) + req.Header.Set("Origin", "https://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "GET") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if !called { + t.Fatal("expected handler to run in production") + } + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("status %d", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "" { + t.Fatal("unexpected CORS headers in production") + } +} + +func TestMiddleware_DevelopmentOptionsPreflight(t *testing.T) { + var called bool + h := cors.Middleware(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodOptions, ts.URL, nil) + req.Header.Set("Origin", "https://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "authorization,content-type") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if called { + t.Fatal("handler should not run for OPTIONS preflight") + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "https://localhost:3000" { + t.Fatalf("allow-origin %q", resp.Header.Get("Access-Control-Allow-Origin")) + } + if resp.Header.Get("Access-Control-Allow-Credentials") != "true" { + t.Fatal("missing allow-credentials") + } + if resp.Header.Get("Access-Control-Allow-Methods") != "GET" { + t.Fatalf("allow-methods %q", resp.Header.Get("Access-Control-Allow-Methods")) + } + if resp.Header.Get("Access-Control-Allow-Headers") != "authorization,content-type" { + t.Fatalf("allow-headers %q", resp.Header.Get("Access-Control-Allow-Headers")) + } +} + +func TestMiddleware_DevelopmentNonOptionsAddsHeaders(t *testing.T) { + h := cors.Middleware(false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL, nil) + req.Header.Set("Origin", "https://localhost:3001") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "https://localhost:3001" { + t.Fatalf("allow-origin %q", resp.Header.Get("Access-Control-Allow-Origin")) + } + if resp.Header.Get("Access-Control-Allow-Credentials") != "true" { + t.Fatal("missing allow-credentials") + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index dc447cd3728..3f06d79b805 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -18,6 +18,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stolostron/console/backend/internal/config" + "github.com/stolostron/console/backend/internal/cors" "github.com/stolostron/console/backend/internal/health" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/oauth" @@ -221,6 +222,7 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { sidecar := proxy.New(target, sidecarTLS) r := chi.NewRouter() + r.Use(cors.Middleware(cfg.Production)) r.Use(requestLogger) r.Get("/livenessProbe", probes.Liveness) r.Get("/readinessProbe", probes.Readiness) diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 50b0f7c3a0e..50fae6b324d 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -460,6 +460,52 @@ func TestConfigureWithoutLoginNotProxied(t *testing.T) { } } +func TestDevelopmentCORSOptionsPreflight(t *testing.T) { + var k8sCalled bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("sidecar should not be called") + })) + defer sidecar.Close() + + k8s := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + k8sCalled = true + w.WriteHeader(http.StatusOK) + }) + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithK8sProxy(k8s)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/api", "/multicloud/api"} { + k8sCalled = false + req, _ := http.NewRequest(http.MethodOptions, ts.URL+path, nil) + req.Header.Set("Origin", "https://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "authorization,content-type") + resp, getErr := ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if k8sCalled { + t.Fatalf("%s reached k8s proxy", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + if resp.Header.Get("Access-Control-Allow-Origin") != "https://localhost:3000" { + t.Fatalf("%s allow-origin %q", path, resp.Header.Get("Access-Control-Allow-Origin")) + } + if resp.Header.Get("Access-Control-Allow-Credentials") != "true" { + t.Fatalf("%s missing allow-credentials", path) + } + } +} + func TestK8sProxyNotProxiedToSidecar(t *testing.T) { var sidecarPaths []string sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 3bdfc73b9c5662067d3b98f40adf5b0957170ddf Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Thu, 3 Sep 2026 08:22:32 +0200 Subject: [PATCH 07/16] ACM-42596 Migrate auth check, user, and cluster-info routes to Go (#54) * cors fix Signed-off-by: Enrique Mingorance Cano * Migrate auth check, user, and cluster-info routes to Go Signed-off-by: Enrique Mingorance Cano * check-hub-alignment.sh Signed-off-by: Enrique Mingorance Cano * generate-certs at setup.sh Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- AGENTS.md | 2 + backend-node/src/app.ts | 20 - backend-node/src/lib/authenticated.ts | 14 - backend-node/src/lib/managed-cluster-addon.ts | 67 --- backend-node/src/routes/apiPaths.ts | 91 --- backend-node/src/routes/clusterVersion.ts | 57 -- backend-node/src/routes/hub.ts | 80 --- backend-node/src/routes/hypershift-status.ts | 71 --- .../routes/multiClusterEngineComponents.ts | 12 - .../src/routes/multiClusterHubComponents.ts | 14 - backend-node/src/routes/operatorCheck.ts | 164 ------ backend-node/src/routes/username.ts | 56 -- backend-node/src/routes/userpreference.ts | 127 ---- backend-node/test/routes/apiPath.test.ts | 45 -- .../test/routes/clusterVersion.test.ts | 147 ----- backend-node/test/routes/hub.test.ts | 120 ---- .../test/routes/hypershift-status.test.ts | 81 --- .../test/routes/operatorCheck.test.ts | 279 --------- backend-node/test/routes/username.test.ts | 47 -- .../test/routes/userpreference.test.ts | 73 --- backend/AGENTS.md | 6 + backend/cmd/console/main.go | 26 + backend/internal/auth/auth.go | 47 +- backend/internal/clusterinfo/clusterinfo.go | 556 ++++++++++++++++++ .../internal/clusterinfo/clusterinfo_test.go | 123 ++++ backend/internal/hubresources/components.go | 69 +++ .../internal/hubresources/components_test.go | 64 ++ backend/internal/server/server.go | 51 ++ backend/internal/server/server_test.go | 59 +- backend/internal/user/user.go | 209 +++++++ backend/internal/user/user_test.go | 157 +++++ docs/ARCHITECTURE.md | 2 + scripts/check-hub-alignment.sh | 64 ++ setup.sh | 5 + start-ocp-console.sh | 1 + 35 files changed, 1426 insertions(+), 1580 deletions(-) delete mode 100644 backend-node/src/lib/authenticated.ts delete mode 100644 backend-node/src/lib/managed-cluster-addon.ts delete mode 100644 backend-node/src/routes/apiPaths.ts delete mode 100644 backend-node/src/routes/clusterVersion.ts delete mode 100644 backend-node/src/routes/hub.ts delete mode 100644 backend-node/src/routes/hypershift-status.ts delete mode 100644 backend-node/src/routes/multiClusterEngineComponents.ts delete mode 100644 backend-node/src/routes/multiClusterHubComponents.ts delete mode 100644 backend-node/src/routes/operatorCheck.ts delete mode 100644 backend-node/src/routes/username.ts delete mode 100644 backend-node/src/routes/userpreference.ts delete mode 100644 backend-node/test/routes/apiPath.test.ts delete mode 100644 backend-node/test/routes/clusterVersion.test.ts delete mode 100644 backend-node/test/routes/hub.test.ts delete mode 100644 backend-node/test/routes/hypershift-status.test.ts delete mode 100644 backend-node/test/routes/operatorCheck.test.ts delete mode 100644 backend-node/test/routes/username.test.ts delete mode 100644 backend-node/test/routes/userpreference.test.ts create mode 100644 backend/internal/clusterinfo/clusterinfo.go create mode 100644 backend/internal/clusterinfo/clusterinfo_test.go create mode 100644 backend/internal/hubresources/components.go create mode 100644 backend/internal/hubresources/components_test.go create mode 100644 backend/internal/user/user.go create mode 100644 backend/internal/user/user_test.go create mode 100755 scripts/check-hub-alignment.sh diff --git a/AGENTS.md b/AGENTS.md index ed5c6c4beef..79ed85b9473 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,3 +142,5 @@ Features can be enabled/disabled via the `console-config` ConfigMap in the insta - **Certificate errors** — Remove `backend/certs/` and run `npm run generate-certs` - **Module resolution errors** — Verify Node.js and npm versions match `.nvmrc` / `.tool-versions`; version mismatches break ESM resolution - **Missing `.env`** — Run `npm run setup` (or `npm run setup:hub` after `oc login` to a new cluster) to generate `backend/.env` +- **Plugin UI redirects to `/dashboards`** — `oc whoami --show-server` must match `CLUSTER_API_URL` in `backend/.env`. After `oc login` to a new hub, run `npm run setup:hub` and restart `npm run plugins`. `start-ocp-console.sh` runs `scripts/check-hub-alignment.sh` to catch this early. +- **Console `tls: first record does not look like a TLS handshake`** — `backend/certs/` is missing or backends were started before certs existed. Run `npm run generate-certs` and restart `npm run plugins` (both Go and Node sidecar read certs only at startup). diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index a39c54bae8d..637a2c3dc45 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -1,7 +1,6 @@ /* Copyright Contributors to the Open Cluster Management project */ import Router from 'find-my-way' import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { authenticated } from './lib/authenticated' import { loadSettings } from './lib/config' import { stopFileWatches } from './lib/fileWatch' import { cors } from './lib/cors' @@ -13,24 +12,15 @@ import { startServer, stopServer } from './lib/server' import { ServerSideEvents } from './lib/server-side-events' import { aggregate, startAggregating, stopAggregating } from './routes/aggregator' import { ansibleTower } from './routes/ansibletower' -import { apiPaths } from './routes/apiPaths' import { events, startWatching, stopWatching } from './routes/events' -import { hub } from './routes/hub' import { liveness } from './routes/liveness' -import { multiClusterHubComponents } from './routes/multiClusterHubComponents' -import { operatorCheck } from './routes/operatorCheck' import { readiness } from './routes/readiness' import { search } from './routes/search' import { placementDebug } from './routes/placementDebug' import { upgradeRiskPredictions } from './routes/upgrade-risks-prediction' -import { username } from './routes/username' -import { userpreference } from './routes/userpreference' -import { hypershiftStatus } from './routes/hypershift-status' -import { clusterVersion } from './routes/clusterVersion' import { watchTLSSecurityProfile } from './lib/tlsProfileWatch' import { watchPlacementDebugCA } from './lib/placementDebugCAWatch' import { invalidatePlacementDebugAgent } from './lib/agent' -import { multiClusterEngineComponents } from './routes/multiClusterEngineComponents' import { getAwsAccountIds, getAwsBillingAccountIds, @@ -55,24 +45,14 @@ export const router = Router({ maxParamLength: 500 }) router.get('/readinessProbe', readiness) router.get('/livenessProbe', liveness) router.get('/ping', respondOK) -router.get('/apiPaths', apiPaths) -router.post('/operatorCheck', operatorCheck) if (eventsEnabled) { router.get('/events', events) } router.post('/proxy/search', search) router.post('/placement-debug', placementDebug) -router.get('/authenticated', authenticated) router.post('/ansibletower', ansibleTower) -router.get('/username', username) -router.all('/userpreference', userpreference) -router.get('/hub', hub) -router.get('/hypershift-status', hypershiftStatus) -router.get('/cluster-version', clusterVersion) router.post('/upgrade-risks-prediction', upgradeRiskPredictions) router.post('/aggregate/*', aggregate) -router.get('/multiclusterhub/components', multiClusterHubComponents) -router.get('/multiclusterengine/components', multiClusterEngineComponents) // rosa wizard routes router.post('/aws-account-ids', getAwsAccountIds) diff --git a/backend-node/src/lib/authenticated.ts b/backend-node/src/lib/authenticated.ts deleted file mode 100644 index f9e1dd2caee..00000000000 --- a/backend-node/src/lib/authenticated.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { catchInternalServerError, unauthorized } from './respond' -import { getToken, isAuthenticated } from './token' - -export function authenticated(req: Http2ServerRequest, res: Http2ServerResponse): void { - const token = getToken(req) - if (!token) return unauthorized(req, res) - isAuthenticated(token) - .then((status) => { - res.writeHead(status).end() - }) - .catch(catchInternalServerError(res)) -} diff --git a/backend-node/src/lib/managed-cluster-addon.ts b/backend-node/src/lib/managed-cluster-addon.ts deleted file mode 100644 index afd93ac88ef..00000000000 --- a/backend-node/src/lib/managed-cluster-addon.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { jsonRequest } from './json-request' -import { logger } from './logger' -import { getServiceAccountToken } from './serviceAccountToken' - -export interface ManagedClusterAddOn { - metadata: { - name: string - } - status?: { - conditions?: Array<{ - reason: string - status: string - }> - } -} - -interface ManagedClusterAddOnList { - items: ManagedClusterAddOn[] -} - -export async function getManagedClusterAddOns( - namespace: string, - throwErrors?: boolean -): Promise { - const serviceAccountToken = getServiceAccountToken() - - try { - const response = await jsonRequest( - process.env.CLUSTER_API_URL + - `/apis/addon.open-cluster-management.io/v1alpha1/namespaces/${namespace}/managedclusteraddons`, - serviceAccountToken - ) - return response.items || [] - } catch (err) { - if (throwErrors) { - throw err - } - logger.error({ - msg: 'Error getting ManagedClusterAddOns', - namespace, - error: err instanceof Error ? err.message : String(err), - }) - return [] - } -} - -export async function getManagedClusterAddOn( - namespace: string, - name: string, - throwErrors?: boolean -): Promise { - const addons = await getManagedClusterAddOns(namespace, throwErrors) - return addons.find((addon) => addon.metadata.name === name) -} - -export function isAddOnHealthy(addon: ManagedClusterAddOn): boolean { - if (!addon.status?.conditions) { - return false - } - - return ( - addon.status.conditions.find((condition) => condition.reason === 'ManagedClusterAddOnLeaseUpdated')?.status === - 'True' - ) -} diff --git a/backend-node/src/routes/apiPaths.ts b/backend-node/src/routes/apiPaths.ts deleted file mode 100644 index dea9bc1853f..00000000000 --- a/backend-node/src/routes/apiPaths.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { jsonRequest } from '../lib/json-request' -import { catchInternalServerError } from '../lib/respond' -import { getAuthenticatedToken } from '../lib/token' -import { getServiceAccountToken } from '../lib/serviceAccountToken' - -interface APIPathResponse { - paths: string[] -} - -interface APIResourcePathResponse { - kind: string - groupVersion: string - resources: APIResourceMetadata[] -} - -interface APIResourceMetadata { - name: string - namespaced: boolean - kind: string - verbs: string[] -} - -export interface APIResourceNames { - [kind: string]: APIResourceMeta -} - -export interface APIResourceMeta { - pluralName: string -} - -export function apiPaths(req: Http2ServerRequest, res: Http2ServerResponse): void { - const errorCatcher = catchInternalServerError(res) - getAuthenticatedToken(req, res) - .then(() => { - const serviceAccountToken = getServiceAccountToken() - jsonRequest(process.env.CLUSTER_API_URL + '/', serviceAccountToken) - .then(async (response: APIPathResponse) => { - const apiResourceLists = await Promise.allSettled( - response.paths - .filter((path) => { - const pathArray = path.substring(1).split('/') - return ( - pathArray.length && - ((pathArray[0] === 'api' && pathArray.length === 2) || - (pathArray[0] === 'apis' && pathArray.length === 3)) - ) - }) - .map(async (path) => { - return jsonRequest( - process.env.CLUSTER_API_URL + path, - serviceAccountToken, - 1 // Limit to 1 retry for misbehaving APIServices - ) - }) - ) - // return apiResourceLists - const paths = buildPathObject(apiResourceLists) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(paths)) - }) - .catch(errorCatcher) - }) - .catch(errorCatcher) -} - -function buildPathObject(apiResourcePathResponse: PromiseSettledResult[]) { - const resourceNames: Record = {} - apiResourcePathResponse.forEach((settledPromise) => { - if (settledPromise.status === 'fulfilled') { - const resourceList = settledPromise.value - const resourceKindMap: { [key: string]: APIResourceMeta } = {} - const groupVersion = resourceList.groupVersion - resourceList.resources.forEach((resource) => { - if (resource['name'].split('/').length === 1) { - const pluralName = resource['name'] - const kind = resource['kind'] - - const apiMetadata: APIResourceMeta = { - pluralName, - } - resourceKindMap[kind] = apiMetadata - } - }) - resourceNames[groupVersion] = resourceKindMap - } - }) - return resourceNames -} diff --git a/backend-node/src/routes/clusterVersion.ts b/backend-node/src/routes/clusterVersion.ts deleted file mode 100644 index c4443ee57f1..00000000000 --- a/backend-node/src/routes/clusterVersion.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { respondInternalServerError } from '../lib/respond' -import { getServiceAccountToken } from '../lib/serviceAccountToken' -import { getAuthenticatedToken } from '../lib/token' -import type { IResource } from '../resources/resource' - -export interface ClusterVersion extends IResource { - apiVersion: 'config.openshift.io/v1' - kind: 'ClusterVersion' - status?: { - desired?: { - version?: string - } - } -} - -export interface ClusterVersionResponse { - version?: string - error?: string -} - -export async function clusterVersion(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - const serviceAccountToken = getServiceAccountToken() - - try { - const path = process.env.CLUSTER_API_URL + '/apis/config.openshift.io/v1/clusterversions/version' - const clusterVersionResource = await jsonRequest(path, serviceAccountToken) - .then((clusterVersion: ClusterVersion) => { - // Extract version from the ClusterVersion resource - const version = clusterVersion.status?.desired?.version - const response: ClusterVersionResponse = { - version: version || undefined, - } - return response - }) - .catch((err: Error) => { - logger.error({ msg: 'Error getting ClusterVersion', error: err.message }) - const response: ClusterVersionResponse = { - error: `Failed to get cluster version: ${err.message}`, - } - return response - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(clusterVersionResource)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} diff --git a/backend-node/src/routes/hub.ts b/backend-node/src/routes/hub.ts deleted file mode 100644 index c58238b00fb..00000000000 --- a/backend-node/src/routes/hub.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { respondInternalServerError } from '../lib/respond' -import { getServiceAccountToken } from '../lib/serviceAccountToken' -import { getAuthenticatedToken } from '../lib/token' -import type { IResource } from '../resources/resource' -import { getHubClusterName, getIsHubSelfManaged, getIsObservabilityInstalled, getKubeResources } from './events' - -interface AuthenticationResource { - spec?: { - type?: string - oidcProviders?: Array<{ - claimMappings?: { - username?: { claim?: string; prefix?: { prefixString?: string }; prefixPolicy?: string } - groups?: { claim?: string; prefix?: string } - } - }> - } -} - -export function buildAuthentication(resources: IResource[]) { - const clusterAuth = resources.find((r) => r.metadata?.name === 'cluster') as AuthenticationResource | undefined - const isDirectAuthenticationEnabled = clusterAuth?.spec?.type === 'OIDC' - - const oidcClaimMappings = clusterAuth?.spec?.oidcProviders?.[0]?.claimMappings - return { - isDirectAuthenticationEnabled, - ...(oidcClaimMappings && { - claimMappings: { - username: { - claim: oidcClaimMappings.username?.claim, - prefix: oidcClaimMappings.username?.prefix, - prefixPolicy: oidcClaimMappings.username?.prefixPolicy, - }, - groups: { - claim: oidcClaimMappings.groups?.claim, - prefix: oidcClaimMappings.groups?.prefix, - }, - }, - }), - } -} - -export async function hub(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - const serviceAccountToken = getServiceAccountToken() - - try { - const crdName = 'multiclusterglobalhubs.operator.open-cluster-management.io' - const path = process.env.CLUSTER_API_URL + `/apis/apiextensions.k8s.io/v1/customresourcedefinitions/${crdName}` - const [crdResponse, authentications] = await Promise.all([ - jsonRequest(path, serviceAccountToken) - .then((response) => ({ isGlobalHub: response.kind === 'CustomResourceDefinition' })) - .catch((err: Error) => { - logger.error({ msg: 'Error getting Multicluster Global Hubs', error: err.message }) - return { isGlobalHub: false } - }), - getKubeResources('Authentication', 'config.openshift.io/v1'), - ]) - - const response = { - isGlobalHub: crdResponse.isGlobalHub, - localHubName: getHubClusterName(), - isHubSelfManaged: getIsHubSelfManaged(), - isObservabilityInstalled: getIsObservabilityInstalled(), - authentication: buildAuthentication(authentications), - } - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(response)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} diff --git a/backend-node/src/routes/hypershift-status.ts b/backend-node/src/routes/hypershift-status.ts deleted file mode 100644 index 69bdcb67ded..00000000000 --- a/backend-node/src/routes/hypershift-status.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { logger } from '../lib/logger' -import { respondInternalServerError } from '../lib/respond' -import { getAuthenticatedToken } from '../lib/token' -import { getMultiClusterEngineComponents, type MultiClusterEngineComponent } from '../lib/multi-cluster-engine' -import { getManagedClusterAddOn, isAddOnHealthy, type ManagedClusterAddOn } from '../lib/managed-cluster-addon' - -function processHypershiftStatus( - components: MultiClusterEngineComponent[] | undefined, - hypershiftAddon: ManagedClusterAddOn | undefined -): boolean { - try { - // Check if we have components - if (!components) { - return false - } - - // Check if hypershift components are enabled - const hypershift = components.find((component) => component.name === 'hypershift') - const hypershiftLocalHosting = components.find((component) => component.name === 'hypershift-local-hosting') - - if (!hypershift?.enabled || !hypershiftLocalHosting?.enabled) { - return false - } - - // Check if the hypershift addon exists and is healthy - if (!hypershiftAddon) { - return false - } - - return isAddOnHealthy(hypershiftAddon) - } catch (error) { - logger.error('Error processing hypershift status:', error) - return false - } -} - -export async function hypershiftStatus(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (!token) { - return // getAuthenticatedToken already handles the response - } - - try { - // Get the local hub name from query parameter or default to 'local-cluster' - const url = new URL(req.url, `http://${req.headers.host}`) - const localHubName = url.searchParams.get('hubName') || 'local-cluster' - - // Fetch MultiClusterEngine components (no cache for fresh data, throw errors) - const components = await getMultiClusterEngineComponents(true, true) - - // Fetch the hypershift-addon for the local hub (throw errors) - const hypershiftAddon = await getManagedClusterAddOn(localHubName, 'hypershift-addon', true) - - // Process the results to determine if hypershift is enabled - const isHypershiftEnabled = processHypershiftStatus(components, hypershiftAddon) - - const responsePayload = { - statusCode: 200, - body: { isHypershiftEnabled }, - } - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(responsePayload)) - } catch (err) { - logger.error('Error fetching hypershift status:', err) - respondInternalServerError(req, res) - } -} diff --git a/backend-node/src/routes/multiClusterEngineComponents.ts b/backend-node/src/routes/multiClusterEngineComponents.ts deleted file mode 100644 index 856e2fbf87c..00000000000 --- a/backend-node/src/routes/multiClusterEngineComponents.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { getMultiClusterEngineComponents } from '../lib/multi-cluster-engine' -import { getAuthenticatedToken } from '../lib/token' -export async function multiClusterEngineComponents(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - const response = await getMultiClusterEngineComponents(true) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(response)) - } -} diff --git a/backend-node/src/routes/multiClusterHubComponents.ts b/backend-node/src/routes/multiClusterHubComponents.ts deleted file mode 100644 index 333459de7bc..00000000000 --- a/backend-node/src/routes/multiClusterHubComponents.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { getMultiClusterHubComponents } from '../lib/multi-cluster-hub' -import { getAuthenticatedToken } from '../lib/token' - -export async function multiClusterHubComponents(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - const response = await getMultiClusterHubComponents(true) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(response)) - } -} diff --git a/backend-node/src/routes/operatorCheck.ts b/backend-node/src/routes/operatorCheck.ts deleted file mode 100644 index 5ca68fa54dd..00000000000 --- a/backend-node/src/routes/operatorCheck.ts +++ /dev/null @@ -1,164 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import get from 'get-value' -import { jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { catchInternalServerError, respondBadRequest } from '../lib/respond' -import { getAuthenticatedToken } from '../lib/token' -import { getServiceAccountToken } from '../lib/serviceAccountToken' - -export enum SupportedOperator { - ansible = 'ansible-automation-platform-operator', - gitOps = 'openshift-gitops-operator', - acm = 'advanced-cluster-management', - kubevirt = 'kubevirt-hyperconverged', -} -type OperatorCheckRequest = { - operator: SupportedOperator -} -type OperatorCheckResponse = { - operator: SupportedOperator - installed: boolean - version?: string -} -function isOperatorCheckRequest(value: unknown): value is OperatorCheckRequest { - if (value && typeof value === 'object' && 'operator' in value) { - return Object.values(SupportedOperator).includes(value.operator as SupportedOperator) - } - return false -} - -function hasCondition(item: object, type: string, status: string): boolean { - const conditions = get(item, 'status.conditions') as unknown[] - return ( - Array.isArray(conditions) && - conditions.some( - (condition: unknown) => - typeof condition === 'object' && - condition !== null && - get(condition, 'type') === type && - get(condition, 'status') === status - ) - ) -} - -function getSubscriptionInstall( - items: unknown[], - operator: SupportedOperator -): { installed: boolean; version?: string } { - const subscription = items.find( - (item: unknown) => - typeof item === 'object' && - item !== null && - get(item, 'spec.name') === operator && - hasCondition(item, 'CatalogSourcesUnhealthy', 'False') - ) as object | undefined - if (subscription) { - return { - installed: true, - version: get(subscription, 'status.installedCSV') as string | undefined, - } - } - return { installed: false } -} - -function getClusterExtensionInstall( - items: unknown[], - operator: SupportedOperator -): { installed: boolean; version?: string } { - const clusterExtension = items.find( - (item: unknown) => - typeof item === 'object' && - item !== null && - get(item, 'spec.source.catalog.packageName') === operator && - hasCondition(item, 'Installed', 'True') - ) as object | undefined - if (clusterExtension) { - return { - installed: true, - version: get(clusterExtension, 'status.install.bundle.version') as string | undefined, - } - } - return { installed: false } -} - -function isResourceList(response: unknown): response is { items: unknown[] } { - return typeof response === 'object' && response !== null && 'items' in response && Array.isArray(response.items) -} - -function respondOperatorCheck(res: Http2ServerResponse, payload: OperatorCheckResponse): void { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(payload)) -} - -async function resolveOperatorInstall( - operator: SupportedOperator, - serviceAccountToken: string -): Promise { - const clusterApiUrl = process.env.CLUSTER_API_URL - const subscriptionResponse = await jsonRequest( - `${clusterApiUrl}/apis/operators.coreos.com/v1alpha1/subscriptions`, - serviceAccountToken - ) - - let installed = false - let version: string | undefined - if (isResourceList(subscriptionResponse)) { - ;({ installed, version } = getSubscriptionInstall(subscriptionResponse.items, operator)) - } - - if (installed) { - return { operator, installed, version } - } - - try { - const clusterExtensionResponse = await jsonRequest( - `${clusterApiUrl}/apis/olm.operatorframework.io/v1/clusterextensions`, - serviceAccountToken - ) - if (isResourceList(clusterExtensionResponse)) { - ;({ installed, version } = getClusterExtensionInstall(clusterExtensionResponse.items, operator)) - } - return { operator, installed, version } - } catch (err: unknown) { - // OLMv1 CRD may not exist on older OpenShift versions — treat as not installed - logger.trace({ - msg: 'operatorCheck ClusterExtension query failed; treating as not installed', - operator, - err, - }) - return { operator, installed: false } - } -} - -function parseOperatorCheckBody(data: string): unknown { - try { - return JSON.parse(data) as unknown - } catch (err) { - logger.error(err) - return undefined - } -} - -export function operatorCheck(req: Http2ServerRequest, res: Http2ServerResponse): void { - const errorCatcher = catchInternalServerError(res) - getAuthenticatedToken(req, res) - .then(() => { - const serviceAccountToken = getServiceAccountToken() - const chunks: string[] = [] - req.on('data', (chunk: string) => { - chunks.push(chunk) - }) - req.on('end', () => { - const operatorCheckRequest = parseOperatorCheckBody(chunks.join('')) - if (!isOperatorCheckRequest(operatorCheckRequest)) { - respondBadRequest(req, res) - return - } - resolveOperatorInstall(operatorCheckRequest.operator, serviceAccountToken) - .then((payload) => respondOperatorCheck(res, payload)) - .catch(errorCatcher) - }) - }) - .catch(errorCatcher) -} diff --git a/backend-node/src/routes/username.ts b/backend-node/src/routes/username.ts deleted file mode 100644 index 6f22a5f701e..00000000000 --- a/backend-node/src/routes/username.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { jsonPost } from '../lib/json-request' -import { logger } from '../lib/logger' -import { respondInternalServerError } from '../lib/respond' -import { getAuthenticatedToken } from '../lib/token' -import { getServiceAccountToken } from '../lib/serviceAccountToken' - -// Type returned by /apis/authentication.k8s.io/v1/tokenreviews -export interface TokenReview { - spec: { - token: string - } - status: { - authenticated: boolean - error: string - user: { - username: string - } - } -} - -export async function username(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - const serviceAccountToken = getServiceAccountToken() - - try { - const response = await jsonPost( - process.env.CLUSTER_API_URL + '/apis/authentication.k8s.io/v1/tokenreviews', - { - apiVersion: 'authentication.k8s.io/v1', - kind: 'TokenReview', - spec: { - token, - }, - }, - serviceAccountToken - ) - const name = - response.body && response.body.status && response.body.status.user && response.body.status.user.username - ? response.body.status.user.username - : '' - const responsePayload = { - statusCode: response.statusCode, - body: { username: name }, - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(responsePayload)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} diff --git a/backend-node/src/routes/userpreference.ts b/backend-node/src/routes/userpreference.ts deleted file mode 100644 index 0774b47da52..00000000000 --- a/backend-node/src/routes/userpreference.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import type { HeadersInit } from 'node-fetch' -import { fetchRetry } from '../lib/fetch-retry' -import { jsonPost, jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { respondInternalServerError } from '../lib/respond' -import { getAuthenticatedToken } from '../lib/token' -import type { IResource } from '../resources/resource' -import { getServiceAccountToken } from '../lib/serviceAccountToken' -import type { TokenReview } from './username' - -const { HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_AUTHORIZATION, HTTP2_HEADER_ACCEPT } = constants - -export interface SavedSearch { - description?: string - id: string - name: string - searchText: string -} -export interface UserPreference extends IResource { - apiVersion: 'console.open-cluster-management.io/v1' - kind: 'UserPreference' - spec?: { - savedSearches?: SavedSearch[] - } -} - -export async function userpreference(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - const serviceAccountToken = getServiceAccountToken() - - const headers: HeadersInit = { - [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${serviceAccountToken}`, - [HTTP2_HEADER_ACCEPT]: 'application/json', - [HTTP2_HEADER_CONTENT_TYPE]: req.method === 'PATCH' ? 'application/json-patch+json' : 'application/json', - } - - jsonPost( - process.env.CLUSTER_API_URL + '/apis/authentication.k8s.io/v1/tokenreviews', - { - apiVersion: 'authentication.k8s.io/v1', - kind: 'TokenReview', - spec: { - token, - }, - }, - serviceAccountToken - ) - .then(async (userResponse) => { - const name = - userResponse.body && - userResponse.body.status && - userResponse.body.status.user && - userResponse.body.status.user.username - ? userResponse.body.status.user.username.toLowerCase().replaceAll(/[^a-z0-9-.]/g, '-') - : '' - if (name) { - let path = process.env.CLUSTER_API_URL + '/apis/console.open-cluster-management.io/v1/userpreferences' - if (req.method === 'PATCH' || req.method === 'GET') { - path = path + '/' + name - } - - if (req.method === 'GET') { - const getResponse = await jsonRequest(path, serviceAccountToken) - .then((response) => response) - .catch((err: Error): undefined => { - logger.error({ msg: 'Error getting UserPreference', error: err.message }) - return undefined - }) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(getResponse)) - } else { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - req.on('end', async () => { - data = chucks.join('') - - const body = - req.method === 'POST' - ? JSON.stringify({ - apiVersion: 'console.open-cluster-management.io/v1', - kind: 'UserPreference', - metadata: { - name: name, - }, - spec: { - savedSearches: JSON.parse(data) as SavedSearch[], - }, - }) - : data - - const fetchResponse = await fetchRetry(path, { - method: req.method, - headers, - body, - compress: true, - }) - .then((response) => response.json() as unknown) - .catch((err: Error): undefined => { - logger.error({ - msg: req.method === 'POST' ? 'Error creating UserPreference' : 'Error updating UserPreference', - error: err.message, - }) - return undefined - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(fetchResponse)) - }) - } - } else { - logger.error(`Error getting username to preform UserPreference ${req.method} request`) - } - }) - .catch((err) => { - logger.error(err) - respondInternalServerError(req, res) - }) - } -} diff --git a/backend-node/test/routes/apiPath.test.ts b/backend-node/test/routes/apiPath.test.ts deleted file mode 100644 index 982ee78b18c..00000000000 --- a/backend-node/test/routes/apiPath.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import { parseResponseJsonBody } from '../../src/lib/body-parser' -import nock from 'nock' - -describe(`apiPath Route`, function () { - it(`should serve resource names`, async function () { - nock(process.env.CLUSTER_API_URL).get('/').reply(200, { - status: 200, - paths, - }) - - nock(process.env.CLUSTER_API_URL).get(paths[0]).reply(200, response) - - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - paths: response, - }) - - const res = await request('GET', '/apiPaths') - expect(res.statusCode).toEqual(200) - expect(JSON.stringify(await parseResponseJsonBody(res))).toEqual(JSON.stringify(buildPathObjectResult)) - }) -}) - -const response = { - kind: 'APIResourceList', - apiVersion: 'v1', - groupVersion: 'action.open-cluster-management.io/v1beta1', - resources: [ - { - name: 'managedclusteractions', - singularName: 'managedclusteraction', - namespaced: true, - kind: 'ManagedClusterAction', - verbs: ['delete', 'deletecollection', 'get', 'list', 'patch', 'create', 'update', 'watch'], - storageVersionHash: 'hCDRbHn7Sxc=', - }, - ], -} -const paths = ['/apis/action.open-cluster-management.io/v1beta1'] - -const buildPathObjectResult = { - 'action.open-cluster-management.io/v1beta1': { ManagedClusterAction: { pluralName: 'managedclusteractions' } }, -} diff --git a/backend-node/test/routes/clusterVersion.test.ts b/backend-node/test/routes/clusterVersion.test.ts deleted file mode 100644 index cf6e8a5b72e..00000000000 --- a/backend-node/test/routes/clusterVersion.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { clusterVersion, type ClusterVersion } from '../../src/routes/clusterVersion' -import * as jsonRequestModule from '../../src/lib/json-request' -import * as tokenModule from '../../src/lib/token' -import * as serviceAccountTokenModule from '../../src/lib/serviceAccountToken' -import * as respondModule from '../../src/lib/respond' - -// Mock modules -jest.mock('../../src/lib/json-request') -jest.mock('../../src/lib/token') -jest.mock('../../src/lib/serviceAccountToken') -jest.mock('../../src/lib/respond') -jest.mock('../../src/lib/logger') - -const mockedJsonRequest = jsonRequestModule.jsonRequest as jest.MockedFunction -const mockedGetAuthenticatedToken = tokenModule.getAuthenticatedToken as jest.MockedFunction< - typeof tokenModule.getAuthenticatedToken -> -const mockedGetServiceAccountToken = serviceAccountTokenModule.getServiceAccountToken as jest.MockedFunction< - typeof serviceAccountTokenModule.getServiceAccountToken -> -const mockedRespondInternalServerError = respondModule.respondInternalServerError as jest.MockedFunction< - typeof respondModule.respondInternalServerError -> - -describe('clusterVersion', () => { - let mockReq: Partial - let mockRes: Partial - let mockSetHeader: jest.Mock - let mockEnd: jest.Mock - - beforeEach(() => { - jest.clearAllMocks() - - mockSetHeader = jest.fn() - mockEnd = jest.fn() - - mockReq = {} - mockRes = { - setHeader: mockSetHeader, - end: mockEnd, - } - - // Set up environment variable - process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' - }) - - afterEach(() => { - delete process.env.CLUSTER_API_URL - }) - - it('should return cluster version when API call succeeds', async () => { - const mockToken = 'mock-token' - const mockServiceAccountToken = 'mock-service-account-token' - const mockClusterVersion: ClusterVersion = { - apiVersion: 'config.openshift.io/v1', - kind: 'ClusterVersion', - status: { - desired: { - version: '4.21.0', - }, - }, - } - - mockedGetAuthenticatedToken.mockResolvedValue(mockToken) - mockedGetServiceAccountToken.mockReturnValue(mockServiceAccountToken) - mockedJsonRequest.mockResolvedValue(mockClusterVersion) - - await clusterVersion(mockReq as Http2ServerRequest, mockRes as Http2ServerResponse) - - expect(mockedGetAuthenticatedToken).toHaveBeenCalledWith(mockReq, mockRes) - expect(mockedGetServiceAccountToken).toHaveBeenCalled() - expect(mockedJsonRequest).toHaveBeenCalledWith( - 'https://api.test-cluster.com:6443/apis/config.openshift.io/v1/clusterversions/version', - mockServiceAccountToken - ) - expect(mockSetHeader).toHaveBeenCalledWith('Content-Type', 'application/json') - expect(mockEnd).toHaveBeenCalledWith(JSON.stringify({ version: '4.21.0' })) - }) - - it('should return error when cluster version is not available', async () => { - const mockToken = 'mock-token' - const mockServiceAccountToken = 'mock-service-account-token' - const mockClusterVersion: ClusterVersion = { - apiVersion: 'config.openshift.io/v1', - kind: 'ClusterVersion', - status: { - desired: { - version: '', - }, - }, - } - - mockedGetAuthenticatedToken.mockResolvedValue(mockToken) - mockedGetServiceAccountToken.mockReturnValue(mockServiceAccountToken) - mockedJsonRequest.mockResolvedValue(mockClusterVersion) - - await clusterVersion(mockReq as Http2ServerRequest, mockRes as Http2ServerResponse) - - expect(mockSetHeader).toHaveBeenCalledWith('Content-Type', 'application/json') - expect(mockEnd).toHaveBeenCalledWith(JSON.stringify({ version: undefined })) - }) - - it('should handle API errors gracefully', async () => { - const mockToken = 'mock-token' - const mockServiceAccountToken = 'mock-service-account-token' - const mockError = new Error('API request failed') - - mockedGetAuthenticatedToken.mockResolvedValue(mockToken) - mockedGetServiceAccountToken.mockReturnValue(mockServiceAccountToken) - mockedJsonRequest.mockRejectedValue(mockError) - - await clusterVersion(mockReq as Http2ServerRequest, mockRes as Http2ServerResponse) - - expect(mockSetHeader).toHaveBeenCalledWith('Content-Type', 'application/json') - expect(mockEnd).toHaveBeenCalledWith(JSON.stringify({ error: 'Failed to get cluster version: API request failed' })) - }) - - it('should not process request when authentication fails', async () => { - mockedGetAuthenticatedToken.mockResolvedValue(null) - - await clusterVersion(mockReq as Http2ServerRequest, mockRes as Http2ServerResponse) - - expect(mockedGetServiceAccountToken).not.toHaveBeenCalled() - expect(mockedJsonRequest).not.toHaveBeenCalled() - expect(mockSetHeader).not.toHaveBeenCalled() - expect(mockEnd).not.toHaveBeenCalled() - }) - - it('should handle unexpected errors', async () => { - const mockToken = 'mock-token' - const mockServiceAccountToken = 'mock-service-account-token' - - mockedGetAuthenticatedToken.mockResolvedValue(mockToken) - mockedGetServiceAccountToken.mockReturnValue(mockServiceAccountToken) - // Mock jsonRequest to throw an error that will be caught in the catch block - mockedJsonRequest.mockImplementation(() => { - throw new Error('Unexpected error') - }) - - await clusterVersion(mockReq as Http2ServerRequest, mockRes as Http2ServerResponse) - - expect(mockedRespondInternalServerError).toHaveBeenCalledWith(mockReq, mockRes) - }) -}) diff --git a/backend-node/test/routes/hub.test.ts b/backend-node/test/routes/hub.test.ts deleted file mode 100644 index 87e801ef6cc..00000000000 --- a/backend-node/test/routes/hub.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import nock from 'nock' -import { parsePipedJsonBody } from '../../src/lib/body-parser' -import type { IResource } from '../../src/resources/resource' -import { cacheResource, resetResourceCache } from '../../src/routes/events' -import { request } from '../mock-request' - -const MCGH_CRD_PATH = - '/apis/apiextensions.k8s.io/v1/customresourcedefinitions/multiclusterglobalhubs.operator.open-cluster-management.io' - -function mockCrdNock(url: string) { - const apisScope = nock(url).get('/api').reply(200, { status: 200 }) - const crdScope = nock(url) - .get(MCGH_CRD_PATH) - .reply(200, { - kind: 'CustomResourceDefinition', - apiVersion: 'apiextensions.k8s.io/v1', - metadata: { - name: 'multiclusterglobalhubs.operator.open-cluster-management.io', - }, - }) - return { apisScope, crdScope } -} - -function mockCrdNock404(url: string) { - const apisScope = nock(url).get('/api').reply(200, { status: 200 }) - const crdScope = nock(url).get(MCGH_CRD_PATH).reply(404, { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - status: 'Failure', - message: - 'customresourcedefinitions.apiextensions.k8s.io "multiclusterglobalhubs.operator.open-cluster-management.io" not found', - reason: 'NotFound', - code: 404, - }) - return { apisScope, crdScope } -} - -describe('global hub', function () { - afterEach(() => resetResourceCache()) - - it('should return authentication without claimMappings when auth type is not OIDC', async function () { - const { apisScope, crdScope } = mockCrdNock(process.env.CLUSTER_API_URL) - const res = await request('GET', '/hub') - expect(res.statusCode).toEqual(200) - const parsed = await parsePipedJsonBody(res) - expect(parsed).toEqual({ - localHubName: 'local-cluster', - isGlobalHub: true, - isHubSelfManaged: false, - isObservabilityInstalled: false, - authentication: { - isDirectAuthenticationEnabled: false, - }, - }) - apisScope.done() - crdScope.done() - }) - - it('should return claimMappings when auth type is OIDC', async function () { - await cacheResource( - { - apiVersion: 'config.openshift.io/v1', - kind: 'Authentication', - metadata: { uid: 'auth-uid', name: 'cluster' }, - spec: { - type: 'OIDC', - oidcProviders: [ - { - claimMappings: { - username: { claim: 'email', prefix: { prefixString: 'oidc:' }, prefixPolicy: 'Prefix' }, - groups: { claim: 'groups', prefix: 'oidc:' }, - }, - }, - ], - }, - } as IResource, - false - ) - - const { apisScope, crdScope } = mockCrdNock(process.env.CLUSTER_API_URL) - const res = await request('GET', '/hub') - expect(res.statusCode).toEqual(200) - const parsed = await parsePipedJsonBody(res) - expect(parsed).toEqual({ - localHubName: 'local-cluster', - isGlobalHub: true, - isHubSelfManaged: false, - isObservabilityInstalled: false, - authentication: { - isDirectAuthenticationEnabled: true, - claimMappings: { - username: { claim: 'email', prefix: { prefixString: 'oidc:' }, prefixPolicy: 'Prefix' }, - groups: { claim: 'groups', prefix: 'oidc:' }, - }, - }, - }) - apisScope.done() - crdScope.done() - }) - - it('should return isGlobalHub false when the multiclusterglobalhub CRD does not exist (404)', async function () { - const { apisScope, crdScope } = mockCrdNock404(process.env.CLUSTER_API_URL) - const res = await request('GET', '/hub') - expect(res.statusCode).toEqual(200) - const parsed = await parsePipedJsonBody(res) - expect(parsed).toEqual({ - localHubName: 'local-cluster', - isGlobalHub: false, - isHubSelfManaged: false, - isObservabilityInstalled: false, - authentication: { - isDirectAuthenticationEnabled: false, - }, - }) - apisScope.done() - crdScope.done() - }) -}) diff --git a/backend-node/test/routes/hypershift-status.test.ts b/backend-node/test/routes/hypershift-status.test.ts deleted file mode 100644 index 10467df915f..00000000000 --- a/backend-node/test/routes/hypershift-status.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import { parseResponseJsonBody } from '../../src/lib/body-parser' -import nock from 'nock' - -describe('hypershift-status Route', function () { - const mockAuth = () => nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { status: 200 }) - - const mockMCE = (hypershiftEnabled = true, localHostingEnabled = true) => - nock(process.env.CLUSTER_API_URL) - .get('/apis/multicluster.openshift.io/v1/multiclusterengines') - .reply(200, { - items: [ - { - spec: { - overrides: { - components: [ - { name: 'hypershift', enabled: hypershiftEnabled }, - { name: 'hypershift-local-hosting', enabled: localHostingEnabled }, - ], - }, - }, - }, - ], - }) - - const mockAddons = (addonStatus = 'True') => - nock(process.env.CLUSTER_API_URL) - .get('/apis/addon.open-cluster-management.io/v1alpha1/namespaces/local-cluster/managedclusteraddons') - .reply(200, { - items: [ - { - metadata: { name: 'hypershift-addon' }, - status: { conditions: [{ reason: 'ManagedClusterAddOnLeaseUpdated', status: addonStatus }] }, - }, - ], - }) - - it('should return hypershift enabled when all conditions are met', async function () { - mockAuth() - mockMCE(true, true) - mockAddons('True') - - const res = await request('GET', '/hypershift-status?hubName=local-cluster') - expect(res.statusCode).toEqual(200) - const { body } = await parseResponseJsonBody(res) - expect(body).toEqual({ isHypershiftEnabled: true }) - }) - - it('should return hypershift disabled when components are disabled', async function () { - mockAuth() - mockMCE(false, true) - mockAddons('True') - - const res = await request('GET', '/hypershift-status?hubName=local-cluster') - expect(res.statusCode).toEqual(200) - const { body } = await parseResponseJsonBody(res) - expect(body).toEqual({ isHypershiftEnabled: false }) - }) - - it('should return hypershift disabled when addon is unhealthy', async function () { - mockAuth() - mockMCE(true, true) - mockAddons('False') - - const res = await request('GET', '/hypershift-status?hubName=local-cluster') - expect(res.statusCode).toEqual(200) - const { body } = await parseResponseJsonBody(res) - expect(body).toEqual({ isHypershiftEnabled: false }) - }) - - it('should handle API errors gracefully', async function () { - mockAuth() - nock(process.env.CLUSTER_API_URL) - .get('/apis/multicluster.openshift.io/v1/multiclusterengines') - .replyWithError('API server error') - - const res = await request('GET', '/hypershift-status?hubName=local-cluster') - expect(res.statusCode).toEqual(500) - }) -}) diff --git a/backend-node/test/routes/operatorCheck.test.ts b/backend-node/test/routes/operatorCheck.test.ts deleted file mode 100644 index cb4878a7af7..00000000000 --- a/backend-node/test/routes/operatorCheck.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request, requestMultiChunk } from '../mock-request' -import { parseResponseJsonBody } from '../../src/lib/body-parser' -import nock from 'nock' - -const subscriptionOperators = { - items: [ - { - metadata: { name: 'openshift-gitops' }, - spec: { name: 'openshift-gitops-operator' }, - status: { - installedCSV: 'openshift-gitops-operator.v1.8.2', - conditions: [ - { - status: 'False', - type: 'CatalogSourcesUnhealthy', - }, - ], - }, - }, - ], -} - -const clusterExtensions = { - items: [ - { - metadata: { name: 'ansible-automation-platform' }, - spec: { - namespace: 'ansible-automation-platform', - source: { - sourceType: 'Catalog', - catalog: { packageName: 'ansible-automation-platform-operator' }, - }, - }, - status: { - install: { bundle: { version: '2.5.0' } }, - conditions: [{ type: 'Installed', status: 'True', reason: 'Succeeded' }], - }, - }, - ], -} - -describe(`operatorCheck Route`, function () { - it(`returns valid response with version for installed operator`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/operators.coreos.com/v1alpha1/subscriptions') - .reply(200, subscriptionOperators) - const res = await request('POST', '/operatorCheck', { operator: 'openshift-gitops-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'openshift-gitops-operator', - installed: true, - version: 'openshift-gitops-operator.v1.8.2', - }) - }) - it(`returns valid response for not-installed operator`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/operators.coreos.com/v1alpha1/subscriptions') - .reply(200, subscriptionOperators) - nock(process.env.CLUSTER_API_URL) - .get('/apis/olm.operatorframework.io/v1/clusterextensions') - .reply(200, { items: [] }) - const res = await request('POST', '/operatorCheck', { operator: 'ansible-automation-platform-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'ansible-automation-platform-operator', - installed: false, - }) - }) - it(`returns installed via ClusterExtension when Subscription is missing`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL).get('/apis/operators.coreos.com/v1alpha1/subscriptions').reply(200, { items: [] }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/olm.operatorframework.io/v1/clusterextensions') - .reply(200, clusterExtensions) - const res = await request('POST', '/operatorCheck', { operator: 'ansible-automation-platform-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'ansible-automation-platform-operator', - installed: true, - version: '2.5.0', - }) - }) - it(`prefers Subscription when both Subscription and ClusterExtension are present`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/operators.coreos.com/v1alpha1/subscriptions') - .reply(200, subscriptionOperators) - const res = await request('POST', '/operatorCheck', { operator: 'openshift-gitops-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'openshift-gitops-operator', - installed: true, - version: 'openshift-gitops-operator.v1.8.2', - }) - }) - it(`returns installed Subscription when an unhealthy package match precedes a healthy one`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/operators.coreos.com/v1alpha1/subscriptions') - .reply(200, { - items: [ - { - metadata: { name: 'openshift-gitops-unhealthy' }, - spec: { name: 'openshift-gitops-operator' }, - status: { - installedCSV: 'openshift-gitops-operator.v1.0.0', - conditions: [{ status: 'True', type: 'CatalogSourcesUnhealthy' }], - }, - }, - { - metadata: { name: 'openshift-gitops' }, - spec: { name: 'openshift-gitops-operator' }, - status: { - installedCSV: 'openshift-gitops-operator.v1.8.2', - conditions: [{ status: 'False', type: 'CatalogSourcesUnhealthy' }], - }, - }, - ], - }) - const res = await request('POST', '/operatorCheck', { operator: 'openshift-gitops-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'openshift-gitops-operator', - installed: true, - version: 'openshift-gitops-operator.v1.8.2', - }) - }) - it(`returns installed ClusterExtension when an uninstalled package match precedes an installed one`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL).get('/apis/operators.coreos.com/v1alpha1/subscriptions').reply(200, { items: [] }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/olm.operatorframework.io/v1/clusterextensions') - .reply(200, { - items: [ - { - metadata: { name: 'ansible-automation-platform-pending' }, - spec: { - namespace: 'ansible-automation-platform', - source: { - sourceType: 'Catalog', - catalog: { packageName: 'ansible-automation-platform-operator' }, - }, - }, - status: { - install: { bundle: { version: '2.4.0' } }, - conditions: [{ type: 'Installed', status: 'False', reason: 'Failed' }], - }, - }, - { - metadata: { name: 'ansible-automation-platform' }, - spec: { - namespace: 'ansible-automation-platform', - source: { - sourceType: 'Catalog', - catalog: { packageName: 'ansible-automation-platform-operator' }, - }, - }, - status: { - install: { bundle: { version: '2.5.0' } }, - conditions: [{ type: 'Installed', status: 'True', reason: 'Succeeded' }], - }, - }, - ], - }) - const res = await request('POST', '/operatorCheck', { operator: 'ansible-automation-platform-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'ansible-automation-platform-operator', - installed: true, - version: '2.5.0', - }) - }) - it(`falls back to ClusterExtension when Subscription matches but is unhealthy`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/operators.coreos.com/v1alpha1/subscriptions') - .reply(200, { - items: [ - { - metadata: { name: 'ansible-automation-platform-unhealthy' }, - spec: { name: 'ansible-automation-platform-operator' }, - status: { - installedCSV: 'ansible-automation-platform-operator.v2.4.0', - conditions: [{ status: 'True', type: 'CatalogSourcesUnhealthy' }], - }, - }, - ], - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/olm.operatorframework.io/v1/clusterextensions') - .reply(200, clusterExtensions) - const res = await request('POST', '/operatorCheck', { operator: 'ansible-automation-platform-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'ansible-automation-platform-operator', - installed: true, - version: '2.5.0', - }) - }) - it(`returns not installed when ClusterExtension CRD is missing`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL).get('/apis/operators.coreos.com/v1alpha1/subscriptions').reply(200, { items: [] }) - nock(process.env.CLUSTER_API_URL).get('/apis/olm.operatorframework.io/v1/clusterextensions').reply(404, { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - status: 'Failure', - message: 'the server could not find the requested resource', - reason: 'NotFound', - code: 404, - }) - const res = await request('POST', '/operatorCheck', { operator: 'openshift-gitops-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'openshift-gitops-operator', - installed: false, - }) - }) - it(`returns not installed when ClusterExtension query fails`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL).get('/apis/operators.coreos.com/v1alpha1/subscriptions').reply(200, { items: [] }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/olm.operatorframework.io/v1/clusterextensions') - .replyWithError('getaddrinfo ENOTFOUND') - const res = await request('POST', '/operatorCheck', { operator: 'openshift-gitops-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'openshift-gitops-operator', - installed: false, - }) - }) - it(`returns bad request for arbitrary operator`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/operators.coreos.com/v1alpha1/subscriptions') - .reply(200, subscriptionOperators) - const res = await request('POST', '/operatorCheck', { operator: 'multicluster-engine' }) - expect(res.statusCode).toEqual(400) - }) - - it('correctly parses request body received in multiple chunks', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/operators.coreos.com/v1alpha1/subscriptions') - .reply(200, subscriptionOperators) - const res = await requestMultiChunk('POST', '/operatorCheck', { operator: 'openshift-gitops-operator' }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual({ - operator: 'openshift-gitops-operator', - installed: true, - version: 'openshift-gitops-operator.v1.8.2', - }) - }) -}) diff --git a/backend-node/test/routes/username.test.ts b/backend-node/test/routes/username.test.ts deleted file mode 100644 index 2f9c24eec5f..00000000000 --- a/backend-node/test/routes/username.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import { parseResponseJsonBody } from '../../src/lib/body-parser' -import nock from 'nock' - -describe('username Route', function () { - it('should return the username', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .post('/apis/authentication.k8s.io/v1/tokenreviews') - .reply(200, { - status: { - user: { - username: 'testuser', - }, - }, - }) - const res = await request('GET', '/username') - expect(res.statusCode).toEqual(200) - const { body } = await parseResponseJsonBody(res) - expect(body).toEqual({ username: 'testuser' }) - }) - it('should return empty string if no username provided', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .post('/apis/authentication.k8s.io/v1/tokenreviews') - .reply(200, { - status: { - user: {}, - }, - }) - const res = await request('GET', '/username') - expect(res.statusCode).toEqual(200) - const { body } = await parseResponseJsonBody(res) - expect(body).toEqual({ username: '' }) - }) - it('should handle errors', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL).post('/apis/authentication.k8s.io/v1/tokenreviews').replyWithError('failed') - const res = await request('GET', '/username') - expect(res.statusCode).toEqual(500) - }) -}) diff --git a/backend-node/test/routes/userpreference.test.ts b/backend-node/test/routes/userpreference.test.ts deleted file mode 100644 index 8f53479cf89..00000000000 --- a/backend-node/test/routes/userpreference.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import nock from 'nock' -import { parsePipedJsonBody } from '../../src/lib/body-parser' -import { request } from '../mock-request' - -describe('userpreference Route', function () { - it('should return the userpreference', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post('/apis/authentication.k8s.io/v1/tokenreviews') - .reply(200, { - status: { - user: { - username: 'kube:admin', - }, - }, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/console.open-cluster-management.io/v1/userpreferences/kube-admin') - .reply(200, { - apiVersion: 'console.open-cluster-management.io/v1', - kind: 'UserPreference', - metadata: { - name: 'kube-admin', - }, - spec: { - savedSearches: [{ description: '', id: '1678205878189', name: 'testing', searchText: 'kind:Pod' }], - }, - }) - const res = await request('GET', '/userpreference') - expect(res.statusCode).toEqual(200) - expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual( - JSON.stringify({ - apiVersion: 'console.open-cluster-management.io/v1', - kind: 'UserPreference', - metadata: { - name: 'kube-admin', - }, - spec: { - savedSearches: [{ description: '', id: '1678205878189', name: 'testing', searchText: 'kind:Pod' }], - }, - }) - ) - }) - it('should create the userpreference', async function () { - const postBody = { - apiVersion: 'console.open-cluster-management.io/v1', - kind: 'UserPreference', - metadata: { - name: 'kube-admin', - }, - spec: { - savedSearches: [{ description: '', id: '1678205878189', name: 'testing', searchText: 'kind:Pod' }], - }, - } - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .post('/apis/authentication.k8s.io/v1/tokenreviews') - .reply(200, { - status: { - user: { - username: 'kube:admin', - }, - }, - }) - nock(process.env.CLUSTER_API_URL) - .post('/apis/console.open-cluster-management.io/v1/userpreferences') - .reply(200, postBody) - const res = await request('POST', '/userpreference', postBody) - expect(res.statusCode).toEqual(200) - expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual(JSON.stringify(postBody)) - }) -}) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 7e4e94b6a04..0dcce38cf72 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -27,6 +27,9 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/config` | `.env` + `config/` directory (filename = key) | | `internal/auth` | Cookie/Bearer, SA token/CA, TokenReview helper, OCM SSO client-credentials token | | `internal/oauth` | `/configure` discovery; standalone `/login` `/login/callback` `/logout` (OpenShift OAuth and OIDC) | +| `internal/user` | `/authenticated`, `/username`, `/userpreference` (TokenReview and UserPreference CR) | +| `internal/clusterinfo` | `/hub`, `/cluster-version`, `/hypershift-status`, MCH/MCE components, `/operatorCheck`, `/apiPaths` | +| `internal/cors` | Development CORS middleware (OPTIONS preflight for standalone dev) | | `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | | `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | @@ -60,6 +63,9 @@ Go backend :4000 (TLS / HTTP/2) │ (also /multicloud/…) ├─ GET /configure (OAuth/OIDC token_endpoint discovery) ├─ GET /login, /login/callback, /logout (standalone OAuth/OIDC; non-production) + ├─ GET /authenticated, /username, /userpreference (user auth and preferences) + ├─ GET /hub, /cluster-version, /hypershift-status, /multiclusterhub/components, + │ /multiclusterengine/components, GET /apiPaths, POST /operatorCheck ├─ ALL /managedclusterproxy/* → cluster-proxy addon (user token; WebSocket) ├─ GET /prometheus/*, /observability/* → metrics backends (user token) ├─ /virtualmachines/*, /virtualmachineinstances/*, /virtualmachinesnapshots/*, diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index 2a748957d40..6aca6157466 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -11,9 +11,12 @@ import ( "os/signal" "syscall" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/clusterinfo" "github.com/stolostron/console/backend/internal/clusterproxy" "github.com/stolostron/console/backend/internal/config" rbacevents "github.com/stolostron/console/backend/internal/events/rbac" @@ -24,6 +27,7 @@ import ( "github.com/stolostron/console/backend/internal/metricsproxy" "github.com/stolostron/console/backend/internal/server" "github.com/stolostron/console/backend/internal/static" + "github.com/stolostron/console/backend/internal/user" "github.com/stolostron/console/backend/internal/vmproxy" ) @@ -63,6 +67,18 @@ func run() error { if err != nil { return err } + dyn, err := dynamic.NewForConfig(restCfg) + if err != nil { + return err + } + disc, err := discovery.NewDiscoveryClientForConfig(restCfg) + if err != nil { + return err + } + reviewer, err := auth.NewTokenReviewer(cfg, sa) + if err != nil { + return err + } store := rbacevents.NewStore() if err = rbacevents.StartInformer(ctx, kube, store); err != nil { return err @@ -128,6 +144,16 @@ func run() error { RESTConfig: restCfg, SAToken: sa.Token, })), + server.WithUser(user.New(user.Options{ + RESTConfig: restCfg, + Reviewer: reviewer, + Dynamic: dyn, + })), + server.WithClusterInfo(clusterinfo.New(clusterinfo.Options{ + RESTConfig: restCfg, + Dynamic: dyn, + Discovery: disc, + })), ) handler, err := server.Handler(cfg, opts...) diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 5973a0bcd41..dfd3491dc17 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -115,9 +115,15 @@ func TokenFromRequest(r *http.Request) string { return "" } +// TokenReviewResult is the outcome of a TokenReview API call. +type TokenReviewResult struct { + Authenticated bool + Username string +} + // TokenReviewer validates a bearer token against the hub API. type TokenReviewer interface { - Review(ctx context.Context, token string) (bool, error) + Review(ctx context.Context, token string) (TokenReviewResult, error) } type kubeReviewer struct { @@ -150,30 +156,39 @@ func UserRESTConfig(base *rest.Config, userToken string) *rest.Config { return c } -// ValidateUserToken checks the token the same way the Node sidecar does: GET /api. -// TokenReview is not used here because console-mce can create TokenReviews for some -// identities that still fail Review, while GET /api matches /events auth. -func ValidateUserToken(ctx context.Context, base *rest.Config, token string) error { +// ValidateUserTokenStatus probes GET /api with the user token and returns the HTTP status. +func ValidateUserTokenStatus(ctx context.Context, base *rest.Config, token string) (int, error) { if base == nil { - return errors.New("rest config is required") + return 0, errors.New("rest config is required") } cfg := UserRESTConfig(base, token) httpClient, err := rest.HTTPClientFor(cfg) if err != nil { - return err + return 0, err } host := strings.TrimRight(cfg.Host, "/") req, err := http.NewRequestWithContext(ctx, http.MethodGet, host+"/api", nil) if err != nil { - return err + return 0, err } resp, err := httpClient.Do(req) if err != nil { - return err + return 0, err } defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - return &StatusError{Status: resp.StatusCode} + return resp.StatusCode, nil +} + +// ValidateUserToken checks the token the same way the Node sidecar does: GET /api. +// TokenReview is not used here because console-mce can create TokenReviews for some +// identities that still fail Review, while GET /api matches /events auth. +func ValidateUserToken(ctx context.Context, base *rest.Config, token string) error { + status, err := ValidateUserTokenStatus(ctx, base, token) + if err != nil { + return err + } + if status != http.StatusOK { + return &StatusError{Status: status} } return nil } @@ -219,12 +234,16 @@ func NewTokenReviewer(cfg *config.Config, sa ServiceAccount) (TokenReviewer, err return &kubeReviewer{client: client}, nil } -func (k *kubeReviewer) Review(ctx context.Context, token string) (bool, error) { +func (k *kubeReviewer) Review(ctx context.Context, token string) (TokenReviewResult, error) { tr, err := k.client.AuthenticationV1().TokenReviews().Create(ctx, &authv1.TokenReview{ Spec: authv1.TokenReviewSpec{Token: token}, }, metav1.CreateOptions{}) if err != nil { - return false, err + return TokenReviewResult{}, err } - return tr.Status.Authenticated, nil + username := tr.Status.User.Username + return TokenReviewResult{ + Authenticated: tr.Status.Authenticated, + Username: username, + }, nil } diff --git a/backend/internal/clusterinfo/clusterinfo.go b/backend/internal/clusterinfo/clusterinfo.go new file mode 100644 index 00000000000..f3f6959e752 --- /dev/null +++ b/backend/internal/clusterinfo/clusterinfo.go @@ -0,0 +1,556 @@ +// Copyright Contributors to the Open Cluster Management project + +package clusterinfo + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/hubresources" + applog "github.com/stolostron/console/backend/internal/log" +) + +var ( + managedClusterGVR = schema.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", + Version: "v1", + Resource: "managedclusters", + } + managedClusterAddOnGVR = schema.GroupVersionResource{ + Group: "addon.open-cluster-management.io", + Version: "v1alpha1", + Resource: "managedclusteraddons", + } + authenticationGVR = schema.GroupVersionResource{ + Group: "config.openshift.io", + Version: "v1", + Resource: "authentications", + } + clusterVersionGVR = schema.GroupVersionResource{ + Group: "config.openshift.io", + Version: "v1", + Resource: "clusterversions", + } + crdGVR = schema.GroupVersionResource{ + Group: "apiextensions.k8s.io", + Version: "v1", + Resource: "customresourcedefinitions", + } +) + +// SupportedOperator names accepted by POST /operatorCheck. +type SupportedOperator string + +const ( + OperatorAnsible SupportedOperator = "ansible-automation-platform-operator" + OperatorGitOps SupportedOperator = "openshift-gitops-operator" + OperatorACM SupportedOperator = "advanced-cluster-management" + OperatorKubeVirt SupportedOperator = "kubevirt-hyperconverged" +) + +// Options configure cluster-info route handlers. +type Options struct { + RESTConfig *rest.Config + Dynamic dynamic.Interface + Discovery discovery.DiscoveryInterface +} + +// Handler serves hub, cluster-version, hypershift-status, MCH/MCE components, operatorCheck, and apiPaths. +type Handler struct { + base *rest.Config + dynamic dynamic.Interface + discovery discovery.DiscoveryInterface +} + +// New builds a cluster-info routes handler. +func New(opts Options) *Handler { + return &Handler{ + base: opts.RESTConfig, + dynamic: opts.Dynamic, + discovery: opts.Discovery, + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case path == "/hub" && r.Method == http.MethodGet: + h.hub(w, r) + case path == "/cluster-version" && r.Method == http.MethodGet: + h.clusterVersion(w, r) + case path == "/hypershift-status" && r.Method == http.MethodGet: + h.hypershiftStatus(w, r) + case path == "/multiclusterhub/components" && r.Method == http.MethodGet: + h.mchComponents(w, r) + case path == "/multiclusterengine/components" && r.Method == http.MethodGet: + h.mceComponents(w, r) + case path == "/operatorCheck" && r.Method == http.MethodPost: + h.operatorCheck(w, r) + case path == "/apiPaths" && r.Method == http.MethodGet: + h.apiPaths(w, r) + default: + http.NotFound(w, r) + } +} + +func (h *Handler) hub(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { + return + } + ctx := r.Context() + + isGlobalHub := false + crd, err := h.dynamic.Resource(crdGVR).Get(ctx, + "multiclusterglobalhubs.operator.open-cluster-management.io", metav1.GetOptions{}) + if err == nil { + kind, _, _ := unstructured.NestedString(crd.Object, "kind") + if kind == "CustomResourceDefinition" { + isGlobalHub = true + } + } else if !apierrors.IsNotFound(err) { + applog.Logger().Error("get global hub CRD failed", "error", err) + } + + localHubName := "local-cluster" + isHubSelfManaged := false + mcList, err := h.dynamic.Resource(managedClusterGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + applog.Logger().Error("list managedclusters failed", "error", err) + } else { + for _, item := range mcList.Items { + labels, _, _ := unstructured.NestedStringMap(item.Object, "metadata", "labels") + if labels["local-cluster"] == "true" { + name, _, _ := unstructured.NestedString(item.Object, "metadata", "name") + if name != "" { + localHubName = name + } + isHubSelfManaged = true + break + } + } + } + + isObservabilityInstalled := false + addonList, err := h.dynamic.Resource(managedClusterAddOnGVR).Namespace(localHubName).List(ctx, metav1.ListOptions{}) + if err != nil { + applog.Logger().Error("list managedclusteraddons failed", "error", err) + } else { + for _, item := range addonList.Items { + name, _, _ := unstructured.NestedString(item.Object, "metadata", "name") + if name == "observability-controller" || name == "multicluster-observability-addon" { + isObservabilityInstalled = true + break + } + } + } + + authObj, err := h.dynamic.Resource(authenticationGVR).Get(ctx, "cluster", metav1.GetOptions{}) + authentication := buildAuthentication(nil) + if err == nil { + authentication = buildAuthentication(authObj.Object) + } else if !apierrors.IsNotFound(err) { + applog.Logger().Error("get authentication cluster failed", "error", err) + } + + resp := map[string]interface{}{ + "isGlobalHub": isGlobalHub, + "localHubName": localHubName, + "isHubSelfManaged": isHubSelfManaged, + "isObservabilityInstalled": isObservabilityInstalled, + "authentication": authentication, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func buildAuthentication(obj map[string]interface{}) map[string]interface{} { + if obj == nil { + return map[string]interface{}{ + "isDirectAuthenticationEnabled": false, + } + } + authType, _, _ := unstructured.NestedString(obj, "spec", "type") + isOIDC := authType == "OIDC" + result := map[string]interface{}{ + "isDirectAuthenticationEnabled": isOIDC, + } + providers, found, _ := unstructured.NestedSlice(obj, "spec", "oidcProviders") + if !found || len(providers) == 0 { + return result + } + provider, ok := providers[0].(map[string]interface{}) + if !ok { + return result + } + mappings, found, _ := unstructured.NestedMap(provider, "claimMappings") + if !found { + return result + } + claimMappings := map[string]interface{}{} + if username, ok := mappings["username"].(map[string]interface{}); ok { + entry := map[string]interface{}{} + if claim, ok := username["claim"].(string); ok { + entry["claim"] = claim + } + if prefix, ok := username["prefix"]; ok { + entry["prefix"] = prefix + } + if prefixPolicy, ok := username["prefixPolicy"].(string); ok { + entry["prefixPolicy"] = prefixPolicy + } + claimMappings["username"] = entry + } + if groups, ok := mappings["groups"].(map[string]interface{}); ok { + entry := map[string]interface{}{} + if claim, ok := groups["claim"].(string); ok { + entry["claim"] = claim + } + if prefix, ok := groups["prefix"].(string); ok { + entry["prefix"] = prefix + } + claimMappings["groups"] = entry + } + if len(claimMappings) > 0 { + result["claimMappings"] = claimMappings + } + return result +} + +func (h *Handler) clusterVersion(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { + return + } + obj, err := h.dynamic.Resource(clusterVersionGVR).Get(r.Context(), "version", metav1.GetOptions{}) + w.Header().Set("Content-Type", "application/json") + if err != nil { + applog.Logger().Error("get clusterversion failed", "error", err) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "Failed to get cluster version: " + err.Error(), + }) + return + } + version, _, _ := unstructured.NestedString(obj.Object, "status", "desired", "version") + payload := map[string]interface{}{} + if version != "" { + payload["version"] = version + } + _ = json.NewEncoder(w).Encode(payload) +} + +func (h *Handler) hypershiftStatus(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { + return + } + ctx := r.Context() + hubName := r.URL.Query().Get("hubName") + if hubName == "" { + hubName = "local-cluster" + } + + components, err := hubresources.MCEComponents(ctx, h.dynamic) + if err != nil { + if isMissingAPI(err) { + enabled := processHypershiftStatus(nil, nil) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "statusCode": http.StatusOK, + "body": map[string]bool{ + "isHypershiftEnabled": enabled, + }, + }) + return + } + applog.Logger().Error("hypershift status mce components failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + addon, err := findManagedClusterAddOn(ctx, h.dynamic, hubName, "hypershift-addon") + if err != nil { + if apierrors.IsForbidden(err) { + addon = nil + } else { + applog.Logger().Error("hypershift status addon failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + } + enabled := processHypershiftStatus(components, addon) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "statusCode": http.StatusOK, + "body": map[string]bool{ + "isHypershiftEnabled": enabled, + }, + }) +} + +func processHypershiftStatus(components []hubresources.Component, addon *unstructured.Unstructured) bool { + if len(components) == 0 { + return false + } + var hypershift, localHosting bool + for _, c := range components { + switch c.Name { + case "hypershift": + hypershift = c.Enabled + case "hypershift-local-hosting": + localHosting = c.Enabled + } + } + if !hypershift || !localHosting { + return false + } + if addon == nil { + return false + } + return isAddOnHealthy(addon) +} + +func findManagedClusterAddOn(ctx context.Context, client dynamic.Interface, namespace, name string) (*unstructured.Unstructured, error) { + list, err := client.Resource(managedClusterAddOnGVR).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + for i := range list.Items { + if list.Items[i].GetName() == name { + return &list.Items[i], nil + } + } + return nil, nil +} + +func isAddOnHealthy(addon *unstructured.Unstructured) bool { + conditions, found, _ := unstructured.NestedSlice(addon.Object, "status", "conditions") + if !found { + return false + } + for _, raw := range conditions { + cond, ok := raw.(map[string]interface{}) + if !ok { + continue + } + reason, _ := cond["reason"].(string) + status, _ := cond["status"].(string) + if reason == "ManagedClusterAddOnLeaseUpdated" && status == "True" { + return true + } + } + return false +} + +func (h *Handler) mchComponents(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { + return + } + components, err := hubresources.MCHComponents(r.Context(), h.dynamic) + if err != nil { + if isMissingAPI(err) { + writeJSON(w, nil) + return + } + applog.Logger().Error("mch components failed", "error", err) + writeJSON(w, nil) + return + } + writeJSON(w, components) +} + +func (h *Handler) mceComponents(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { + return + } + components, err := hubresources.MCEComponents(r.Context(), h.dynamic) + if err != nil { + if isMissingAPI(err) { + writeJSON(w, nil) + return + } + applog.Logger().Error("mce components failed", "error", err) + writeJSON(w, nil) + return + } + writeJSON(w, components) +} + +type operatorCheckRequest struct { + Operator SupportedOperator `json:"operator"` +} + +type operatorCheckResponse struct { + Operator SupportedOperator `json:"operator"` + Installed bool `json:"installed"` + Version string `json:"version,omitempty"` +} + +func (h *Handler) operatorCheck(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + applog.Logger().Error("read operatorCheck body failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + var req operatorCheckRequest + if err := json.Unmarshal(body, &req); err != nil || !isSupportedOperator(req.Operator) { + w.WriteHeader(http.StatusBadRequest) + return + } + resp, err := resolveOperatorInstall(r.Context(), h.dynamic, req.Operator) + if err != nil { + applog.Logger().Error("operatorCheck failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func isSupportedOperator(op SupportedOperator) bool { + switch op { + case OperatorAnsible, OperatorGitOps, OperatorACM, OperatorKubeVirt: + return true + default: + return false + } +} + +func resolveOperatorInstall(ctx context.Context, client dynamic.Interface, operator SupportedOperator) (operatorCheckResponse, error) { + subGVR := schema.GroupVersionResource{ + Group: "operators.coreos.com", + Version: "v1alpha1", + Resource: "subscriptions", + } + list, err := client.Resource(subGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return operatorCheckResponse{}, err + } + if installed, version := subscriptionInstall(list.Items, operator); installed { + return operatorCheckResponse{Operator: operator, Installed: true, Version: version}, nil + } + extGVR := schema.GroupVersionResource{ + Group: "olm.operatorframework.io", + Version: "v1", + Resource: "clusterextensions", + } + extList, err := client.Resource(extGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) { + return operatorCheckResponse{Operator: operator, Installed: false}, nil + } + return operatorCheckResponse{}, err + } + installed, version := clusterExtensionInstall(extList.Items, operator) + return operatorCheckResponse{Operator: operator, Installed: installed, Version: version}, nil +} + +func subscriptionInstall(items []unstructured.Unstructured, operator SupportedOperator) (bool, string) { + for _, item := range items { + specName, _, _ := unstructured.NestedString(item.Object, "spec", "name") + if specName != string(operator) { + continue + } + if !hasCondition(item.Object, "CatalogSourcesUnhealthy", "False") { + continue + } + version, _, _ := unstructured.NestedString(item.Object, "status", "installedCSV") + return true, version + } + return false, "" +} + +func clusterExtensionInstall(items []unstructured.Unstructured, operator SupportedOperator) (bool, string) { + for _, item := range items { + pkg, _, _ := unstructured.NestedString(item.Object, "spec", "source", "catalog", "packageName") + if pkg != string(operator) { + continue + } + if !hasCondition(item.Object, "Installed", "True") { + continue + } + version, _, _ := unstructured.NestedString(item.Object, "status", "install", "bundle", "version") + return true, version + } + return false, "" +} + +func hasCondition(obj map[string]interface{}, condType, status string) bool { + conditions, found, _ := unstructured.NestedSlice(obj, "status", "conditions") + if !found { + return false + } + for _, raw := range conditions { + cond, ok := raw.(map[string]interface{}) + if !ok { + continue + } + t, _ := cond["type"].(string) + s, _ := cond["status"].(string) + if t == condType && s == status { + return true + } + } + return false +} + +type apiResourceMeta struct { + PluralName string `json:"pluralName"` +} + +func (h *Handler) apiPaths(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { + return + } + _, lists, err := h.discovery.ServerGroupsAndResources() + if err != nil { + applog.Logger().Error("apiPaths discovery failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + result := make(map[string]map[string]apiResourceMeta) + for _, list := range lists { + if list == nil { + continue + } + kindMap := make(map[string]apiResourceMeta) + for _, res := range list.APIResources { + if strings.Contains(res.Name, "/") { + continue + } + kindMap[res.Kind] = apiResourceMeta{PluralName: res.Name} + } + if len(kindMap) == 0 { + continue + } + result[list.GroupVersion] = kindMap + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(result) +} + +func writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + if v == nil { + _, _ = w.Write([]byte("null")) + return + } + _ = json.NewEncoder(w).Encode(v) +} + +func isMissingAPI(err error) bool { + return meta.IsNoMatchError(err) || apierrors.IsNotFound(err) +} diff --git a/backend/internal/clusterinfo/clusterinfo_test.go b/backend/internal/clusterinfo/clusterinfo_test.go new file mode 100644 index 00000000000..f824db26bd0 --- /dev/null +++ b/backend/internal/clusterinfo/clusterinfo_test.go @@ -0,0 +1,123 @@ +// Copyright Contributors to the Open Cluster Management project + +package clusterinfo_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + discoveryfake "k8s.io/client-go/discovery/fake" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" + + "github.com/stolostron/console/backend/internal/clusterinfo" +) + +func apiProbeServer(t *testing.T) (*httptest.Server, *rest.Config) { + t.Helper() + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer good" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(ts.Close) + return ts, &rest.Config{Host: ts.URL, TLSClientConfig: rest.TLSClientConfig{Insecure: true}} +} + +func TestOperatorCheck_BadBody(t *testing.T) { + _, base := apiProbeServer(t) + h := clusterinfo.New(clusterinfo.Options{RESTConfig: base}) + req := httptest.NewRequest(http.MethodPost, "/operatorCheck", bytes.NewReader([]byte(`{"operator":"not-real"}`))) + req.Header.Set("Authorization", "Bearer good") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} + +func TestHypershiftStatus_Disabled(t *testing.T) { + _, base := apiProbeServer(t) + mce := &unstructured.Unstructured{} + mce.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "multicluster.openshift.io", Version: "v1", Kind: "MultiClusterEngine", + }) + mce.SetName("engine") + components := []interface{}{ + map[string]interface{}{"name": "hypershift", "enabled": false}, + map[string]interface{}{"name": "hypershift-local-hosting", "enabled": true}, + } + mce.Object = map[string]interface{}{ + "apiVersion": "multicluster.openshift.io/v1", + "kind": "MultiClusterEngine", + "metadata": map[string]interface{}{"name": "engine"}, + "spec": map[string]interface{}{ + "overrides": map[string]interface{}{"components": components}, + }, + } + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"}: "MultiClusterEngineList", + {Group: "addon.open-cluster-management.io", Version: "v1alpha1", Resource: "managedclusteraddons"}: "ManagedClusterAddOnList", + }, mce) + h := clusterinfo.New(clusterinfo.Options{RESTConfig: base, Dynamic: dyn}) + req := httptest.NewRequest(http.MethodGet, "/hypershift-status?hubName=local-cluster", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var payload map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + body := payload["body"].(map[string]interface{}) + if body["isHypershiftEnabled"] != false { + t.Fatalf("payload %#v", payload) + } +} + +func TestAPIPaths(t *testing.T) { + _, base := apiProbeServer(t) + disc := &discoveryfake.FakeDiscovery{ + Fake: &k8stesting.Fake{ + Resources: []*metav1.APIResourceList{{ + GroupVersion: "action.open-cluster-management.io/v1beta1", + APIResources: []metav1.APIResource{{ + Name: "managedclusteractions", + Kind: "ManagedClusterAction", + }}, + }}, + }, + } + h := clusterinfo.New(clusterinfo.Options{RESTConfig: base, Discovery: disc}) + req := httptest.NewRequest(http.MethodGet, "/apiPaths", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var got map[string]map[string]map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["action.open-cluster-management.io/v1beta1"]["ManagedClusterAction"]["pluralName"] != "managedclusteractions" { + t.Fatalf("got %#v", got) + } +} diff --git a/backend/internal/hubresources/components.go b/backend/internal/hubresources/components.go new file mode 100644 index 00000000000..52c9bd55ebc --- /dev/null +++ b/backend/internal/hubresources/components.go @@ -0,0 +1,69 @@ +// Copyright Contributors to the Open Cluster Management project + +package hubresources + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" +) + +// Component is an MCH/MCE override component entry. +type Component struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` +} + +// MCHComponents returns spec.overrides.components from the first MulticlusterHub. +func MCHComponents(ctx context.Context, client dynamic.Interface) ([]Component, error) { + if client == nil { + return nil, fmt.Errorf("kubernetes dynamic client is required") + } + list, err := client.Resource(mchGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + if len(list.Items) == 0 { + return nil, nil + } + return parseComponents(list.Items[0].Object) +} + +// MCEComponents returns spec.overrides.components from the first MultiClusterEngine. +func MCEComponents(ctx context.Context, client dynamic.Interface) ([]Component, error) { + if client == nil { + return nil, fmt.Errorf("kubernetes dynamic client is required") + } + list, err := client.Resource(mceGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + if len(list.Items) == 0 { + return nil, nil + } + return parseComponents(list.Items[0].Object) +} + +func parseComponents(obj map[string]interface{}) ([]Component, error) { + raw, found, err := unstructured.NestedSlice(obj, "spec", "overrides", "components") + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + out := make([]Component, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + name, _ := m["name"].(string) + enabled, _ := m["enabled"].(bool) + out = append(out, Component{Name: name, Enabled: enabled}) + } + return out, nil +} diff --git a/backend/internal/hubresources/components_test.go b/backend/internal/hubresources/components_test.go new file mode 100644 index 00000000000..7e8c39b3286 --- /dev/null +++ b/backend/internal/hubresources/components_test.go @@ -0,0 +1,64 @@ +// Copyright Contributors to the Open Cluster Management project + +package hubresources_test + +import ( + "context" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + + "github.com/stolostron/console/backend/internal/hubresources" +) + +func componentObject(kind, name string, enabled bool) *unstructured.Unstructured { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "operator.open-cluster-management.io", + Version: "v1", + Kind: kind, + }) + obj.SetName("instance") + components := []interface{}{ + map[string]interface{}{"name": name, "enabled": enabled}, + } + if err := unstructured.SetNestedSlice(obj.Object, components, "spec", "overrides", "components"); err != nil { + panic(err) + } + return obj +} + +func TestMCHComponents(t *testing.T) { + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "operator.open-cluster-management.io", Version: "v1", Resource: "multiclusterhubs"}: "MultiClusterHubList", + }, componentObject("MultiClusterHub", "search", true)) + components, err := hubresources.MCHComponents(context.Background(), client) + if err != nil { + t.Fatal(err) + } + if len(components) != 1 || components[0].Name != "search" || !components[0].Enabled { + t.Fatalf("components %#v", components) + } +} + +func TestMCEComponents(t *testing.T) { + obj := componentObject("MultiClusterEngine", "hypershift", true) + obj.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "multicluster.openshift.io", + Version: "v1", + Kind: "MultiClusterEngine", + }) + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"}: "MultiClusterEngineList", + }, obj) + components, err := hubresources.MCEComponents(context.Background(), client) + if err != nil { + t.Fatal(err) + } + if len(components) != 1 || components[0].Name != "hypershift" || !components[0].Enabled { + t.Fatalf("components %#v", components) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 3f06d79b805..f7982c373b2 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -38,6 +38,8 @@ type handlerOptions struct { observability http.Handler vmProxy http.Handler staticH http.Handler + user http.Handler + clusterInfo http.Handler } // Option configures Handler. @@ -106,6 +108,20 @@ func WithStatic(h http.Handler) Option { } } +// WithUser registers /authenticated, /username, and /userpreference. +func WithUser(h http.Handler) Option { + return func(o *handlerOptions) { + o.user = h + } +} + +// WithClusterInfo registers hub, cluster-version, hypershift, MCH/MCE components, operatorCheck, and apiPaths. +func WithClusterInfo(h http.Handler) Option { + return func(o *handlerOptions) { + o.clusterInfo = h + } +} + // StripMulticloud returns the path used for Go-owned route matching. func StripMulticloud(path string) string { if path == multicloudPrefix { @@ -242,6 +258,8 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { registerOAuth(r, multicloudPrefix, o.oauth, o.oauthLogin) } registerStatelessProxies(r, o) + registerUserRoutes(r, o) + registerClusterInfoRoutes(r, o) r.NotFound(notFoundHandler(o.staticH, sidecar)) r.MethodNotAllowed(sidecar.ServeHTTP) return r, nil @@ -258,6 +276,39 @@ func registerOAuth(r chi.Router, prefix string, h *oauth.Handler, login bool) { r.Get(prefix+"/logout/", h.Logout) } +func stripPathHandler(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r2 := r.Clone(r.Context()) + r2.URL.Path = StripMulticloud(r.URL.Path) + h.ServeHTTP(w, r2) + }) +} + +func registerUserRoutes(r chi.Router, o *handlerOptions) { + if o.user == nil { + return + } + h := stripPathHandler(o.user) + registerAliasedGet(r, h, "/authenticated", "/username") + registerAliased(r, h, "/userpreference") +} + +func registerClusterInfoRoutes(r chi.Router, o *handlerOptions) { + if o.clusterInfo == nil { + return + } + h := stripPathHandler(o.clusterInfo) + registerAliasedGet(r, h, + "/hub", + "/cluster-version", + "/hypershift-status", + "/multiclusterhub/components", + "/multiclusterengine/components", + "/apiPaths", + ) + registerAliased(r, h, "/operatorCheck") +} + func notFoundHandler(staticH, sidecar http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { stripped := StripMulticloud(r.URL.Path) diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 50fae6b324d..b671f1f856d 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -584,7 +584,7 @@ func TestUnmigratedRoutesStillProxied(t *testing.T) { ts := httptest.NewServer(h) defer ts.Close() - for _, path := range []string{"/hub", "/multicloud/search", "/apiPaths", "/multicloud/apiPaths"} { + for _, path := range []string{"/multicloud/proxy/search", "/multicloud/events"} { resp, getErr := ts.Client().Get(ts.URL + path) if getErr != nil { t.Fatal(getErr) @@ -595,3 +595,60 @@ func TestUnmigratedRoutesStillProxied(t *testing.T) { } } } + +func TestMigratedUserAndClusterInfoNotProxied(t *testing.T) { + var sidecarHit bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarHit = true + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + userH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"route":"user"}`)) + }) + clusterH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"route":"cluster"}`)) + }) + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithUser(userH), server.WithClusterInfo(clusterH)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{ + "/hub", + "/multicloud/hub", + "/username", + "/multicloud/authenticated", + "/apiPaths", + "/multicloud/operatorCheck", + } { + sidecarHit = false + method := http.MethodGet + if path == "/multicloud/operatorCheck" { + method = http.MethodPost + } + req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(`{"operator":"advanced-cluster-management"}`)) + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } + resp, getErr := ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if sidecarHit { + t.Fatalf("%s was proxied to sidecar", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) + } + } +} diff --git a/backend/internal/user/user.go b/backend/internal/user/user.go new file mode 100644 index 00000000000..5275614e011 --- /dev/null +++ b/backend/internal/user/user.go @@ -0,0 +1,209 @@ +// Copyright Contributors to the Open Cluster Management project + +package user + +import ( + "encoding/json" + "io" + "net/http" + "regexp" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + applog "github.com/stolostron/console/backend/internal/log" +) + +var userPreferenceGVR = schema.GroupVersionResource{ + Group: "console.open-cluster-management.io", + Version: "v1", + Resource: "userpreferences", +} + +var sanitizeUsername = regexp.MustCompile(`[^a-z0-9\-.]`) + +// Options configure user route handlers. +type Options struct { + RESTConfig *rest.Config + Reviewer auth.TokenReviewer + Dynamic dynamic.Interface +} + +// Handler serves /authenticated, /username, and /userpreference. +type Handler struct { + base *rest.Config + reviewer auth.TokenReviewer + dynamic dynamic.Interface +} + +// New builds a user routes handler. +func New(opts Options) *Handler { + return &Handler{ + base: opts.RESTConfig, + reviewer: opts.Reviewer, + dynamic: opts.Dynamic, + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/authenticated": + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + h.authenticated(w, r) + case "/username": + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + h.username(w, r) + case "/userpreference": + switch r.Method { + case http.MethodGet, http.MethodPost, http.MethodPatch: + h.userpreference(w, r) + default: + http.NotFound(w, r) + } + default: + http.NotFound(w, r) + } +} + +func (h *Handler) authenticated(w http.ResponseWriter, r *http.Request) { + token := auth.TokenFromRequest(r) + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + status, err := auth.ValidateUserTokenStatus(r.Context(), h.base, token) + if err != nil { + applog.Logger().Error("authenticated probe failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(status) +} + +func (h *Handler) username(w http.ResponseWriter, r *http.Request) { + token, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r) + if !ok { + return + } + result, err := h.reviewer.Review(r.Context(), token) + if err != nil { + applog.Logger().Error("token review failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + username := "" + if result.Authenticated && result.Username != "" { + username = result.Username + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "statusCode": http.StatusOK, + "body": map[string]string{ + "username": username, + }, + }) +} + +func (h *Handler) userpreference(w http.ResponseWriter, r *http.Request) { + token, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r) + if !ok { + return + } + result, err := h.reviewer.Review(r.Context(), token) + if err != nil { + applog.Logger().Error("token review failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + name := preferenceName(result.Username) + if name == "" { + applog.Logger().Error("userpreference missing username", "method", r.Method) + return + } + + ctx := r.Context() + client := h.dynamic.Resource(userPreferenceGVR) + + switch r.Method { + case http.MethodGet: + obj, getErr := client.Get(ctx, name, metav1.GetOptions{}) + w.Header().Set("Content-Type", "application/json") + if getErr != nil { + if apierrors.IsNotFound(getErr) { + _, _ = w.Write([]byte("null")) + return + } + applog.Logger().Error("get userpreference failed", "error", getErr) + _, _ = w.Write([]byte("null")) + return + } + _ = json.NewEncoder(w).Encode(obj.Object) + case http.MethodPost: + body, readErr := io.ReadAll(r.Body) + if readErr != nil { + applog.Logger().Error("read userpreference body failed", "error", readErr) + w.WriteHeader(http.StatusInternalServerError) + return + } + var savedSearches interface{} + if len(body) > 0 { + if err := json.Unmarshal(body, &savedSearches); err != nil { + applog.Logger().Error("parse userpreference body failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + } + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "console.open-cluster-management.io/v1", + "kind": "UserPreference", + "metadata": map[string]interface{}{ + "name": name, + }, + "spec": map[string]interface{}{ + "savedSearches": savedSearches, + }, + }} + created, createErr := client.Create(ctx, obj, metav1.CreateOptions{}) + w.Header().Set("Content-Type", "application/json") + if createErr != nil { + applog.Logger().Error("create userpreference failed", "error", createErr) + _, _ = w.Write([]byte("null")) + return + } + _ = json.NewEncoder(w).Encode(created.Object) + case http.MethodPatch: + body, readErr := io.ReadAll(r.Body) + if readErr != nil { + applog.Logger().Error("read userpreference patch failed", "error", readErr) + w.WriteHeader(http.StatusInternalServerError) + return + } + patched, patchErr := client.Patch(ctx, name, "application/json-patch+json", body, metav1.PatchOptions{}) + w.Header().Set("Content-Type", "application/json") + if patchErr != nil { + applog.Logger().Error("patch userpreference failed", "error", patchErr) + _, _ = w.Write([]byte("null")) + return + } + _ = json.NewEncoder(w).Encode(patched.Object) + } +} + +func preferenceName(username string) string { + if username == "" { + return "" + } + return sanitizeUsername.ReplaceAllString(strings.ToLower(username), "-") +} diff --git a/backend/internal/user/user_test.go b/backend/internal/user/user_test.go new file mode 100644 index 00000000000..aff3676c855 --- /dev/null +++ b/backend/internal/user/user_test.go @@ -0,0 +1,157 @@ +// Copyright Contributors to the Open Cluster Management project + +package user_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/user" +) + +type stubReviewer struct { + result auth.TokenReviewResult + err error +} + +func (s stubReviewer) Review(context.Context, string) (auth.TokenReviewResult, error) { + return s.result, s.err +} + +func apiProbeServer(t *testing.T) (*httptest.Server, *rest.Config) { + t.Helper() + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api" { + http.NotFound(w, r) + return + } + if r.Header.Get("Authorization") != "Bearer good" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(ts.Close) + return ts, &rest.Config{Host: ts.URL, TLSClientConfig: rest.TLSClientConfig{Insecure: true}} +} + +func TestAuthenticated_OK(t *testing.T) { + _, base := apiProbeServer(t) + h := user.New(user.Options{RESTConfig: base, Reviewer: stubReviewer{}}) + req := httptest.NewRequest(http.MethodGet, "/authenticated", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } +} + +func TestAuthenticated_Unauthorized(t *testing.T) { + _, base := apiProbeServer(t) + h := user.New(user.Options{RESTConfig: base, Reviewer: stubReviewer{}}) + req := httptest.NewRequest(http.MethodGet, "/authenticated", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } +} + +func TestUsername(t *testing.T) { + _, base := apiProbeServer(t) + h := user.New(user.Options{ + RESTConfig: base, + Reviewer: stubReviewer{result: auth.TokenReviewResult{ + Authenticated: true, + Username: "testuser", + }}, + }) + req := httptest.NewRequest(http.MethodGet, "/username", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var payload map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + body, ok := payload["body"].(map[string]interface{}) + if !ok || body["username"] != "testuser" { + t.Fatalf("payload %#v", payload) + } +} + +func TestUserPreferenceGet_NotFoundReturnsNull(t *testing.T) { + _, base := apiProbeServer(t) + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "console.open-cluster-management.io", Version: "v1", Resource: "userpreferences"}: "UserPreferenceList", + }) + h := user.New(user.Options{ + RESTConfig: base, + Reviewer: stubReviewer{result: auth.TokenReviewResult{ + Authenticated: true, + Username: "kube:admin", + }}, + Dynamic: client, + }) + req := httptest.NewRequest(http.MethodGet, "/userpreference", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.String() != "null" { + t.Fatalf("body %q", rec.Body.String()) + } +} + +func TestUserPreferenceGet_Existing(t *testing.T) { + _, base := apiProbeServer(t) + obj := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "console.open-cluster-management.io/v1", + "kind": "UserPreference", + "metadata": map[string]interface{}{"name": "kube-admin"}, + }} + client := fake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + {Group: "console.open-cluster-management.io", Version: "v1", Resource: "userpreferences"}: "UserPreferenceList", + }, obj) + h := user.New(user.Options{ + RESTConfig: base, + Reviewer: stubReviewer{result: auth.TokenReviewResult{ + Authenticated: true, + Username: "kube:admin", + }}, + Dynamic: client, + }) + req := httptest.NewRequest(http.MethodGet, "/userpreference", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var got map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["kind"] != "UserPreference" { + t.Fatalf("got %#v", got) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec9b6459620..09769e5c08f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,4 +41,6 @@ All REST calls use the token passed from the console frontend. Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. +Auth check, username, user preferences, and cluster-info routes (`/authenticated`, `/username`, `/userpreference`, `/hub`, `/cluster-version`, `/hypershift-status`, `/multiclusterhub/components`, `/multiclusterengine/components`, `/operatorCheck`, `/apiPaths`) are served by the Go listener using client-go with the service-account token for hub reads and per-user GET `/api` validation for auth gating. + Static plugin assets (`plugin-manifest.json`, `plugin-entry.js`, hashed JS/CSS, locales) are served by the Go listener with the same cache headers, CSP, and brotli/gzip content negotiation as the former Node `serve` route. diff --git a/scripts/check-hub-alignment.sh b/scripts/check-hub-alignment.sh new file mode 100755 index 00000000000..5895bf7d92b --- /dev/null +++ b/scripts/check-hub-alignment.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Copyright Contributors to the Open Cluster Management project + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${ROOT_DIR}/backend/.env" + +if ! command -v oc >/dev/null 2>&1; then + echo "warning: oc not found; skipping hub alignment check" >&2 + exit 0 +fi + +OC_SERVER="$(oc whoami --show-server 2>/dev/null || true)" +if [[ -z "$OC_SERVER" ]]; then + echo "warning: not logged in to a cluster (oc whoami); skipping hub alignment check" >&2 + exit 0 +fi + +if [[ ! -f "$ENV_FILE" ]]; then + echo "error: ${ENV_FILE} not found. Run: npm run setup" >&2 + exit 1 +fi + +CLUSTER_API_URL="$(grep -E '^CLUSTER_API_URL=' "$ENV_FILE" | cut -d= -f2- || true)" +if [[ -z "$CLUSTER_API_URL" ]]; then + echo "error: CLUSTER_API_URL missing from ${ENV_FILE}. Run: npm run setup:hub" >&2 + exit 1 +fi + +OC_SERVER="${OC_SERVER%/}" +CLUSTER_API_URL="${CLUSTER_API_URL%/}" + +if [[ "$OC_SERVER" != "$CLUSTER_API_URL" ]]; then + cat >&2 <&2 <> ./backend/.env PROMETHEUS_ROUTE=https://$(oc get route prometheus-k8s -n openshift-monitoring -o="jsonpath={.status.ingress[0].host}") echo PROMETHEUS_ROUTE=$PROMETHEUS_ROUTE >> ./backend/.env + +if [[ ! -f ./backend/certs/tls.crt || ! -f ./backend/certs/tls.key ]]; then + echo "backend/certs missing; generating TLS certs (required for https://localhost:4000 in plugin mode)" + npm run generate-certs +fi diff --git a/start-ocp-console.sh b/start-ocp-console.sh index 92cd89a3c2b..103913ffffc 100755 --- a/start-ocp-console.sh +++ b/start-ocp-console.sh @@ -4,6 +4,7 @@ set -euo pipefail source ./port-defaults.sh source ./oauth-client-name.sh +./scripts/check-hub-alignment.sh source ./backend/.env CONSOLE_VERSION=${CONSOLE_VERSION:=5.0} From 82826aed09b932ac5092d3bcbfa9b55752fa27c0 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Thu, 3 Sep 2026 08:30:37 +0200 Subject: [PATCH 08/16] ACM-42597 Implement informer cache with client-go SharedInformerFactory (#55) * cors fix Signed-off-by: Enrique Mingorance Cano * Implement informer cache with client-go SharedInformerFactory Signed-off-by: Enrique Mingorance Cano * hang issue fixed Signed-off-by: Enrique Mingorance Cano * restoring rm -rf backend/.env backend/certs/ && npm run setup && npm run ci:backend flow Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- AGENTS.md | 13 +- CONTRIBUTING.md | 11 +- README.md | 14 +- backend-node/AGENTS.md | 2 +- backend/AGENTS.md | 9 +- backend/README.md | 6 +- backend/cmd/console/main.go | 27 +- backend/internal/config/config.go | 12 + backend/internal/config/config_test.go | 22 ++ backend/internal/informers/factory.go | 216 ++++++++++++ backend/internal/informers/factory_test.go | 344 +++++++++++++++++++ backend/internal/informers/gvr.go | 49 +++ backend/internal/informers/gvr_test.go | 73 ++++ backend/internal/informers/handler.go | 79 +++++ backend/internal/informers/handler_test.go | 89 +++++ backend/internal/informers/retry.go | 32 ++ backend/internal/informers/retry_test.go | 31 ++ backend/internal/informers/specs.go | 146 ++++++++ backend/internal/informers/specs_test.go | 188 ++++++++++ backend/internal/informers/store.go | 229 ++++++++++++ backend/internal/informers/store_test.go | 153 +++++++++ backend/internal/informers/transform.go | 51 +++ backend/internal/informers/transform_test.go | 67 ++++ backend/internal/server/server.go | 51 ++- backend/internal/server/server_test.go | 38 ++ docs/ARCHITECTURE.md | 1 + package.json | 7 +- scripts/generate-backend-certs.sh | 20 ++ setup.sh | 5 +- 29 files changed, 1949 insertions(+), 36 deletions(-) create mode 100644 backend/internal/informers/factory.go create mode 100644 backend/internal/informers/factory_test.go create mode 100644 backend/internal/informers/gvr.go create mode 100644 backend/internal/informers/gvr_test.go create mode 100644 backend/internal/informers/handler.go create mode 100644 backend/internal/informers/handler_test.go create mode 100644 backend/internal/informers/retry.go create mode 100644 backend/internal/informers/retry_test.go create mode 100644 backend/internal/informers/specs.go create mode 100644 backend/internal/informers/specs_test.go create mode 100644 backend/internal/informers/store.go create mode 100644 backend/internal/informers/store_test.go create mode 100644 backend/internal/informers/transform.go create mode 100644 backend/internal/informers/transform_test.go create mode 100755 scripts/generate-backend-certs.sh diff --git a/AGENTS.md b/AGENTS.md index 79ed85b9473..2682e718ce1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,11 +34,16 @@ console/ ```bash npm ci # installs frontend, backend-node; go mod download when Go is installed -npm run setup # writes backend/.env from the current oc context -npm run generate-certs # writes backend/certs/ (required for local TLS) +npm run setup # writes backend/.env and backend/certs/ from the current oc context ``` -After `oc login` to a new hub: `npm run setup:hub` (regenerates `.env` and certs). +After wiping local config or `oc login` to a new hub: + +```bash +rm -rf backend/.env backend/certs/ && npm run setup && npm run ci:backend +``` + +(`npm run setup:hub` runs the same steps with the `rm` included.) ## Development Commands @@ -139,7 +144,7 @@ Features can be enabled/disabled via the `console-config` ConfigMap in the insta ## Troubleshooting - **`concurrently: command not found`** — Run `npm ci` at the repo root first -- **Certificate errors** — Remove `backend/certs/` and run `npm run generate-certs` +- **Certificate errors** — Remove `backend/certs/` and run `npm run setup && npm run ci:backend` (or `npm run generate-certs` to force regeneration) - **Module resolution errors** — Verify Node.js and npm versions match `.nvmrc` / `.tool-versions`; version mismatches break ESM resolution - **Missing `.env`** — Run `npm run setup` (or `npm run setup:hub` after `oc login` to a new cluster) to generate `backend/.env` - **Plugin UI redirects to `/dashboards`** — `oc whoami --show-server` must match `CLUSTER_API_URL` in `backend/.env`. After `oc login` to a new hub, run `npm run setup:hub` and restart `npm run plugins`. `start-ocp-console.sh` runs `scripts/check-hub-alignment.sh` to catch this early. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 852f8ccba19..7792ee16765 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,12 +65,17 @@ Prerequisites match [README.md](README.md#prerequisites) (Node.js 24, Go 1.26+, ```bash npm ci npm run setup -npm run generate-certs ``` -`npm ci` runs a `postinstall` that installs `frontend`, `backend-node`, and (when Go is installed) `go mod download` in `backend/`. +`npm ci` runs a `postinstall` that installs `frontend`, `backend-node`, and (when Go is installed) `go mod download` in `backend/`. `npm run setup` writes `backend/.env` and creates `backend/certs/` when missing; `npm run ci:backend` also ensures certs exist. -After `oc login` to a different hub, run `npm run setup:hub` to regenerate `backend/.env` and `backend/certs/`. +After `oc login` to a different hub: + +```bash +rm -rf backend/.env backend/certs/ && npm run setup && npm run ci:backend +``` + +(`npm run setup:hub` runs the same steps with the `rm` included.) #### Recommended: Run as OpenShift Console plugins diff --git a/README.md b/README.md index 181e600e072..b31abeebb06 100644 --- a/README.md +++ b/README.md @@ -91,15 +91,15 @@ The recommended way to run the console for development is as OpenShift Console d npm run setup ``` - This creates `backend/.env` with cluster connection variables. Some optional routes (for example ACM Observability) may log `NotFound` if the component is not installed on the cluster; local development can continue. + This creates `backend/.env` with cluster connection variables and writes `backend/certs/` when TLS material is missing. Some optional routes (for example ACM Observability) may log `NotFound` if the component is not installed on the cluster; local development can continue. -4. Generate TLS certificates + After `oc login` to a different hub, or to reset local config: ```sh - npm run generate-certs + rm -rf backend/.env backend/certs/ && npm run setup && npm run ci:backend ``` - Writes self-signed certs to `backend/certs/` for the Go backend and Node sidecar. After `oc login` to a different hub, run `npm run setup:hub` instead to regenerate `.env` and certs together. + (`npm run setup:hub` runs the same steps with the `rm` included.) 5. Start the console plugins @@ -131,7 +131,7 @@ The `npm start` command runs a standalone development console that **does not** Use this mode for rapid iteration on features that don't depend on OpenShift Console APIs, but **always verify your work with `npm run plugins` before submitting**. -Complete the [setup steps above](#setup) (`npm ci`, `npm run setup`, `npm run generate-certs`), then: +Complete the [setup steps above](#setup) (`npm ci`, `npm run setup`), then: ```sh npm start @@ -347,9 +347,7 @@ And if the logs are inspected right after running `npm start` command an error i The problem is about the certs not being generated properly, `./backend/certs` folder is most probably empty. -The solution is to remove `./backend/certs` and run `npm run generate-certs` at the repo root (or `npm run setup:hub` after switching clusters). - -> Be sure the openssl CLI is installed before running `npm run generate-certs`. +The solution is to remove `./backend/certs` and run `npm run setup && npm run ci:backend` (or `npm run setup:hub` after switching clusters). Use `npm run generate-certs` to force regeneration without re-running setup. ## Related Packages diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md index 0c65b6553f7..c171ebfbc57 100644 --- a/backend-node/AGENTS.md +++ b/backend-node/AGENTS.md @@ -20,7 +20,7 @@ Node.js ESM proxy server. Sits between the browser and the hub cluster API serve | `src/resources/` | Backend resource watchers and handlers | | `test/` | Jest test files | | `config/` | Runtime configuration lives in `../backend/config` (Go backend) | -| `certs/` | TLS certificates live in `../backend/certs` (`npm run generate-certs` at repo root) | +| `certs/` | TLS certificates live in `../backend/certs` (created by `npm run setup` / `npm run ci:backend` when missing) | ## Commands diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 0dcce38cf72..69db0006606 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -31,10 +31,11 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/clusterinfo` | `/hub`, `/cluster-version`, `/hypershift-status`, MCH/MCE components, `/operatorCheck`, `/apiPaths` | | `internal/cors` | Development CORS middleware (OPTIONS preflight for standalone dev) | | `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | +| `internal/informers` | Hub resource cache (~67 watch specs, dual-run with Node; `CONSOLE_INFORMER_CACHE=0` disables). Dev: `GET /debug/informer-snapshot`. `GET /events` still sidecar | | `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | | `config/` | Runtime settings shared with the Node sidecar | -| `certs/` | TLS material (`npm run generate-certs` at repo root) | +| `certs/` | TLS material (`npm run setup` / `npm run ci:backend` create when missing; `npm run generate-certs` to force) | ## Commands @@ -47,7 +48,7 @@ From the repo root (preferred), or `cd backend`: | `npm run lint:backend` | `golangci-lint` (see `backend/.golangci.yml`) | | `npm run check:backend` | tests + golangci-lint | | `npm run build:backend` | `go build -o bin/console ./cmd/console` | -| `npm run setup:hub` | Regenerate `backend/.env` and `backend/certs` after `oc login` to a new cluster | +| `npm run setup:hub` | `rm -rf backend/.env backend/certs && npm run setup && npm run ci:backend` after `oc login` to a new cluster | ## Architecture @@ -59,6 +60,8 @@ Go backend :4000 (TLS / HTTP/2) ├─ GET /livenessProbe, /readinessProbe, /ping │ (also /multicloud/…) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) + ├─ GET /debug/informer-snapshot (dev only; Go informer cache dump) + ├─ SA informers (~67 specs) in process (cache only; SSE still sidecar) ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) │ (also /multicloud/…) ├─ GET /configure (OAuth/OIDC token_endpoint discovery) @@ -79,6 +82,8 @@ Go backend :4000 (TLS / HTTP/2) `/multicloud` is stripped only when matching Go-owned routes. The proxy forwards the original path so Node can keep stripping it. +During ACM-42597 the Go process watches the same specs as Node `startWatching()` **after** the public listener is bound. Startup is capped at 8 concurrent list/watch setups; the informer client uses QPS 20 / Burst 40; resync is disabled. Set `CONSOLE_INFORMER_CACHE=0` (or `false`/`off`) to skip Go watches. Node SSE is unchanged. After informers sync, logs `informer cache memory` with `heapAlloc` — compare that to the sidecar deflate cache, not combined RSS. + ## Shared artifacts `npm run setup` writes `backend/.env`. The sidecar loads the same file via `ENV_FILE` / `CONFIG_DIR` / `CERTS_DIR`. `godotenv` does not override `PORT`, so the sidecar can listen on `NODE_BACKEND_PORT` while `.env` still has `PORT=4000` for Go. diff --git a/backend/README.md b/backend/README.md index 8d2c743ee89..14d1298f89a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -10,16 +10,14 @@ From the repo root: ```sh npm ci # required once; runs go mod download when Go is installed -npm run setup # writes backend/.env from the current oc context -npm run generate-certs +npm run setup # writes backend/.env and backend/certs/ from the current oc context npm start # or npm run plugins ``` After `oc login` to a new hub: ```sh -npm run setup:hub -# restart npm start / npm run plugins +rm -rf backend/.env backend/certs/ && npm run setup && npm run ci:backend ``` See [AGENTS.md](AGENTS.md) for layout, architecture, and commands. diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index 6aca6157466..e527cb46d8d 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -12,6 +12,7 @@ import ( "syscall" "k8s.io/client-go/discovery" + discocache "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" @@ -20,11 +21,12 @@ import ( "github.com/stolostron/console/backend/internal/clusterproxy" "github.com/stolostron/console/backend/internal/config" rbacevents "github.com/stolostron/console/backend/internal/events/rbac" + "github.com/stolostron/console/backend/internal/informers" "github.com/stolostron/console/backend/internal/k8sproxy" applog "github.com/stolostron/console/backend/internal/log" - "github.com/stolostron/console/backend/internal/oauth" "github.com/stolostron/console/backend/internal/mcproxy" "github.com/stolostron/console/backend/internal/metricsproxy" + "github.com/stolostron/console/backend/internal/oauth" "github.com/stolostron/console/backend/internal/server" "github.com/stolostron/console/backend/internal/static" "github.com/stolostron/console/backend/internal/user" @@ -85,6 +87,18 @@ func run() error { } rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacevents.NewSSARAccess(restCfg)) + infCfg := informers.RESTConfig(restCfg) + dyn, err := dynamic.NewForConfig(infCfg) + if err != nil { + return err + } + disco, err := discovery.NewDiscoveryClientForConfig(infCfg) + if err != nil { + return err + } + mapper := discocache.NewMemCacheClient(disco) + infCache := informers.New(informers.DefaultWatchSpecs()) + oauthH := oauth.New(oauth.Options{ ClientID: cfg.OAuth2ClientID, ClientSecret: cfg.OAuth2ClientSecret, @@ -99,7 +113,7 @@ func run() error { var opts []server.Option opts = append(opts, server.WithRBACEvents(rbacHandler), server.WithOAuth(oauthH)) if !cfg.Production { - opts = append(opts, server.WithOAuthLogin()) + opts = append(opts, server.WithOAuthLogin(), server.WithDebugSnapshot(informers.NewSnapshotHandler(infCache, restCfg))) } clusterURL, err := url.Parse(cfg.ClusterAPIURL) if err != nil { @@ -164,10 +178,17 @@ func run() error { applog.Logger().Info("process start", "PORT", cfg.Port, "NODE_BACKEND_URL", cfg.NodeBackendURL, + "informerCache", cfg.InformerCache, slog.String("CONFIG_DIR", cfg.ConfigDir), slog.String("PUBLIC_FOLDER", cfg.PublicFolder), ) - return server.ListenAndServe(ctx, cfg, handler) + return server.ListenAndServe(ctx, cfg, handler, func() { + if !cfg.InformerCache { + applog.Logger().Info("informer cache disabled", "CONSOLE_INFORMER_CACHE", os.Getenv("CONSOLE_INFORMER_CACHE")) + return + } + informers.StartCache(ctx, infCache, dyn, mapper) + }) } var errMissingToken = errors.New("service account token missing") diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5f10098db00..814721d9839 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -40,6 +40,7 @@ type Config struct { OIDCIssuerURL string FrontendURL string Production bool + InformerCache bool mu sync.RWMutex settings map[string]string @@ -52,6 +53,16 @@ func envOr(key, fallback string) string { return fallback } +// envEnabledDefaultOn is true unless the env var is an explicit off value (0/false/off/no). +func envEnabledDefaultOn(key string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "0", "false", "off", "no": + return false + default: + return true + } +} + // Load reads ENV_FILE (if present) then environment variables. func Load() *Config { envFile := envOr("ENV_FILE", ".env") @@ -79,6 +90,7 @@ func Load() *Config { OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), FrontendURL: os.Getenv("FRONTEND_URL"), Production: os.Getenv("NODE_ENV") == "production", + InformerCache: envEnabledDefaultOn("CONSOLE_INFORMER_CACHE"), settings: map[string]string{}, } _ = cfg.ReloadSettings() diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index b1f04443928..50b5f291bd7 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -137,6 +137,28 @@ func TestLoad_PublicFolder(t *testing.T) { } } +func TestLoad_InformerCacheDefaultOn(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) + t.Setenv("CONSOLE_INFORMER_CACHE", "") + cfg := config.Load() + if !cfg.InformerCache { + t.Fatal("expected InformerCache on by default") + } +} + +func TestLoad_InformerCacheOff(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) + for _, v := range []string{"0", "false", "off", "NO"} { + t.Setenv("CONSOLE_INFORMER_CACHE", v) + cfg := config.Load() + if cfg.InformerCache { + t.Fatalf("CONSOLE_INFORMER_CACHE=%q should disable cache", v) + } + } +} + func TestReloadSettings_MissingDir(t *testing.T) { cfg := &config.Config{ConfigDir: filepath.Join(t.TempDir(), "missing")} if err := cfg.ReloadSettings(); err != nil { diff --git a/backend/internal/informers/factory.go b/backend/internal/informers/factory.go new file mode 100644 index 00000000000..26ba36f9e21 --- /dev/null +++ b/backend/internal/informers/factory.go @@ -0,0 +1,216 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "context" + "runtime" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8swatch "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/cache" + + applog "github.com/stolostron/console/backend/internal/log" +) + +const ( + // InformerQPS and InformerBurst apply only to the dedicated informer rest.Config. + InformerQPS float32 = 20 + InformerBurst int = 40 + defaultStartConcurrency int = 8 +) + +var ( + startConcurrency = defaultStartConcurrency + startSem chan struct{} +) + +func init() { + resetStartSem() +} + +func resetStartSem() { + startSem = make(chan struct{}, startConcurrency) +} + +func acquireStartSlot(ctx context.Context) bool { + select { + case startSem <- struct{}{}: + return true + case <-ctx.Done(): + return false + } +} + +func releaseStartSlot() { + <-startSem +} + +// RESTConfig copies base and sets informer-only QPS/Burst so watches do not share +// the default 5/10 limiter used by other service-account clients. +func RESTConfig(base *rest.Config) *rest.Config { + c := rest.CopyConfig(base) + c.QPS = InformerQPS + c.Burst = InformerBurst + return c +} + +// New builds an empty cache for specs. Call StartCache after the HTTP listener is bound. +func New(specs []WatchSpec) *InformerCache { + return newCache(specs) +} + +// Start launches one informer per DefaultWatchSpecs entry. It does not block the caller. +func Start(ctx context.Context, dyn dynamic.Interface, mapper ResourceMapper) *InformerCache { + return StartSpecs(ctx, dyn, mapper, DefaultWatchSpecs()) +} + +// StartSpecs is Start with an explicit spec list (tests). +func StartSpecs(ctx context.Context, dyn dynamic.Interface, mapper ResourceMapper, specs []WatchSpec) *InformerCache { + c := New(specs) + StartCache(ctx, c, dyn, mapper) + return c +} + +// StartCache begins list/watch goroutines for an existing cache (listener-first startup). +func StartCache(ctx context.Context, c *InformerCache, dyn dynamic.Interface, mapper ResourceMapper) { + if c == nil { + return + } + for i := range c.states { + st := c.states[i] + go c.runSpec(ctx, dyn, mapper, st) + } + go c.logMemoryWhenReady(ctx) +} + +func (c *InformerCache) runSpec(ctx context.Context, dyn dynamic.Interface, mapper ResourceMapper, st *specRuntime) { + for { + if ctx.Err() != nil { + return + } + gvr, err := ResolveGVR(mapper, st.spec.APIVersion, st.spec.Kind) + if err != nil { + st.setError(err) + if isUnavailable(err) { + st.unavailable.Store(true) + applog.Logger().Info("informer spec unavailable; retrying", + "kind", st.spec.Kind, "apiVersion", st.spec.APIVersion, "error", err) + } else { + applog.Logger().Warn("informer GVR resolve failed; retrying", + "kind", st.spec.Kind, "apiVersion", st.spec.APIVersion, "error", err) + } + if !waitRetry(ctx) { + return + } + continue + } + st.unavailable.Store(false) + st.setError(nil) + + if !acquireStartSlot(ctx) { + return + } + + lw := newListWatch(dyn, gvr, st.spec) + inf := cache.NewSharedIndexInformer(lw, &unstructured.Unstructured{}, resyncPeriod, cache.Indexers{ + cache.NamespaceIndex: cache.MetaNamespaceIndexFunc, + }) + if err := inf.SetTransform(transformFor(st.spec)); err != nil { + applog.Logger().Warn("informer transform", "kind", st.spec.Kind, "error", err) + } + + c.mu.Lock() + st.gvr = gvr + st.informer = inf + c.mu.Unlock() + + go inf.Run(ctx.Done()) + + syncCtx, cancel := context.WithTimeout(ctx, syncGiveUpAfter) + synced := cache.WaitForCacheSync(syncCtx.Done(), inf.HasSynced) + cancel() + releaseStartSlot() + + if synced { + st.unavailable.Store(false) + st.synced.Store(true) + applog.Logger().Info("informer synced", + "kind", st.spec.Kind, "apiVersion", st.spec.APIVersion, "resource", gvr.Resource) + <-ctx.Done() + return + } + if ctx.Err() != nil { + return + } + applog.Logger().Error("informer cache sync timed out; not blocking HasSynced", + "kind", st.spec.Kind, "apiVersion", st.spec.APIVersion) + st.unavailable.Store(true) + if cache.WaitForCacheSync(ctx.Done(), inf.HasSynced) { + st.unavailable.Store(false) + st.synced.Store(true) + } + return + } +} + +func newListWatch(dyn dynamic.Interface, gvr schema.GroupVersionResource, spec WatchSpec) *cache.ListWatch { + ns := metav1.NamespaceAll + return &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (k8sruntime.Object, error) { + applySelectors(spec, &options) + return dyn.Resource(gvr).Namespace(ns).List(context.TODO(), options) + }, + WatchFunc: func(options metav1.ListOptions) (k8swatch.Interface, error) { + applySelectors(spec, &options) + return dyn.Resource(gvr).Namespace(ns).Watch(context.TODO(), options) + }, + } +} + +func applySelectors(spec WatchSpec, options *metav1.ListOptions) { + if s := SelectorQuery(spec.LabelSelector); s != "" { + options.LabelSelector = s + } + if s := SelectorQuery(spec.FieldSelector); s != "" { + options.FieldSelector = s + } +} + +func (c *InformerCache) logMemoryWhenReady(ctx context.Context) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + timeout := time.NewTimer(2 * time.Minute) + defer timeout.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timeout.C: + c.logHeap("informer cache memory (sync wait timed out)") + return + case <-ticker.C: + if c.HasSynced() { + c.logHeap("informer cache memory") + return + } + } + } +} + +func (c *InformerCache) logHeap(msg string) { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + applog.Logger().Info(msg, + "heapAlloc", ms.HeapAlloc, + "heapInuse", ms.HeapInuse, + "items", c.itemCount(), + "note", "compare Go heapAlloc of this process after sync to Node deflate cache size, not combined dual-run RSS", + ) +} diff --git a/backend/internal/informers/factory_test.go b/backend/internal/informers/factory_test.go new file mode 100644 index 00000000000..f51d77ec21a --- /dev/null +++ b/backend/internal/informers/factory_test.go @@ -0,0 +1,344 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + ktesting "k8s.io/client-go/testing" +) + +func uObj(apiVersion, kind, ns, name, uid string, metaExtra map[string]any) *unstructured.Unstructured { + meta := map[string]any{"name": name, "uid": uid} + if ns != "" { + meta["namespace"] = ns + } + for k, v := range metaExtra { + meta[k] = v + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": meta, + }} +} + +type staticMapper struct { + lists map[string]*metav1.APIResourceList + errs map[string]error +} + +func (m staticMapper) ServerResourcesForGroupVersion(gv string) (*metav1.APIResourceList, error) { + if err, ok := m.errs[gv]; ok { + return nil, err + } + if l, ok := m.lists[gv]; ok { + return l, nil + } + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "resource"}, gv) +} + +func mapperFor(apiVersion string, resources ...metav1.APIResource) staticMapper { + return staticMapper{lists: map[string]*metav1.APIResourceList{ + apiVersion: {GroupVersion: apiVersion, APIResources: resources}, + }} +} + +func waitSynced(t *testing.T, c *InformerCache) { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if c.HasSynced() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("cache did not sync; statuses=%+v", c.SpecStatuses()) +} + +func TestSelectorListWatchCapturesFieldSelector(t *testing.T) { + scheme := runtime.NewScheme() + listKinds := map[schema.GroupVersionResource]string{ + {Version: "v1", Resource: "configmaps"}: "ConfigMapList", + } + assisted := uObj("v1", "ConfigMap", "ns", "assisted-service", "uid-1", nil) + other := uObj("v1", "ConfigMap", "ns", "other", "uid-2", nil) + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, assisted, other) + + var gotField string + client.PrependReactor("list", "configmaps", func(action ktesting.Action) (bool, runtime.Object, error) { + la, ok := action.(ktesting.ListAction) + if ok { + gotField = la.GetListRestrictions().Fields.String() + } + return false, nil, nil + }) + + mapper := mapperFor("v1", metav1.APIResource{Name: "configmaps", Kind: "ConfigMap", Namespaced: true, Verbs: []string{"list", "watch"}}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + spec := watch("ConfigMap", "v1").fields("metadata.name", "assisted-service") + c := StartSpecs(ctx, client, mapper, []WatchSpec{spec}) + waitSynced(t, c) + if gotField == "" { + t.Fatal("expected field selector on list") + } + if gotField != "metadata.name=assisted-service" { + t.Fatalf("field selector %q", gotField) + } +} + +func TestLabelSelectorInformerStore(t *testing.T) { + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{Version: "v1", Resource: "secrets"} + listKinds := map[schema.GroupVersionResource]string{gvr: "SecretList"} + keep := uObj("v1", "Secret", "ns", "creds", "uid-1", map[string]any{ + "labels": map[string]any{"cluster.open-cluster-management.io/credentials": ""}, + }) + drop := uObj("v1", "Secret", "ns", "other", "uid-2", nil) + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, keep, drop) + mapper := mapperFor("v1", metav1.APIResource{Name: "secrets", Kind: "Secret", Namespaced: true, Verbs: []string{"list", "watch"}}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + spec := watch("Secret", "v1").labels("cluster.open-cluster-management.io/credentials", "") + c := StartSpecs(ctx, client, mapper, []WatchSpec{spec}) + waitSynced(t, c) + got := c.ListByKind("v1", "Secret") + if len(got) != 1 || got[0].GetName() != "creds" { + t.Fatalf("store %+v", names(got)) + } +} + +func TestMissingGVRDoesNotBlockHasSynced(t *testing.T) { + scheme := runtime.NewScheme() + listKinds := map[schema.GroupVersionResource]string{ + {Version: "v1", Resource: "namespaces"}: "NamespaceList", + } + ns := uObj("v1", "Namespace", "", "default", "uid-ns", nil) + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, ns) + mapper := staticMapper{ + lists: map[string]*metav1.APIResourceList{ + "v1": {GroupVersion: "v1", APIResources: []metav1.APIResource{ + {Name: "namespaces", Kind: "Namespace", Verbs: []string{"list", "watch"}}, + }}, + }, + errs: map[string]error{ + "hypershift.openshift.io/v1beta1": apierrors.NewNotFound(schema.GroupResource{Group: "hypershift.openshift.io", Resource: "hostedclusters"}, ""), + }, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := StartSpecs(ctx, client, mapper, []WatchSpec{ + watch("Namespace", "v1"), + watch("HostedCluster", "hypershift.openshift.io/v1beta1"), + }) + waitSynced(t, c) + snap := c.Snapshot() + if len(snap) != 1 || snap[0].Kind != "Namespace" { + t.Fatalf("snapshot %+v", snap) + } + var sawUnavailable bool + for _, st := range c.SpecStatuses() { + if st.Kind == "HostedCluster" && st.Unavailable { + sawUnavailable = true + } + } + if !sawUnavailable { + t.Fatalf("expected HostedCluster unavailable: %+v", c.SpecStatuses()) + } +} + +func TestAuthenticationInCacheAndSnapshot(t *testing.T) { + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{Group: "config.openshift.io", Version: "v1", Resource: "authentications"} + listKinds := map[schema.GroupVersionResource]string{gvr: "AuthenticationList"} + authn := uObj("config.openshift.io/v1", "Authentication", "", "cluster", "uid-auth", nil) + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, authn) + mapper := mapperFor("config.openshift.io/v1", metav1.APIResource{ + Name: "authentications", Kind: "Authentication", Verbs: []string{"list", "watch"}, + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := StartSpecs(ctx, client, mapper, []WatchSpec{watch("Authentication", "config.openshift.io/v1").cacheOnly()}) + waitSynced(t, c) + got := c.ListByKind("config.openshift.io/v1", "Authentication") + if len(got) != 1 || got[0].GetName() != "cluster" { + t.Fatalf("auth %+v", names(got)) + } + snap := c.Snapshot() + if len(snap) != 1 || snap[0].Kind != "Authentication" || snap[0].Name != "cluster" { + t.Fatalf("snapshot %+v", snap) + } +} + +func TestManagedFieldsStrippedExceptPolicy(t *testing.T) { + scheme := runtime.NewScheme() + nsGVR := schema.GroupVersionResource{Version: "v1", Resource: "namespaces"} + polGVR := schema.GroupVersionResource{Group: "policy.open-cluster-management.io", Version: "v1", Resource: "policies"} + listKinds := map[schema.GroupVersionResource]string{ + nsGVR: "NamespaceList", + polGVR: "PolicyList", + } + mf := []any{map[string]any{"manager": "kubectl"}} + ns := uObj("v1", "Namespace", "", "default", "uid-ns", map[string]any{"managedFields": mf}) + pol := uObj("policy.open-cluster-management.io/v1", "Policy", "ns", "p1", "uid-p", map[string]any{"managedFields": mf}) + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, ns, pol) + mapper := staticMapper{lists: map[string]*metav1.APIResourceList{ + "v1": {GroupVersion: "v1", APIResources: []metav1.APIResource{ + {Name: "namespaces", Kind: "Namespace", Verbs: []string{"list", "watch"}}, + }}, + "policy.open-cluster-management.io/v1": {GroupVersion: "policy.open-cluster-management.io/v1", APIResources: []metav1.APIResource{ + {Name: "policies", Kind: "Policy", Namespaced: true, Verbs: []string{"list", "watch"}}, + }}, + }} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := StartSpecs(ctx, client, mapper, []WatchSpec{ + watch("Namespace", "v1"), + watch("Policy", "policy.open-cluster-management.io/v1"), + }) + waitSynced(t, c) + nss := c.ListByKind("v1", "Namespace") + if len(nss) != 1 { + t.Fatalf("ns %d", len(nss)) + } + if len(managedFieldsOf(&nss[0])) != 0 { + t.Fatalf("namespace managedFields should be stripped: %+v", nss[0].Object) + } + pols := c.ListByKind("policy.open-cluster-management.io/v1", "Policy") + if len(pols) != 1 { + t.Fatalf("policy %d", len(pols)) + } + if len(managedFieldsOf(&pols[0])) == 0 { + t.Fatal("Policy should keep managedFields") + } +} + +func TestSnapshotHandlerJSON(t *testing.T) { + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{Version: "v1", Resource: "namespaces"} + listKinds := map[schema.GroupVersionResource]string{gvr: "NamespaceList"} + ns := uObj("v1", "Namespace", "", "default", "uid-ns", nil) + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, ns) + mapper := mapperFor("v1", metav1.APIResource{Name: "namespaces", Kind: "Namespace", Verbs: []string{"list", "watch"}}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := StartSpecs(ctx, client, mapper, []WatchSpec{watch("Namespace", "v1")}) + waitSynced(t, c) + + h := NewSnapshotHandler(c, nil) + req := httptest.NewRequest(http.MethodGet, "/debug/informer-snapshot", nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("Content-Type") != "application/json" { + t.Fatal(rec.Header().Get("Content-Type")) + } + if !strings.Contains(rec.Body.String(), `"kind":"Namespace"`) || + !strings.Contains(rec.Body.String(), `"name":"default"`) || + !strings.Contains(rec.Body.String(), `"synced":true`) { + t.Fatalf("body %s", rec.Body.String()) + } +} + +func names(objs []unstructured.Unstructured) []string { + out := make([]string, 0, len(objs)) + for _, o := range objs { + out = append(out, o.GetName()) + } + return out +} + +func TestRESTConfigDoesNotMutateBase(t *testing.T) { + base := &rest.Config{Host: "https://example.com", QPS: 5, Burst: 10} + got := RESTConfig(base) + if got.QPS != InformerQPS || got.Burst != InformerBurst { + t.Fatalf("QPS/Burst %v/%d", got.QPS, got.Burst) + } + if base.QPS != 5 || base.Burst != 10 { + t.Fatal("base rest.Config must not be mutated") + } + if got.Host != base.Host { + t.Fatal("host should copy") + } +} + +func TestStartCacheNil(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + StartCache(ctx, nil, nil, nil) +} + +func TestStartConcurrencyLimitsLists(t *testing.T) { + orig := startConcurrency + startConcurrency = 2 + resetStartSem() + t.Cleanup(func() { + startConcurrency = orig + resetStartSem() + }) + + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + listKinds := map[schema.GroupVersionResource]string{gvr: "ConfigMapList"} + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, + uObj("v1", "ConfigMap", "ns", "a", "uid-a", nil), + uObj("v1", "ConfigMap", "ns", "b", "uid-b", nil), + uObj("v1", "ConfigMap", "ns", "c", "uid-c", nil), + uObj("v1", "ConfigMap", "ns", "d", "uid-d", nil), + ) + + var inflight, max atomic.Int32 + block := make(chan struct{}) + client.PrependReactor("list", "configmaps", func(action ktesting.Action) (bool, runtime.Object, error) { + n := inflight.Add(1) + for { + old := max.Load() + if n <= old || max.CompareAndSwap(old, n) { + break + } + } + <-block + inflight.Add(-1) + return false, nil, nil + }) + + mapper := mapperFor("v1", metav1.APIResource{Name: "configmaps", Kind: "ConfigMap", Namespaced: true, Verbs: []string{"list", "watch"}}) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := StartSpecs(ctx, client, mapper, []WatchSpec{ + watch("ConfigMap", "v1").fields("metadata.name", "a"), + watch("ConfigMap", "v1").fields("metadata.name", "b"), + watch("ConfigMap", "v1").fields("metadata.name", "c"), + watch("ConfigMap", "v1").fields("metadata.name", "d"), + }) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if max.Load() >= 2 { + break + } + time.Sleep(10 * time.Millisecond) + } + if got := max.Load(); got > 2 { + t.Fatalf("concurrent lists %d want <= 2", got) + } + close(block) + waitSynced(t, c) +} diff --git a/backend/internal/informers/gvr.go b/backend/internal/informers/gvr.go new file mode 100644 index 00000000000..e4215cc5eda --- /dev/null +++ b/backend/internal/informers/gvr.go @@ -0,0 +1,49 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "errors" + "fmt" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// ResourceMapper resolves apiVersion+kind to a GVR (typically discovery). +type ResourceMapper interface { + ServerResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) +} + +var errKindNotFound = errors.New("kind not found for apiVersion") + +// ResolveGVR maps apiVersion and kind using server discovery (not naive pluralize). +func ResolveGVR(mapper ResourceMapper, apiVersion, kind string) (schema.GroupVersionResource, error) { + list, err := mapper.ServerResourcesForGroupVersion(apiVersion) + if err != nil { + return schema.GroupVersionResource{}, err + } + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return schema.GroupVersionResource{}, err + } + for _, r := range list.APIResources { + if r.Kind == kind && !strings.Contains(r.Name, "/") { + return gv.WithResource(r.Name), nil + } + } + return schema.GroupVersionResource{}, fmt.Errorf("%w: %s %s", errKindNotFound, apiVersion, kind) +} + +func isUnavailable(err error) bool { + if err == nil { + return false + } + if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) || errors.Is(err, errKindNotFound) { + return true + } + // Cached discovery returns a plain "not found" for missing API groups (not apierrors.StatusError). + return strings.Contains(strings.ToLower(err.Error()), "not found") +} diff --git a/backend/internal/informers/gvr_test.go b/backend/internal/informers/gvr_test.go new file mode 100644 index 00000000000..4cf266d37b5 --- /dev/null +++ b/backend/internal/informers/gvr_test.go @@ -0,0 +1,73 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "errors" + "fmt" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestResolveGVRSuccess(t *testing.T) { + m := mapperFor("apps/v1", + metav1.APIResource{Name: "deployments", Kind: "Deployment", Namespaced: true}, + metav1.APIResource{Name: "deployments/scale", Kind: "Scale"}, + ) + gvr, err := ResolveGVR(m, "apps/v1", "Deployment") + if err != nil { + t.Fatal(err) + } + want := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + if gvr != want { + t.Fatalf("got %+v want %+v", gvr, want) + } +} + +func TestResolveGVRSkipsSubresource(t *testing.T) { + m := mapperFor("v1", metav1.APIResource{Name: "pods/status", Kind: "Pod"}) + _, err := ResolveGVR(m, "v1", "Pod") + if !errors.Is(err, errKindNotFound) { + t.Fatalf("got %v", err) + } +} + +func TestResolveGVRDiscoveryError(t *testing.T) { + m := staticMapper{errs: map[string]error{ + "v1": apierrors.NewForbidden(schema.GroupResource{Resource: "namespaces"}, "x", errors.New("no")), + }} + _, err := ResolveGVR(m, "v1", "Namespace") + if !apierrors.IsForbidden(err) { + t.Fatalf("got %v", err) + } +} + +func TestResolveGVRKindNotFound(t *testing.T) { + m := mapperFor("v1", metav1.APIResource{Name: "pods", Kind: "Pod"}) + _, err := ResolveGVR(m, "v1", "NoSuchKind") + if !errors.Is(err, errKindNotFound) { + t.Fatalf("got %v", err) + } +} + +func TestIsUnavailable(t *testing.T) { + cases := []struct { + err error + ok bool + }{ + {nil, false}, + {errors.New("other"), false}, + {apierrors.NewNotFound(schema.GroupResource{Resource: "x"}, "n"), true}, + {apierrors.NewForbidden(schema.GroupResource{Resource: "x"}, "n", errors.New("denied")), true}, + {fmt.Errorf("%w: v1 Thing", errKindNotFound), true}, + {errors.New("not found"), true}, + } + for _, tc := range cases { + if got := isUnavailable(tc.err); got != tc.ok { + t.Fatalf("isUnavailable(%v)=%v want %v", tc.err, got, tc.ok) + } + } +} diff --git a/backend/internal/informers/handler.go b/backend/internal/informers/handler.go new file mode 100644 index 00000000000..76150bb73b0 --- /dev/null +++ b/backend/internal/informers/handler.go @@ -0,0 +1,79 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "encoding/json" + "net/http" + + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + applog "github.com/stolostron/console/backend/internal/log" +) + +// SnapshotDoc is the JSON body of GET /debug/informer-snapshot. +type SnapshotDoc struct { + Synced bool `json:"synced"` + Items []ResourceKey `json:"items"` + Specs []SpecStatus `json:"specs,omitempty"` +} + +// SnapshotHandler serves GET /debug/informer-snapshot (development only). +type SnapshotHandler struct { + Cache *InformerCache + Base *rest.Config +} + +// NewSnapshotHandler requires a user token (cookie or Bearer) validated with GET /api. +func NewSnapshotHandler(c *InformerCache, base *rest.Config) *SnapshotHandler { + return &SnapshotHandler{Cache: c, Base: base} +} + +func (h *SnapshotHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + token, ok := auth.RequireToken(w, r) + if !ok { + return + } + if h.Base != nil { + if err := auth.ValidateUserToken(r.Context(), h.Base, token); err != nil { + applog.Logger().Warn("informer snapshot unauthorized", "error", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + } + doc := SnapshotDoc{ + Synced: h.Cache.HasSynced(), + Items: h.Cache.Snapshot(), + Specs: h.Cache.SpecStatuses(), + } + if r.URL.Query().Get("excludePolled") == "true" { + doc.Items = excludePolled(doc.Items, h.Cache) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(doc); err != nil { + applog.Logger().Warn("informer snapshot encode", "error", err) + } +} + +func excludePolled(items []ResourceKey, c *InformerCache) []ResourceKey { + if c == nil { + return items + } + c.mu.RLock() + polled := map[string]struct{}{} + for _, s := range c.states { + if s.spec.Polled { + polled[s.spec.APIVersion+"|"+s.spec.Kind] = struct{}{} + } + } + c.mu.RUnlock() + out := items[:0] + for _, k := range items { + if _, skip := polled[k.APIVersion+"|"+k.Kind]; skip { + continue + } + out = append(out, k) + } + return out +} diff --git a/backend/internal/informers/handler_test.go b/backend/internal/informers/handler_test.go new file mode 100644 index 00000000000..1b413352e23 --- /dev/null +++ b/backend/internal/informers/handler_test.go @@ -0,0 +1,89 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "k8s.io/client-go/rest" +) + +func TestNewSnapshotHandler(t *testing.T) { + c := newCache(nil) + h := NewSnapshotHandler(c, &rest.Config{Host: "https://example.com"}) + if h.Cache != c || h.Base == nil { + t.Fatal("constructor") + } +} + +func TestSnapshotHandlerNoToken(t *testing.T) { + h := NewSnapshotHandler(newCache(nil), nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/debug/informer-snapshot", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } +} + +func TestSnapshotHandlerTokenValidationFails(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := newCache([]WatchSpec{watch("Namespace", "v1")}) + c.states[0].synced.Store(true) + h := NewSnapshotHandler(c, &rest.Config{Host: srv.URL}) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/debug/informer-snapshot", nil) + req.Header.Set("Authorization", "Bearer bad") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } +} + +func TestSnapshotHandlerExcludePolled(t *testing.T) { + c := newCache([]WatchSpec{ + watch("Namespace", "v1"), + watch("Application", "argoproj.io/v1alpha1").polled(), + }) + c.states[0].informer = newTestInformer(t, uObj("v1", "Namespace", "", "default", "uid-ns", nil)) + c.states[0].synced.Store(true) + c.states[1].informer = newTestInformer(t, uObj("argoproj.io/v1alpha1", "Application", "ns", "app", "uid-app", nil)) + c.states[1].synced.Store(true) + + h := NewSnapshotHandler(c, nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/debug/informer-snapshot?excludePolled=true", nil) + req.Header.Set("Authorization", "Bearer test") + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var doc SnapshotDoc + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + for _, item := range doc.Items { + if item.Kind == "Application" { + t.Fatalf("polled item should be excluded: %+v", doc.Items) + } + } + if !strings.Contains(rec.Body.String(), `"kind":"Namespace"`) { + t.Fatalf("body %s", rec.Body.String()) + } +} + +func TestExcludePolledNilCache(t *testing.T) { + items := []ResourceKey{{Kind: "Application", APIVersion: "argoproj.io/v1alpha1", Name: "x"}} + got := excludePolled(items, nil) + if len(got) != 1 { + t.Fatalf("got %d", len(got)) + } +} diff --git a/backend/internal/informers/retry.go b/backend/internal/informers/retry.go new file mode 100644 index 00000000000..ae1a2615c57 --- /dev/null +++ b/backend/internal/informers/retry.go @@ -0,0 +1,32 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "context" + "math/rand/v2" + "time" +) + +const ( + // resyncPeriod 0 matches Node startWatching: no periodic full relist. + resyncPeriod = 0 + retryBase = 60 * time.Second + retryJitter = 10 * time.Second + syncGiveUpAfter = 45 * time.Second +) + +func retryDelay() time.Duration { + return retryBase + time.Duration(rand.IntN(int(retryJitter/time.Second)))*time.Second +} + +func waitRetry(ctx context.Context) bool { + t := time.NewTimer(retryDelay()) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} diff --git a/backend/internal/informers/retry_test.go b/backend/internal/informers/retry_test.go new file mode 100644 index 00000000000..ba8edf7b4ef --- /dev/null +++ b/backend/internal/informers/retry_test.go @@ -0,0 +1,31 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "context" + "testing" +) + +func TestRetryDelayInRange(t *testing.T) { + for i := 0; i < 20; i++ { + d := retryDelay() + if d < retryBase || d >= retryBase+retryJitter { + t.Fatalf("retryDelay %v outside [%v,%v)", d, retryBase, retryBase+retryJitter) + } + } +} + +func TestWaitRetryCancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if waitRetry(ctx) { + t.Fatal("expected false when context already canceled") + } +} + +func TestResyncDisabled(t *testing.T) { + if resyncPeriod != 0 { + t.Fatalf("resyncPeriod=%v want 0 (no periodic full relist)", resyncPeriod) + } +} diff --git a/backend/internal/informers/specs.go b/backend/internal/informers/specs.go new file mode 100644 index 00000000000..0d832f22978 --- /dev/null +++ b/backend/internal/informers/specs.go @@ -0,0 +1,146 @@ +// Copyright Contributors to the Open Cluster Management project + +// Package informers watches hub resources with client-go (ACM-42597). +// GET /events remains on the Node sidecar until ACM-42598. +package informers + +import ( + "sort" + "strings" +) + +// WatchSpec is one events.ts definition (selectors included). +type WatchSpec struct { + APIVersion string + Kind string + LabelSelector map[string]string + FieldSelector map[string]string + Polled bool + ForwardEventsToClients bool +} + +func (s WatchSpec) SpecKey() string { + return s.APIVersion + "|" + s.Kind + "|" + SelectorQuery(s.LabelSelector) + "|" + SelectorQuery(s.FieldSelector) +} + +// SelectorQuery encodes a Kubernetes label or field selector (k=v,k2=v2). +func SelectorQuery(m map[string]string) string { + if len(m) == 0 { + return "" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+m[k]) + } + return strings.Join(parts, ",") +} + +func watch(kind, apiVersion string) WatchSpec { + return WatchSpec{Kind: kind, APIVersion: apiVersion, ForwardEventsToClients: true} +} + +func (s WatchSpec) labels(pairs ...string) WatchSpec { + s.LabelSelector = pairsToMap(pairs) + return s +} + +func (s WatchSpec) fields(pairs ...string) WatchSpec { + s.FieldSelector = pairsToMap(pairs) + return s +} + +func (s WatchSpec) polled() WatchSpec { + s.Polled = true + return s +} + +func (s WatchSpec) cacheOnly() WatchSpec { + s.ForwardEventsToClients = false + return s +} + +func pairsToMap(pairs []string) map[string]string { + m := make(map[string]string, len(pairs)/2) + for i := 0; i+1 < len(pairs); i += 2 { + m[pairs[i]] = pairs[i+1] + } + return m +} + +// DefaultWatchSpecs is the port of backend-node/src/routes/events.ts `definitions`. +func DefaultWatchSpecs() []WatchSpec { + return []WatchSpec{ + watch("ClusterManagementAddOn", "addon.open-cluster-management.io/v1alpha1"), + watch("ManagedClusterAddOn", "addon.open-cluster-management.io/v1alpha1"), + watch("Agent", "agent-install.openshift.io/v1beta1"), + watch("AgentServiceConfig", "agent-install.openshift.io/v1beta1"), + watch("InfraEnv", "agent-install.openshift.io/v1beta1"), + watch("NMStateConfig", "agent-install.openshift.io/v1beta1"), + watch("Application", "app.k8s.io/v1beta1"), + watch("Channel", "apps.open-cluster-management.io/v1"), + watch("GitOpsCluster", "apps.open-cluster-management.io/v1beta1"), + watch("HelmRelease", "apps.open-cluster-management.io/v1"), + watch("Subscription", "apps.open-cluster-management.io/v1"), + watch("SubscriptionReport", "apps.open-cluster-management.io/v1alpha1"), + watch("Application", "argoproj.io/v1alpha1").polled(), + watch("ApplicationSet", "argoproj.io/v1alpha1").polled(), + watch("ArgoCD", "argoproj.io/v1alpha1"), + watch("Authentication", "config.openshift.io/v1").cacheOnly(), + watch("Infrastructure", "config.openshift.io/v1"), + watch("CertificateSigningRequest", "certificates.k8s.io/v1").labels("open-cluster-management.io/cluster-name", ""), + watch("ManagedCluster", "cluster.open-cluster-management.io/v1"), + watch("Placement", "cluster.open-cluster-management.io/v1beta1"), + watch("PlacementDecision", "cluster.open-cluster-management.io/v1beta1"), + watch("ManagedClusterSetBinding", "cluster.open-cluster-management.io/v1beta2"), + watch("ManagedClusterSet", "cluster.open-cluster-management.io/v1beta2"), + watch("ClusterCurator", "cluster.open-cluster-management.io/v1beta1"), + watch("Subscription", "operators.coreos.com/v1alpha1"), + watch("ClusterExtension", "olm.operatorframework.io/v1"), + watch("DiscoveredCluster", "discovery.open-cluster-management.io/v1"), + watch("DiscoveryConfig", "discovery.open-cluster-management.io/v1"), + watch("AgentClusterInstall", "extensions.hive.openshift.io/v1beta1"), + watch("ClusterClaim", "hive.openshift.io/v1"), + watch("ClusterDeployment", "hive.openshift.io/v1"), + watch("ClusterImageSet", "hive.openshift.io/v1"), + watch("ClusterPool", "hive.openshift.io/v1"), + watch("ClusterProvision", "hive.openshift.io/v1"), + watch("MachinePool", "hive.openshift.io/v1"), + watch("ManagedClusterInfo", "internal.open-cluster-management.io/v1beta1"), + watch("BareMetalHost", "metal3.io/v1alpha1"), + watch("MultiClusterEngine", "multicluster.openshift.io/v1"), + watch("ClusterVersion", "config.openshift.io/v1"), + watch("StorageClass", "storage.k8s.io/v1"), + watch("PlacementBinding", "policy.open-cluster-management.io/v1"), + watch("Policy", "policy.open-cluster-management.io/v1"), + watch("PolicyAutomation", "policy.open-cluster-management.io/v1beta1"), + watch("PolicySet", "policy.open-cluster-management.io/v1beta1"), + watch("SubmarinerConfig", "submarineraddon.open-cluster-management.io/v1alpha1"), + watch("AnsibleJob", "tower.ansible.com/v1alpha1"), + watch("AnsibleWorkflow", "tower.ansible.com/v1alpha1"), + watch("ConfigMap", "v1").fields("metadata.name", "assisted-service"), + watch("ConfigMap", "v1").fields("metadata.namespace", "openshift-config-managed", "metadata.name", "console-public"), + watch("ConfigMap", "v1").fields("metadata.name", "console-search-config"), + watch("Namespace", "v1"), + watch("Secret", "v1").labels("cluster.open-cluster-management.io/credentials", ""), + watch("Secret", "v1").labels("cluster.open-cluster-management.io/type", "ans"), + watch("Secret", "v1").fields("metadata.name", "auto-import-secret"), + watch("Secret", "v1").labels("argocd.argoproj.io/secret-type", "repository"), + watch("PolicyReport", "wgpolicyk8s.io/v1alpha2"), + watch("HostedCluster", "hypershift.openshift.io/v1beta1"), + watch("NodePool", "hypershift.openshift.io/v1beta1"), + watch("AgentMachine", "capi-provider.agent-install.openshift.io/v1alpha1"), + watch("ConfigMap", "v1").labels("hypershift.openshift.io/supported-versions", "true"), + watch("Search", "search.open-cluster-management.io/v1alpha1"), + watch("ConfigMap", "v1").fields("metadata.name", "grafana-dashboard-acm-openshift-virtualization-clusters-overview"), + watch("ConfigMap", "v1").fields("metadata.name", "grafana-dashboard-acm-openshift-virtualization-single-vm-view"), + watch("MulticlusterRoleAssignment", "rbac.open-cluster-management.io/v1beta1"), + watch("User", "user.openshift.io/v1"), + watch("Group", "user.openshift.io/v1"), + watch("Service", "v1").fields("metadata.name", "cluster-proxy-addon-user", "metadata.namespace", "multicluster-engine"), + } +} diff --git a/backend/internal/informers/specs_test.go b/backend/internal/informers/specs_test.go new file mode 100644 index 00000000000..d46c9711ee4 --- /dev/null +++ b/backend/internal/informers/specs_test.go @@ -0,0 +1,188 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func TestDefaultWatchSpecsCount(t *testing.T) { + specs := DefaultWatchSpecs() + if len(specs) != 67 { + t.Fatalf("got %d specs, want 67", len(specs)) + } + var polled, cacheOnly, withSel int + for _, s := range specs { + if s.Polled { + polled++ + } + if !s.ForwardEventsToClients { + cacheOnly++ + } + if len(s.LabelSelector) > 0 || len(s.FieldSelector) > 0 { + withSel++ + } + } + if polled != 2 { + t.Fatalf("polled=%d want 2", polled) + } + if cacheOnly != 1 { + t.Fatalf("cacheOnly=%d want 1 (Authentication)", cacheOnly) + } + if withSel != 12 { + t.Fatalf("selector specs=%d want 12", withSel) + } +} + +func TestDefaultWatchSpecsMatchEventsTS(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("caller") + } + eventsPath := filepath.Join(filepath.Dir(file), "..", "..", "..", "backend-node", "src", "routes", "events.ts") + src, err := os.ReadFile(eventsPath) + if err != nil { + t.Fatal(err) + } + tsKeys := parseEventsSpecKeys(t, src) + if len(tsKeys) != 67 { + t.Fatalf("events.ts keys=%d", len(tsKeys)) + } + got := map[string]WatchSpec{} + for _, s := range DefaultWatchSpecs() { + got[s.SpecKey()] = s + } + for k := range tsKeys { + if _, ok := got[k]; !ok { + t.Errorf("missing spec %s", k) + } + } + for k, s := range got { + if _, ok := tsKeys[k]; !ok { + t.Errorf("extra spec %s (%s %s)", k, s.APIVersion, s.Kind) + } + } +} + +func TestWatchSpecBuilders(t *testing.T) { + s := watch("Secret", "v1"). + labels("cluster.open-cluster-management.io/type", "ans"). + fields("metadata.name", "auto-import-secret"). + polled(). + cacheOnly() + if s.LabelSelector["cluster.open-cluster-management.io/type"] != "ans" { + t.Fatal("labels") + } + if s.FieldSelector["metadata.name"] != "auto-import-secret" { + t.Fatal("fields") + } + if !s.Polled || s.ForwardEventsToClients { + t.Fatal("polled/cacheOnly") + } + key := s.SpecKey() + want := "v1|Secret|cluster.open-cluster-management.io/type=ans|metadata.name=auto-import-secret" + if key != want { + t.Fatalf("got %q want %q", key, want) + } +} + +func TestWatchSpecDefaultForwardsEvents(t *testing.T) { + s := watch("Namespace", "v1") + if !s.ForwardEventsToClients { + t.Fatal("default should forward") + } +} + +func TestSelectorQueryEmpty(t *testing.T) { + if SelectorQuery(nil) != "" { + t.Fatal("expected empty") + } +} + +func TestSelectorQueryOrder(t *testing.T) { + got := SelectorQuery(map[string]string{"metadata.namespace": "mce", "metadata.name": "svc"}) + if got != "metadata.name=svc,metadata.namespace=mce" { + t.Fatal(got) + } +} + +var ( + kindRE = regexp.MustCompile(`kind:\s*'([^']+)'`) + apiVersionRE = regexp.MustCompile(`apiVersion:\s*'([^']+)'`) + selectorRE = regexp.MustCompile(`'([^']+)':\s*'([^']*)'`) +) + +func parseEventsSpecKeys(t *testing.T, src []byte) map[string]struct{} { + t.Helper() + s := string(src) + marker := "const definitions: IWatchOptions[] = [" + start := strings.Index(s, marker) + if start < 0 { + t.Fatal("definitions not found") + } + rest := s[start+len(marker):] + end := strings.Index(rest, "\nexport function startWatching") + body := rest[:end] + var lines []string + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "//") { + continue + } + lines = append(lines, line) + } + body = strings.Join(lines, "\n") + keys := map[string]struct{}{} + depth, objStart, inQ := 0, -1, false + for i := 0; i < len(body); i++ { + c := body[i] + if c == '\'' && (i == 0 || body[i-1] != '\\') { + inQ = !inQ + continue + } + if inQ { + continue + } + switch c { + case '{': + if depth == 0 { + objStart = i + } + depth++ + case '}': + depth-- + if depth == 0 && objStart >= 0 { + obj := body[objStart : i+1] + km := kindRE.FindStringSubmatch(obj) + am := apiVersionRE.FindStringSubmatch(obj) + labels := map[string]string{} + fields := map[string]string{} + if j := strings.Index(obj, "labelSelector:"); j >= 0 { + labels = parseSel(obj[j:]) + } + if j := strings.Index(obj, "fieldSelector:"); j >= 0 { + fields = parseSel(obj[j:]) + } + key := am[1] + "|" + km[1] + "|" + SelectorQuery(labels) + "|" + SelectorQuery(fields) + keys[key] = struct{}{} + objStart = -1 + } + } + } + return keys +} + +func parseSel(s string) map[string]string { + b := strings.Index(s, "{") + e := strings.Index(s[b:], "}") + inner := s[b : b+e] + out := map[string]string{} + for _, m := range selectorRE.FindAllStringSubmatch(inner, -1) { + out[m[1]] = m[2] + } + return out +} diff --git a/backend/internal/informers/store.go b/backend/internal/informers/store.go new file mode 100644 index 00000000000..7827bd6f00d --- /dev/null +++ b/backend/internal/informers/store.go @@ -0,0 +1,229 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "sort" + "sync" + "sync/atomic" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/tools/cache" +) + +// ResourceKey is the normalized cache identity for snapshot compare. +type ResourceKey struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + UID string `json:"uid,omitempty"` +} + +func (k ResourceKey) CompareKey() string { + return k.APIVersion + "|" + k.Kind + "|" + k.Namespace + "|" + k.Name +} + +type specRuntime struct { + spec WatchSpec + gvr schema.GroupVersionResource + informer cache.SharedIndexInformer + synced atomic.Bool + unavailable atomic.Bool + lastError atomic.Value // string +} + +// InformerCache holds one SharedIndexInformer per WatchSpec. +type InformerCache struct { + mu sync.RWMutex + states []*specRuntime +} + +func newCache(specs []WatchSpec) *InformerCache { + c := &InformerCache{states: make([]*specRuntime, 0, len(specs))} + for _, spec := range specs { + st := &specRuntime{spec: spec} + st.lastError.Store("") + c.states = append(c.states, st) + } + return c +} + +func (s *specRuntime) setError(err error) { + if err == nil { + s.lastError.Store("") + return + } + s.lastError.Store(err.Error()) +} + +// HasSynced is true when every spec is either synced or unavailable (404/403 / missing CRD). +func (c *InformerCache) HasSynced() bool { + if c == nil { + return false + } + c.mu.RLock() + defer c.mu.RUnlock() + if len(c.states) == 0 { + return false + } + for _, s := range c.states { + if s.unavailable.Load() { + continue + } + if !s.synced.Load() { + return false + } + } + return true +} + +// List returns objects for a GVR across all matching specs (union). +func (c *InformerCache) List(gvr schema.GroupVersionResource) []unstructured.Unstructured { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + var out []unstructured.Unstructured + seen := map[string]struct{}{} + for _, s := range c.states { + if s.informer == nil || s.gvr != gvr { + continue + } + for _, obj := range s.informer.GetStore().List() { + u, ok := asUnstructured(obj) + if !ok { + continue + } + key := string(u.GetUID()) + if key == "" { + key = u.GetNamespace() + "/" + u.GetName() + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, *u.DeepCopy()) + } + } + return out +} + +// ListByKind returns cached objects for apiVersion+kind across matching specs. +func (c *InformerCache) ListByKind(apiVersion, kind string) []unstructured.Unstructured { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + var out []unstructured.Unstructured + seen := map[string]struct{}{} + for _, s := range c.states { + if s.informer == nil || s.spec.APIVersion != apiVersion || s.spec.Kind != kind { + continue + } + for _, obj := range s.informer.GetStore().List() { + u, ok := asUnstructured(obj) + if !ok { + continue + } + key := string(u.GetUID()) + if key == "" { + key = u.GetNamespace() + "/" + u.GetName() + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, *u.DeepCopy()) + } + } + return out +} + +// Snapshot returns normalized keys for every object currently in the store. +func (c *InformerCache) Snapshot() []ResourceKey { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + var keys []ResourceKey + seen := map[string]struct{}{} + for _, s := range c.states { + if s.informer == nil { + continue + } + for _, obj := range s.informer.GetStore().List() { + u, ok := asUnstructured(obj) + if !ok { + continue + } + k := ResourceKey{ + APIVersion: s.spec.APIVersion, + Kind: s.spec.Kind, + Namespace: u.GetNamespace(), + Name: u.GetName(), + UID: string(u.GetUID()), + } + id := k.CompareKey() + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + keys = append(keys, k) + } + } + sort.Slice(keys, func(i, j int) bool { return keys[i].CompareKey() < keys[j].CompareKey() }) + return keys +} + +// itemCount is a lock-scoped store length sum for logs (not deduplicated). +func (c *InformerCache) itemCount() int { + if c == nil { + return 0 + } + c.mu.RLock() + defer c.mu.RUnlock() + n := 0 + for _, s := range c.states { + if s.informer == nil { + continue + } + n += len(s.informer.GetStore().List()) + } + return n +} + +// SpecStatuses is included in the debug dump for operators. +type SpecStatus struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Synced bool `json:"synced"` + Unavailable bool `json:"unavailable"` + Error string `json:"error,omitempty"` + Polled bool `json:"polled,omitempty"` +} + +func (c *InformerCache) SpecStatuses() []SpecStatus { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]SpecStatus, 0, len(c.states)) + for _, s := range c.states { + errStr, _ := s.lastError.Load().(string) + out = append(out, SpecStatus{ + Kind: s.spec.Kind, + APIVersion: s.spec.APIVersion, + Synced: s.synced.Load(), + Unavailable: s.unavailable.Load(), + Error: errStr, + Polled: s.spec.Polled, + }) + } + return out +} diff --git a/backend/internal/informers/store_test.go b/backend/internal/informers/store_test.go new file mode 100644 index 00000000000..03e2161e1c5 --- /dev/null +++ b/backend/internal/informers/store_test.go @@ -0,0 +1,153 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "context" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + watchpkg "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/tools/cache" +) + +func TestResourceKeyCompareKey(t *testing.T) { + k := ResourceKey{APIVersion: "v1", Kind: "Namespace", Namespace: "", Name: "default"} + if k.CompareKey() != "v1|Namespace||default" { + t.Fatal(k.CompareKey()) + } +} + +func TestHasSyncedNilAndEmpty(t *testing.T) { + var c *InformerCache + if c.HasSynced() { + t.Fatal("nil cache") + } + if newCache(nil).HasSynced() { + t.Fatal("empty specs") + } + if c.itemCount() != 0 { + t.Fatal("nil itemCount") + } +} + +func TestHasSyncedUnavailableAndSynced(t *testing.T) { + c := newCache([]WatchSpec{ + watch("Namespace", "v1"), + watch("HostedCluster", "hypershift.openshift.io/v1beta1"), + }) + if c.HasSynced() { + t.Fatal("nothing synced yet") + } + c.states[0].synced.Store(true) + if c.HasSynced() { + t.Fatal("second spec not ready") + } + c.states[1].unavailable.Store(true) + if !c.HasSynced() { + t.Fatal("unavailable spec should not block") + } +} + +func TestSpecStatusesAndSetError(t *testing.T) { + c := newCache([]WatchSpec{watch("Application", "argoproj.io/v1alpha1").polled()}) + c.states[0].synced.Store(true) + c.states[0].setError(errorsNew("boom")) + st := c.SpecStatuses() + if len(st) != 1 || !st[0].Synced || !st[0].Polled || st[0].Error != "boom" { + t.Fatalf("%+v", st) + } + c.states[0].setError(nil) + if c.SpecStatuses()[0].Error != "" { + t.Fatal("error should clear") + } +} + +func TestListSnapshotDedupAndSort(t *testing.T) { + c := newCache([]WatchSpec{ + watch("ConfigMap", "v1").fields("metadata.name", "a"), + watch("ConfigMap", "v1").fields("metadata.name", "b"), + }) + gvr := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + inf := newTestInformer(t, uObj("v1", "ConfigMap", "ns", "a", "uid-a", nil)) + c.states[0].gvr = gvr + c.states[0].informer = inf + c.states[0].synced.Store(true) + inf2 := newTestInformer(t, + uObj("v1", "ConfigMap", "ns", "b", "uid-b", nil), + uObj("v1", "ConfigMap", "ns", "a", "uid-a", nil), // duplicate uid across specs + ) + c.states[1].gvr = gvr + c.states[1].informer = inf2 + c.states[1].synced.Store(true) + + list := c.List(gvr) + if len(list) != 2 { + t.Fatalf("List len %d", len(list)) + } + snap := c.Snapshot() + if len(snap) != 2 { + t.Fatalf("Snapshot len %d", len(snap)) + } + if snap[0].Name != "a" || snap[1].Name != "b" { + t.Fatalf("sort order %+v", snap) + } + if snap[0].UID != "uid-a" { + t.Fatalf("uid %+v", snap[0]) + } +} + +func TestListByKindFiltersSpec(t *testing.T) { + c := newCache([]WatchSpec{ + watch("Namespace", "v1"), + watch("Secret", "v1"), + }) + nsInf := newTestInformer(t, uObj("v1", "Namespace", "", "default", "uid-ns", nil)) + c.states[0].informer = nsInf + secInf := newTestInformer(t, uObj("v1", "Secret", "ns", "s", "uid-s", nil)) + c.states[1].informer = secInf + + got := c.ListByKind("v1", "Secret") + if len(got) != 1 || got[0].GetName() != "s" { + t.Fatalf("%+v", names(got)) + } +} + +func newTestInformer(t *testing.T, objs ...*unstructured.Unstructured) cache.SharedIndexInformer { + t.Helper() + lw := &cache.ListWatch{ + ListFunc: func(metav1.ListOptions) (runtime.Object, error) { + items := make([]unstructured.Unstructured, len(objs)) + for i, o := range objs { + items[i] = *o + } + return &unstructured.UnstructuredList{Items: items}, nil + }, + WatchFunc: func(metav1.ListOptions) (watchpkg.Interface, error) { + return watchpkg.NewFake(), nil + }, + } + inf := cache.NewSharedIndexInformer(lw, &unstructured.Unstructured{}, time.Hour, cache.Indexers{ + cache.NamespaceIndex: cache.MetaNamespaceIndexFunc, + }) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go inf.Run(ctx.Done()) + if !cache.WaitForCacheSync(ctx.Done(), inf.HasSynced) { + t.Fatal("informer sync") + } + for _, o := range objs { + if err := inf.GetStore().Add(o); err != nil { + t.Fatal(err) + } + } + return inf +} + +type errorsNew string + +func (e errorsNew) Error() string { return string(e) } diff --git a/backend/internal/informers/transform.go b/backend/internal/informers/transform.go new file mode 100644 index 00000000000..f1443c98c1d --- /dev/null +++ b/backend/internal/informers/transform.go @@ -0,0 +1,51 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/cache" +) + +func asUnstructured(obj any) (*unstructured.Unstructured, bool) { + switch t := obj.(type) { + case *unstructured.Unstructured: + return t, true + case unstructured.Unstructured: + return &t, true + case cache.DeletedFinalStateUnknown: + return asUnstructured(t.Obj) + default: + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, false + } + return &unstructured.Unstructured{Object: u}, true + } +} + +func transformFor(spec WatchSpec) cache.TransformFunc { + return func(obj any) (any, error) { + u, ok := asUnstructured(obj) + if !ok { + return obj, nil + } + out := u.DeepCopy() + out.SetAPIVersion(spec.APIVersion) + out.SetKind(spec.Kind) + if spec.Kind != "Policy" { + unstructured.RemoveNestedField(out.Object, "metadata", "managedFields") + out.SetManagedFields(nil) + } + return out, nil + } +} + +func managedFieldsOf(u *unstructured.Unstructured) []any { + mf, found, _ := unstructured.NestedSlice(u.Object, "metadata", "managedFields") + if !found { + return nil + } + return mf +} diff --git a/backend/internal/informers/transform_test.go b/backend/internal/informers/transform_test.go new file mode 100644 index 00000000000..bb81024c267 --- /dev/null +++ b/backend/internal/informers/transform_test.go @@ -0,0 +1,67 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/tools/cache" +) + +func TestAsUnstructuredVariants(t *testing.T) { + ptr := uObj("v1", "Pod", "ns", "p", "uid-1", nil) + if got, ok := asUnstructured(ptr); !ok || got.GetName() != "p" { + t.Fatalf("ptr %+v", got) + } + val := *ptr + if got, ok := asUnstructured(val); !ok || got.GetName() != "p" { + t.Fatalf("val %+v", got) + } + tomb := cache.DeletedFinalStateUnknown{Obj: ptr} + if got, ok := asUnstructured(tomb); !ok || got.GetName() != "p" { + t.Fatalf("tombstone %+v", got) + } + if _, ok := asUnstructured(struct{}{}); ok { + t.Fatal("expected false for unknown type") + } +} + +func TestTransformForSetsKindAndStripsManagedFields(t *testing.T) { + mf := []any{map[string]any{"manager": "kubectl"}} + in := uObj("v1", "Namespace", "", "default", "uid", map[string]any{"managedFields": mf}) + spec := watch("Namespace", "v1") + out, err := transformFor(spec)(in) + if err != nil { + t.Fatal(err) + } + u := out.(*unstructured.Unstructured) + if u.GetKind() != "Namespace" || u.GetAPIVersion() != "v1" { + t.Fatalf("metadata %+v", u.Object) + } + if len(managedFieldsOf(u)) != 0 { + t.Fatal("managedFields should be stripped") + } +} + +func TestTransformForKeepsPolicyManagedFields(t *testing.T) { + mf := []any{map[string]any{"manager": "kubectl"}} + in := uObj("policy.open-cluster-management.io/v1", "Policy", "ns", "p", "uid", map[string]any{"managedFields": mf}) + spec := watch("Policy", "policy.open-cluster-management.io/v1") + out, err := transformFor(spec)(in) + if err != nil { + t.Fatal(err) + } + u := out.(*unstructured.Unstructured) + if len(managedFieldsOf(u)) == 0 { + t.Fatal("Policy should keep managedFields") + } +} + +func TestTransformForPassthroughNonUnstructured(t *testing.T) { + raw := "not-a-resource" + out, err := transformFor(watch("Pod", "v1"))(raw) + if err != nil || out != raw { + t.Fatalf("got %v err %v", out, err) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index f7982c373b2..4a658599c9c 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -40,6 +40,7 @@ type handlerOptions struct { staticH http.Handler user http.Handler clusterInfo http.Handler + debugSnapshot http.Handler } // Option configures Handler. @@ -122,6 +123,13 @@ func WithClusterInfo(h http.Handler) Option { } } +// WithDebugSnapshot registers GET /debug/informer-snapshot (development informer cache dump). +func WithDebugSnapshot(h http.Handler) Option { + return func(o *handlerOptions) { + o.debugSnapshot = h + } +} + // StripMulticloud returns the path used for Go-owned route matching. func StripMulticloud(path string) string { if path == multicloudPrefix { @@ -260,11 +268,38 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { registerStatelessProxies(r, o) registerUserRoutes(r, o) registerClusterInfoRoutes(r, o) + if o.debugSnapshot != nil { + r.Get("/debug/informer-snapshot", o.debugSnapshot.ServeHTTP) + r.Get(multicloudPrefix+"/debug/informer-snapshot", o.debugSnapshot.ServeHTTP) + } r.NotFound(notFoundHandler(o.staticH, sidecar)) r.MethodNotAllowed(sidecar.ServeHTTP) return r, nil } +func registerUserRoutes(r chi.Router, o *handlerOptions) { + if o.user == nil { + return + } + registerAliasedGet(r, o.user, "/authenticated", "/username", "/userpreference") +} + +func registerClusterInfoRoutes(r chi.Router, o *handlerOptions) { + if o.clusterInfo == nil { + return + } + registerAliasedGet(r, o.clusterInfo, + "/hub", + "/cluster-version", + "/hypershift-status", + "/multiclusterhub/components", + "/multiclusterengine/components", + "/apiPaths", + ) + r.Post("/operatorCheck", o.clusterInfo.ServeHTTP) + r.Post(multicloudPrefix+"/operatorCheck", o.clusterInfo.ServeHTTP) +} + func registerOAuth(r chi.Router, prefix string, h *oauth.Handler, login bool) { r.Get(prefix+"/configure", h.Configure) if !login { @@ -370,14 +405,22 @@ func (s *statusRecorder) Flush() { func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } -// ListenAndServe starts TLS when certs exist (net/http enables HTTP/2 automatically), otherwise cleartext HTTP/1.1. -func ListenAndServe(ctx context.Context, cfg *config.Config, handler http.Handler) error { +// ListenAndServe binds the listener first, then runs optional onListening hooks +// (informer start) so the public port is up before hub list/watch begins. +func ListenAndServe(ctx context.Context, cfg *config.Config, handler http.Handler, onListening ...func()) error { addr := net.JoinHostPort("", cfg.Port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } srv := &http.Server{ Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second, } + if len(onListening) > 0 && onListening[0] != nil { + onListening[0]() + } errCh := make(chan error, 1) go func() { @@ -386,12 +429,12 @@ func ListenAndServe(ctx context.Context, cfg *config.Config, handler http.Handle if _, err := os.Stat(certFile); err == nil { if _, err := os.Stat(keyFile); err == nil { applog.Logger().Info("server start", "secure", true, "addr", addr) - errCh <- srv.ListenAndServeTLS(certFile, keyFile) + errCh <- srv.ServeTLS(ln, certFile, keyFile) return } } applog.Logger().Info("server start", "secure", false, "addr", addr) - errCh <- srv.ListenAndServe() + errCh <- srv.Serve(ln) }() select { diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index b671f1f856d..b3d2896849f 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -652,3 +652,41 @@ func TestMigratedUserAndClusterInfoNotProxied(t *testing.T) { } } } + +func TestDebugSnapshotNotProxied(t *testing.T) { + var sidecarHit bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarHit = true + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + dump := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"synced":true,"items":[]}`)) + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithDebugSnapshot(dump)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/debug/informer-snapshot", "/multicloud/debug/informer-snapshot"} { + sidecarHit = false + resp, getErr := ts.Client().Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if sidecarHit { + t.Fatalf("%s was proxied to sidecar", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 09769e5c08f..11f8a47372f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,6 +41,7 @@ All REST calls use the token passed from the console frontend. Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. +The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` SSE, aggregators, and RBAC fan-out stay on the Node sidecar until ACM-42598. Dual-run is intentional: both caches list/watch the hub so snapshots can be compared. Go starts informers **after** binding `:4000`, with a startup semaphore (8 concurrent lists), a dedicated client (`QPS=20`,`Burst=40`), and **no periodic resync**. Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches. The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy) and does **not** port Node deflate compression — compare Go `heapAlloc` after sync to the Node deflate cache size, not combined process RSS. Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. Auth check, username, user preferences, and cluster-info routes (`/authenticated`, `/username`, `/userpreference`, `/hub`, `/cluster-version`, `/hypershift-status`, `/multiclusterhub/components`, `/multiclusterengine/components`, `/operatorCheck`, `/apiPaths`) are served by the Go listener using client-go with the service-account token for hub reads and per-user GET `/api` validation for auth gating. Static plugin assets (`plugin-manifest.json`, `plugin-entry.js`, hashed JS/CSS, locales) are served by the Go listener with the same cache headers, CSP, and brotli/gzip content negotiation as the former Node `serve` route. diff --git a/package.json b/package.json index ebb98dd8949..5fb50f999ee 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "postinstall": "concurrently npm:ci:* -c green,blue", "ci:frontend": "cd frontend && npm ci", - "ci:backend": "if command -v go >/dev/null 2>&1; then cd backend && go mod download; else echo 'Go not installed; skipping go mod download'; fi", + "ci:backend": "npm run ensure-certs && if command -v go >/dev/null 2>&1; then cd backend && go mod download; else echo 'Go not installed; skipping go mod download'; fi", "ci:backend-node": "cd backend-node && npm ci", "start": "concurrently npm:start:backend npm:start:frontend -c green,blue", "start:hot": "concurrently npm:start:backend npm:start:frontend:hot -c green,blue", @@ -72,9 +72,10 @@ "podman:deploy": "npm run podman:build && podman tag console quay.io/$USER/console:latest && podman push quay.io/$USER/console:latest && ./scripts/patch-deployment.sh latest quay.io/$USER/console", "podman:deploy:mce": "npm run podman:build:mce && podman tag console-mce quay.io/$USER/console-mce:latest && podman push quay.io/$USER/console-mce:latest && ./scripts/patch-deployment.sh latest quay.io/$USER/console-mce", "playwright:sanity": "npx playwright test --config e2e-template/playwright-sanity.config.ts", - "generate-certs": "mkdir -p backend/certs && openssl req -subj '/C=US' -new -newkey rsa:2048 -sha256 -days 365 -nodes -x509 -keyout backend/certs/tls.key -out backend/certs/tls.crt", + "ensure-certs": "./scripts/generate-backend-certs.sh --if-missing", + "generate-certs": "./scripts/generate-backend-certs.sh", "setup": "./setup.sh", - "setup:hub": "rm -rf backend/.env backend/certs && npm run setup && npm run generate-certs", + "setup:hub": "rm -rf backend/.env backend/certs && npm run setup && npm run ci:backend", "prepare": "husky install" }, "devDependencies": { diff --git a/scripts/generate-backend-certs.sh b/scripts/generate-backend-certs.sh new file mode 100755 index 00000000000..098aa55ac42 --- /dev/null +++ b/scripts/generate-backend-certs.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Copyright Contributors to the Open Cluster Management project + +set -euo pipefail + +readonly ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly CERT_DIR="${ROOT_DIR}/backend/certs" + +if [[ "${1:-}" == "--if-missing" && -f "${CERT_DIR}/tls.crt" && -f "${CERT_DIR}/tls.key" ]]; then + exit 0 +fi + +if ! command -v openssl >/dev/null 2>&1; then + echo "openssl is required to generate backend/certs/; install openssl and retry" >&2 + exit 1 +fi + +mkdir -p "${CERT_DIR}" +openssl req -subj '/C=US' -new -newkey rsa:2048 -sha256 -days 365 -nodes -x509 \ + -keyout "${CERT_DIR}/tls.key" -out "${CERT_DIR}/tls.crt" diff --git a/setup.sh b/setup.sh index d0a8276adcf..357f705ec57 100755 --- a/setup.sh +++ b/setup.sh @@ -189,7 +189,4 @@ echo OBSERVABILITY_ROUTE=$OBSERVABILITY_ROUTE >> ./backend/.env PROMETHEUS_ROUTE=https://$(oc get route prometheus-k8s -n openshift-monitoring -o="jsonpath={.status.ingress[0].host}") echo PROMETHEUS_ROUTE=$PROMETHEUS_ROUTE >> ./backend/.env -if [[ ! -f ./backend/certs/tls.crt || ! -f ./backend/certs/tls.key ]]; then - echo "backend/certs missing; generating TLS certs (required for https://localhost:4000 in plugin mode)" - npm run generate-certs -fi +"$(dirname "$0")/scripts/generate-backend-certs.sh" --if-missing From da825c6329972c3b57ba1e9e66a3c5f411f50879 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Thu, 3 Sep 2026 08:58:46 +0200 Subject: [PATCH 09/16] ACM-42598 Implement SSE hub with per-user RBAC filtering (#57) * cors fix Signed-off-by: Enrique Mingorance Cano * Implement informer cache with client-go SharedInformerFactory Signed-off-by: Enrique Mingorance Cano * hang issue fixed Signed-off-by: Enrique Mingorance Cano * restoring rm -rf backend/.env backend/certs/ && npm run setup && npm run ci:backend flow Signed-off-by: Enrique Mingorance Cano * Implement SSE hub with per-user RBAC filtering Signed-off-by: Enrique Mingorance Cano * unauth-events - expected empty body, got "Unauthorized\n" fixed Signed-off-by: Enrique Mingorance Cano * pending tests implemented Signed-off-by: Enrique Mingorance Cano * merge conflict errors Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- backend-node/AGENTS.md | 7 +- backend-node/src/app.ts | 2 + backend/AGENTS.md | 10 +- backend/cmd/console/main.go | 16 +- backend/internal/clusterinfo/clusterinfo.go | 2 +- backend/internal/config/config.go | 18 ++ backend/internal/config/config_test.go | 16 ++ backend/internal/events/hub/access.go | 259 ++++++++++++++++++ backend/internal/events/hub/access_test.go | 176 ++++++++++++ backend/internal/events/hub/encode.go | 83 ++++++ backend/internal/events/hub/encode_test.go | 49 ++++ backend/internal/events/hub/event.go | 25 ++ backend/internal/events/hub/frame.go | 26 ++ backend/internal/events/hub/frame_test.go | 25 ++ backend/internal/events/hub/handler.go | 195 ++++++++++++++ backend/internal/events/hub/handler_test.go | 265 +++++++++++++++++++ backend/internal/events/hub/hub.go | 138 ++++++++++ backend/internal/events/hub/hub_test.go | 85 ++++++ backend/internal/events/hub/parity_test.go | 103 +++++++ backend/internal/events/hub/snapshot.go | 107 ++++++++ backend/internal/events/hub/snapshot_test.go | 98 +++++++ backend/internal/events/rbac/handler.go | 4 +- backend/internal/events/rbac/handler_test.go | 6 + backend/internal/informers/factory.go | 7 + backend/internal/informers/factory_test.go | 53 ++++ backend/internal/informers/sink.go | 80 ++++++ backend/internal/informers/sink_test.go | 76 ++++++ backend/internal/informers/specs.go | 8 +- backend/internal/informers/specs_test.go | 104 ++++++-- backend/internal/informers/store.go | 52 ++++ backend/internal/informers/store_test.go | 27 ++ backend/internal/proxy/proxy_test.go | 242 +++++++++++++++++ backend/internal/server/server.go | 35 +-- backend/internal/server/server_test.go | 70 +++++ docs/ARCHITECTURE.md | 5 +- docs/RESOURCES.md | 9 +- 36 files changed, 2420 insertions(+), 63 deletions(-) create mode 100644 backend/internal/events/hub/access.go create mode 100644 backend/internal/events/hub/access_test.go create mode 100644 backend/internal/events/hub/encode.go create mode 100644 backend/internal/events/hub/encode_test.go create mode 100644 backend/internal/events/hub/event.go create mode 100644 backend/internal/events/hub/frame.go create mode 100644 backend/internal/events/hub/frame_test.go create mode 100644 backend/internal/events/hub/handler.go create mode 100644 backend/internal/events/hub/handler_test.go create mode 100644 backend/internal/events/hub/hub.go create mode 100644 backend/internal/events/hub/hub_test.go create mode 100644 backend/internal/events/hub/parity_test.go create mode 100644 backend/internal/events/hub/snapshot.go create mode 100644 backend/internal/events/hub/snapshot_test.go create mode 100644 backend/internal/informers/sink.go create mode 100644 backend/internal/informers/sink_test.go create mode 100644 backend/internal/proxy/proxy_test.go diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md index c171ebfbc57..840efca7438 100644 --- a/backend-node/AGENTS.md +++ b/backend-node/AGENTS.md @@ -40,11 +40,12 @@ Run from the `backend-node/` directory, or use the `npm run *:backend-node` vari The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated. OAuth login, logout, and `/configure` discovery are served by Go. ```text -Browser / plugin → Go :4000 → Node sidecar (this package) → Hub Cluster API Server +Browser / plugin → Go :4000 (GET /events is native Go when CONSOLE_INFORMER_CACHE is on) + → Node sidecar (this package) → Hub Cluster API Server ↓ - Watches resources via service account + Watches resources via service account (aggregators / dual-run) Enforces RBAC via user token + SubjectAccessReview - Streams events to frontend via SSE + Sidecar GET /events remains for aggregators and when Go cache is off ``` ## Route Handlers diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 637a2c3dc45..8e2a2df1da8 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -46,6 +46,8 @@ router.get('/readinessProbe', readiness) router.get('/livenessProbe', liveness) router.get('/ping', respondOK) if (eventsEnabled) { + // Public GET /events is served by the Go listener when CONSOLE_INFORMER_CACHE is on (ACM-42598). + // This sidecar route remains for dual-run, aggregators, and when the Go cache is disabled. router.get('/events', events) } router.post('/proxy/search', search) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 69db0006606..0ae77cd0015 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -31,7 +31,8 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/clusterinfo` | `/hub`, `/cluster-version`, `/hypershift-status`, MCH/MCE components, `/operatorCheck`, `/apiPaths` | | `internal/cors` | Development CORS middleware (OPTIONS preflight for standalone dev) | | `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | -| `internal/informers` | Hub resource cache (~67 watch specs, dual-run with Node; `CONSOLE_INFORMER_CACHE=0` disables). Dev: `GET /debug/informer-snapshot`. `GET /events` still sidecar | +| `internal/events/hub` | `GET /events` SSE: informer fan-out, snapshot packets, per-user SSAR (60s TTL). DELETED is not RBAC-filtered (bug-compatible with Node). `CONSOLE_INFORMER_CACHE=0` proxies `/events` to Node | +| `internal/informers` | Hub resource cache (~67 watch specs, dual-run with Node). Dev: `GET /debug/informer-snapshot` | | `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | | `config/` | Runtime settings shared with the Node sidecar | @@ -59,9 +60,10 @@ Browser / OpenShift Console plugin Go backend :4000 (TLS / HTTP/2) ├─ GET /livenessProbe, /readinessProbe, /ping │ (also /multicloud/…) + ├─ GET /events (resource watch SSE + per-user SSAR; also /multicloud/events) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) ├─ GET /debug/informer-snapshot (dev only; Go informer cache dump) - ├─ SA informers (~67 specs) in process (cache only; SSE still sidecar) + ├─ SA informers (~67 specs) feed GET /events; Node startWatching() still runs for aggregators ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) │ (also /multicloud/…) ├─ GET /configure (OAuth/OIDC token_endpoint discovery) @@ -82,7 +84,9 @@ Go backend :4000 (TLS / HTTP/2) `/multicloud` is stripped only when matching Go-owned routes. The proxy forwards the original path so Node can keep stripping it. -During ACM-42597 the Go process watches the same specs as Node `startWatching()` **after** the public listener is bound. Startup is capped at 8 concurrent list/watch setups; the informer client uses QPS 20 / Burst 40; resync is disabled. Set `CONSOLE_INFORMER_CACHE=0` (or `false`/`off`) to skip Go watches. Node SSE is unchanged. After informers sync, logs `informer cache memory` with `heapAlloc` — compare that to the sidecar deflate cache, not combined RSS. +During ACM-42597/42598 the Go process watches the same specs as Node `startWatching()` **after** the public listener is bound. Startup is capped at 8 concurrent list/watch setups; the informer client uses QPS 20 / Burst 40; resync is disabled. Set `CONSOLE_INFORMER_CACHE=0` (or `false`/`off`) to skip Go watches and keep proxying `GET /events` to Node. Node `startWatching()` still runs for aggregators (`getKubeResources`). After informers sync, logs `informer cache memory` with `heapAlloc` — compare that to the sidecar deflate cache, not combined RSS. + +`GET /events` framing matches Node `server-side-events.ts`: `id:` + `data:` (no space), gzip when `Accept-Encoding` includes gzip, keepalive `:\n\n` every 10s, snapshot `START` → `SETTINGS` → priority packets with `EOP` → `LOADED`, live `MODIFIED`/`DELETED` then `LOADED`. Creates and updates are both `MODIFIED` (not `ADDED`). **DELETED events are broadcast without per-user SSAR** — the same known gap as Node; do not “fix” it in this stream without a follow-up. ## Shared artifacts diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index e527cb46d8d..9bd4665c669 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -20,6 +20,7 @@ import ( "github.com/stolostron/console/backend/internal/clusterinfo" "github.com/stolostron/console/backend/internal/clusterproxy" "github.com/stolostron/console/backend/internal/config" + eventshub "github.com/stolostron/console/backend/internal/events/hub" rbacevents "github.com/stolostron/console/backend/internal/events/rbac" "github.com/stolostron/console/backend/internal/informers" "github.com/stolostron/console/backend/internal/k8sproxy" @@ -88,7 +89,7 @@ func run() error { rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacevents.NewSSARAccess(restCfg)) infCfg := informers.RESTConfig(restCfg) - dyn, err := dynamic.NewForConfig(infCfg) + infDyn, err := dynamic.NewForConfig(infCfg) if err != nil { return err } @@ -98,6 +99,14 @@ func run() error { } mapper := discocache.NewMemCacheClient(disco) infCache := informers.New(informers.DefaultWatchSpecs()) + eventHub := eventshub.New(infCache, cfg.Settings) + ssar := eventshub.NewSSARAccess(restCfg) + ssar.StartCleanup(ctx) + eventsHandler := eventshub.NewHandler(eventHub, eventshub.NewAPIAuth(restCfg), ssar) + if cfg.InformerCache { + infCache.SetSink(eventHub) + cfg.OnReload(eventHub.PublishSettings) + } oauthH := oauth.New(oauth.Options{ ClientID: cfg.OAuth2ClientID, @@ -112,6 +121,9 @@ func run() error { }) var opts []server.Option opts = append(opts, server.WithRBACEvents(rbacHandler), server.WithOAuth(oauthH)) + if cfg.InformerCache { + opts = append(opts, server.WithEvents(eventsHandler)) + } if !cfg.Production { opts = append(opts, server.WithOAuthLogin(), server.WithDebugSnapshot(informers.NewSnapshotHandler(infCache, restCfg))) } @@ -187,7 +199,7 @@ func run() error { applog.Logger().Info("informer cache disabled", "CONSOLE_INFORMER_CACHE", os.Getenv("CONSOLE_INFORMER_CACHE")) return } - informers.StartCache(ctx, infCache, dyn, mapper) + informers.StartCache(ctx, infCache, infDyn, mapper) }) } diff --git a/backend/internal/clusterinfo/clusterinfo.go b/backend/internal/clusterinfo/clusterinfo.go index f3f6959e752..a5b0472e51e 100644 --- a/backend/internal/clusterinfo/clusterinfo.go +++ b/backend/internal/clusterinfo/clusterinfo.go @@ -406,7 +406,7 @@ func (h *Handler) operatorCheck(w http.ResponseWriter, r *http.Request) { return } var req operatorCheckRequest - if err := json.Unmarshal(body, &req); err != nil || !isSupportedOperator(req.Operator) { + if err = json.Unmarshal(body, &req); err != nil || !isSupportedOperator(req.Operator) { w.WriteHeader(http.StatusBadRequest) return } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 814721d9839..b737d57e104 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -44,6 +44,7 @@ type Config struct { mu sync.RWMutex settings map[string]string + hooks []func() } func envOr(key, fallback string) string { @@ -161,9 +162,26 @@ func (c *Config) ReloadSettings() error { c.LogLevel = lvl applog.SetLevel(lvl) } + + c.mu.RLock() + hooks := append([]func(){}, c.hooks...) + c.mu.RUnlock() + for _, fn := range hooks { + fn() + } return nil } +// OnReload registers fn to run after each successful ReloadSettings (config file watch). +func (c *Config) OnReload(fn func()) { + if c == nil || fn == nil { + return + } + c.mu.Lock() + c.hooks = append(c.hooks, fn) + c.mu.Unlock() +} + // Watch reloads settings when files under ConfigDir change. Call cancel to stop. func (c *Config) Watch() (cancel func(), err error) { watcher, err := fsnotify.NewWatcher() diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 50b5f291bd7..b94adff7238 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -50,6 +50,22 @@ func TestReloadSettings_PromotesKeys(t *testing.T) { } } +func TestOnReload_RunsAfterReloadSettings(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "flag"), []byte("on"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &config.Config{ConfigDir: dir} + var n int + cfg.OnReload(func() { n++ }) + if err := cfg.ReloadSettings(); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("hooks=%d", n) + } +} + func TestLoad_FromEnvFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".env") diff --git a/backend/internal/events/hub/access.go b/backend/internal/events/hub/access.go new file mode 100644 index 00000000000..75646c51cd5 --- /dev/null +++ b/backend/internal/events/hub/access.go @@ -0,0 +1,259 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "sort" + "sync" + "time" + + authzv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" +) + +const ( + accessCacheTTL = 60 * time.Second + accessCleanupEvery = 90 * time.Second +) + +var accessCacheMaxTokens = 1000 + +// AccessChecker decides whether a user may receive an SSE event. +type AccessChecker interface { + Allow(ctx context.Context, token string, ev Event) (bool, error) +} + +// AllowAllAccess is for tests. +type AllowAllAccess struct{} + +func (AllowAllAccess) Allow(context.Context, string, Event) (bool, error) { + return true, nil +} + +type ssarKey struct { + kind, namespace, name string +} + +type cacheEntry struct { + allowed bool + expiry time.Time +} + +type tokenState struct { + last time.Time + entries map[ssarKey]cacheEntry +} + +// SSARAccess ports Node eventFilter / canAccess (list cluster → list namespaced → get). +type SSARAccess struct { + newClient func(userToken string) (kubernetes.Interface, error) + + mu sync.Mutex + byToken map[string]*tokenState +} + +func NewSSARAccess(base *rest.Config) *SSARAccess { + return NewSSARAccessWithClient(func(userToken string) (kubernetes.Interface, error) { + return kubernetes.NewForConfig(auth.UserRESTConfig(base, userToken)) + }) +} + +func NewSSARAccessWithClient(newClient func(userToken string) (kubernetes.Interface, error)) *SSARAccess { + return &SSARAccess{ + byToken: map[string]*tokenState{}, + newClient: newClient, + } +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func apiGroup(apiVersion string) string { + gv, err := schema.ParseGroupVersion(apiVersion) + if err != nil { + return "" + } + return gv.Group +} + +func objectMeta(ev Event) (kind, apiVersion, name, namespace string) { + if ev.Object == nil { + return "", "", "", "" + } + kind, _ = ev.Object["kind"].(string) + apiVersion, _ = ev.Object["apiVersion"].(string) + meta, _ := ev.Object["metadata"].(map[string]any) + if meta == nil { + return kind, apiVersion, "", "" + } + name, _ = meta["name"].(string) + namespace, _ = meta["namespace"].(string) + return kind, apiVersion, name, namespace +} + +func resourceName(ev Event) string { + if ev.GVR.Resource != "" { + return ev.GVR.Resource + } + return "" +} + +func (a *SSARAccess) Allow(ctx context.Context, token string, ev Event) (bool, error) { + switch ev.Type { + case TypeStart, TypeEOP, TypeLoaded, TypeSettings: + return true, nil + case TypeDeleted: + // Bug-compatible with Node: DELETED is sent to every client without SSAR. + // Namespace deletes make a follow-up access check fail. Track for a later fix. + return true, nil + case TypeModified, "ADDED": + return a.canSee(ctx, token, ev) + default: + return false, nil + } +} + +func (a *SSARAccess) canSee(ctx context.Context, token string, ev Event) (bool, error) { + kind, apiVersion, name, namespace := objectMeta(ev) + resource := resourceName(ev) + if resource == "" { + return false, nil + } + group := apiGroup(apiVersion) + + allowed, err := a.ssar(ctx, token, ssarKey{kind: kind}, group, resource, "list", "", "") + if err != nil { + return false, err + } + if allowed { + return true, nil + } + if namespace == "" { + return a.ssar(ctx, token, ssarKey{kind: kind, name: name}, group, resource, "get", name, ssarNamespace(kind, name, namespace)) + } + allowed, err = a.ssar(ctx, token, ssarKey{kind: kind, namespace: namespace}, group, resource, "list", "", namespace) + if err != nil { + return false, err + } + if allowed { + return true, nil + } + return a.ssar(ctx, token, ssarKey{kind: kind, namespace: namespace, name: name}, group, resource, "get", name, ssarNamespace(kind, name, namespace)) +} + +func ssarNamespace(kind, name, namespace string) string { + if kind == "Namespace" { + return name + } + return namespace +} + +func (a *SSARAccess) ssar(ctx context.Context, token string, key ssarKey, group, resource, verb, name, namespace string) (bool, error) { + now := time.Now() + th := hashToken(token) + a.mu.Lock() + if st, ok := a.byToken[th]; ok { + if e, hit := st.entries[key]; hit && e.expiry.After(now) { + st.last = now + allowed := e.allowed + a.mu.Unlock() + return allowed, nil + } + } + a.mu.Unlock() + + client, err := a.newClient(token) + if err != nil { + return false, err + } + review, err := client.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Group: group, + Resource: resource, + Verb: verb, + Name: name, + Namespace: namespace, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + return false, err + } + allowed := review.Status.Allowed + a.mu.Lock() + st := a.byToken[th] + if st == nil { + st = &tokenState{entries: map[ssarKey]cacheEntry{}} + a.byToken[th] = st + } + st.last = now + st.entries[key] = cacheEntry{allowed: allowed, expiry: now.Add(accessCacheTTL)} + a.mu.Unlock() + return allowed, nil +} + +func (a *SSARAccess) StartCleanup(ctx context.Context) { + if a == nil { + return + } + go func() { + tick := time.NewTicker(accessCleanupEvery) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + a.cleanup(time.Now()) + } + } + }() +} + +func (a *SSARAccess) cleanup(now time.Time) { + a.mu.Lock() + defer a.mu.Unlock() + for th, st := range a.byToken { + for k, e := range st.entries { + if !e.expiry.After(now) { + delete(st.entries, k) + } + } + if len(st.entries) == 0 { + delete(a.byToken, th) + } + } + if len(a.byToken) <= accessCacheMaxTokens { + return + } + type pair struct { + hash string + last time.Time + } + all := make([]pair, 0, len(a.byToken)) + for h, st := range a.byToken { + all = append(all, pair{h, st.last}) + } + sort.Slice(all, func(i, j int) bool { return all[i].last.Before(all[j].last) }) + extra := len(all) - accessCacheMaxTokens + for i := 0; i < extra; i++ { + delete(a.byToken, all[i].hash) + } +} + +func (a *SSARAccess) tokenCount() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.byToken) +} diff --git a/backend/internal/events/hub/access_test.go b/backend/internal/events/hub/access_test.go new file mode 100644 index 00000000000..0ed6e9ffddf --- /dev/null +++ b/backend/internal/events/hub/access_test.go @@ -0,0 +1,176 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "context" + "testing" + "time" + + authzv1 "k8s.io/api/authorization/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func modifiedNS(name string) Event { + return Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, + Object: map[string]any{ + "kind": "Namespace", + "apiVersion": "v1", + "metadata": map[string]any{"name": name}, + }, + } +} + +func TestAllowControlAndDeleted(t *testing.T) { + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { + t.Fatal("SSAR should not run") + return nil, nil + }) + for _, typ := range []string{TypeStart, TypeEOP, TypeLoaded, TypeSettings, TypeDeleted} { + ok, err := a.Allow(context.Background(), "tok", Event{Type: typ}) + if err != nil || !ok { + t.Fatalf("%s allowed=%v err=%v", typ, ok, err) + } + } +} + +func TestSSARCascadeListThenGetNamespace(t *testing.T) { + var verbs []string + var namespaces []string + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + review := create.GetObject().(*authzv1.SelfSubjectAccessReview) + attr := review.Spec.ResourceAttributes + verbs = append(verbs, attr.Verb) + namespaces = append(namespaces, attr.Namespace) + allowed := attr.Verb == "get" + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: allowed}, + }, nil + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + ok, err := a.Allow(context.Background(), "tok", modifiedNS("default")) + if err != nil || !ok { + t.Fatalf("allowed=%v err=%v", ok, err) + } + if len(verbs) < 2 || verbs[0] != "list" || verbs[1] != "get" { + t.Fatalf("verbs %v", verbs) + } + if namespaces[1] != "default" { + t.Fatalf("Namespace SSAR namespace must be the object name, got %q", namespaces[1]) + } +} + +func TestSSARNamespacedListThenGet(t *testing.T) { + var verbs []string + var namespaces []string + var names []string + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + review := create.GetObject().(*authzv1.SelfSubjectAccessReview) + attr := review.Spec.ResourceAttributes + verbs = append(verbs, attr.Verb) + namespaces = append(namespaces, attr.Namespace) + names = append(names, attr.Name) + allowed := attr.Verb == "get" + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: allowed}, + }, nil + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + ev := Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, + Object: map[string]any{ + "kind": "Secret", "apiVersion": "v1", + "metadata": map[string]any{"name": "creds", "namespace": "ns"}, + }, + } + ok, err := a.Allow(context.Background(), "tok", ev) + if err != nil || !ok { + t.Fatalf("allowed=%v err=%v", ok, err) + } + if len(verbs) != 3 || verbs[0] != "list" || verbs[1] != "list" || verbs[2] != "get" { + t.Fatalf("verbs %v", verbs) + } + if namespaces[0] != "" || namespaces[1] != "ns" || names[2] != "creds" { + t.Fatalf("ns=%v names=%v", namespaces, names) + } +} + +func TestSSARCacheTTL(t *testing.T) { + var n int + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + n++ + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: true}, + }, nil + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + ev := modifiedNS("default") + if _, err := a.Allow(context.Background(), "tok", ev); err != nil { + t.Fatal(err) + } + if _, err := a.Allow(context.Background(), "tok", ev); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("SSAR calls %d want 1 (cached)", n) + } +} + +func TestSSARCleanupExpiresAndMaxTokens(t *testing.T) { + orig := accessCacheMaxTokens + accessCacheMaxTokens = 2 + t.Cleanup(func() { accessCacheMaxTokens = orig }) + + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: true}, + }, nil + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + ev := modifiedNS("default") + for _, tok := range []string{"a", "b", "c"} { + if _, err := a.Allow(context.Background(), tok, ev); err != nil { + t.Fatal(err) + } + } + a.cleanup(time.Now()) + if a.tokenCount() != 2 { + t.Fatalf("tokens %d want 2", a.tokenCount()) + } + + a.mu.Lock() + for _, st := range a.byToken { + for k, e := range st.entries { + e.expiry = time.Now().Add(-time.Second) + st.entries[k] = e + } + } + a.mu.Unlock() + a.cleanup(time.Now()) + if a.tokenCount() != 0 { + t.Fatalf("expired tokens left %d", a.tokenCount()) + } +} + +func TestAllowUnknownTypeDenied(t *testing.T) { + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { + return fake.NewSimpleClientset(), nil + }) + ok, err := a.Allow(context.Background(), "tok", Event{Type: "NOPE"}) + if err != nil || ok { + t.Fatalf("ok=%v err=%v", ok, err) + } +} diff --git a/backend/internal/events/hub/encode.go b/backend/internal/events/hub/encode.go new file mode 100644 index 00000000000..9de5ec20dab --- /dev/null +++ b/backend/internal/events/hub/encode.go @@ -0,0 +1,83 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "compress/gzip" + "compress/zlib" + "io" + "net/http" + "os" + "strings" +) + +type streamEncoder struct { + w http.ResponseWriter + flusher http.Flusher + gz *gzip.Writer + zl *zlib.Writer + closer io.Closer +} + +func negotiateEncoding(accept string, disabled bool) string { + if disabled { + return "identity" + } + if strings.Contains(accept, "gzip") { + return "gzip" + } + if strings.Contains(accept, "deflate") { + return "deflate" + } + return "identity" +} + +func streamCompressionDisabled() bool { + return os.Getenv("DISABLE_STREAM_COMPRESSION") == "true" +} + +func newStreamEncoder(w http.ResponseWriter, flusher http.Flusher, encoding string) *streamEncoder { + enc := &streamEncoder{w: w, flusher: flusher} + switch encoding { + case "gzip": + gz := gzip.NewWriter(w) + enc.gz = gz + enc.closer = gz + case "deflate": + zl := zlib.NewWriter(w) + enc.zl = zl + enc.closer = zl + } + return enc +} + +func (s *streamEncoder) Write(p []byte) (int, error) { + if s.gz != nil { + return s.gz.Write(p) + } + if s.zl != nil { + return s.zl.Write(p) + } + return s.w.Write(p) +} + +func (s *streamEncoder) Flush() error { + if s.gz != nil { + if err := s.gz.Flush(); err != nil { + return err + } + } + if s.zl != nil { + if err := s.zl.Flush(); err != nil { + return err + } + } + s.flusher.Flush() + return nil +} + +func (s *streamEncoder) Close() { + if s.closer != nil { + _ = s.closer.Close() + } +} diff --git a/backend/internal/events/hub/encode_test.go b/backend/internal/events/hub/encode_test.go new file mode 100644 index 00000000000..d00c7576ce5 --- /dev/null +++ b/backend/internal/events/hub/encode_test.go @@ -0,0 +1,49 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "bytes" + "compress/gzip" + "io" + "net/http/httptest" + "testing" +) + +func TestNegotiateEncoding(t *testing.T) { + if got := negotiateEncoding("gzip, deflate, br", false); got != "gzip" { + t.Fatalf("gzip preferred, got %s", got) + } + if got := negotiateEncoding("deflate", false); got != "deflate" { + t.Fatalf("got %s", got) + } + if got := negotiateEncoding("gzip", true); got != "identity" { + t.Fatalf("disabled got %s", got) + } + if got := negotiateEncoding("", false); got != "identity" { + t.Fatalf("empty got %s", got) + } +} + +func TestGzipFlushRoundTrip(t *testing.T) { + rec := httptest.NewRecorder() + enc := newStreamEncoder(rec, rec, "gzip") + if _, err := enc.Write(FormatSSE("1", []byte(`{"type":"START"}`))); err != nil { + t.Fatal(err) + } + if err := enc.Flush(); err != nil { + t.Fatal(err) + } + enc.Close() + r, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes())) + if err != nil { + t.Fatal(err) + } + plain, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(plain, []byte(`{"type":"START"}`)) { + t.Fatalf("%s", plain) + } +} diff --git a/backend/internal/events/hub/event.go b/backend/internal/events/hub/event.go new file mode 100644 index 00000000000..820988099a2 --- /dev/null +++ b/backend/internal/events/hub/event.go @@ -0,0 +1,25 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + TypeStart = "START" + TypeSettings = "SETTINGS" + TypeModified = "MODIFIED" + TypeDeleted = "DELETED" + TypeEOP = "EOP" + TypeLoaded = "LOADED" +) + +// Event is one SSE data payload for GET /events. +type Event struct { + Type string `json:"type"` + Object map[string]any `json:"object,omitempty"` + Settings map[string]string `json:"settings,omitempty"` + GVR schema.GroupVersionResource `json:"-"` + ID string `json:"-"` +} diff --git a/backend/internal/events/hub/frame.go b/backend/internal/events/hub/frame.go new file mode 100644 index 00000000000..37503aef7eb --- /dev/null +++ b/backend/internal/events/hub/frame.go @@ -0,0 +1,26 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "strconv" +) + +// FormatSSE is the Node createEventString frame: id + data JSON + blank line. +func FormatSSE(id string, data []byte) []byte { + buf := make([]byte, 0, 4+len(id)+6+len(data)+2) + buf = append(buf, "id:"...) + buf = append(buf, id...) + buf = append(buf, "\ndata:"...) + buf = append(buf, data...) + buf = append(buf, "\n\n"...) + return buf +} + +func pingFrame() []byte { + return []byte(":\n\n") +} + +func nextIDString(n uint64) string { + return strconv.FormatUint(n, 10) +} diff --git a/backend/internal/events/hub/frame_test.go b/backend/internal/events/hub/frame_test.go new file mode 100644 index 00000000000..e946aa0043b --- /dev/null +++ b/backend/internal/events/hub/frame_test.go @@ -0,0 +1,25 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "strings" + "testing" +) + +func TestFormatSSE(t *testing.T) { + got := string(FormatSSE("7", []byte(`{"type":"START"}`))) + want := "id:7\ndata:{\"type\":\"START\"}\n\n" + if got != want { + t.Fatalf("got %q want %q", got, want) + } + if strings.Contains(got, "id: ") || strings.Contains(got, "data: ") { + t.Fatal("Node /events has no space after id:/data:") + } +} + +func TestPingFrame(t *testing.T) { + if string(pingFrame()) != ":\n\n" { + t.Fatalf("%q", pingFrame()) + } +} diff --git a/backend/internal/events/hub/handler.go b/backend/internal/events/hub/handler.go new file mode 100644 index 00000000000..8b4c30c0cb4 --- /dev/null +++ b/backend/internal/events/hub/handler.go @@ -0,0 +1,195 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "context" + "crypto/rand" + "encoding/json" + "net/http" + "time" + + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + applog "github.com/stolostron/console/backend/internal/log" +) + +const keepAlive = 10 * time.Second + +const instanceIDLen = 8 + +// Authenticator validates a user token (GET /api, same as Node /events). +type Authenticator interface { + Authenticate(ctx context.Context, token string) (bool, error) +} + +// APIAuth validates the browser token with GET /api using the user Bearer token. +type APIAuth struct { + base *rest.Config +} + +func NewAPIAuth(base *rest.Config) *APIAuth { + return &APIAuth{base: base} +} + +func (a *APIAuth) Authenticate(ctx context.Context, token string) (bool, error) { + if a == nil || a.base == nil { + return false, nil + } + if err := auth.ValidateUserToken(ctx, a.base, token); err != nil { + return false, err + } + return true, nil +} + +// StaticAuth is for tests. +type StaticAuth struct { + OK bool +} + +func (s StaticAuth) Authenticate(context.Context, string) (bool, error) { + return s.OK, nil +} + +// Handler serves GET /events as gzip/deflate/identity SSE. +type Handler struct { + hub *Hub + authn Authenticator + access AccessChecker + id string +} + +func NewHandler(h *Hub, authn Authenticator, access AccessChecker) *Handler { + if access == nil { + access = AllowAllAccess{} + } + return &Handler{hub: h, authn: authn, access: access, id: randomID(instanceIDLen)} +} + +func randomID(n int) string { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + for i := range b { + b[i] = alphabet[i%len(alphabet)] + } + return string(b) + } + for i := range b { + b[i] = alphabet[int(b[i])%len(alphabet)] + } + return string(b) +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + token := auth.TokenFromRequest(r) + if token == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + ok, err := h.authn.Authenticate(r.Context(), token) + if err != nil || !ok { + applog.Logger().Warn("events unauthorized", "error", err) + w.WriteHeader(http.StatusUnauthorized) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + encoding := negotiateEncoding(r.Header.Get("Accept-Encoding"), streamCompressionDisabled()) + http.SetCookie(w, &http.Cookie{ + Name: "watch", + Value: h.id, + Path: "/", + HttpOnly: true, + Secure: true, + }) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store, no-transform") + w.Header().Set("Content-Encoding", encoding) + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + enc := newStreamEncoder(w, flusher, encoding) + defer enc.Close() + + c := h.hub.subscribe() + defer h.hub.unsubscribe(c) + + for _, ev := range h.hub.snapshotEvents() { + if err := h.writeFiltered(r.Context(), token, enc, ev); err != nil { + return + } + } + + ping := time.NewTicker(keepAlive) + defer ping.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-ping.C: + if _, err := enc.Write(pingFrame()); err != nil { + return + } + if err := enc.Flush(); err != nil { + return + } + case ev, ok := <-c.ch: + if !ok { + return + } + if err := h.writeFiltered(r.Context(), token, enc, ev); err != nil { + return + } + } + } +} + +func (h *Handler) writeFiltered(ctx context.Context, token string, enc *streamEncoder, ev Event) error { + allowed, err := h.access.Allow(ctx, token, ev) + if err != nil { + applog.Logger().Warn("events ssar failed", "error", err) + return nil + } + if !allowed { + return nil + } + return writeEvent(enc, h.hub.assignID(ev)) +} + +func marshalEvent(ev Event) ([]byte, error) { + switch ev.Type { + case TypeSettings: + settings := ev.Settings + if settings == nil { + settings = map[string]string{} + } + return json.Marshal(struct { + Type string `json:"type"` + Settings map[string]string `json:"settings"` + }{Type: ev.Type, Settings: settings}) + default: + return json.Marshal(struct { + Type string `json:"type"` + Object map[string]any `json:"object,omitempty"` + }{Type: ev.Type, Object: ev.Object}) + } +} + +func writeEvent(enc *streamEncoder, ev Event) error { + body, err := marshalEvent(ev) + if err != nil { + return err + } + if _, err := enc.Write(FormatSSE(ev.ID, body)); err != nil { + return err + } + return enc.Flush() +} diff --git a/backend/internal/events/hub/handler_test.go b/backend/internal/events/hub/handler_test.go new file mode 100644 index 00000000000..b5ccfe96443 --- /dev/null +++ b/backend/internal/events/hub/handler_test.go @@ -0,0 +1,265 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/informers" +) + +func waitBody(t *testing.T, rec *httptest.ResponseRecorder, cancel context.CancelFunc, substr string) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + var body string + for time.Now().Before(deadline) { + body = rec.Body.String() + if strings.Contains(body, substr) { + break + } + time.Sleep(10 * time.Millisecond) + } + cancel() + if !strings.Contains(body, substr) { + t.Fatalf("missing %s in %s", substr, body) + } + return body +} + +func TestHandlerUnauthorized(t *testing.T) { + h := NewHandler(New(nil, nil), StaticAuth{OK: true}, AllowAllAccess{}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/events", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } + + req := httptest.NewRequest(http.MethodGet, "/events", nil) + req.Header.Set("Authorization", "Bearer bad") + h = NewHandler(New(nil, nil), StaticAuth{OK: false}, AllowAllAccess{}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } +} + +func TestHandlerSnapshotSSE(t *testing.T) { + scheme := runtime.NewScheme() + gvr := schema.GroupVersionResource{Version: "v1", Resource: "namespaces"} + listKinds := map[schema.GroupVersionResource]string{gvr: "NamespaceList"} + ns := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", "kind": "Namespace", + "metadata": map[string]any{"name": "default", "uid": "uid-ns"}, + }} + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, ns) + mapper := staticMapper{lists: map[string]*metav1.APIResourceList{ + "v1": {GroupVersion: "v1", APIResources: []metav1.APIResource{ + {Name: "namespaces", Kind: "Namespace", Verbs: []string{"list", "watch"}}, + }}, + }} + ctx, cancelCache := context.WithCancel(context.Background()) + defer cancelCache() + cache := informers.New([]informers.WatchSpec{{Kind: "Namespace", APIVersion: "v1", ForwardEventsToClients: true}}) + informers.StartCache(ctx, cache, client, mapper) + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if cache.HasSynced() { + break + } + time.Sleep(20 * time.Millisecond) + } + if !cache.HasSynced() { + t.Fatal("cache sync") + } + + hub := New(cache, func() map[string]string { return map[string]string{"LOG_LEVEL": "info"} }) + h := NewHandler(hub, StaticAuth{OK: true}, AllowAllAccess{}) + + reqCtx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(reqCtx) + req.AddCookie(&http.Cookie{Name: auth.AccessTokenCookie, Value: "user-token"}) + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + h.ServeHTTP(rec, req) + close(done) + }() + body := waitBody(t, rec, cancel, `"type":"LOADED"`) + <-done + + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/event-stream") { + t.Fatalf("content-type %s", ct) + } + if rec.Header().Get("Cache-Control") != "no-store, no-transform" { + t.Fatalf("cache-control %s", rec.Header().Get("Cache-Control")) + } + if rec.Header().Get("Content-Encoding") != "identity" { + t.Fatalf("encoding %s", rec.Header().Get("Content-Encoding")) + } + cookie := rec.Header().Get("Set-Cookie") + if !strings.Contains(cookie, "watch=") || !strings.Contains(cookie, "HttpOnly") { + t.Fatalf("watch cookie %s", cookie) + } + for _, typ := range []string{"START", "SETTINGS", "MODIFIED", "EOP", "LOADED"} { + if !strings.Contains(body, `"type":"`+typ+`"`) { + t.Fatalf("missing %s in %s", typ, body) + } + } + if strings.Contains(body, `"type":"ADDED"`) { + t.Fatal("GET /events must not emit ADDED") + } + if !strings.Contains(body, `"LOG_LEVEL":"info"`) { + t.Fatalf("settings %s", body) + } + if !strings.HasPrefix(strings.TrimSpace(body), "id:") { + t.Fatalf("want id: prefix %s", body) + } + if strings.Contains(body, "id: ") { + t.Fatal("no space after id:") + } + + var payload struct { + Type string `json:"type"` + Object json.RawMessage `json:"object"` + } + foundModified := false + for _, line := range strings.Split(body, "\n") { + if !strings.HasPrefix(line, "data:") || strings.HasPrefix(line, "data: ") { + if strings.HasPrefix(line, "data: ") { + t.Fatal("data: must not have a space (Node createEventString)") + } + continue + } + raw := strings.TrimPrefix(line, "data:") + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatal(err) + } + if payload.Type == TypeModified { + foundModified = true + if !strings.Contains(string(payload.Object), "default") { + t.Fatalf("object %s", payload.Object) + } + } + } + if !foundModified { + t.Fatal("expected MODIFIED") + } +} + +func TestHandlerSSARDenyOmitsResource(t *testing.T) { + hub := New(nil, nil) + h := NewHandler(hub, StaticAuth{OK: true}, denyAccess{}) + reqCtx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(reqCtx) + req.Header.Set("Authorization", "Bearer user") + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + h.ServeHTTP(rec, req) + close(done) + }() + waitBody(t, rec, func() {}, `"type":"LOADED"`) + hub.OnResource(informers.ResourceEvent{ + Type: TypeModified, + Object: &unstructured.Unstructured{Object: map[string]any{ + "kind": "Secret", "apiVersion": "v1", + "metadata": map[string]any{"name": "creds", "namespace": "ns"}, + }}, + }) + time.Sleep(50 * time.Millisecond) + cancel() + <-done + if strings.Contains(rec.Body.String(), "creds") { + t.Fatalf("denied resource leaked: %s", rec.Body.String()) + } +} + +type denyAccess struct{} + +func (denyAccess) Allow(_ context.Context, _ string, ev Event) (bool, error) { + if ev.Type == TypeModified { + return false, nil + } + return true, nil +} + +func TestHandlerLiveModifiedThenLoaded(t *testing.T) { + hub := New(nil, nil) + h := NewHandler(hub, StaticAuth{OK: true}, AllowAllAccess{}) + reqCtx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(reqCtx) + req.Header.Set("Authorization", "Bearer user") + rec := httptest.NewRecorder() + done := make(chan struct{}) + go func() { + h.ServeHTTP(rec, req) + close(done) + }() + waitBody(t, rec, func() {}, `"type":"LOADED"`) + hub.OnResource(informers.ResourceEvent{ + Type: TypeDeleted, + Object: &unstructured.Unstructured{Object: map[string]any{ + "kind": "Namespace", "apiVersion": "v1", + "metadata": map[string]any{"name": "gone", "namespace": ""}, + }}, + }) + deadline := time.Now().Add(2 * time.Second) + var body string + for time.Now().Before(deadline) { + body = rec.Body.String() + if strings.Contains(body, `"type":"DELETED"`) && strings.Count(body, `"type":"LOADED"`) >= 2 { + break + } + time.Sleep(10 * time.Millisecond) + } + cancel() + <-done + if !strings.Contains(body, `"type":"DELETED"`) { + t.Fatalf("missing DELETED %s", body) + } + if strings.Count(body, `"type":"LOADED"`) < 2 { + t.Fatalf("live DELETED should be followed by LOADED: %s", body) + } + for _, line := range strings.Split(body, "\n") { + if !strings.Contains(line, `"type":"DELETED"`) { + continue + } + if strings.Contains(line, `"uid"`) { + t.Fatalf("DELETED must be minimal: %s", line) + } + } +} + +type staticMapper struct { + lists map[string]*metav1.APIResourceList +} + +func (m staticMapper) ServerResourcesForGroupVersion(gv string) (*metav1.APIResourceList, error) { + if l, ok := m.lists[gv]; ok { + return l, nil + } + return &metav1.APIResourceList{GroupVersion: gv}, nil +} diff --git a/backend/internal/events/hub/hub.go b/backend/internal/events/hub/hub.go new file mode 100644 index 00000000000..7f0893fbfd4 --- /dev/null +++ b/backend/internal/events/hub/hub.go @@ -0,0 +1,138 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/stolostron/console/backend/internal/informers" +) + +const ( + defaultBuffer = 256 + defaultPurge = 30 * time.Minute +) + +type client struct { + ch chan Event + blocked time.Time +} + +// Hub fans informer events out to GET /events clients (Node ServerSideEvents). +type Hub struct { + cache *informers.InformerCache + settings func() map[string]string + + mu sync.Mutex + clients map[*client]struct{} + nextID atomic.Uint64 + buf int + purge time.Duration +} + +func New(cache *informers.InformerCache, settings func() map[string]string) *Hub { + if settings == nil { + settings = func() map[string]string { return map[string]string{} } + } + return &Hub{ + cache: cache, + settings: settings, + clients: map[*client]struct{}{}, + buf: defaultBuffer, + purge: defaultPurge, + } +} + +func (h *Hub) bufSize() int { + if h.buf <= 0 { + return defaultBuffer + } + return h.buf +} + +func (h *Hub) purgeAfter() time.Duration { + if h.purge <= 0 { + return defaultPurge + } + return h.purge +} + +func (h *Hub) subscribe() *client { + c := &client{ch: make(chan Event, h.bufSize())} + h.mu.Lock() + h.clients[c] = struct{}{} + h.mu.Unlock() + return c +} + +func (h *Hub) unsubscribe(c *client) { + h.mu.Lock() + h.dropLocked(c) + h.mu.Unlock() +} + +func (h *Hub) dropLocked(c *client) { + if _, ok := h.clients[c]; !ok { + return + } + delete(h.clients, c) + close(c.ch) +} + +func (h *Hub) clientCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.clients) +} + +func (h *Hub) assignID(ev Event) Event { + ev.ID = nextIDString(h.nextID.Add(1)) + return ev +} + +func (h *Hub) fanout(ev Event) { + now := time.Now() + purge := h.purgeAfter() + h.mu.Lock() + defer h.mu.Unlock() + for c := range h.clients { + select { + case c.ch <- ev: + c.blocked = time.Time{} + default: + if c.blocked.IsZero() { + c.blocked = now + } + if now.Sub(c.blocked) >= purge { + h.dropLocked(c) + } + } + } +} + +// OnResource implements informers.ResourceSink. Matches Node pushEvent (payload then LOADED). +func (h *Hub) OnResource(ev informers.ResourceEvent) { + if h == nil { + return + } + obj := map[string]any{} + if ev.Object != nil && ev.Object.Object != nil { + obj = ev.Object.Object + } + h.push(Event{Type: ev.Type, Object: obj, GVR: ev.GVR}) +} + +func (h *Hub) push(ev Event) { + h.fanout(ev) + h.fanout(Event{Type: TypeLoaded}) +} + +// PublishSettings broadcasts SETTINGS from the config directory (plus LOADED). +func (h *Hub) PublishSettings() { + if h == nil { + return + } + h.push(Event{Type: TypeSettings, Settings: h.settings()}) +} diff --git a/backend/internal/events/hub/hub_test.go b/backend/internal/events/hub/hub_test.go new file mode 100644 index 00000000000..71f2c0f24d7 --- /dev/null +++ b/backend/internal/events/hub/hub_test.go @@ -0,0 +1,85 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "testing" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/stolostron/console/backend/internal/informers" +) + +func TestOnResourceFansModifiedThenLoaded(t *testing.T) { + h := New(nil, nil) + c := h.subscribe() + defer h.unsubscribe(c) + h.OnResource(informers.ResourceEvent{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, + Object: &unstructured.Unstructured{Object: map[string]any{ + "kind": "Namespace", "apiVersion": "v1", + "metadata": map[string]any{"name": "default"}, + }}, + }) + ev1 := recv(t, c.ch) + ev2 := recv(t, c.ch) + if ev1.Type != TypeModified || ev2.Type != TypeLoaded { + t.Fatalf("%s then %s", ev1.Type, ev2.Type) + } +} + +func TestPublishSettingsThenLoaded(t *testing.T) { + h := New(nil, func() map[string]string { return map[string]string{"x": "1"} }) + c := h.subscribe() + defer h.unsubscribe(c) + h.PublishSettings() + ev1 := recv(t, c.ch) + ev2 := recv(t, c.ch) + if ev1.Type != TypeSettings || ev1.Settings["x"] != "1" || ev2.Type != TypeLoaded { + t.Fatalf("%+v %+v", ev1, ev2) + } +} + +func TestUnsubscribeRemovesClient(t *testing.T) { + h := New(nil, nil) + c := h.subscribe() + if h.clientCount() != 1 { + t.Fatal("expected 1") + } + h.unsubscribe(c) + if h.clientCount() != 0 { + t.Fatal("expected 0") + } + h.unsubscribe(c) +} + +func TestSlowClientPurged(t *testing.T) { + h := New(nil, nil) + h.buf = 1 + h.purge = time.Millisecond + c := h.subscribe() + c.ch <- Event{Type: TypeStart} + h.fanout(Event{Type: TypeModified, Object: map[string]any{"kind": "x"}}) + time.Sleep(3 * time.Millisecond) + h.fanout(Event{Type: TypeLoaded}) + if h.clientCount() != 0 { + t.Fatalf("slow client still subscribed: %d", h.clientCount()) + } +} + +func recv(t *testing.T, ch <-chan Event) Event { + t.Helper() + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("channel closed") + } + return ev + case <-time.After(time.Second): + t.Fatal("timeout") + return Event{} + } +} diff --git a/backend/internal/events/hub/parity_test.go b/backend/internal/events/hub/parity_test.go new file mode 100644 index 00000000000..ea56eefa200 --- /dev/null +++ b/backend/internal/events/hub/parity_test.go @@ -0,0 +1,103 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "bufio" + "encoding/json" + "net/http" + "os" + "strings" + "testing" + "time" +) + +// TestSSEParitySkipWhenMissing compares GET /events snapshots from Go vs Node. +// Set CONTRACT_GO_EVENTS_URL (e.g. https://localhost:4000/events), +// CONTRACT_NODE_EVENTS_URL (e.g. https://localhost:4001/events), and +// CONTRACT_EVENTS_TOKEN (or rely on cookies in CONTRACT_EVENTS_COOKIE). +func TestSSEParitySkipWhenMissing(t *testing.T) { + goURL := os.Getenv("CONTRACT_GO_EVENTS_URL") + nodeURL := os.Getenv("CONTRACT_NODE_EVENTS_URL") + if goURL == "" || nodeURL == "" { + t.Skip("set CONTRACT_GO_EVENTS_URL and CONTRACT_NODE_EVENTS_URL to compare SSE snapshots") + } + token := os.Getenv("CONTRACT_EVENTS_TOKEN") + goSet := snapshotIdentities(t, goURL, token) + nodeSet := snapshotIdentities(t, nodeURL, token) + if len(goSet) == 0 && len(nodeSet) == 0 { + t.Fatal("both snapshots empty") + } + var missing []string + for id := range nodeSet { + if !goSet[id] { + missing = append(missing, "go missing "+id) + } + } + for id := range goSet { + if !nodeSet[id] { + missing = append(missing, "node missing "+id) + } + } + if len(missing) > 0 { + t.Fatalf("SSE snapshot mismatch (%d): %s", len(missing), strings.Join(missing[:min(10, len(missing))], "; ")) + } +} + +func snapshotIdentities(t *testing.T, url, token string) map[string]bool { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Accept-Encoding", "identity") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if c := os.Getenv("CONTRACT_EVENTS_COOKIE"); c != "" { + req.Header.Set("Cookie", c) + } + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", url, resp.StatusCode) + } + out := map[string]bool{} + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 1024*1024), 16*1024*1024) + for sc.Scan() { + line := sc.Text() + payload := strings.TrimPrefix(line, "data:") + payload = strings.TrimPrefix(payload, " ") + if payload == line || payload == "" { + continue + } + var ev struct { + Type string `json:"type"` + Object struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"metadata"` + } `json:"object"` + } + if err := json.Unmarshal([]byte(payload), &ev); err != nil { + continue + } + if ev.Type == TypeLoaded { + break + } + if ev.Type != TypeModified && ev.Type != TypeDeleted && ev.Type != "ADDED" { + continue + } + id := ev.Type + "|" + ev.Object.APIVersion + "|" + ev.Object.Kind + "|" + ev.Object.Metadata.Namespace + "|" + ev.Object.Metadata.Name + out[id] = true + } + return out +} diff --git a/backend/internal/events/hub/snapshot.go b/backend/internal/events/hub/snapshot.go new file mode 100644 index 00000000000..499ca81c895 --- /dev/null +++ b/backend/internal/events/hub/snapshot.go @@ -0,0 +1,107 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "sort" + + "github.com/stolostron/console/backend/internal/informers" +) + +func modifiedEvent(o informers.ForwardedObject) Event { + obj := o.Object.Object + if obj == nil { + obj = map[string]any{} + } + return Event{Type: TypeModified, Object: obj, GVR: o.GVR} +} + +func splice(src *[]informers.ForwardedObject, n int) []Event { + if n <= 0 || len(*src) == 0 { + return nil + } + if n > len(*src) { + n = len(*src) + } + chunk := (*src)[:n] + *src = (*src)[n:] + out := make([]Event, len(chunk)) + for i, o := range chunk { + out[i] = modifiedEvent(o) + } + return out +} + +func sortByName(items []informers.ForwardedObject) { + sort.Slice(items, func(i, j int) bool { + return items[i].Object.GetName() < items[j].Object.GetName() + }) +} + +func sortByNamespace(items []informers.ForwardedObject) { + sort.Slice(items, func(i, j int) bool { + return items[i].Object.GetNamespace() < items[j].Object.GetNamespace() + }) +} + +func packetize(objs []informers.ForwardedObject) []Event { + var clusters, agents, infos, policies, addons, rbac, other, remainder []informers.ForwardedObject + for _, o := range objs { + switch o.Object.GetKind() { + case "ManagedCluster", "HostedCluster", "ClusterDeployment", "ManagedClusterSet": + clusters = append(clusters, o) + case "Policy", "PolicySet": + policies = append(policies, o) + case "AgentClusterInstall": + agents = append(agents, o) + case "ManagedClusterInfo": + infos = append(infos, o) + case "ManagedClusterAddOn": + addons = append(addons, o) + case "MulticlusterRoleAssignment", "User", "Group": + rbac = append(rbac, o) + case "Search", "Secret": + other = append(other, o) + default: + remainder = append(remainder, o) + } + } + sortByName(clusters) + sortByNamespace(infos) + sortByName(policies) + sortByNamespace(addons) + sortByName(rbac) + + var out []Event + for { + out = append(out, splice(&clusters, 200)...) + out = append(out, splice(&agents, 200)...) + out = append(out, splice(&infos, 200)...) + out = append(out, splice(&policies, 200)...) + out = append(out, splice(&addons, 400)...) + out = append(out, splice(&rbac, 200)...) + out = append(out, splice(&other, 100)...) + out = append(out, Event{Type: TypeEOP}) + if len(clusters)+len(agents)+len(infos)+len(policies)+len(addons)+len(rbac)+len(other) == 0 { + break + } + } + for len(remainder) > 0 { + out = append(out, splice(&remainder, 1978)...) + } + return out +} + +func (h *Hub) snapshotEvents() []Event { + var objs []informers.ForwardedObject + if h.cache != nil { + objs = h.cache.ListForwarded() + } + out := []Event{ + {Type: TypeStart}, + {Type: TypeSettings, Settings: h.settings()}, + } + out = append(out, packetize(objs)...) + out = append(out, Event{Type: TypeLoaded}) + return out +} diff --git a/backend/internal/events/hub/snapshot_test.go b/backend/internal/events/hub/snapshot_test.go new file mode 100644 index 00000000000..5b2a82f96c5 --- /dev/null +++ b/backend/internal/events/hub/snapshot_test.go @@ -0,0 +1,98 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/stolostron/console/backend/internal/informers" +) + +func fwd(kind, apiVersion, ns, name string) informers.ForwardedObject { + meta := map[string]any{"name": name} + if ns != "" { + meta["namespace"] = ns + } + return informers.ForwardedObject{ + GVR: schema.GroupVersionResource{Version: "v1", Resource: strings.ToLower(kind) + "s"}, + Object: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": meta, + }}, + } +} + +func typesOf(evs []Event) []string { + out := make([]string, len(evs)) + for i, e := range evs { + out[i] = e.Type + if e.Type == TypeModified { + if kind, _ := e.Object["kind"].(string); kind != "" { + out[i] = kind + } + } + } + return out +} + +func TestPacketizeEmptyEmitsEOP(t *testing.T) { + got := packetize(nil) + if len(got) != 1 || got[0].Type != TypeEOP { + t.Fatalf("%+v", got) + } +} + +func TestPacketizePriorityAndModified(t *testing.T) { + objs := []informers.ForwardedObject{ + fwd("Namespace", "v1", "", "z"), + fwd("ManagedCluster", "cluster.open-cluster-management.io/v1", "", "b"), + fwd("ManagedCluster", "cluster.open-cluster-management.io/v1", "", "a"), + fwd("Secret", "v1", "ns", "s"), + } + got := packetize(objs) + var kinds []string + var eop int + for _, e := range got { + switch e.Type { + case TypeEOP: + eop++ + case TypeModified: + kinds = append(kinds, e.Object["kind"].(string)) + if e.Type != TypeModified { + t.Fatal("resources must be MODIFIED not ADDED") + } + default: + t.Fatalf("unexpected %s", e.Type) + } + } + if eop != 1 { + t.Fatalf("EOP count %d", eop) + } + if kinds[0] != "ManagedCluster" || kinds[1] != "ManagedCluster" || kinds[2] != "Secret" || kinds[3] != "Namespace" { + t.Fatalf("order %v", kinds) + } + name0, _ := got[0].Object["metadata"].(map[string]any)["name"].(string) + name1, _ := got[1].Object["metadata"].(map[string]any)["name"].(string) + if name0 != "a" || name1 != "b" { + t.Fatalf("cluster sort %s %s", name0, name1) + } +} + +func TestSnapshotEventsShape(t *testing.T) { + h := New(nil, func() map[string]string { return map[string]string{"LOG_LEVEL": "info"} }) + got := h.snapshotEvents() + if got[0].Type != TypeStart || got[1].Type != TypeSettings || got[len(got)-1].Type != TypeLoaded { + t.Fatalf("%v", typesOf(got)) + } + if got[1].Settings["LOG_LEVEL"] != "info" { + t.Fatalf("settings %+v", got[1].Settings) + } + if got[2].Type != TypeEOP { + t.Fatalf("empty snapshot should EOP before LOADED, got %v", typesOf(got)) + } +} diff --git a/backend/internal/events/rbac/handler.go b/backend/internal/events/rbac/handler.go index f0cf58e6ba9..2c704fbbb17 100644 --- a/backend/internal/events/rbac/handler.go +++ b/backend/internal/events/rbac/handler.go @@ -75,13 +75,13 @@ func NewHandler(store *Store, authn Authenticator, access AccessChecker) *Handle func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { token := auth.TokenFromRequest(r) if token == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + w.WriteHeader(http.StatusUnauthorized) return } ok, err := h.authn.Authenticate(r.Context(), token) if err != nil || !ok { applog.Logger().Warn("rbac events unauthorized", "error", err) - http.Error(w, "Unauthorized", http.StatusUnauthorized) + w.WriteHeader(http.StatusUnauthorized) return } diff --git a/backend/internal/events/rbac/handler_test.go b/backend/internal/events/rbac/handler_test.go index 74fbe69e4de..d8dc5e6bbfb 100644 --- a/backend/internal/events/rbac/handler_test.go +++ b/backend/internal/events/rbac/handler_test.go @@ -84,6 +84,9 @@ func TestHandlerUnauthorized(t *testing.T) { if rec.Code != http.StatusUnauthorized { t.Fatalf("status %d", rec.Code) } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } h = rbacevents.NewHandler(rbacevents.NewStore(), rbacevents.StaticAuth{OK: false}, rbacevents.AllowAllAccess{}) req := httptest.NewRequest(http.MethodGet, "/events/rbac", nil) @@ -93,6 +96,9 @@ func TestHandlerUnauthorized(t *testing.T) { if rec.Code != http.StatusUnauthorized { t.Fatalf("status %d", rec.Code) } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } } func TestHandlerSnapshotSSE(t *testing.T) { diff --git a/backend/internal/informers/factory.go b/backend/internal/informers/factory.go index 26ba36f9e21..bb0c34e82a1 100644 --- a/backend/internal/informers/factory.go +++ b/backend/internal/informers/factory.go @@ -129,8 +129,15 @@ func (c *InformerCache) runSpec(ctx context.Context, dyn dynamic.Interface, mapp c.mu.Lock() st.gvr = gvr st.informer = inf + sink := c.sink c.mu.Unlock() + if sink != nil && st.spec.ShouldForward() { + if _, err := inf.AddEventHandler(resourceHandler{spec: st.spec, gvr: gvr, sink: sink}); err != nil { + applog.Logger().Warn("informer event handler", "kind", st.spec.Kind, "error", err) + } + } + go inf.Run(ctx.Done()) syncCtx, cancel := context.WithTimeout(ctx, syncGiveUpAfter) diff --git a/backend/internal/informers/factory_test.go b/backend/internal/informers/factory_test.go index f51d77ec21a..828df29df1a 100644 --- a/backend/internal/informers/factory_test.go +++ b/backend/internal/informers/factory_test.go @@ -182,6 +182,59 @@ func TestAuthenticationInCacheAndSnapshot(t *testing.T) { if len(snap) != 1 || snap[0].Kind != "Authentication" || snap[0].Name != "cluster" { t.Fatalf("snapshot %+v", snap) } + if n := len(c.ListForwarded()); n != 0 { + t.Fatalf("cacheOnly must not appear in ListForwarded, got %d", n) + } +} + +func TestSinkReceivesModifiedAndSkipsCacheOnly(t *testing.T) { + scheme := runtime.NewScheme() + nsGVR := schema.GroupVersionResource{Version: "v1", Resource: "namespaces"} + authGVR := schema.GroupVersionResource{Group: "config.openshift.io", Version: "v1", Resource: "authentications"} + listKinds := map[schema.GroupVersionResource]string{ + nsGVR: "NamespaceList", + authGVR: "AuthenticationList", + } + ns := uObj("v1", "Namespace", "", "default", "uid-ns", map[string]any{"resourceVersion": "1"}) + authn := uObj("config.openshift.io/v1", "Authentication", "", "cluster", "uid-auth", nil) + client := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, ns, authn) + mapper := staticMapper{lists: map[string]*metav1.APIResourceList{ + "v1": {GroupVersion: "v1", APIResources: []metav1.APIResource{ + {Name: "namespaces", Kind: "Namespace", Verbs: []string{"list", "watch"}}, + }}, + "config.openshift.io/v1": {GroupVersion: "config.openshift.io/v1", APIResources: []metav1.APIResource{ + {Name: "authentications", Kind: "Authentication", Verbs: []string{"list", "watch"}}, + }}, + }} + sink := &collectSink{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + c := New([]WatchSpec{ + watch("Namespace", "v1"), + watch("Authentication", "config.openshift.io/v1").cacheOnly(), + }) + c.SetSink(sink) + StartCache(ctx, c, client, mapper) + waitSynced(t, c) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if len(sink.types()) >= 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + for _, ev := range sink.ev { + if ev.Object.GetKind() == "Authentication" { + t.Fatal("cacheOnly Authentication must not be forwarded") + } + if ev.Type != EventModified { + t.Fatalf("initial list should be MODIFIED, got %s", ev.Type) + } + } + if len(sink.types()) == 0 { + t.Fatal("expected Namespace MODIFIED from initial list") + } } func TestManagedFieldsStrippedExceptPolicy(t *testing.T) { diff --git a/backend/internal/informers/sink.go b/backend/internal/informers/sink.go new file mode 100644 index 00000000000..3d44958000e --- /dev/null +++ b/backend/internal/informers/sink.go @@ -0,0 +1,80 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + EventModified = "MODIFIED" + EventDeleted = "DELETED" +) + +// ResourceEvent is a cache change for GET /events (Node cacheResource / deleteResource). +type ResourceEvent struct { + Type string + Object *unstructured.Unstructured + GVR schema.GroupVersionResource +} + +// ResourceSink receives forwarded watch events. The SSE hub implements this. +type ResourceSink interface { + OnResource(ev ResourceEvent) +} + +type resourceHandler struct { + spec WatchSpec + gvr schema.GroupVersionResource + sink ResourceSink +} + +func (h resourceHandler) OnAdd(obj any, _ bool) { + h.emitModified(obj) +} + +func (h resourceHandler) OnUpdate(oldObj, newObj any) { + oldU, oldOK := asUnstructured(oldObj) + newU, newOK := asUnstructured(newObj) + if oldOK && newOK && oldU.GetResourceVersion() == newU.GetResourceVersion() { + return + } + h.emitModified(newObj) +} + +func (h resourceHandler) OnDelete(obj any) { + if h.sink == nil { + return + } + u, ok := asUnstructured(obj) + if !ok { + return + } + h.sink.OnResource(ResourceEvent{ + Type: EventDeleted, + GVR: h.gvr, + Object: &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": h.spec.APIVersion, + "kind": h.spec.Kind, + "metadata": map[string]any{ + "name": u.GetName(), + "namespace": u.GetNamespace(), + }, + }}, + }) +} + +func (h resourceHandler) emitModified(obj any) { + if h.sink == nil { + return + } + u, ok := asUnstructured(obj) + if !ok { + return + } + cp := u.DeepCopy() + cp.SetAPIVersion(h.spec.APIVersion) + cp.SetKind(h.spec.Kind) + h.sink.OnResource(ResourceEvent{Type: EventModified, Object: cp, GVR: h.gvr}) +} diff --git a/backend/internal/informers/sink_test.go b/backend/internal/informers/sink_test.go new file mode 100644 index 00000000000..ca9ac509da7 --- /dev/null +++ b/backend/internal/informers/sink_test.go @@ -0,0 +1,76 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "sync" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type collectSink struct { + mu sync.Mutex + ev []ResourceEvent +} + +func (s *collectSink) OnResource(ev ResourceEvent) { + s.mu.Lock() + s.ev = append(s.ev, ev) + s.mu.Unlock() +} + +func (s *collectSink) types() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.ev)) + for i, e := range s.ev { + out[i] = e.Type + } + return out +} + +func TestResourceHandlerModifiedAndDeleted(t *testing.T) { + sink := &collectSink{} + gvr := schema.GroupVersionResource{Version: "v1", Resource: "namespaces"} + h := resourceHandler{spec: watch("Namespace", "v1"), gvr: gvr, sink: sink} + obj := uObj("v1", "Namespace", "", "default", "uid-1", map[string]any{"resourceVersion": "1"}) + h.OnAdd(obj, true) + if got := sink.types(); len(got) != 1 || got[0] != EventModified { + t.Fatalf("add %v", got) + } + if sink.ev[0].Object.GetKind() != "Namespace" || sink.ev[0].GVR != gvr { + t.Fatalf("payload %+v", sink.ev[0]) + } + + same := uObj("v1", "Namespace", "", "default", "uid-1", map[string]any{"resourceVersion": "1"}) + h.OnUpdate(obj, same) + if len(sink.types()) != 1 { + t.Fatalf("same RV should skip: %v", sink.types()) + } + + updated := uObj("v1", "Namespace", "", "default", "uid-1", map[string]any{"resourceVersion": "2"}) + h.OnUpdate(obj, updated) + if got := sink.types(); len(got) != 2 || got[1] != EventModified { + t.Fatalf("update %v", got) + } + + h.OnDelete(updated) + if got := sink.types(); len(got) != 3 || got[2] != EventDeleted { + t.Fatalf("delete %v", got) + } + del := sink.ev[2].Object + if del.GetName() != "default" || del.GetUID() != "" { + t.Fatalf("deleted object should be minimal: %+v", del.Object) + } + if _, ok := del.Object["metadata"].(map[string]any)["resourceVersion"]; ok { + t.Fatal("DELETED must not include resourceVersion") + } +} + +func TestResourceHandlerNilSink(t *testing.T) { + h := resourceHandler{spec: watch("Namespace", "v1")} + h.OnAdd(&unstructured.Unstructured{}, false) + h.OnDelete(&unstructured.Unstructured{}) +} diff --git a/backend/internal/informers/specs.go b/backend/internal/informers/specs.go index 0d832f22978..d2e87e343fd 100644 --- a/backend/internal/informers/specs.go +++ b/backend/internal/informers/specs.go @@ -1,7 +1,8 @@ // Copyright Contributors to the Open Cluster Management project // Package informers watches hub resources with client-go (ACM-42597). -// GET /events remains on the Node sidecar until ACM-42598. +// GET /events SSE is served by internal/events/hub (ACM-42598). Node startWatching() +// still runs for aggregators until those routes migrate. package informers import ( @@ -64,6 +65,11 @@ func (s WatchSpec) cacheOnly() WatchSpec { return s } +// ShouldForward is true when watch events should be fanned out to GET /events clients. +func (s WatchSpec) ShouldForward() bool { + return s.ForwardEventsToClients && !s.Polled +} + func pairsToMap(pairs []string) map[string]string { m := make(map[string]string, len(pairs)/2) for i := 0; i+1 < len(pairs); i += 2 { diff --git a/backend/internal/informers/specs_test.go b/backend/internal/informers/specs_test.go index d46c9711ee4..72583a36bb9 100644 --- a/backend/internal/informers/specs_test.go +++ b/backend/internal/informers/specs_test.go @@ -49,21 +49,32 @@ func TestDefaultWatchSpecsMatchEventsTS(t *testing.T) { if err != nil { t.Fatal(err) } - tsKeys := parseEventsSpecKeys(t, src) - if len(tsKeys) != 67 { - t.Fatalf("events.ts keys=%d", len(tsKeys)) + tsSpecs := parseEventsTSSpecs(t, src) + if len(tsSpecs) != 67 { + t.Fatalf("events.ts keys=%d", len(tsSpecs)) } got := map[string]WatchSpec{} for _, s := range DefaultWatchSpecs() { got[s.SpecKey()] = s } - for k := range tsKeys { - if _, ok := got[k]; !ok { + for k, ts := range tsSpecs { + goSpec, ok := got[k] + if !ok { t.Errorf("missing spec %s", k) + continue + } + if goSpec.Polled != ts.Polled { + t.Errorf("%s polled go=%v ts=%v", k, goSpec.Polled, ts.Polled) + } + if goSpec.ForwardEventsToClients != ts.ForwardEventsToClients { + t.Errorf("%s forwardEventsToClients go=%v ts=%v", k, goSpec.ForwardEventsToClients, ts.ForwardEventsToClients) + } + if goSpec.ShouldForward() != ts.ShouldForward() { + t.Errorf("%s shouldForward go=%v ts=%v", k, goSpec.ShouldForward(), ts.ShouldForward()) } } for k, s := range got { - if _, ok := tsKeys[k]; !ok { + if _, ok := tsSpecs[k]; !ok { t.Errorf("extra spec %s (%s %s)", k, s.APIVersion, s.Kind) } } @@ -98,6 +109,43 @@ func TestWatchSpecDefaultForwardsEvents(t *testing.T) { } } +func TestShouldForward(t *testing.T) { + cases := []struct { + name string + spec WatchSpec + want bool + }{ + {"default", watch("Namespace", "v1"), true}, + {"cacheOnly", watch("Authentication", "config.openshift.io/v1").cacheOnly(), false}, + {"polled", watch("Application", "argoproj.io/v1alpha1").polled(), false}, + {"polledAndCacheOnly", watch("Secret", "v1").polled().cacheOnly(), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.spec.ShouldForward(); got != tc.want { + t.Fatalf("ShouldForward()=%v want %v", got, tc.want) + } + }) + } +} + +func TestDefaultWatchSpecsShouldForwardCount(t *testing.T) { + var forward, skip int + for _, s := range DefaultWatchSpecs() { + if s.ShouldForward() { + forward++ + } else { + skip++ + } + } + if forward != 64 { + t.Fatalf("forward=%d want 64", forward) + } + if skip != 3 { + t.Fatalf("skip=%d want 3 (2 polled + 1 cacheOnly)", skip) + } +} + func TestSelectorQueryEmpty(t *testing.T) { if SelectorQuery(nil) != "" { t.Fatal("expected empty") @@ -117,7 +165,7 @@ var ( selectorRE = regexp.MustCompile(`'([^']+)':\s*'([^']*)'`) ) -func parseEventsSpecKeys(t *testing.T, src []byte) map[string]struct{} { +func parseEventsTSSpecs(t *testing.T, src []byte) map[string]WatchSpec { t.Helper() s := string(src) marker := "const definitions: IWatchOptions[] = [" @@ -136,7 +184,7 @@ func parseEventsSpecKeys(t *testing.T, src []byte) map[string]struct{} { lines = append(lines, line) } body = strings.Join(lines, "\n") - keys := map[string]struct{}{} + specs := map[string]WatchSpec{} depth, objStart, inQ := 0, -1, false for i := 0; i < len(body); i++ { c := body[i] @@ -156,24 +204,36 @@ func parseEventsSpecKeys(t *testing.T, src []byte) map[string]struct{} { case '}': depth-- if depth == 0 && objStart >= 0 { - obj := body[objStart : i+1] - km := kindRE.FindStringSubmatch(obj) - am := apiVersionRE.FindStringSubmatch(obj) - labels := map[string]string{} - fields := map[string]string{} - if j := strings.Index(obj, "labelSelector:"); j >= 0 { - labels = parseSel(obj[j:]) - } - if j := strings.Index(obj, "fieldSelector:"); j >= 0 { - fields = parseSel(obj[j:]) - } - key := am[1] + "|" + km[1] + "|" + SelectorQuery(labels) + "|" + SelectorQuery(fields) - keys[key] = struct{}{} + spec := parseTSWatchSpec(body[objStart : i+1]) + specs[spec.SpecKey()] = spec objStart = -1 } } } - return keys + return specs +} + +func parseTSWatchSpec(obj string) WatchSpec { + km := kindRE.FindStringSubmatch(obj) + am := apiVersionRE.FindStringSubmatch(obj) + spec := WatchSpec{ + Kind: km[1], + APIVersion: am[1], + ForwardEventsToClients: true, + } + if strings.Contains(obj, "isPolled: true") { + spec.Polled = true + } + if strings.Contains(obj, "forwardEventsToClients: false") { + spec.ForwardEventsToClients = false + } + if j := strings.Index(obj, "labelSelector:"); j >= 0 { + spec.LabelSelector = parseSel(obj[j:]) + } + if j := strings.Index(obj, "fieldSelector:"); j >= 0 { + spec.FieldSelector = parseSel(obj[j:]) + } + return spec } func parseSel(s string) map[string]string { diff --git a/backend/internal/informers/store.go b/backend/internal/informers/store.go index 7827bd6f00d..35ed135fd1e 100644 --- a/backend/internal/informers/store.go +++ b/backend/internal/informers/store.go @@ -38,6 +38,17 @@ type specRuntime struct { type InformerCache struct { mu sync.RWMutex states []*specRuntime + sink ResourceSink +} + +// SetSink registers the SSE hub (or test collector). Call before StartCache. +func (c *InformerCache) SetSink(s ResourceSink) { + if c == nil { + return + } + c.mu.Lock() + c.sink = s + c.mu.Unlock() } func newCache(specs []WatchSpec) *InformerCache { @@ -143,6 +154,47 @@ func (c *InformerCache) ListByKind(apiVersion, kind string) []unstructured.Unstr return out } +// ForwardedObject is a cache entry that GET /events should include in snapshots. +type ForwardedObject struct { + GVR schema.GroupVersionResource + Object unstructured.Unstructured +} + +// ListForwarded returns objects whose WatchSpec fans out to SSE clients. +func (c *InformerCache) ListForwarded() []ForwardedObject { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + var out []ForwardedObject + seen := map[string]struct{}{} + for _, s := range c.states { + if s.informer == nil || !s.spec.ShouldForward() { + continue + } + for _, obj := range s.informer.GetStore().List() { + u, ok := asUnstructured(obj) + if !ok { + continue + } + key := string(u.GetUID()) + if key == "" { + key = u.GetNamespace() + "/" + u.GetName() + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + cp := u.DeepCopy() + cp.SetAPIVersion(s.spec.APIVersion) + cp.SetKind(s.spec.Kind) + out = append(out, ForwardedObject{GVR: s.gvr, Object: *cp}) + } + } + return out +} + // Snapshot returns normalized keys for every object currently in the store. func (c *InformerCache) Snapshot() []ResourceKey { if c == nil { diff --git a/backend/internal/informers/store_test.go b/backend/internal/informers/store_test.go index 03e2161e1c5..9f1f1fcb5f8 100644 --- a/backend/internal/informers/store_test.go +++ b/backend/internal/informers/store_test.go @@ -117,6 +117,33 @@ func TestListByKindFiltersSpec(t *testing.T) { } } +func TestListForwardedSkipsCacheOnlyAndPolled(t *testing.T) { + c := newCache([]WatchSpec{ + watch("Namespace", "v1"), + watch("Authentication", "config.openshift.io/v1").cacheOnly(), + watch("Application", "argoproj.io/v1alpha1").polled(), + }) + c.states[0].gvr = schema.GroupVersionResource{Version: "v1", Resource: "namespaces"} + c.states[0].informer = newTestInformer(t, uObj("v1", "Namespace", "", "default", "uid-ns", nil)) + c.states[1].gvr = schema.GroupVersionResource{Group: "config.openshift.io", Version: "v1", Resource: "authentications"} + c.states[1].informer = newTestInformer(t, uObj("config.openshift.io/v1", "Authentication", "", "cluster", "uid-auth", nil)) + c.states[2].gvr = schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "applications"} + c.states[2].informer = newTestInformer(t, uObj("argoproj.io/v1alpha1", "Application", "ns", "app", "uid-app", nil)) + + got := c.ListForwarded() + if len(got) != 1 || got[0].Object.GetKind() != "Namespace" || got[0].Object.GetName() != "default" { + t.Fatalf("%+v", got) + } +} + +func TestSetSinkNilCache(t *testing.T) { + var c *InformerCache + c.SetSink(nil) + if c.ListForwarded() != nil { + t.Fatal("nil cache ListForwarded") + } +} + func newTestInformer(t *testing.T, objs ...*unstructured.Unstructured) cache.SharedIndexInformer { t.Helper() lw := &cache.ListWatch{ diff --git a/backend/internal/proxy/proxy_test.go b/backend/internal/proxy/proxy_test.go new file mode 100644 index 00000000000..11813a5a78c --- /dev/null +++ b/backend/internal/proxy/proxy_test.go @@ -0,0 +1,242 @@ +// Copyright Contributors to the Open Cluster Management project + +package proxy_test + +import ( + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stolostron/console/backend/internal/proxy" +) + +func newHandler(t *testing.T, upstream http.Handler) http.Handler { + t.Helper() + up := httptest.NewServer(upstream) + t.Cleanup(up.Close) + target, err := url.Parse(up.URL) + if err != nil { + t.Fatal(err) + } + return proxy.New(target, nil) +} + +func TestPreservesOriginalPath(t *testing.T) { + var capturedPath string + h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + for _, path := range []string{"/multicloud/hub", "/proxy/search", "/events"} { + resp, err := ts.Client().Get(ts.URL + path) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedPath != path { + t.Fatalf("%s: upstream path %q", path, capturedPath) + } + } +} + +func TestForwardsQueryMethodAndBody(t *testing.T) { + var capturedPath, capturedQuery, capturedMethod, capturedBody string + h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedQuery = r.URL.RawQuery + capturedMethod = r.Method + b, _ := io.ReadAll(r.Body) + capturedBody = string(b) + w.WriteHeader(http.StatusCreated) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + body := `{"q":"clusters"}` + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/multicloud/proxy/search?limit=10", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("status %d", resp.StatusCode) + } + if capturedPath != "/multicloud/proxy/search" { + t.Fatalf("path %q", capturedPath) + } + if capturedQuery != "limit=10" { + t.Fatalf("query %q", capturedQuery) + } + if capturedMethod != http.MethodPost { + t.Fatalf("method %q", capturedMethod) + } + if capturedBody != body { + t.Fatalf("body %q", capturedBody) + } +} + +func TestSetsUpstreamHost(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Captured-Host", r.Host) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(up.Close) + target, err := url.Parse(up.URL) + if err != nil { + t.Fatal(err) + } + h := proxy.New(target, nil) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + resp, err := ts.Client().Get(ts.URL + "/ping") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + wantHost := target.Host + if got := resp.Header.Get("X-Captured-Host"); got != wantHost { + t.Fatalf("upstream Host %q want %q", got, wantHost) + } +} + +func TestForwardsRequestHeaders(t *testing.T) { + var captured http.Header + h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/events", nil) + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("X-Custom", "keep-me") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if captured.Get("Authorization") != "Bearer user-token" { + t.Fatalf("Authorization %q", captured.Get("Authorization")) + } + if captured.Get("Accept-Encoding") != "gzip" { + t.Fatal("missing Accept-Encoding") + } + if captured.Get("X-Custom") != "keep-me" { + t.Fatal("custom header not forwarded") + } +} + +func TestPassesThroughResponse(t *testing.T) { + h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Sidecar", "yes") + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + resp, err := ts.Client().Get(ts.URL + "/multicloud/hub") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("status %d", resp.StatusCode) + } + if string(body) != `{"ok":true}` { + t.Fatalf("body %q", body) + } + if resp.Header.Get("Content-Type") != "application/json" { + t.Fatal("missing Content-Type") + } + if resp.Header.Get("X-Sidecar") != "yes" { + t.Fatal("missing X-Sidecar") + } +} + +func TestHTTPSUpstreamWithTLSConfig(t *testing.T) { + var hit bool + up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hit = true + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(up.Close) + target, err := url.Parse(up.URL) + if err != nil { + t.Fatal(err) + } + h := proxy.New(target, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // test server + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + resp, err := ts.Client().Get(ts.URL + "/ping") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if !hit { + t.Fatal("TLS upstream not reached") + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestBadGatewayWhenUpstreamUnreachable(t *testing.T) { + target, err := url.Parse("http://127.0.0.1:1") + if err != nil { + t.Fatal(err) + } + h := proxy.New(target, nil) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + resp, err := ts.Client().Get(ts.URL + "/multicloud/hub") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestForwardsWebSocketUpgradeHeaders(t *testing.T) { + var capturedUpgrade, capturedConnection string + h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedUpgrade = r.Header.Get("Upgrade") + capturedConnection = r.Header.Get("Connection") + w.WriteHeader(http.StatusSwitchingProtocols) + })) + ts := httptest.NewServer(h) + t.Cleanup(ts.Close) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/ws", nil) + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "websocket") + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if capturedUpgrade != "websocket" { + t.Fatalf("Upgrade %q", capturedUpgrade) + } + if !strings.EqualFold(capturedConnection, "Upgrade") { + t.Fatalf("Connection %q", capturedConnection) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 4a658599c9c..3b8437db953 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -40,12 +40,20 @@ type handlerOptions struct { staticH http.Handler user http.Handler clusterInfo http.Handler + events http.Handler debugSnapshot http.Handler } // Option configures Handler. type Option func(*handlerOptions) +// WithEvents registers GET /events (and /multicloud/events). +func WithEvents(h http.Handler) Option { + return func(o *handlerOptions) { + o.events = h + } +} + // WithRBACEvents registers GET /events/rbac (and /multicloud/events/rbac). func WithRBACEvents(h http.Handler) Option { return func(o *handlerOptions) { @@ -258,6 +266,10 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { r.Get("/events/rbac", o.rbacEvents.ServeHTTP) r.Get(multicloudPrefix+"/events/rbac", o.rbacEvents.ServeHTTP) } + if o.events != nil { + r.Get("/events", o.events.ServeHTTP) + r.Get(multicloudPrefix+"/events", o.events.ServeHTTP) + } if o.k8sProxy != nil { registerK8sProxyRoutes(r, o.k8sProxy) } @@ -277,29 +289,6 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { return r, nil } -func registerUserRoutes(r chi.Router, o *handlerOptions) { - if o.user == nil { - return - } - registerAliasedGet(r, o.user, "/authenticated", "/username", "/userpreference") -} - -func registerClusterInfoRoutes(r chi.Router, o *handlerOptions) { - if o.clusterInfo == nil { - return - } - registerAliasedGet(r, o.clusterInfo, - "/hub", - "/cluster-version", - "/hypershift-status", - "/multiclusterhub/components", - "/multiclusterengine/components", - "/apiPaths", - ) - r.Post("/operatorCheck", o.clusterInfo.ServeHTTP) - r.Post(multicloudPrefix+"/operatorCheck", o.clusterInfo.ServeHTTP) -} - func registerOAuth(r chi.Router, prefix string, h *oauth.Handler, login bool) { r.Get(prefix+"/configure", h.Configure) if !login { diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index b3d2896849f..bd6472413b9 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -203,6 +203,76 @@ func TestRBACEventsNotProxied(t *testing.T) { } } +func TestEventsNotProxied(t *testing.T) { + var proxied bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxied = true + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + events := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("id:1\ndata:{\"type\":\"START\"}\n\n")) + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithEvents(events)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/events", "/multicloud/events"} { + proxied = false + resp, getErr := ts.Client().Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if proxied { + t.Fatalf("%s was proxied to sidecar", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + if !strings.Contains(string(body), `"type":"START"`) { + t.Fatalf("%s body %s", path, body) + } + } +} + +func TestEventsProxiedWithoutWithEvents(t *testing.T) { + var captured string + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = r.URL.Path + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + resp, err := ts.Client().Get(ts.URL + "/events") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if captured != "/events" { + t.Fatalf("sidecar path %q", captured) + } + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("status %d", resp.StatusCode) + } +} + func TestOAuthNotProxiedToSidecar(t *testing.T) { var sidecarPaths []string sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 11f8a47372f..14b0ebb830d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,7 +41,8 @@ All REST calls use the token passed from the console frontend. Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. -The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` SSE, aggregators, and RBAC fan-out stay on the Node sidecar until ACM-42598. Dual-run is intentional: both caches list/watch the hub so snapshots can be compared. Go starts informers **after** binding `:4000`, with a startup semaphore (8 concurrent lists), a dedicated client (`QPS=20`,`Burst=40`), and **no periodic resync**. Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches. The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy) and does **not** port Node deflate compression — compare Go `heapAlloc` after sync to the Node deflate cache size, not combined process RSS. Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. -Auth check, username, user preferences, and cluster-info routes (`/authenticated`, `/username`, `/userpreference`, `/hub`, `/cluster-version`, `/hypershift-status`, `/multiclusterhub/components`, `/multiclusterengine/components`, `/operatorCheck`, `/apiPaths`) are served by the Go listener using client-go with the service-account token for hub reads and per-user GET `/api` validation for auth gating. +The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). Node `startWatching()` still runs so aggregators can read `resourceCache` (dual-run). Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches and proxy `/events` to the sidecar. The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. + +DELETED resource events are sent to every SSE client without an access check (bug-compatible with Node). That is a known quirk to fix later. Static plugin assets (`plugin-manifest.json`, `plugin-entry.js`, hashed JS/CSS, locales) are served by the Go listener with the same cache headers, CSP, and brotli/gzip content negotiation as the former Node `serve` route. diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md index 0e8ce8f4ed6..5c7dd4145c9 100644 --- a/docs/RESOURCES.md +++ b/docs/RESOURCES.md @@ -1,9 +1,10 @@ # To add a new resource -1. Add a watch to `/backend-node/src/routes/events.ts` for the resource. -2. Add a resource definition in `/fronend/src/resources`. -3. Add recoil setup for the resource in `/frontend/src/atoms.tsx`. -4. In `frontend` use the resources by +1. Add a watch to `/backend-node/src/routes/events.ts` for the resource (still required for Node aggregators / `getKubeResources`). +2. Add the same watch to `/backend/internal/informers/specs.go` `DefaultWatchSpecs()` so Go `GET /events` and the informer cache include it. +3. Add a resource definition in `/frontend/src/resources`. +4. Add recoil setup for the resource in `/frontend/src/atoms.tsx`. +5. In `frontend` use the resources by ``` const namespaces = useRecoilValue(namespacesState) From 1f2fbde3a442afca1e8fbb2bd263a9480a94b2cb Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Fri, 11 Sep 2026 08:46:15 +0200 Subject: [PATCH 10/16] ACM-42600 Migrate aggregation layer to Go (#59) * ACM-42600 Signed-off-by: Enrique Mingorance Cano * Feng's proposal already applied Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- backend-node/AGENTS.md | 7 +- backend-node/src/app.ts | 6 +- backend-node/src/routes/aggregator.ts | 2 + backend-node/src/routes/events.ts | 2 - backend-node/test/routes/aggregator.test.ts | 1165 ----------------- backend/AGENTS.md | 9 +- backend/cmd/console/main.go | 38 + backend/internal/aggregate/appset.go | 226 ++++ backend/internal/aggregate/argo.go | 402 ++++++ backend/internal/aggregate/argo_test.go | 90 ++ backend/internal/aggregate/clusters.go | 370 ++++++ backend/internal/aggregate/engine.go | 273 ++++ backend/internal/aggregate/engine_test.go | 95 ++ backend/internal/aggregate/fuse.go | 114 ++ backend/internal/aggregate/fuse_test.go | 32 + backend/internal/aggregate/handler.go | 87 ++ backend/internal/aggregate/handler_test.go | 264 ++++ backend/internal/aggregate/helper_test.go | 12 + backend/internal/aggregate/lister.go | 61 + backend/internal/aggregate/ocp.go | 245 ++++ backend/internal/aggregate/pages.go | 127 ++ backend/internal/aggregate/pagination.go | 101 ++ backend/internal/aggregate/pushmodel.go | 62 + backend/internal/aggregate/rbac.go | 246 ++++ backend/internal/aggregate/rbac_test.go | 79 ++ backend/internal/aggregate/status.go | 233 ++++ backend/internal/aggregate/status_test.go | 59 + backend/internal/aggregate/transform.go | 412 ++++++ backend/internal/aggregate/transform_test.go | 110 ++ backend/internal/aggregate/types.go | 376 ++++++ backend/internal/hubresources/hubresources.go | 15 + backend/internal/informers/specs.go | 5 +- backend/internal/searchapi/searchapi.go | 196 +++ backend/internal/searchapi/searchapi_test.go | 95 ++ backend/internal/server/server.go | 18 + backend/internal/server/server_test.go | 40 + docs/ARCHITECTURE.md | 2 +- docs/RESOURCES.md | 4 +- 38 files changed, 4498 insertions(+), 1182 deletions(-) delete mode 100644 backend-node/test/routes/aggregator.test.ts create mode 100644 backend/internal/aggregate/appset.go create mode 100644 backend/internal/aggregate/argo.go create mode 100644 backend/internal/aggregate/argo_test.go create mode 100644 backend/internal/aggregate/clusters.go create mode 100644 backend/internal/aggregate/engine.go create mode 100644 backend/internal/aggregate/engine_test.go create mode 100644 backend/internal/aggregate/fuse.go create mode 100644 backend/internal/aggregate/fuse_test.go create mode 100644 backend/internal/aggregate/handler.go create mode 100644 backend/internal/aggregate/handler_test.go create mode 100644 backend/internal/aggregate/helper_test.go create mode 100644 backend/internal/aggregate/lister.go create mode 100644 backend/internal/aggregate/ocp.go create mode 100644 backend/internal/aggregate/pages.go create mode 100644 backend/internal/aggregate/pagination.go create mode 100644 backend/internal/aggregate/pushmodel.go create mode 100644 backend/internal/aggregate/rbac.go create mode 100644 backend/internal/aggregate/rbac_test.go create mode 100644 backend/internal/aggregate/status.go create mode 100644 backend/internal/aggregate/status_test.go create mode 100644 backend/internal/aggregate/transform.go create mode 100644 backend/internal/aggregate/transform_test.go create mode 100644 backend/internal/aggregate/types.go create mode 100644 backend/internal/searchapi/searchapi.go create mode 100644 backend/internal/searchapi/searchapi_test.go diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md index 840efca7438..c8ed77a0fe7 100644 --- a/backend-node/AGENTS.md +++ b/backend-node/AGENTS.md @@ -40,12 +40,13 @@ Run from the `backend-node/` directory, or use the `npm run *:backend-node` vari The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated. OAuth login, logout, and `/configure` discovery are served by Go. ```text -Browser / plugin → Go :4000 (GET /events is native Go when CONSOLE_INFORMER_CACHE is on) +Browser / plugin → Go :4000 (GET /events and POST /aggregate are native Go when CONSOLE_INFORMER_CACHE is on) → Node sidecar (this package) → Hub Cluster API Server ↓ - Watches resources via service account (aggregators / dual-run) + Watches resources via service account (hub.ts / dual-run) Enforces RBAC via user token + SubjectAccessReview - Sidecar GET /events remains for aggregators and when Go cache is off + Sidecar GET /events remains when Go cache is off + POST /proxy/search stays here until ACM-42601 ``` ## Route Handlers diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 8e2a2df1da8..708572181f3 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -10,7 +10,6 @@ import { startLoggingMemory } from './lib/memory' import { notFound, respondInternalServerError, respondOK } from './lib/respond' import { startServer, stopServer } from './lib/server' import { ServerSideEvents } from './lib/server-side-events' -import { aggregate, startAggregating, stopAggregating } from './routes/aggregator' import { ansibleTower } from './routes/ansibletower' import { events, startWatching, stopWatching } from './routes/events' import { liveness } from './routes/liveness' @@ -47,14 +46,13 @@ router.get('/livenessProbe', liveness) router.get('/ping', respondOK) if (eventsEnabled) { // Public GET /events is served by the Go listener when CONSOLE_INFORMER_CACHE is on (ACM-42598). - // This sidecar route remains for dual-run, aggregators, and when the Go cache is disabled. + // This sidecar route remains for dual-run and when the Go cache is disabled. router.get('/events', events) } router.post('/proxy/search', search) router.post('/placement-debug', placementDebug) router.post('/ansibletower', ansibleTower) router.post('/upgrade-risks-prediction', upgradeRiskPredictions) -router.post('/aggregate/*', aggregate) // rosa wizard routes router.post('/aws-account-ids', getAwsAccountIds) @@ -101,7 +99,6 @@ export async function start() { await loadSettings() if (eventsEnabled) { startWatching() - startAggregating() } stopPlacementDebugCAWatch = watchPlacementDebugCA(() => { invalidatePlacementDebugAgent() @@ -129,7 +126,6 @@ export async function stop(): Promise { stopFileWatches() await ServerSideEvents.dispose() stopWatching() - stopAggregating() stopPlacementDebugCAWatch?.() stopTLSProfileWatch?.() await stopServer() diff --git a/backend-node/src/routes/aggregator.ts b/backend-node/src/routes/aggregator.ts index 4a8d43c1d61..3f38026391d 100644 --- a/backend-node/src/routes/aggregator.ts +++ b/backend-node/src/routes/aggregator.ts @@ -1,4 +1,6 @@ /* Copyright Contributors to the Open Cluster Management project */ +// POST /aggregate/* is served by the Go listener (ACM-42600). +// These helpers remain for Search/compression types and Jest unit tests of the TS cache. import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' import { notFound, unauthorized } from '../lib/respond' import { getAuthenticatedToken } from '../lib/token' diff --git a/backend-node/src/routes/events.ts b/backend-node/src/routes/events.ts index 8c15c11f34c..ced6f14a5ed 100644 --- a/backend-node/src/routes/events.ts +++ b/backend-node/src/routes/events.ts @@ -15,7 +15,6 @@ import { getCACertificate, getServiceAccountToken } from '../lib/serviceAccountT import { getAuthenticatedToken } from '../lib/token' import type { IResource } from '../resources/resource' import type { IWatchOptions } from '../resources/watch-options' -import { polledAggregation } from './aggregator' import { getAppDict, type ICompressedResource, type ITransformedResource } from './aggregators/applications' export async function events(req: Http2ServerRequest, res: Http2ServerResponse): Promise { @@ -412,7 +411,6 @@ async function listKubernetesObjects(serviceAccountToken: string, options: IWatc _continue = body.metadata._continue ?? body.metadata.continue const pruned = pruneResources(options, body.items) if (isPolled) { - await polledAggregation(options, pruned, !_continue) itemCount += pruned.length } else { items = items.concat(pruned) diff --git a/backend-node/test/routes/aggregator.test.ts b/backend-node/test/routes/aggregator.test.ts deleted file mode 100644 index cc71224147f..00000000000 --- a/backend-node/test/routes/aggregator.test.ts +++ /dev/null @@ -1,1165 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { parseResponseJsonBody } from '../../src/lib/body-parser' -import { - aggregateLocalApplications, - aggregateRemoteApplications, - resetApplicationCache, - resetAggregatingApplications, - stopAggregatingApplications, - searchLoop, -} from '../../src/routes/aggregators/applications' -import { cacheResource, resetResourceCache, resetAccessCache, resetHubClusterName } from '../../src/routes/events' -import { resetArgoApplicationState } from '../../src/routes/aggregators/applicationsArgo' -import { request } from '../mock-request' -import nock from 'nock' -import { discoverSystemAppNamespacePrefixes, resetSystemAppNamespacePrefixes } from '../../src/routes/aggregators/utils' -import { resetMultiClusterHubCache } from '../../src/lib/multi-cluster-hub' -import { resetMultiClusterEngineCache } from '../../src/lib/multi-cluster-engine' -import { ServerSideEvents } from '../../src/lib/server-side-events' -import { polledAggregation } from '../../src/routes/aggregator' -import type { IResource } from '../../src/resources/resource' - -/// to get exact nock request body, put bp at line 303 in /backend/node_modules/nock/lib/intercepted_request_router.js -describe(`aggregator Route`, function () { - beforeEach(() => { - // Reset all caches and state before each test for proper test isolation - resetApplicationCache() - resetResourceCache() - resetAccessCache() - resetHubClusterName() - resetArgoApplicationState() - resetAggregatingApplications() - resetSystemAppNamespacePrefixes() - resetMultiClusterHubCache() - resetMultiClusterEngineCache() - ServerSideEvents.reset() - nock.cleanAll() - }) - - afterEach(async () => { - stopAggregatingApplications() - nock.cleanAll() - // Give time for any pending promises to settle - await new Promise((resolve) => setImmediate(resolve)) - }) - - afterAll(async () => { - nock.restore() - // Clean up the ServerSideEvents interval to prevent orphan handles - const { ServerSideEvents } = await import('../../src/lib/server-side-events') - await ServerSideEvents.dispose() - }) - - it(`should page Unfiltered Applications`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - - // initialize events - cache sequentially to ensure deterministic order - for (const resource of resources) { - await cacheResource(resource) - } - - await polledAggregation( - { - kind: 'Application', - apiVersion: '', - }, - argoApps, - true - ) - await polledAggregation( - { - kind: 'ApplicationSet', - apiVersion: '', - }, - argoAppSets, - true - ) - - // setup nocks - setupNocks() - - // fill in application cache from resourceCache and search api mocks - await searchLoop() - await aggregateLocalApplications() - await aggregateRemoteApplications(1) - - // NO FILTER - const res = await request('POST', '/aggregate/applications', { - page: 1, - perPage: 10, - sortBy: { - index: 0, - direction: 'asc', - }, - }) - expect(res.statusCode).toEqual(200) - // const f = await parseResponseJsonBody(res) - // console.log(f) - expect(await parseResponseJsonBody(res)).toEqual(responseNoFilter) - }) - it(`should page Filtered Applications`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - - // initialize events - cache sequentially to ensure deterministic order - for (const resource of resources) { - await cacheResource(resource) - } - - // setup nocks - setupNocks() - - // fill in application cache from resourceCache and search api mocks - await aggregateLocalApplications() - await aggregateRemoteApplications(1) - - // FILTERED - const res = await request('POST', '/aggregate/applications', { - page: 1, - perPage: 10, - search: 'tes', - filters: { - type: ['subscription'], - }, - sortBy: { - index: 0, - direction: 'desc', - }, - }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual(responseFiltered) - }) - it(`should return application counts`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - - // initialize events - cache sequentially to ensure deterministic order - for (const resource of resources) { - await cacheResource(resource) - } - - // setup nocks - setupNocks(true) - - // fill in application cache from resourceCache and search api mocks - const prefixes = await discoverSystemAppNamespacePrefixes() - expect(JSON.stringify(prefixes)).toEqual(JSON.stringify(systemPrefixes)) - await aggregateLocalApplications() - await aggregateRemoteApplications(1) - - // FILTERED - const res = await request('POST', '/aggregate/statuses', { - clusters: ['local-cluster', 'feng-managed'], - }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual(responseCount) - }) - it(`should return appset data`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - - // initialize events - cache sequentially to ensure deterministic order - for (const resource of resources) { - await cacheResource(resource) - } - await polledAggregation( - { - kind: 'Application', - apiVersion: '', - }, - argoApps, - true - ) - await polledAggregation( - { - kind: 'ApplicationSet', - apiVersion: '', - }, - argoAppSets, - true - ) - - // setup nocks - setupNocks(true) - - // fill in application cache from resourceCache and search api mocks - const prefixes = await discoverSystemAppNamespacePrefixes() - expect(JSON.stringify(prefixes)).toEqual(JSON.stringify(systemPrefixes)) - await aggregateLocalApplications() - await aggregateRemoteApplications(1) - - // FILTERED - const res = await request('POST', '/aggregate/appSetData', { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'ApplicationSet', - metadata: { - name: 'argoapplicationset-1', - namespace: 'openshift-gitops', - }, - }) - expect(res.statusCode).toEqual(200) - expect(await parseResponseJsonBody(res)).toEqual(uidata) - }) -}) - -const systemPrefixes = ['openshift', 'hive', 'open-cluster-management', 'multicluster-engine'] - -const responseCount = { - itemCount: '1', - filterCounts: { - type: { - subscription: 1, - }, - cluster: { - 'local-cluster': 1, - }, - podStatuses: {}, - healthStatus: {}, - syncStatus: {}, - }, - systemAppNSPrefixes: ['openshift', 'hive', 'open-cluster-management', 'multicluster-engine'], - loading: false, -} - -type AppSetPlacementDataType = (string | string[])[] - -const uidataAppset = { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'ApplicationSet', - metadata: { - name: 'argoapplicationset-1', - namespace: 'openshift-gitops', - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21ce76', - }, - spec: { - generators: [ - { - clusterDecisionResource: { - configMapRef: 'acm-placement', - labelSelector: { - matchLabels: { - 'cluster.open-cluster-management.io/placement': 'test-placement-1', - }, - }, - requeueAfterSeconds: 180, - }, - }, - ], - template: { - metadata: { - labels: { - 'velero.io/exclude-from-backup': 'true', - }, - name: 'magchen-appset-{{name}}', - }, - spec: { - destination: { - namespace: 'magchen-ns', - server: '{{server}}', - }, - project: 'default', - source: { - path: 'acmnestedapp', - repoURL: 'https://github.com/fxiang1/app-samples', - targetRevision: 'main', - }, - syncPolicy: { - automated: { - prune: true, - selfHeal: true, - }, - syncOptions: ['CreateNamespace=true', 'PruneLast=true'], - }, - }, - }, - }, -} - -const uidataPlacementDecision = { - apiVersion: 'cluster.open-cluster-management.io/v1beta1', - kind: 'PlacementDecision', - metadata: { - creationTimestamp: '2024-07-02T17:45:25Z', - generation: 1, - labels: { - 'cluster.open-cluster-management.io/decision-group-index': '0', - 'cluster.open-cluster-management.io/decision-group-name': '', - 'cluster.open-cluster-management.io/placement': 'test-placement-1', - }, - name: 'test-placement-1-decision-1', - namespace: 'openshift-gitops', - resourceVersion: '1625071', - uid: '7ba09bb1-5211-490f-a6d1-456392886ab0', - ownerReferences: [ - { - apiVersion: 'cluster.open-cluster-management.io/v1beta1', - kind: 'Placement', - name: 'test-placement-1', - uid: '458708a1-f9fd-498b-9c2f-420ba246fe3f', - blockOwnerDeletion: true, - controller: true, - }, - ], - }, - status: { - decisions: [ - { - clusterName: 'mycluster', - reason: '', - }, - ], - }, -} - -const uidata = { - appset: uidataAppset, - clusterList: ['local-cluster'], - placementDecision: uidataPlacementDecision, - appSetApps: [ - { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'Application', - metadata: { - name: 'argoapplication-2', - namespace: 'openshift-gitops', - ownerReferences: [ - { - name: 'argoapplicationset-1', - apiVersion: '', - kind: 'ApplicationSet', - }, - ], - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21ce79', - }, - spec: { - destination: { - namespace: 'argoapplication-2-ns', - server: 'https://api.console-aws-48-pwc27.dev02.red-chesterfield.com:6443', - }, - project: 'default', - source: { - path: 'foo', - repoURL: 'https://test.com/test.git', - targetRevision: 'HEAD', - }, - syncPolicy: {}, - }, - status: {}, - }, - ], - appStatusByNameMap: {}, - isAppSetPullModel: false, -} - -const responseNoFilter = { - page: 1, - items: [ - { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'Application', - metadata: { - name: 'argoapplication-1', - namespace: 'openshift-gitops', - ownerReferences: [ - { - apiVersion: '', - kind: '', - name: 'argoapplication-1', - }, - ], - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21cf72', - }, - spec: { - destination: { - namespace: 'argoapplication-1-ns', - server: 'https://api.console-aws-48-pwc27.dev02.red-chesterfield.com:6443', - }, - project: 'default', - source: { - path: 'foo', - repoURL: 'https://test.com/test.git', - targetRevision: 'HEAD', - }, - syncPolicy: {}, - }, - status: {}, - uidata: { - clusterList: ['unknown'], - appClusterStatuses: [{}], - appSetPlacementData: ['', []] as AppSetPlacementDataType, - appSetApps: [] as string[], - }, - }, - { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'ApplicationSet', - metadata: { - name: 'argoapplicationset-1', - namespace: 'openshift-gitops', - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21ce76', - }, - spec: { - generators: [ - { - clusterDecisionResource: { - configMapRef: 'acm-placement', - labelSelector: { - matchLabels: { - 'cluster.open-cluster-management.io/placement': 'test-placement-1', - }, - }, - requeueAfterSeconds: 180, - }, - }, - ], - template: { - metadata: { - name: 'magchen-appset-{{name}}', - labels: { - 'velero.io/exclude-from-backup': 'true', - }, - }, - spec: { - destination: { - namespace: 'magchen-ns', - server: '{{server}}', - }, - project: 'default', - source: { - path: 'acmnestedapp', - repoURL: 'https://github.com/fxiang1/app-samples', - targetRevision: 'main', - }, - syncPolicy: { - automated: { - prune: true, - selfHeal: true, - }, - syncOptions: ['CreateNamespace=true', 'PruneLast=true'], - }, - }, - }, - }, - uidata: { - clusterList: ['local-cluster'], - appClusterStatuses: [ - { - 'local-cluster': { - deployed: [[0, 0, 0, 0, 0], []], - health: [ - [0, 0, 0, 0, 1], - [ - { - key: 'Status', - value: 'Missing', - }, - ], - ], - synced: [ - [0, 0, 0, 0, 1], - [ - { - key: 'Status', - value: 'Missing', - }, - ], - ], - }, - }, - ], - appSetPlacementData: ['test-placement-1', []] as AppSetPlacementDataType, - appSetApps: ['argoapplication-2'], - }, - }, - { - apiVersion: 'app.k8s.io/v1beta1', - kind: 'Application', - metadata: { - name: 'test', - namespace: 'default', - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21ce72', - annotations: { - 'apps.open-cluster-management.io/deployables': '', - 'apps.open-cluster-management.io/subscriptions': - 'default/test-subscription-1,default/test-subscription-1-local', - }, - labels: { - app: 'test', - 'app.kubernetes.io/part-of': 'test', - 'apps.open-cluster-management.io/reconcile-rate': 'medium', - }, - }, - uidata: { - clusterList: ['local-cluster'], - appClusterStatuses: [{}], - appSetPlacementData: ['', []] as AppSetPlacementDataType, - appSetApps: [] as string[], - }, - }, - ], - processedItemCount: 3, - emptyResult: false, - isPreProcessed: true, - request: { - page: 1, - perPage: 10, - sortBy: { - index: 0, - direction: 'asc', - }, - }, -} - -const responseFiltered = { - page: 1, - items: [ - { - apiVersion: 'app.k8s.io/v1beta1', - kind: 'Application', - metadata: { - annotations: { - 'apps.open-cluster-management.io/deployables': '', - 'apps.open-cluster-management.io/subscriptions': - 'default/test-subscription-1,default/test-subscription-1-local', - }, - labels: { - app: 'test', - 'app.kubernetes.io/part-of': 'test', - 'apps.open-cluster-management.io/reconcile-rate': 'medium', - }, - name: 'test', - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21ce72', - namespace: 'default', - }, - uidata: { - clusterList: ['local-cluster'], - appClusterStatuses: [{}], - appSetPlacementData: ['', []] as AppSetPlacementDataType, - appSetApps: [] as string[], - }, - }, - ], - processedItemCount: 1, - emptyResult: false, - isPreProcessed: true, - request: { - page: 1, - perPage: 10, - search: 'tes', - filters: { - type: ['subscription'], - }, - sortBy: { - index: 0, - direction: 'desc', - }, - }, -} -/// to get exact nock request body, put bp at line 303 in /backend/node_modules/nock/lib/intercepted_request_router.js -function setupNocks(prefixes?: boolean) { - // - // PING SEARCHAPI - nock('https://search-search-api.undefined.svc.cluster.local:4010') - .post( - '/searchapi/graphql', - '{"operationName":"searchResult","variables":{"input":[{"filters":[{"property":"kind","values":["Pod"]},{"property":"name","values":["search-api*"]}],"limit":1}]},"query":"query searchResult($input: [SearchInput]) {\\n searchResult: search(input: $input) {\\n items\\n }\\n}"}' - ) - .reply(200, { - data: { - searchResult: [ - { - items: [ - { - status: 'Running', - }, - ], - }, - ], - }, - }) - - // REMOTES: ARGO, OCP, FLUX - const nocked = nock('https://search-search-api.undefined.svc.cluster.local:4010').post( - '/searchapi/graphql', - '{"operationName":"searchResult","variables":{"input":[{"filters":[{"property":"kind","values":["Pod"]},{"property":"name","values":["search-api*"]}],"limit":1}]},"query":"query searchResult($input: [SearchInput]) {\\n searchResult: search(input: $input) {\\n items\\n }\\n}"}' - ) - nocked.reply(200, { - data: { - searchResult: [ - { - items: [ - // remote ARGO - { - apigroup: 'argoproj.io', - apiversion: 'v1alpha1', - cluster: 'feng-managed', - created: '2021-12-03T18:55:47Z', - destinationName: 'in-cluster', - destinationNamespace: 'feng-remote-namespace', - kind: 'application', - name: 'feng-remote-argo8', - namespace: 'openshift-gitops', - path: 'helloworld-perf', - repoURL: 'https://github.com/fxiang1/app-samples', - status: 'Healthy', - targetRevision: 'HEAD', - _clusterNamespace: 'feng-managed', - _rbac: 'feng-managed_argoproj.io_applications', - _uid: 'feng-managed/9896aad3-6789-4350-876c-bd3749c85b5d', - }, - ], - }, - { - items: [ - // local OCP - { - apiversion: 'apps/v1', - kind: 'deployment', - label: 'app=authentication-operator', - name: 'authentication-operator', - namespace: 'authentication-operator-ns', - cluster: 'local-cluster', - }, - // remote OCP - { - apiversion: 'apps/v1', - kind: 'deployment', - label: 'app=authentication-operator', - name: 'authentication-operator', - namespace: 'authentication-operator-ns', - cluster: 'test-cluster', - }, - // FLUX - { - apiversion: 'apps/v1', - kind: 'deployment', - name: 'test-app', - namespace: 'test-app-ns', - label: - 'app=test-app;kustomize.toolkit.fluxcd.io/name=test-app;kustomize.toolkit.fluxcd.io/namespace=test-app-ns', - cluster: 'test-cluster', - }, - ], - }, - // remote System - { items: [] }, - ], - }, - }) - - // - // RBAC - use persist() so nocks can be reused within a test - // Catch-all RBAC nock - matches any authorization request - nock(process.env.CLUSTER_API_URL) - .persist() - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { - status: { - allowed: true, - }, - }) - - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"argoproj.io","resource":"applications","verb":"list"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"view.open-cluster-management.io","namespace":"default","resource":"managedclusterviews","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"view.open-cluster-management.io","namespace":"feng-managed","resource":"managedclusterviews","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"app.k8s.io","resource":"applications","verb":"list"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"argoproj.io","resource":"applicationsets","verb":"list"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"apps","resource":"deployments","verb":"list"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"view.open-cluster-management.io","namespace":"openshift-gitops","resource":"managedclusterviews","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"view.open-cluster-management.io","namespace":"authentication-operator-ns","resource":"managedclusterviews","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"view.open-cluster-management.io","namespace":"test-cluster","resource":"managedclusterviews","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - - nock(process.env.CLUSTER_API_URL) - .persist() - .post( - '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - '{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"view.open-cluster-management.io","namespace":"test-app-ns","resource":"managedclusterviews","verb":"create"}}}' - ) - .reply(200, { - status: { - allowed: true, - }, - }) - - if (prefixes) { - // Nock for getMultiClusterHub - nock(process.env.CLUSTER_API_URL) - .get('/apis/operator.open-cluster-management.io/v1/multiclusterhubs') - .reply(200, { items: [] }) - - // Nock for getMultiClusterEngine - nock(process.env.CLUSTER_API_URL) - .get('/apis/multicluster.openshift.io/v1/multiclusterengines') - .reply(200, { - items: [ - { - spec: { - targetNamespace: 'multicluster-engine', - }, - }, - ], - }) - - // Nock for appSetData GET of single ApplicationSet - nock(process.env.CLUSTER_API_URL) - .get('/apis/argoproj.io/v1alpha1/namespaces/openshift-gitops/applicationsets/argoapplicationset-1') - .reply(200, uidataAppset) - } -} - -const resources = [ - // cluster - { - apiVersion: 'cluster.open-cluster-management.io/v1', - kind: 'ManagedCluster', - metadata: { - annotations: { - 'installer.multicluster.openshift.io/release-version': '2.7.0', - 'open-cluster-management/created-via': 'other', - }, - creationTimestamp: '2024-09-12T13:39:41Z', - finalizers: [ - 'managedcluster-import-controller.open-cluster-management.io/cleanup', - 'open-cluster-management.io/managedclusterrole', - 'cluster.open-cluster-management.io/api-resource-cleanup', - 'managedclusterinfo.finalizers.open-cluster-management.io', - 'managedcluster-import-controller.open-cluster-management.io/manifestwork-cleanup', - ], - generation: 4, - labels: { - cloud: 'Amazon', - 'cluster.open-cluster-management.io/clusterset': 'default', - clusterID: '075c2ab5-a818-468c-935b-ebbbc45a42f8', - 'feature.open-cluster-management.io/addon-application-manager': 'available', - 'feature.open-cluster-management.io/addon-cert-policy-controller': 'available', - 'feature.open-cluster-management.io/addon-cluster-proxy': 'available', - 'feature.open-cluster-management.io/addon-config-policy-controller': 'available', - 'feature.open-cluster-management.io/addon-governance-policy-framework': 'available', - 'feature.open-cluster-management.io/addon-hypershift-addon': 'available', - 'feature.open-cluster-management.io/addon-managed-serviceaccount': 'available', - 'feature.open-cluster-management.io/addon-work-manager': 'available', - 'local-cluster': 'true', - name: 'local-cluster', - openshiftVersion: '4.17.0-rc.2', - 'openshiftVersion-major': '4', - 'openshiftVersion-major-minor': '4.17', - 'velero.io/exclude-from-backup': 'true', - vendor: 'OpenShift', - }, - name: 'local-cluster', - resourceVersion: '7522024', - uid: '29496936-2d1d-4460-af37-f68471293e75', - }, - }, - // cluster info - { - apiVersion: 'internal.open-cluster-management.io/v1beta1', - kind: 'ManagedClusterInfo', - metadata: { - name: 'local-cluster', - uid: '29496936-2d1d-4460-af37-f68471293e66', - }, - status: { - consoleURL: 'https://api.console-aws-48-pwc27.dev02.red-chesterfield.com:6443', - }, - }, - // subscription app - { - apiVersion: 'app.k8s.io/v1beta1', - kind: 'Application', - metadata: { - name: 'test', - namespace: 'default', - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21ce72', - annotations: { - 'apps.open-cluster-management.io/deployables': '', - 'apps.open-cluster-management.io/subscriptions': - 'default/test-subscription-1,default/test-subscription-1-local', - }, - }, - }, - { - apiVersion: 'apps.open-cluster-management.io/v1', - kind: 'Subscription', - metadata: { - annotations: { - 'apps.open-cluster-management.io/git-branch': 'main', - 'apps.open-cluster-management.io/git-current-commit': '8f862b04775d23ba4aefe3064d031c968fdc5a3f', - 'apps.open-cluster-management.io/git-path': 'helloworld', - 'apps.open-cluster-management.io/reconcile-option': 'merge', - 'open-cluster-management.io/user-group': 'c3lzdGVtOmNsdXN0ZXItYWRtaW5zLHN5c3RlbTphdXRoZW50aWNhdGVk', - 'open-cluster-management.io/user-identity': 'a3ViZTphZG1pbg==', - }, - creationTimestamp: '2024-07-02T17:45:25Z', - generation: 1, - labels: { - app: 'test', - 'app.kubernetes.io/part-of': 'test', - 'apps.open-cluster-management.io/reconcile-rate': 'medium', - }, - name: 'test-subscription-1', - namespace: 'default', - resourceVersion: '1625088', - uid: '8b6d6503-dc8c-4ed6-b420-aa0df015fbf1', - }, - spec: { - channel: 'ggithubcom-fxiang1-app-samples-ns/ggithubcom-fxiang1-app-samples', - placement: { - placementRef: { - kind: 'Placement', - name: 'test-placement-1', - }, - }, - }, - status: { - lastUpdateTime: '2024-07-02T17:45:26Z', - message: 'Active', - phase: 'Propagated', - }, - }, - { - apiVersion: 'apps.open-cluster-management.io/v1', - kind: 'Subscription', - metadata: { - annotations: { - 'apps.open-cluster-management.io/git-branch': 'main', - 'apps.open-cluster-management.io/git-path': 'helloworld', - 'apps.open-cluster-management.io/hosting-subscription': 'default/test-subscription-1', - 'apps.open-cluster-management.io/reconcile-option': 'merge', - 'open-cluster-management.io/user-group': 'c3lzdGVtOmNsdXN0ZXItYWRtaW5zLHN5c3RlbTphdXRoZW50aWNhdGVk', - 'open-cluster-management.io/user-identity': 'a3ViZTphZG1pbg==', - }, - creationTimestamp: '2024-07-02T17:45:26Z', - generation: 1, - labels: { - app: 'test', - 'app.kubernetes.io/part-of': 'test', - 'apps.open-cluster-management.io/reconcile-rate': 'medium', - }, - uid: 'b7009958-d850-4ffc-9b04-57baa403ce47', - name: 'test-subscription-1-local', - namespace: 'default', - ownerReferences: [ - { - apiVersion: 'work.open-cluster-management.io/v1', - kind: 'AppliedManifestWork', - name: '099081ddd1c54a21bda5eae2f2c5013f0947c6ba3b8bdb1ceb7c38d7cfae3685-default-test-subscription-1', - uid: 'e9054859-bfca-46d2-8952-bea381adc6fa', - }, - ], - resourceVersion: '2441151', - }, - spec: { - channel: 'ggithubcom-fxiang1-app-samples-ns/ggithubcom-fxiang1-app-samples', - placement: { - local: true, - }, - }, - status: { - ansiblejobs: {}, - appstatusReference: 'kubectl get appsubstatus -n default test-subscription-1', - lastUpdateTime: '2024-07-03T13:05:00Z', - message: 'Active', - phase: 'Subscribed', - }, - }, - { - apiVersion: 'cluster.open-cluster-management.io/v1beta1', - kind: 'PlacementDecision', - metadata: { - creationTimestamp: '2024-07-02T17:45:25Z', - generation: 1, - labels: { - 'cluster.open-cluster-management.io/decision-group-index': '0', - 'cluster.open-cluster-management.io/decision-group-name': '', - 'cluster.open-cluster-management.io/placement': 'test-placement-1', - }, - name: 'test-placement-1-decision-1', - namespace: 'default', - uid: '7ba09bb1-5211-490f-a6d1-456322886ab0', - ownerReferences: [ - { - apiVersion: 'cluster.open-cluster-management.io/v1beta1', - blockOwnerDeletion: true, - controller: true, - kind: 'Placement', - name: 'test-placement-1', - uid: '458708a1-f9fd-498b-9c2f-420ba246fe3f', - }, - ], - resourceVersion: '1625071', - }, - status: { - decisions: [ - { - clusterName: 'local-cluster', - reason: '', - }, - ], - }, - }, - { - apiVersion: 'cluster.open-cluster-management.io/v1beta1', - kind: 'PlacementDecision', - metadata: { - creationTimestamp: '2024-07-02T17:45:25Z', - generation: 1, - labels: { - 'cluster.open-cluster-management.io/decision-group-index': '0', - 'cluster.open-cluster-management.io/decision-group-name': '', - 'cluster.open-cluster-management.io/placement': 'test-placement-1', - }, - name: 'test-placement-1-decision-1', - namespace: 'openshift-gitops', - uid: '7ba09bb1-5211-490f-a6d1-456392886ab0', - ownerReferences: [ - { - apiVersion: 'cluster.open-cluster-management.io/v1beta1', - blockOwnerDeletion: true, - controller: true, - kind: 'Placement', - name: 'test-placement-1', - uid: '458708a1-f9fd-498b-9c2f-420ba246fe3f', - }, - ], - resourceVersion: '1625071', - }, - status: { - decisions: [ - { - clusterName: 'mycluster', - reason: '', - }, - ], - }, - }, - { - apiVersion: 'cluster.open-cluster-management.io/v1beta1', - kind: 'PlacementDecision', - metadata: { - creationTimestamp: '2024-07-01T04:36:10Z', - generation: 1, - labels: { - 'cluster.open-cluster-management.io/decision-group-index': '0', - 'cluster.open-cluster-management.io/decision-group-name': '', - 'cluster.open-cluster-management.io/placement': 'global', - }, - name: 'global-decision-1', - namespace: 'open-cluster-management-global-set', - uid: 'c93db359-83b3-435b-9e30-065ac8a10143', - ownerReferences: [ - { - apiVersion: 'cluster.open-cluster-management.io/v1beta1', - blockOwnerDeletion: true, - controller: true, - kind: 'Placement', - name: 'global', - uid: '8e2ff464-d716-4be2-95e5-498cd5a14258', - }, - ], - resourceVersion: '33592', - }, - status: { - decisions: [ - { - clusterName: 'local-cluster', - reason: '', - }, - ], - }, - }, -] - -const argoApps = [ - { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'Application', - metadata: { - name: 'argoapplication-1', - namespace: 'openshift-gitops', - ownerReferences: [{ name: 'argoapplication-1', apiVersion: '', kind: '' }], - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21cf72', - }, - spec: { - destination: { - namespace: 'argoapplication-1-ns', - server: 'https://api.console-aws-48-pwc27.dev02.red-chesterfield.com:6443', - }, - project: 'default', - source: { - path: 'foo', - repoURL: 'https://test.com/test.git', - targetRevision: 'HEAD', - }, - syncPolicy: {}, - }, - status: {}, - }, - { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'Application', - metadata: { - name: 'argoapplication-2', - namespace: 'openshift-gitops', - ownerReferences: [{ name: 'argoapplicationset-1', apiVersion: '', kind: 'ApplicationSet' }], - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21ce79', - }, - spec: { - destination: { - namespace: 'argoapplication-2-ns', - server: 'https://api.console-aws-48-pwc27.dev02.red-chesterfield.com:6443', - }, - project: 'default', - source: { - path: 'foo', - repoURL: 'https://test.com/test.git', - targetRevision: 'HEAD', - }, - syncPolicy: {}, - }, - status: {}, - }, -] - -const argoAppSets = [ - { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'ApplicationSet', - metadata: { - name: 'argoapplicationset-1', - namespace: 'openshift-gitops', - uid: 'cc84e62f-edb9-413b-8bd7-38a32a21ce76', - }, - spec: { - generators: [ - { - clusterDecisionResource: { - configMapRef: 'acm-placement', - labelSelector: { - matchLabels: { - 'cluster.open-cluster-management.io/placement': 'test-placement-1', - }, - }, - requeueAfterSeconds: 180, - }, - }, - ], - template: { - metadata: { - labels: { - 'velero.io/exclude-from-backup': 'true', - }, - name: 'magchen-appset-{{name}}', - }, - spec: { - destination: { - namespace: 'magchen-ns', - server: '{{server}}', - }, - project: 'default', - source: { - path: 'acmnestedapp', - repoURL: 'https://github.com/fxiang1/app-samples', - targetRevision: 'main', - }, - syncPolicy: { - automated: { - prune: true, - selfHeal: true, - }, - syncOptions: ['CreateNamespace=true', 'PruneLast=true'], - }, - }, - }, - }, - }, -] as unknown as IResource[] diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 0ae77cd0015..c81e1a9b8ea 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -32,6 +32,8 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/cors` | Development CORS middleware (OPTIONS preflight for standalone dev) | | `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | | `internal/events/hub` | `GET /events` SSE: informer fan-out, snapshot packets, per-user SSAR (60s TTL). DELETED is not RBAC-filtered (bug-compatible with Node). `CONSOLE_INFORMER_CACHE=0` proxies `/events` to Node | +| `internal/aggregate` | `POST /aggregate/{applications,statuses,appSetData}`: informer cache + Search SA GraphQL, Fuse.js-compatible filter, windowed SSAR. `CONSOLE_INFORMER_CACHE=0` does not register the route | +| `internal/searchapi` | Search GraphQL client used by the aggregator (`/searchapi/graphql` or `/federated`) | | `internal/informers` | Hub resource cache (~67 watch specs, dual-run with Node). Dev: `GET /debug/informer-snapshot` | | `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | @@ -62,8 +64,9 @@ Go backend :4000 (TLS / HTTP/2) │ (also /multicloud/…) ├─ GET /events (resource watch SSE + per-user SSAR; also /multicloud/events) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) + ├─ POST /aggregate/{applications,statuses,appSetData} (application inventory; also /multicloud/…) ├─ GET /debug/informer-snapshot (dev only; Go informer cache dump) - ├─ SA informers (~67 specs) feed GET /events; Node startWatching() still runs for aggregators + ├─ SA informers (~67 specs) feed GET /events and POST /aggregate; Node startWatching() still runs for hub.ts ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) │ (also /multicloud/…) ├─ GET /configure (OAuth/OIDC token_endpoint discovery) @@ -84,7 +87,9 @@ Go backend :4000 (TLS / HTTP/2) `/multicloud` is stripped only when matching Go-owned routes. The proxy forwards the original path so Node can keep stripping it. -During ACM-42597/42598 the Go process watches the same specs as Node `startWatching()` **after** the public listener is bound. Startup is capped at 8 concurrent list/watch setups; the informer client uses QPS 20 / Burst 40; resync is disabled. Set `CONSOLE_INFORMER_CACHE=0` (or `false`/`off`) to skip Go watches and keep proxying `GET /events` to Node. Node `startWatching()` still runs for aggregators (`getKubeResources`). After informers sync, logs `informer cache memory` with `heapAlloc` — compare that to the sidecar deflate cache, not combined RSS. +During ACM-42597/42598 the Go process watches the same specs as Node `startWatching()` **after** the public listener is bound. Startup is capped at 8 concurrent list/watch setups; the informer client uses QPS 20 / Burst 40; resync is disabled. Set `CONSOLE_INFORMER_CACHE=0` (or `false`/`off`) to skip Go watches and keep proxying `GET /events` to Node (and not register `POST /aggregate`). Node `startWatching()` still runs for `hub.ts` (`getKubeResources`). After informers sync, logs `informer cache memory` with `heapAlloc` — compare that to the sidecar deflate cache, not combined RSS. + +`POST /aggregate/*` rebuilds ACM/Argo Application rows from `InformerCache.ListByKind` and refreshes remote OCP/Flux/Argo status from Search (15s for the first three passes, then `APP_SEARCH_INTERVAL` or 60s). Pagination uses Fuse.js 6.6.2 options (`ignoreLocation`, threshold 0.3) when there are more than 500 items; `itemCount` in `/aggregate/statuses` is a JSON string. `POST /proxy/search` stays on Node until ACM-42601. `GET /events` framing matches Node `server-side-events.ts`: `id:` + `data:` (no space), gzip when `Accept-Encoding` includes gzip, keepalive `:\n\n` every 10s, snapshot `START` → `SETTINGS` → priority packets with `EOP` → `LOADED`, live `MODIFIED`/`DELETED` then `LOADED`. Creates and updates are both `MODIFIED` (not `ADDED`). **DELETED events are broadcast without per-user SSAR** — the same known gap as Node; do not “fix” it in this stream without a follow-up. diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index 9bd4665c669..10af794f4ca 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "os/signal" + "strings" "syscall" "k8s.io/client-go/discovery" @@ -16,18 +17,21 @@ import ( "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" + "github.com/stolostron/console/backend/internal/aggregate" "github.com/stolostron/console/backend/internal/auth" "github.com/stolostron/console/backend/internal/clusterinfo" "github.com/stolostron/console/backend/internal/clusterproxy" "github.com/stolostron/console/backend/internal/config" eventshub "github.com/stolostron/console/backend/internal/events/hub" rbacevents "github.com/stolostron/console/backend/internal/events/rbac" + "github.com/stolostron/console/backend/internal/hubresources" "github.com/stolostron/console/backend/internal/informers" "github.com/stolostron/console/backend/internal/k8sproxy" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/mcproxy" "github.com/stolostron/console/backend/internal/metricsproxy" "github.com/stolostron/console/backend/internal/oauth" + "github.com/stolostron/console/backend/internal/searchapi" "github.com/stolostron/console/backend/internal/server" "github.com/stolostron/console/backend/internal/static" "github.com/stolostron/console/backend/internal/user" @@ -121,8 +125,31 @@ func run() error { }) var opts []server.Option opts = append(opts, server.WithRBACEvents(rbacHandler), server.WithOAuth(oauthH)) + var aggEng *aggregate.Engine if cfg.InformerCache { opts = append(opts, server.WithEvents(eventsHandler)) + ca := sa.ServiceCACert + if len(ca) == 0 { + ca = sa.CACert + } + searchClient := &searchapi.Client{ + HTTP: auth.HTTPClient(ca, 0), + Token: sa.Token, + SearchAPIURL: os.Getenv("SEARCH_API_URL"), + Federated: func() bool { return os.Getenv("globalSearchFeatureFlag") == "enabled" }, + Namespace: serviceAccountNamespace(), + MCHNamespace: func(reqCtx context.Context) string { + ns, nsErr := hubresources.MCHNamespace(reqCtx, dyn) + if nsErr != nil { + return "" + } + return ns + }, + } + aggEng = aggregate.NewEngine(infCache, searchClient, dyn) + aggAccess := aggregate.NewSSARAccess(restCfg) + aggAccess.StartCleanup(ctx) + opts = append(opts, server.WithAggregate(aggregate.NewHandler(aggEng, restCfg, aggAccess))) } if !cfg.Production { opts = append(opts, server.WithOAuthLogin(), server.WithDebugSnapshot(informers.NewSnapshotHandler(infCache, restCfg))) @@ -200,7 +227,18 @@ func run() error { return } informers.StartCache(ctx, infCache, infDyn, mapper) + if aggEng != nil { + aggEng.Start(ctx) + } }) } +func serviceAccountNamespace() string { + data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if err != nil { + return strings.TrimSpace(os.Getenv("NAMESPACE")) + } + return strings.TrimSpace(string(data)) +} + var errMissingToken = errors.New("service account token missing") diff --git a/backend/internal/aggregate/appset.go b/backend/internal/aggregate/appset.go new file mode 100644 index 00000000000..adcd4c3e041 --- /dev/null +++ b/backend/internal/aggregate/appset.go @@ -0,0 +1,226 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/stolostron/console/backend/internal/auth" +) + +type requestStatuses struct { + Clusters []string `json:"clusters"` +} + +type resultStatuses struct { + ItemCount string `json:"itemCount"` + FilterCounts map[string]map[string]int `json:"filterCounts"` + SystemAppNSPrefixes []string `json:"systemAppNSPrefixes"` + Loading bool `json:"loading"` +} + +type resultAppSetData struct { + Appset map[string]any `json:"appset"` + ClusterList []string `json:"clusterList"` + Placement map[string]any `json:"placement,omitempty"` + PlacementDecision map[string]any `json:"placementDecision,omitempty"` + AppSetApps []map[string]any `json:"appSetApps"` + AppStatusByNameMap map[string]AppHealthSync `json:"appStatusByNameMap"` + IsAppSetPullModel bool `json:"isAppSetPullModel"` +} + +func (h *Handler) statuses(w http.ResponseWriter, r *http.Request, token string) { + var req requestStatuses + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + items := h.Engine.applications() + if len(req.Clusters) > 0 { + filtered := make([]App, 0, len(items)) + for _, item := range items { + match := false + for _, want := range req.Clusters { + for _, c := range item.Transform.Clusters { + if c == want { + match = true + break + } + } + if match { + break + } + } + if match { + filtered = append(filtered, item) + } + } + items = filtered + } + authorized := h.Access.Authorized(r.Context(), token, items, 0, len(items)) + counts := map[string]map[string]int{ + "type": {}, "cluster": {}, "podStatuses": {}, "healthStatus": {}, "syncStatus": {}, + } + for _, item := range authorized { + incFilterCounts(counts, "type", []string{item.Transform.Type}) + incFilterCounts(counts, "cluster", item.Transform.Clusters) + incStatusCounts(counts, "healthStatus", item, colHealth) + incStatusCounts(counts, "syncStatus", item, colSynced) + incStatusCounts(counts, "podStatuses", item, colDeployed) + } + h.Engine.mu.RLock() + prefixes := append([]string{}, h.Engine.systemPrefixes...) + h.Engine.mu.RUnlock() + writeJSON(w, resultStatuses{ + ItemCount: itoaCount(len(authorized)), + FilterCounts: counts, + SystemAppNSPrefixes: prefixes, + Loading: false, + }) +} + +func itoaCount(n int) string { + return strconv.Itoa(n) +} + +func incFilterCounts(m map[string]map[string]int, id string, keys []string) { + inner := m[id] + if inner == nil { + inner = map[string]int{} + m[id] = inner + } + for _, key := range keys { + inner[key]++ + } +} + +func incStatusCounts(m map[string]map[string]int, id string, item App, index int) { + inner := m[id] + if inner == nil { + inner = map[string]int{} + m[id] = inner + } + typ := item.Transform.Type + if (index == colHealth || index == colSynced) && (typ == kindAppSet || typ == kindArgo) { + if len(item.Transform.Statuses) == 0 { + return + } + key := statusFilterKey(item, index) + inner[key]++ + } +} + +func (h *Handler) appSetData(w http.ResponseWriter, r *http.Request, token string) { + var stub map[string]any + if err := json.NewDecoder(r.Body).Decode(&stub); err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "Invalid request body"}) + return + } + appset, err := h.fetchAppSet(r.Context(), token, stub) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]string{"error": "Failed to fetch resource"}) + return + } + h.Engine.mu.RLock() + defer h.Engine.mu.RUnlock() + name := metaName(appset) + nsName := metaNamespace(appset) + "/" + name + appSetApps := eMaps(h.Engine.appSetAppsMap[name]) + statusByName := h.Engine.appStatusByName[nsName] + if statusByName == nil { + statusByName = map[string]AppHealthSync{} + } + hub := h.Engine.hubClusterName() + clusters := h.Engine.clusters() + var local *Cluster + for i := range clusters { + if clusters[i].Name == hub { + c := clusters[i] + local = &c + break + } + } + clusterList := h.Engine.applicationClusters(appset, kindAppSet, nil, nil, local, clusters) + if clusterList == nil { + clusterList = []string{} + } + var placement, placementDecision map[string]any + spec, _ := appset["spec"].(map[string]any) + placementName := placementNameFromSpec(spec) + if placementName != "" { + placements := h.Engine.listKind("cluster.open-cluster-management.io/v1beta1", "Placement") + decisions := h.Engine.listKind("cluster.open-cluster-management.io/v1beta1", "PlacementDecision") + for _, p := range decisions { + labels := metaLabels(p) + if metaNamespace(p) == metaNamespace(appset) && strVal(labels["cluster.open-cluster-management.io/placement"]) == placementName { + placementDecision = p + break + } + } + if len(clusterList) == 0 && placementDecision != nil { + for _, d := range nestedSlice(placementDecision, "status", "decisions") { + dm, _ := d.(map[string]any) + if n := strVal(dm["clusterName"]); n != "" { + clusterList = append(clusterList, n) + } + } + } + owners := nestedSlice(placementDecision, "metadata", "ownerReferences") + if len(owners) > 0 { + owner0, _ := owners[0].(map[string]any) + for _, resource := range placements { + if kindOf(resource) == strVal(owner0["kind"]) && + metaName(resource) == strVal(owner0["name"]) && + metaNamespace(resource) == metaNamespace(appset) { + placement = resource + break + } + } + } + } + writeJSON(w, resultAppSetData{ + Appset: appset, + ClusterList: clusterList, + Placement: placement, + PlacementDecision: placementDecision, + AppSetApps: appSetApps, + AppStatusByNameMap: statusByName, + IsAppSetPullModel: isArgoPullModel(appset), + }) +} + +func eMaps(in []map[string]any) []map[string]any { + if in == nil { + return []map[string]any{} + } + return in +} + +func (h *Handler) fetchAppSet(ctx context.Context, token string, stub map[string]any) (map[string]any, error) { + if h.GetAppSet != nil { + return h.GetAppSet(ctx, token, stub) + } + if h.REST == nil { + return stub, nil + } + cfg := auth.UserRESTConfig(h.REST, token) + client, err := dynamic.NewForConfig(cfg) + if err != nil { + return nil, err + } + gvr := schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "applicationsets"} + obj, err := client.Resource(gvr).Namespace(metaNamespace(stub)).Get(ctx, metaName(stub), metav1.GetOptions{}) + if err != nil { + return nil, err + } + return obj.Object, nil +} diff --git a/backend/internal/aggregate/argo.go b/backend/internal/aggregate/argo.go new file mode 100644 index 00000000000..671fa4b91d3 --- /dev/null +++ b/backend/internal/aggregate/argo.go @@ -0,0 +1,402 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "strings" + + "github.com/stolostron/console/backend/internal/searchapi" +) + +func relatedKinds(related []searchapi.Related) []mapKind { + out := make([]mapKind, 0, len(related)) + for _, r := range related { + out = append(out, mapKind{Kind: r.Kind, Items: r.Items}) + } + return out +} + +func (e *Engine) addArgoQueryInputs(q *searchapi.Query) { + e.lastArgoChunk = e.nextAppPageChunk(&e.argoPageChunks, cacheRemoteArgo) + chunk := e.lastArgoChunk + filters := []searchapi.Filter{ + {Property: "kind", Values: []string{"Application"}}, + {Property: "apigroup", Values: []string{"argoproj.io"}}, + } + if chunk != nil && len(chunk.Keys) > 0 { + filters = append(filters, searchapi.Filter{Property: "name", Values: chunk.Keys}) + } + q.Variables.Input = append(q.Variables.Input, searchapi.Input{ + Filters: filters, + RelatedKinds: []string{"Pod", "ReplicaSet", "Deployment", "StatefulSet"}, + Limit: searchQueryLimit, + }) +} + +func (e *Engine) cacheArgoApplications(search searchapi.ResultBucket, pushResult *searchapi.ResultBucket, pushMap map[string]pushEntry) map[string]struct{} { + hub := e.hubClusterName() + clusters := e.clusters() + var local *Cluster + for i := range clusters { + if clusters[i].Name == hub { + c := clusters[i] + local = &c + break + } + } + var remote []map[string]any + for _, app := range search.Items { + if searchStr(app, "cluster") != hub { + remote = append(remote, app) + } + } + statusMap := e.createArgoStatusMap(search, clusters) + if pushResult != nil && len(pushMap) > 0 { + mergePushModelPodStatuses(*pushResult, pushMap, statusMap) + } + e.lastArgoStatus = statusMap + if e.cache[cacheLocalArgo].ResourceUIDMap != nil { + vals := make([]map[string]any, 0, len(e.cache[cacheLocalArgo].ResourceUIDMap)) + uidMap := e.cache[cacheLocalArgo].ResourceUIDMap + for _, a := range uidMap { + vals = append(vals, a.Object) + } + e.transform(vals, statusMap, false, local, clusters, uidMap) + } + e.cacheRemoteApps(statusMap, e.remoteArgoApps(remote), e.lastArgoChunk, cacheRemoteArgo) + if e.cache[cacheAppSet].ResourceUIDMap != nil { + vals := make([]map[string]any, 0, len(e.cache[cacheAppSet].ResourceUIDMap)) + uidMap := e.cache[cacheAppSet].ResourceUIDMap + for _, a := range uidMap { + vals = append(vals, a.Object) + } + e.transform(vals, statusMap, false, local, clusters, uidMap) + } + return e.ocpArgoFilter +} + +func filterArgoApps(items []map[string]any, clusters []Cluster, ocpFilter map[string]struct{}, appSetApps map[string][]map[string]any, hub string) []map[string]any { + var out []map[string]any + for _, app := range items { + dest := nestedMap(app, "spec", "destination") + resources := nestedSlice(app, "status", "resources") + definedNS := "" + if len(resources) > 0 { + if r, ok := resources[0].(map[string]any); ok { + definedNS = strVal(r["namespace"]) + } + } + ns := "" + if dest != nil { + ns = strVal(dest["namespace"]) + } + if definedNS != "" { + ns = definedNS + } + ocpFilter[metaName(app)+"-"+ns+"-"+simpleDest(dest, clusters, hub)] = struct{}{} + owners := nestedSlice(app, "metadata", "ownerReferences") + isChild := false + appSetName := "" + if len(owners) > 0 { + if o, ok := owners[0].(map[string]any); ok { + if strVal(o["kind"]) == "ApplicationSet" { + isChild = true + appSetName = strVal(o["name"]) + } + } + } + if len(owners) == 0 || !isChild { + out = append(out, app) + continue + } + apps := appSetApps[appSetName] + replaced := false + for i, it := range apps { + if metaUID(it) == metaUID(app) { + apps[i] = app + replaced = true + break + } + } + if !replaced { + apps = append(apps, app) + } + appSetApps[appSetName] = apps + } + return out +} + +func simpleDest(dest map[string]any, clusters []Cluster, hub string) string { + if dest == nil { + return "unknown" + } + serverAPI := strVal(dest["server"]) + if serverAPI != "" { + if serverAPI == "https://kubernetes.default.svc" { + return hub + } + for _, cls := range clusters { + if cls.KubeAPIServer == serverAPI { + return cls.Name + } + } + return "unknown" + } + name := strVal(dest["name"]) + if name == "" { + name = "unknown" + } + if name == "in-cluster" || name == hub { + return hub + } + return name +} + +func (e *Engine) remoteArgoApps(remote []map[string]any) []map[string]any { + if len(e.argoPageChunks) == 0 { + e.pulledAppSetMap = e.tempPulled + e.tempPulled = map[string][]map[string]any{} + } + var apps []map[string]any + for _, argoApp := range remote { + e.ocpArgoFilter[searchStr(argoApp, "name")+"-"+searchStr(argoApp, "destinationNamespace")+"-"+searchStr(argoApp, "cluster")] = struct{}{} + hosting := searchStr(argoApp, "_hostingResource") + if hosting != "" { + parts := strings.Split(hosting, "/") + if len(parts) >= 3 && parts[0] == "ApplicationSet" { + appSetName := parts[2] + pulled := e.tempPulled[appSetName] + replaced := false + for i, it := range pulled { + if searchStr(it, "_uid") == searchStr(argoApp, "_uid") { + pulled[i] = argoApp + replaced = true + break + } + } + if !replaced { + pulled = append(pulled, argoApp) + } + e.tempPulled[appSetName] = pulled + } + continue + } + apps = append(apps, map[string]any{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "Application", + "metadata": map[string]any{ + "name": searchStr(argoApp, "name"), + "namespace": searchStr(argoApp, "namespace"), + "creationTimestamp": searchStr(argoApp, "created"), + }, + "spec": map[string]any{ + "destination": map[string]any{ + "namespace": searchStr(argoApp, "destinationNamespace"), + "name": searchStr(argoApp, "destinationName"), + "server": firstNonEmpty(searchStr(argoApp, "destinationCluster"), searchStr(argoApp, "destinationServer")), + }, + "source": map[string]any{ + "path": searchStr(argoApp, "path"), + "repoURL": searchStr(argoApp, "repoURL"), + "targetRevision": searchStr(argoApp, "targetRevision"), + "chart": searchStr(argoApp, "chart"), + }, + }, + "status": map[string]any{ + "cluster": searchStr(argoApp, "cluster"), + "health": map[string]any{"status": searchStr(argoApp, "healthStatus")}, + "sync": map[string]any{"status": searchStr(argoApp, "syncStatus")}, + }, + }) + } + return apps +} + +func (e *Engine) appSetPlacementData(appSet map[string]any, applicationSets []App) []any { + current := placementFromAppSet(appSet) + if current == "" { + return []any{"", []string{}} + } + sharing := []string{} + for _, item := range applicationSets { + p := placementFromAppSet(item.Object) + if p == "" { + continue + } + sameName := metaName(item.Object) == metaName(appSet) + sameNS := metaNamespace(item.Object) == metaNamespace(appSet) + if !sameName || (sameName && !sameNS) { + if p == current && metaName(item.Object) != "" { + sharing = append(sharing, metaName(item.Object)) + } + } + } + return []any{current, sharing} +} + +func placementFromAppSet(obj map[string]any) string { + spec, _ := obj["spec"].(map[string]any) + gens := nestedSlice(obj, "spec", "generators") + if len(gens) == 0 { + return placementNameFromSpec(spec) + } + if g, ok := gens[0].(map[string]any); ok { + return nestedString(g, "clusterDecisionResource", "labelSelector", "matchLabels", "cluster.open-cluster-management.io/placement") + } + return "" +} + +func (e *Engine) createArgoStatusMap(search searchapi.ResultBucket, clusters []Cluster) map[string]StatusMap { + out := map[string]StatusMap{} + ids := map[string]*statusIDs{} + sorted := make([]string, 0, len(clusters)) + for _, c := range clusters { + sorted = append(sorted, c.Name) + } + // longest name first + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if len(sorted[j]) > len(sorted[i]) { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + for _, app := range search.Items { + var appKey, appName, appSetName, appNamespace string + appCluster := searchStr(app, "cluster") + appNamespace = searchStr(app, "namespace") + if hosting := searchStr(app, "_hostingResource"); hosting != "" { + parts := strings.Split(hosting, "/") + if len(parts) >= 3 { + appNamespace, appSetName = parts[1], parts[2] + appName = appNamespace + "/" + appSetName + appKey = "appset/" + appName + } + } else if aset := searchStr(app, "applicationSet"); aset != "" { + if !strings.Contains(searchStr(app, "label"), "apps.open-cluster-management.io/pull-to-ocm-managed-cluster=true") { + appName = searchStr(app, "namespace") + "/" + aset + appKey = "appset/" + appName + namePart := searchStr(app, "name") + if len(namePart) > len(aset) && namePart[:len(aset)] == aset { + namePart = namePart[len(aset)+1:] + } else { + namePart = aset + } + for _, cluster := range sorted { + if namePart == cluster || strings.Contains(namePart, "-"+cluster) || strings.Contains(namePart, cluster+"-") { + appCluster = cluster + break + } + } + appSetName = aset + } + } else { + appName = searchStr(app, "namespace") + "/" + searchStr(app, "name") + appKey = "argo/" + appName + } + if appKey == "" { + continue + } + if out[appKey] == nil { + out[appKey] = StatusMap{} + } + st, ok := out[appKey][appCluster] + if !ok { + st = emptyClusterStatuses() + } + computeAppHealthStatus(&st.Health, app) + computeAppSyncStatus(&st.Synced, app) + if appSetName != "" { + key := appNamespace + "/" + appSetName + if e.appStatusByName[key] == nil { + e.appStatusByName[key] = map[string]AppHealthSync{} + } + var hs AppHealthSync + hs.Health.Status = searchStr(app, "healthStatus") + hs.Sync.Status = searchStr(app, "syncStatus") + e.appStatusByName[key][searchStr(app, "name")] = hs + } + idKey := statusIDKey(appKey, appCluster) + id := ids[idKey] + if id == nil { + id = &statusIDs{appName: appName} + ids[idKey] = id + } + id.uids = append(id.uids, searchStr(app, "_uid")) + out[appKey][appCluster] = st + } + computeDeployedPodStatuses(relatedKinds(search.Related), out, ids, false) + return out +} + +type pushEntry struct { + appSetKey string + targetCluster string +} + +func mergePushModelPodStatuses(search searchapi.ResultBucket, pushMap map[string]pushEntry, argo map[string]StatusMap) { + if len(search.Items) == 0 { + return + } + workloadUID := map[string]pushEntry{} + for _, item := range search.Items { + key := searchStr(item, "cluster") + "/" + searchStr(item, "namespace") + "/" + searchStr(item, "name") + if entry, ok := pushMap[key]; ok { + workloadUID[searchStr(item, "_uid")] = entry + } + } + var pods []map[string]any + for _, r := range search.Related { + if r.Kind == "Pod" { + pods = r.Items + break + } + } + if pods == nil { + return + } + already := map[string]struct{}{} + for _, entry := range pushMap { + if st, ok := argo[entry.appSetKey][entry.targetCluster]; ok { + counts := st.Deployed.Counts + if counts[scoreHealthy]+counts[scoreProgress]+counts[scoreWarning]+counts[scoreDanger] > 0 { + already[entry.appSetKey+"/"+entry.targetCluster] = struct{}{} + } + } + } + buckets := map[string][]map[string]any{} + statusPtr := map[string]string{} + for _, pod := range pods { + uids, _ := pod["_relatedUids"].([]any) + var matched *pushEntry + for _, u := range uids { + if entry, ok := workloadUID[strVal(u)]; ok { + e := entry + matched = &e + break + } + } + if matched == nil { + continue + } + entryKey := matched.appSetKey + "/" + matched.targetCluster + if _, ok := already[entryKey]; ok { + continue + } + if _, ok := argo[matched.appSetKey][matched.targetCluster]; !ok { + continue + } + buckets[entryKey] = append(buckets[entryKey], pod) + statusPtr[entryKey] = matched.appSetKey + "\x00" + matched.targetCluster + } + for entryKey, plist := range buckets { + appSetKey, targetCluster, ok := strings.Cut(statusPtr[entryKey], "\x00") + if !ok { + continue + } + st := argo[appSetKey][targetCluster] + computePodStatus(&st.Deployed, plist) + argo[appSetKey][targetCluster] = st + _ = entryKey + } +} diff --git a/backend/internal/aggregate/argo_test.go b/backend/internal/aggregate/argo_test.go new file mode 100644 index 00000000000..22a7be545e0 --- /dev/null +++ b/backend/internal/aggregate/argo_test.go @@ -0,0 +1,90 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "testing" + + "github.com/stolostron/console/backend/internal/searchapi" +) + +func TestFilterArgoAppsSkipsAppSetChildren(t *testing.T) { + ocp := map[string]struct{}{} + appSets := map[string][]map[string]any{} + parent := map[string]any{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "Application", + "metadata": map[string]any{"name": "app", "namespace": "argocd", "uid": "1"}, + "spec": map[string]any{"destination": map[string]any{"namespace": "dest", "name": "in-cluster"}}, + } + child := map[string]any{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "Application", + "metadata": map[string]any{ + "name": "child", + "namespace": "argocd", + "uid": "2", + "ownerReferences": []any{ + map[string]any{"kind": "ApplicationSet", "name": "set-1"}, + }, + }, + "spec": map[string]any{"destination": map[string]any{"namespace": "dest", "name": "in-cluster"}}, + } + out := filterArgoApps([]map[string]any{parent, child}, nil, ocp, appSets, "local-cluster") + if len(out) != 1 || metaName(out[0]) != "app" { + t.Fatalf("parents %+v", out) + } + if len(appSets["set-1"]) != 1 { + t.Fatalf("child map %+v", appSets) + } +} + +func TestMergePushModelPodStatuses(t *testing.T) { + argo := map[string]StatusMap{ + "appset/ns/set": { + "remote": emptyClusterStatuses(), + }, + } + st := argo["appset/ns/set"]["remote"] + st.Health.Counts[scoreHealthy] = 1 + st.Synced.Counts[scoreHealthy] = 1 + argo["appset/ns/set"]["remote"] = st + pushMap := map[string]pushEntry{ + "remote/ns/deploy": {appSetKey: "appset/ns/set", targetCluster: "remote"}, + } + search := searchapi.ResultBucket{ + Items: []map[string]any{ + {"cluster": "remote", "namespace": "ns", "name": "deploy", "_uid": "w1"}, + }, + Related: []searchapi.Related{ + {Kind: "Pod", Items: []map[string]any{ + {"status": "Running", "_relatedUids": []any{"w1"}, "_uid": "p1"}, + }}, + }, + } + mergePushModelPodStatuses(search, pushMap, argo) + if argo["appset/ns/set"]["remote"].Deployed.Counts[scoreHealthy] != 1 { + t.Fatalf("%+v", argo["appset/ns/set"]["remote"].Deployed) + } +} + +func TestCreateArgoStatusMap(t *testing.T) { + e := NewEngine(nil, nil, nil) + search := searchapi.ResultBucket{ + Items: []map[string]any{ + { + "name": "app", + "namespace": "argocd", + "cluster": "local-cluster", + "healthStatus": "Healthy", + "syncStatus": "Synced", + "_uid": "u1", + }, + }, + } + out := e.createArgoStatusMap(search, []Cluster{{Name: "local-cluster"}}) + st := out["argo/argocd/app"]["local-cluster"] + if st.Health.Counts[scoreHealthy] != 1 || st.Synced.Counts[scoreHealthy] != 1 { + t.Fatalf("%+v", st) + } +} diff --git a/backend/internal/aggregate/clusters.go b/backend/internal/aggregate/clusters.go new file mode 100644 index 00000000000..9d9dfa8683c --- /dev/null +++ b/backend/internal/aggregate/clusters.go @@ -0,0 +1,370 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "net/url" + "strconv" + "strings" +) + +// Cluster is the aggregator subset of a managed cluster. +type Cluster struct { + Name string + KubeAPIServer string + ConsoleURL string +} + +func (e *Engine) clusters() []Cluster { + managed := e.listKind("cluster.open-cluster-management.io/v1", "ManagedCluster") + cds := e.listKind("hive.openshift.io/v1", "ClusterDeployment") + infos := e.listKind("internal.open-cluster-management.io/v1beta1", "ManagedClusterInfo") + hosted := e.listKind("hypershift.openshift.io/v1beta1", "HostedCluster") + + filteredCD := make([]map[string]any, 0, len(cds)) + for _, cd := range cds { + skip := false + if owners, _ := metaMap(cd)["ownerReferences"].([]any); len(owners) > 0 { + for _, o := range owners { + om, _ := o.(map[string]any) + if strVal(om["kind"]) == "AgentCluster" { + skip = true + break + } + } + } + if !skip { + filteredCD = append(filteredCD, cd) + } + } + + names := map[string]struct{}{} + add := func(n string) { + if n != "" { + names[n] = struct{}{} + } + } + for _, cd := range filteredCD { + add(metaName(cd)) + } + for _, mc := range infos { + add(metaName(mc)) + } + for _, mc := range managed { + add(metaName(mc)) + } + for _, hc := range hosted { + add(metaName(hc)) + } + + mcMap := keyByName(managed) + hcMap := keyByName(hosted) + cdMap := keyByName(filteredCD) + infoMap := keyByName(infos) + + out := make([]Cluster, 0, len(names)) + for name := range names { + cd := cdMap[name] + mc := mcMap[name] + info := infoMap[name] + hc := hcMap[name] + out = append(out, Cluster{ + Name: firstNonEmpty(metaName(cd), metaName(mc), metaName(info), metaName(hc)), + KubeAPIServer: kubeAPIServer(cd, info), + ConsoleURL: consoleURL(cd, info, mc, hc), + }) + } + return out +} + +func keyByName(items []map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, item := range items { + if n := metaName(item); n != "" { + out[n] = item + } + } + return out +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func kubeAPIServer(cd, info map[string]any) string { + if u := nestedString(cd, "status", "apiURL"); u != "" { + return u + } + if u := nestedString(info, "spec", "masterEndpoint"); u != "" { + return u + } + cn := nestedString(cd, "spec", "clusterName") + bd := nestedString(cd, "spec", "baseDomain") + return "https://api." + cn + "." + bd +} + +func consoleURL(cd, info, mc, hosted map[string]any) string { + if claims := nestedSlice(mc, "status", "clusterClaims"); len(claims) > 0 { + for _, raw := range claims { + claim, _ := raw.(map[string]any) + if strVal(claim["name"]) == "consoleurl.cluster.open-cluster-management.io" { + if v := strVal(claim["value"]); v != "" { + return v + } + } + } + } + if u := nestedString(cd, "status", "webConsoleURL"); u != "" { + return u + } + if u := nestedString(info, "status", "consoleURL"); u != "" { + return u + } + return hypershiftConsoleURL(hosted) +} + +func hypershiftConsoleURL(hosted map[string]any) string { + if hosted == nil { + return "" + } + name := metaName(hosted) + base := nestedString(hosted, "spec", "dns", "baseDomain") + if name == "" || base == "" { + return "" + } + return "https://console-openshift-console.apps." + name + "." + base +} + +func (e *Engine) clusterMap() map[string]map[string]any { + out := map[string]map[string]any{} + for _, c := range e.listKind("cluster.open-cluster-management.io/v1", "ManagedCluster") { + if n := metaName(c); n != "" { + out[n] = c + } + } + return out +} + +func (e *Engine) applicationClusters(obj map[string]any, typ string, subscriptions, placementDecisions []map[string]any, local *Cluster, clusters []Cluster) []string { + switch typ { + case kindFlux, kindOpenShift, kindOpenShiftDefault: + if st, _ := obj["status"].(map[string]any); st != nil { + if c := strVal(st["cluster"]); c != "" { + return []string{c} + } + } + case kindArgo: + return []string{e.argoCluster(obj, clusters)} + case kindAppSet: + if isArgoPullModel(obj) { + return argoPullModelClusters(e.pulledAppSetMap[metaName(obj)]) + } + return e.argoPushModelClusters(e.appSetAppsMap[metaName(obj)], local, clusters) + case kindSubscriptionApp: + return subscriptionClusters(obj, subscriptions, placementDecisions) + } + return []string{e.hubClusterName()} +} + +func isArgoPullModel(obj map[string]any) bool { + return nestedString(obj, "spec", "template", "metadata", "annotations", "apps.open-cluster-management.io/ocm-managed-cluster") != "" +} + +func argoPullModelClusters(apps []map[string]any) []string { + set := map[string]struct{}{} + for _, app := range apps { + if c := searchStr(app, "cluster"); c != "" { + set[c] = struct{}{} + } + } + return setKeys(set) +} + +func (e *Engine) argoPushModelClusters(resources []map[string]any, local *Cluster, managed []Cluster) []string { + set := map[string]struct{}{} + localName := "" + if local != nil { + localName = local.Name + } + for _, resource := range resources { + clusterHint := nestedString(resource, "status", "cluster") + isRemote := clusterHint != "" + + dest := nestedMap(resource, "spec", "destination") + destName := strVal(dest["name"]) + destServer := strVal(dest["server"]) + + if (destName == "in-cluster" || destName == localName || isLocalClusterURL(destServer, local)) && !isRemote { + set[localName] = struct{}{} + continue + } + set[e.argoDestinationCluster(dest, managed, clusterHint, localName)] = struct{}{} + } + return setKeys(set) +} + +func isLocalClusterURL(raw string, local *Cluster) bool { + if raw == "https://kubernetes.default.svc" { + return true + } + localHost := "localhost" + if local != nil && local.ConsoleURL != "" { + if u, err := url.Parse(local.ConsoleURL); err == nil { + localHost = u.Host + } + } + u, err := url.Parse(raw) + if err != nil { + return false + } + host := u.Hostname() + idx := strings.Index(host, "api.") + if idx < 0 { + return strings.Contains(localHost, host) + } + hostnameWithoutAPI := host[idx+4:] + return strings.Contains(localHost, hostnameWithoutAPI) +} + +func subscriptionClusters(obj map[string]any, subscriptions, placementDecisions []map[string]any) []string { + set := map[string]struct{}{} + ann := strVal(metaAnnotations(obj)["apps.open-cluster-management.io/subscriptions"]) + if ann == "" { + return nil + } + subs := strings.Split(ann, ",") + for _, sa := range subs { + if isLocalSubscription(sa, subs) { + continue + } + details := strings.Split(sa, "/") + if len(details) < 2 { + continue + } + for _, sub := range subscriptions { + if metaName(sub) != details[1] || metaNamespace(sub) != details[0] { + continue + } + placementRef := nestedString(sub, "spec", "placement", "placementRef", "name") + for _, pd := range placementDecisions { + labels := metaLabels(pd) + if strVal(labels["cluster.open-cluster-management.io/placement"]) != placementRef { + continue + } + for _, d := range nestedSlice(pd, "status", "decisions") { + dm, _ := d.(map[string]any) + if n := strVal(dm["clusterName"]); n != "" { + set[n] = struct{}{} + } + } + } + } + } + return setKeys(set) +} + +func isLocalSubscription(subName string, subList []string) bool { + const suffix = "-local" + if !strings.HasSuffix(subName, suffix) { + return false + } + base := subName[:len(subName)-len(suffix)] + for _, s := range subList { + if s == base { + return true + } + } + return false +} + +func (e *Engine) argoCluster(obj map[string]any, clusters []Cluster) string { + if c := nestedString(obj, "status", "cluster"); c != "" { + return c + } + dest := nestedMap(obj, "spec", "destination") + hub := e.hubClusterName() + if strVal(dest["name"]) == "in-cluster" || strVal(dest["name"]) == hub || strVal(dest["server"]) == "https://kubernetes.default.svc" { + return hub + } + return e.argoDestinationCluster(dest, clusters, nestedString(obj, "status", "cluster"), hub) +} + +func (e *Engine) argoDestinationCluster(dest map[string]any, clusters []Cluster, cluster, hubName string) string { + if dest == nil { + return "unknown" + } + serverAPI := strVal(dest["server"]) + if serverAPI != "" { + if serverAPI == "https://kubernetes.default.svc" { + if cluster != "" { + return cluster + } + return hubName + } + if svc := e.clusterProxyService(); svc != nil { + for _, cls := range clusters { + if clusterProxyURL(svc, cls.Name) == serverAPI { + return cls.Name + } + } + } else { + for _, cls := range clusters { + if cls.KubeAPIServer == serverAPI { + return cls.Name + } + } + } + return "unknown" + } + clusterName := strVal(dest["name"]) + if clusterName == "" { + clusterName = "unknown" + } + if cluster != "" && (clusterName == "in-cluster" || clusterName == hubName) { + clusterName = cluster + } + if clusterName == "in-cluster" { + clusterName = hubName + } + return clusterName +} + +func (e *Engine) clusterProxyService() map[string]any { + for _, s := range e.listKind("v1", "Service") { + if metaName(s) == "cluster-proxy-addon-user" && metaNamespace(s) == "multicluster-engine" { + return s + } + } + return nil +} + +func clusterProxyURL(service map[string]any, cluster string) string { + if service == nil || cluster == "" { + return "" + } + port := 9092 + if ports := nestedSlice(service, "spec", "ports"); len(ports) > 0 { + if p, ok := ports[0].(map[string]any); ok { + switch v := p["port"].(type) { + case float64: + port = int(v) + case int: + port = v + } + } + } + return "https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:" + strconv.Itoa(port) + "/" + cluster +} + +func setKeys(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + return out +} diff --git a/backend/internal/aggregate/engine.go b/backend/internal/aggregate/engine.go new file mode 100644 index 00000000000..b552b4e4d7e --- /dev/null +++ b/backend/internal/aggregate/engine.go @@ -0,0 +1,273 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "context" + "os" + "strconv" + "sync" + "time" + + "k8s.io/client-go/dynamic" + + "github.com/stolostron/console/backend/internal/hubresources" + applog "github.com/stolostron/console/backend/internal/log" + "github.com/stolostron/console/backend/internal/searchapi" +) + +// Engine holds the application cache and Search loop (ACM-42600). +type Engine struct { + Lister Lister + Search *searchapi.Client + Dynamic dynamic.Interface + + // PreLimit is PREPROCESS_BREAKPOINT (500). Set 0 in tests to always preprocess. + PreLimit *int + + mu sync.RWMutex + cache map[string]*cacheBucket + appSetAppsMap map[string][]map[string]any + pulledAppSetMap map[string][]map[string]any + tempPulled map[string][]map[string]any + appStatusByName map[string]map[string]AppHealthSync + ocpArgoFilter map[string]struct{} + systemPrefixes []string + lastArgoStatus map[string]StatusMap + argoPageChunks []pageChunk + ocpPageChunks []pageChunk + clusterNameChunks [][]string + lastArgoChunk *pageChunk + lastOCPChunk *pageChunk + lastSystemChunk []string +} + +// AppHealthSync is topology health/sync for one Argo app in an AppSet. +type AppHealthSync struct { + Health struct { + Status string `json:"status"` + } `json:"health"` + Sync struct { + Status string `json:"status"` + } `json:"sync"` +} + +type pageChunk struct { + Keys []string + Limit int +} + +// NewEngine builds an empty aggregator cache. +func NewEngine(lister Lister, search *searchapi.Client, dyn dynamic.Interface) *Engine { + return &Engine{ + Lister: lister, + Search: search, + Dynamic: dyn, + cache: emptyCache(), + appSetAppsMap: map[string][]map[string]any{}, + pulledAppSetMap: map[string][]map[string]any{}, + tempPulled: map[string][]map[string]any{}, + appStatusByName: map[string]map[string]AppHealthSync{}, + ocpArgoFilter: map[string]struct{}{}, + lastArgoStatus: map[string]StatusMap{}, + } +} + +func (e *Engine) searchLimit() int { + if v := os.Getenv("APP_SEARCH_LIMIT"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return appSearchLimitDefault +} + +func (e *Engine) searchInterval() time.Duration { + if v := os.Getenv("APP_SEARCH_INTERVAL"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return time.Duration(n) * time.Second + } + } + return time.Duration(appSearchIntervalDefault) * time.Second +} + +func (e *Engine) preprocessLimit() int { + if e.PreLimit != nil { + return *e.PreLimit + } + return preprocessBreakpoint +} + +// Start discovers system prefixes and runs the Search loop until ctx is done. +func (e *Engine) Start(ctx context.Context) { + e.discoverPrefixes(ctx) + go e.searchLoop(ctx) +} + +func (e *Engine) discoverPrefixes(ctx context.Context) { + prefixes := []string{"openshift", "hive", "open-cluster-management"} + if e.Dynamic != nil { + ns, err := hubresources.MCHNamespace(ctx, e.Dynamic) + if err != nil { + applog.Logger().Error("mch namespace", "error", err) + } else if ns != "" && ns != "open-cluster-management" { + prefixes = append(prefixes, ns) + } + mce, err := hubresources.MCETargetNamespace(ctx, e.Dynamic) + if err != nil || mce == "" { + prefixes = append(prefixes, "multicluster-engine") + } else { + prefixes = append(prefixes, mce) + } + } else { + prefixes = append(prefixes, "multicluster-engine") + } + e.mu.Lock() + e.systemPrefixes = prefixes + e.mu.Unlock() +} + +func (e *Engine) searchLoop(ctx context.Context) { + pass := 1 + searchAPIMissing := false + for { + if ctx.Err() != nil { + return + } + if e.Search != nil { + for { + ok, err := e.Search.Ping(ctx) + if err != nil || !ok { + if !searchAPIMissing { + applog.Logger().Error("search API missing") + searchAPIMissing = true + } + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Minute): + } + continue + } + break + } + if searchAPIMissing { + applog.Logger().Info("search API found") + searchAPIMissing = false + } + if err := e.aggregateRemote(ctx, pass); err != nil { + applog.Logger().Error("aggregateRemoteApplications exception", "error", err) + } + } + e.mu.Lock() + e.rebuildLocalLocked() + e.mu.Unlock() + pass++ + wait := 15 * time.Second + if pass > firstPassesFastInterval { + wait = e.searchInterval() + } + select { + case <-ctx.Done(): + return + case <-time.After(wait): + } + } +} + +func (e *Engine) applications() []App { + e.mu.Lock() + defer e.mu.Unlock() + e.rebuildLocalLocked() + items := getApplicationsHelper(e.cache, cacheKeys) + if items == nil { + return []App{} + } + return items +} + +func (e *Engine) rebuildLocalLocked() { + subs := e.listKind("app.k8s.io/v1beta1", "Application") + e.cache[cacheSubscription].Resources = e.transform(subs, map[string]StatusMap{}, false, nil, nil, nil) + e.cache[cacheSubscription].ResourceUIDMap = nil + e.cache[cacheSubscription].ResourceMap = nil + + clusters := e.clusters() + hub := e.hubClusterName() + var local *Cluster + for i := range clusters { + if clusters[i].Name == hub { + c := clusters[i] + local = &c + break + } + } + e.ocpArgoFilter = map[string]struct{}{} + temp := map[string][]map[string]any{} + argoItems := e.listKind("argoproj.io/v1alpha1", "Application") + filtered := filterArgoApps(argoItems, clusters, e.ocpArgoFilter, temp, hub) + e.appSetAppsMap = temp + uidMap := map[string]App{} + e.transform(filtered, e.lastArgoStatus, false, local, clusters, uidMap) + e.cache[cacheLocalArgo].Resources = nil + e.cache[cacheLocalArgo].ResourceUIDMap = uidMap + e.cache[cacheLocalArgo].ResourceMap = nil + + appsets := e.listKind("argoproj.io/v1alpha1", "ApplicationSet") + asetMap := map[string]App{} + e.transform(appsets, e.lastArgoStatus, false, local, clusters, asetMap) + e.cache[cacheAppSet].Resources = nil + e.cache[cacheAppSet].ResourceUIDMap = asetMap + e.cache[cacheAppSet].ResourceMap = nil +} + +func (e *Engine) aggregateRemote(ctx context.Context, pass int) error { + querySystem := pass < 60 || pass%5 == 0 + q := searchapi.NewQuery() + e.mu.Lock() + e.addArgoQueryInputs(&q) + e.addOCPQueryInputs(&q) + e.mu.Unlock() + if querySystem { + e.mu.Lock() + e.addSystemQueryInputs(&q) + e.mu.Unlock() + } + pushIndex := len(q.Variables.Input) + pushMap, err := e.addPushModelPodQueryInputs(&q) + if err != nil { + applog.Logger().Error("addPushModelPodQueryInputs exception", "error", err) + } + hasPush := len(pushMap) > 0 + resp, err := e.Search.Search(ctx, q) + if err != nil { + return err + } + var buckets []searchapi.ResultBucket + if resp != nil && resp.Data != nil { + buckets = resp.Data.SearchResult + } + var argo, ocp, sys searchapi.ResultBucket + if len(buckets) > 0 { + argo = buckets[0] + } + if len(buckets) > 1 { + ocp = buckets[1] + } + if querySystem && len(buckets) > 2 { + sys = buckets[2] + } + var pushPtr *searchapi.ResultBucket + if hasPush && pushIndex < len(buckets) { + b := buckets[pushIndex] + pushPtr = &b + } + e.mu.Lock() + defer e.mu.Unlock() + filter := e.cacheArgoApplications(argo, pushPtr, pushMap) + e.cacheOCPApplications(ocp, filter, false) + if querySystem { + e.cacheOCPApplications(sys, filter, true) + } + return nil +} diff --git a/backend/internal/aggregate/engine_test.go b/backend/internal/aggregate/engine_test.go new file mode 100644 index 00000000000..9f5d925188e --- /dev/null +++ b/backend/internal/aggregate/engine_test.go @@ -0,0 +1,95 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stolostron/console/backend/internal/searchapi" +) + +func TestAddQueryInputs(t *testing.T) { + e := NewEngine(MapLister{ + "cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()}, + }, nil, nil) + q := searchapi.NewQuery() + e.addArgoQueryInputs(&q) + e.addOCPQueryInputs(&q) + e.addSystemQueryInputs(&q) + if len(q.Variables.Input) != 3 { + t.Fatalf("inputs %d", len(q.Variables.Input)) + } + if q.Variables.Input[0].Filters[0].Values[0] != "Application" { + t.Fatalf("%+v", q.Variables.Input[0]) + } + if q.Variables.Input[1].Filters[0].Values[0] != "Deployment" { + t.Fatalf("%+v", q.Variables.Input[1]) + } +} + +func TestAggregateRemoteCachesArgo(t *testing.T) { + var gotQuery searchapi.Query + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotQuery) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"searchResult":[ + {"items":[{"name":"remote-app","namespace":"argocd","cluster":"remote","healthStatus":"Healthy","syncStatus":"Synced","_uid":"1","destinationNamespace":"ns","destinationName":"in-cluster"}],"related":[]}, + {"items":[],"related":[]} + ]}}`)) + })) + defer ts.Close() + client := &searchapi.Client{HTTP: ts.Client(), SearchAPIURL: ts.URL, Token: "sa"} + e := NewEngine(MapLister{ + "cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()}, + }, client, nil) + if err := e.aggregateRemote(context.Background(), 1); err != nil { + t.Fatal(err) + } + apps := e.applications() + found := false + for _, a := range apps { + if metaName(a.Object) == "remote-app" { + found = true + } + } + if !found { + t.Fatalf("missing remote app in %+v", apps) + } +} + +func TestPushModelQueryFromAppSet(t *testing.T) { + e := NewEngine(MapLister{ + "cluster.open-cluster-management.io/v1|ManagedCluster": { + localCluster(), + uObj("cluster.open-cluster-management.io/v1", "ManagedCluster", "remote", "", nil), + }, + }, nil, nil) + e.appSetAppsMap = map[string][]map[string]any{ + "set-1": {{ + "metadata": map[string]any{"name": "child", "namespace": "argocd"}, + "spec": map[string]any{"destination": map[string]any{"name": "remote", "namespace": "ns"}}, + "status": map[string]any{ + "resources": []any{ + map[string]any{"kind": "Deployment", "name": "web", "namespace": "ns"}, + }, + }, + }}, + } + q := searchapi.NewQuery() + push, err := e.addPushModelPodQueryInputs(&q) + if err != nil { + t.Fatal(err) + } + if len(push) != 1 { + t.Fatalf("push %v", push) + } + if len(q.Variables.Input) != 1 { + t.Fatalf("query %+v", q) + } +} diff --git a/backend/internal/aggregate/fuse.go b/backend/internal/aggregate/fuse.go new file mode 100644 index 00000000000..b977e0da8f5 --- /dev/null +++ b/backend/internal/aggregate/fuse.go @@ -0,0 +1,114 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "strings" + "unicode/utf8" +) + +const fuseThreshold = 0.3 + +// fuseFilter ports Fuse.js 6.6.2 ignoreLocation + threshold 0.3 over name/namespace/clusters. +func fuseFilter(items []App, pattern string) []App { + if pattern == "" { + return items + } + out := make([]App, 0, len(items)) + for _, item := range items { + texts := []string{item.Transform.Name, item.Transform.Namespace} + if len(item.Transform.Clusters) > 0 { + texts = append(texts, item.Transform.Clusters[0]) + } + best := 1.0 + for _, text := range texts { + if s := bitapScore(pattern, text); s < best { + best = s + } + } + if best <= fuseThreshold { + out = append(out, item) + } + } + return out +} + +// bitapScore is Fuse.js Bitap with ignoreLocation (errors / patternLen). +func bitapScore(pattern, text string) float64 { + if pattern == "" { + return 0 + } + p := strings.ToLower(pattern) + t := strings.ToLower(text) + if t == "" { + return 1 + } + if strings.Contains(t, p) { + return 0 + } + plen := utf8.RuneCountInString(p) + if plen == 0 { + return 0 + } + maxErrors := int(float64(plen) * fuseThreshold) + if maxErrors < 0 { + maxErrors = 0 + } + best := 1.0 + pr := []rune(p) + tr := []rune(t) + for start := 0; start < len(tr); start++ { + remain := len(tr) - start + if remain <= 0 { + break + } + window := remain + if window > plen+maxErrors { + window = plen + maxErrors + } + dist := levenshtein(pr, tr[start:start+window]) + score := float64(dist) / float64(plen) + if score < best { + best = score + } + if best == 0 { + return 0 + } + } + return best +} + +func levenshtein(a, b []rune) int { + if len(a) == 0 { + return len(b) + } + if len(b) == 0 { + return len(a) + } + prev := make([]int, len(b)+1) + curr := make([]int, len(b)+1) + for j := 0; j <= len(b); j++ { + prev[j] = j + } + for i := 1; i <= len(a); i++ { + curr[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + del := prev[j] + 1 + ins := curr[j-1] + 1 + sub := prev[j-1] + cost + curr[j] = del + if ins < curr[j] { + curr[j] = ins + } + if sub < curr[j] { + curr[j] = sub + } + } + prev, curr = curr, prev + } + return prev[len(b)] +} diff --git a/backend/internal/aggregate/fuse_test.go b/backend/internal/aggregate/fuse_test.go new file mode 100644 index 00000000000..bec989b3a5e --- /dev/null +++ b/backend/internal/aggregate/fuse_test.go @@ -0,0 +1,32 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import "testing" + +func TestFuseSubstringAndThreshold(t *testing.T) { + items := []App{ + {Transform: Transform{Name: "test-app", Namespace: "default", Clusters: []string{"local-cluster"}}}, + {Transform: Transform{Name: "other", Namespace: "kube-system", Clusters: []string{"remote"}}}, + } + got := fuseFilter(items, "tes") + if len(got) != 1 || got[0].Transform.Name != "test-app" { + t.Fatalf("%+v", got) + } + got = fuseFilter(items, "zzzzzzzz") + if len(got) != 0 { + t.Fatalf("expected no match, got %+v", got) + } +} + +func TestBitapExactAndEmpty(t *testing.T) { + if bitapScore("", "x") != 0 { + t.Fatal("empty pattern") + } + if bitapScore("abc", "") != 1 { + t.Fatal("empty text") + } + if bitapScore("test", "test-app") != 0 { + t.Fatal("substring should be exact") + } +} diff --git a/backend/internal/aggregate/handler.go b/backend/internal/aggregate/handler.go new file mode 100644 index 00000000000..97f1a41bfb1 --- /dev/null +++ b/backend/internal/aggregate/handler.go @@ -0,0 +1,87 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" +) + +// Handler serves POST /aggregate/{applications,statuses,appSetData}. +type Handler struct { + Engine *Engine + REST *rest.Config + Access Access + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + // GetAppSet overrides the user-token ApplicationSet GET (tests). + GetAppSet func(ctx context.Context, token string, stub map[string]any) (map[string]any, error) +} + +// NewHandler wires auth (GET /api) and SSAR. +func NewHandler(engine *Engine, restCfg *rest.Config, access Access) *Handler { + if access == nil { + access = AllowAll{} + } + return &Handler{ + Engine: engine, + REST: restCfg, + Access: access, + Authn: func(w http.ResponseWriter, r *http.Request) (string, bool) { + return auth.AuthenticateRequest(r.Context(), restCfg, w, r) + }, + } +} + +func stripMulticloud(path string) string { + const prefix = "/multicloud" + if path == prefix { + return "/" + } + if strings.HasPrefix(path, prefix+"/") || path == prefix { + return path[len(prefix):] + } + if strings.HasPrefix(path, prefix) { + return path[len(prefix):] + } + return path +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + token, ok := h.Authn(w, r) + if !ok { + return + } + path := strings.Trim(stripMulticloud(r.URL.Path), "/") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[0] != "aggregate" { + w.WriteHeader(http.StatusNotFound) + return + } + switch parts[1] { + case "applications": + h.paginate(w, r, token) + case "statuses": + h.statuses(w, r, token) + case "appSetData": + h.appSetData(w, r, token) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) +} diff --git a/backend/internal/aggregate/handler_test.go b/backend/internal/aggregate/handler_test.go new file mode 100644 index 00000000000..233332f6653 --- /dev/null +++ b/backend/internal/aggregate/handler_test.go @@ -0,0 +1,264 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func testAuthOK(_ http.ResponseWriter, _ *http.Request) (string, bool) { + return "tok", true +} + +func testHandler(t *testing.T, lister Lister) *Handler { + t.Helper() + eng := NewEngine(lister, nil, nil) + zero := 0 + eng.PreLimit = &zero + h := NewHandler(eng, nil, AllowAll{}) + h.Authn = testAuthOK + return h +} + +func postAggregate(t *testing.T, h http.Handler, path string, body any) *http.Response { + t.Helper() + var r io.Reader + if body != nil { + if s, ok := body.(string); ok { + r = bytes.NewBufferString(s) + } else { + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + r = bytes.NewReader(b) + } + } + req := httptest.NewRequest(http.MethodPost, path, r) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Result() +} + +func localCluster() unstructured.Unstructured { + return uObj("cluster.open-cluster-management.io/v1", "ManagedCluster", "local-cluster", "", map[string]any{ + "metadata": map[string]any{ + "name": "local-cluster", + "labels": map[string]any{"local-cluster": "true"}, + }, + }) +} + +func TestUnauthorizedEmptyBody(t *testing.T) { + h := testHandler(t, nil) + h.Authn = func(w http.ResponseWriter, _ *http.Request) (string, bool) { + w.WriteHeader(http.StatusUnauthorized) + return "", false + } + resp := postAggregate(t, h, "/aggregate/applications", RequestListView{Page: 1, PerPage: 10}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status %d", resp.StatusCode) + } + b, _ := io.ReadAll(resp.Body) + if len(b) != 0 { + t.Fatalf("body %q", b) + } +} + +func TestNotFoundEmptyBody(t *testing.T) { + h := testHandler(t, nil) + resp := postAggregate(t, h, "/aggregate/unknown", map[string]any{}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) + } + b, _ := io.ReadAll(resp.Body) + if len(b) != 0 { + t.Fatalf("body %q", b) + } +} + +func TestApplicationsInvalidJSON500(t *testing.T) { + h := testHandler(t, nil) + resp := postAggregate(t, h, "/aggregate/applications", "{") + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestAppSetDataInvalidJSON400(t *testing.T) { + h := testHandler(t, nil) + resp := postAggregate(t, h, "/aggregate/appSetData", "{") + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status %d", resp.StatusCode) + } + var out map[string]string + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out["error"] != "Invalid request body" { + t.Fatalf("%v", out) + } +} + +func TestAppSetDataFetchError400(t *testing.T) { + h := testHandler(t, nil) + h.GetAppSet = func(context.Context, string, map[string]any) (map[string]any, error) { + return nil, errors.New("no") + } + resp := postAggregate(t, h, "/aggregate/appSetData", map[string]any{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "ApplicationSet", + "metadata": map[string]any{"name": "s", "namespace": "ns"}, + }) + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestMulticloudAggregatePath(t *testing.T) { + h := testHandler(t, MapLister{ + "cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()}, + }) + resp := postAggregate(t, h, "/multicloud/aggregate/applications", RequestListView{Page: 1, PerPage: 10}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestApplicationsAndStatuses(t *testing.T) { + lister := MapLister{ + "cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()}, + "app.k8s.io/v1beta1|Application": { + uObj("app.k8s.io/v1beta1", "Application", "test-app", "default", map[string]any{ + "metadata": map[string]any{ + "name": "test-app", + "namespace": "default", + "creationTimestamp": "2024-01-01T00:00:00Z", + "annotations": map[string]any{ + "apps.open-cluster-management.io/subscriptions": "default/sub", + }, + }, + }), + }, + "apps.open-cluster-management.io/v1|Subscription": { + uObj("apps.open-cluster-management.io/v1", "Subscription", "sub", "default", nil), + }, + } + h := testHandler(t, lister) + resp := postAggregate(t, h, "/aggregate/applications", RequestListView{Page: 1, PerPage: 10}) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + var list ResultListView + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatal(err) + } + if list.ProcessedItemCount != 1 || len(list.Items) != 1 { + t.Fatalf("list %+v", list) + } + if !list.IsPreProcessed { + t.Fatal("test breakpoint 0 should preprocess") + } + + resp2 := postAggregate(t, h, "/aggregate/statuses", requestStatuses{}) + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp2.StatusCode) + } + var st resultStatuses + if err := json.NewDecoder(resp2.Body).Decode(&st); err != nil { + t.Fatal(err) + } + if st.ItemCount != "1" { + t.Fatalf("itemCount %q", st.ItemCount) + } + if st.FilterCounts["type"]["subscription"] != 1 { + t.Fatalf("counts %+v", st.FilterCounts) + } + if len(st.FilterCounts["healthStatus"]) != 0 || len(st.FilterCounts["podStatuses"]) != 0 { + t.Fatalf("status counts should stay empty for subscription: %+v", st.FilterCounts) + } +} + +func TestAppSetDataOK(t *testing.T) { + appset := map[string]any{ + "apiVersion": "argoproj.io/v1alpha1", + "kind": "ApplicationSet", + "metadata": map[string]any{"name": "set-1", "namespace": "openshift-gitops"}, + "spec": map[string]any{ + "generators": []any{ + map[string]any{ + "clusterDecisionResource": map[string]any{ + "labelSelector": map[string]any{ + "matchLabels": map[string]any{ + "cluster.open-cluster-management.io/placement": "place-1", + }, + }, + }, + }, + }, + }, + } + lister := MapLister{ + "cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()}, + "cluster.open-cluster-management.io/v1beta1|Placement": { + uObj("cluster.open-cluster-management.io/v1beta1", "Placement", "place-1", "openshift-gitops", nil), + }, + "cluster.open-cluster-management.io/v1beta1|PlacementDecision": { + uObj("cluster.open-cluster-management.io/v1beta1", "PlacementDecision", "place-1-dec", "openshift-gitops", map[string]any{ + "metadata": map[string]any{ + "name": "place-1-dec", + "namespace": "openshift-gitops", + "labels": map[string]any{ + "cluster.open-cluster-management.io/placement": "place-1", + }, + "ownerReferences": []any{ + map[string]any{"kind": "Placement", "name": "place-1"}, + }, + }, + "status": map[string]any{ + "decisions": []any{map[string]any{"clusterName": "remote-1"}}, + }, + }), + }, + } + h := testHandler(t, lister) + h.GetAppSet = func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return appset, nil + } + resp := postAggregate(t, h, "/aggregate/appSetData", appset) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + var out resultAppSetData + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if metaName(out.Appset) != "set-1" { + t.Fatalf("%v", out.Appset) + } + if len(out.ClusterList) == 0 || out.ClusterList[0] != "remote-1" { + t.Fatalf("clusters %v", out.ClusterList) + } + if out.Placement == nil || metaName(out.Placement) != "place-1" { + t.Fatalf("placement %v", out.Placement) + } +} diff --git a/backend/internal/aggregate/helper_test.go b/backend/internal/aggregate/helper_test.go new file mode 100644 index 00000000000..ee604c6b9ce --- /dev/null +++ b/backend/internal/aggregate/helper_test.go @@ -0,0 +1,12 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "encoding/json" + "net/http" +) + +func decodeJSON(resp *http.Response, v any) error { + return json.NewDecoder(resp.Body).Decode(v) +} diff --git a/backend/internal/aggregate/lister.go b/backend/internal/aggregate/lister.go new file mode 100644 index 00000000000..d90dff030ed --- /dev/null +++ b/backend/internal/aggregate/lister.go @@ -0,0 +1,61 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// Lister is InformerCache.ListByKind (or a test fake). +type Lister interface { + ListByKind(apiVersion, kind string) []unstructured.Unstructured +} + +// MapLister is a test double for Lister. +type MapLister map[string][]unstructured.Unstructured + +func (m MapLister) ListByKind(apiVersion, kind string) []unstructured.Unstructured { + if m == nil { + return nil + } + return m[apiVersion+"|"+kind] +} + +func (e *Engine) listKind(apiVersion, kind string) []map[string]any { + if e == nil || e.Lister == nil { + return nil + } + items := e.Lister.ListByKind(apiVersion, kind) + out := make([]map[string]any, 0, len(items)) + for i := range items { + out = append(out, items[i].DeepCopy().Object) + } + return out +} + +func (e *Engine) hubClusterName() string { + for _, obj := range e.listKind("cluster.open-cluster-management.io/v1", "ManagedCluster") { + labels := metaLabels(obj) + if strVal(labels["local-cluster"]) == "true" { + if name := metaName(obj); name != "" { + return name + } + } + } + return "local-cluster" +} + +func uObj(apiVersion, kind, name, namespace string, extra map[string]any) unstructured.Unstructured { + obj := map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + }, + } + for k, v := range extra { + obj[k] = v + } + return unstructured.Unstructured{Object: obj} +} diff --git a/backend/internal/aggregate/ocp.go b/backend/internal/aggregate/ocp.go new file mode 100644 index 00000000000..50c8e7b44a4 --- /dev/null +++ b/backend/internal/aggregate/ocp.go @@ -0,0 +1,245 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "strings" + + "github.com/stolostron/console/backend/internal/searchapi" +) + +func (e *Engine) addOCPQueryInputs(q *searchapi.Query) { + e.lastOCPChunk = e.nextAppPageChunk(&e.ocpPageChunks, cacheRemoteOCP) + chunk := e.lastOCPChunk + filters := []searchapi.Filter{ + {Property: "kind", Values: []string{"Deployment"}}, + {Property: "label", Values: ownerLabelStars()}, + {Property: "namespace", Values: []string{"!openshift*"}}, + {Property: "namespace", Values: []string{"!open-cluster-management*"}}, + } + if chunk != nil && len(chunk.Keys) > 0 { + filters = append(filters, searchapi.Filter{Property: "name", Values: chunk.Keys}) + } + q.Variables.Input = append(q.Variables.Input, searchapi.Input{ + Filters: filters, + RelatedKinds: []string{"Pod", "ReplicaSet", "StatefulSet"}, + Limit: searchQueryLimit, + }) +} + +func ownerLabelStars() []string { + out := make([]string, len(appOwnerLabels)) + for i, l := range appOwnerLabels { + out[i] = l + "*" + } + return out +} + +func (e *Engine) addSystemQueryInputs(q *searchapi.Query) { + e.lastSystemChunk = e.nextClusterNameChunk() + chunk := e.lastSystemChunk + q.Variables.Input = append(q.Variables.Input, searchapi.Input{ + Filters: []searchapi.Filter{ + {Property: "kind", Values: []string{"Deployment"}}, + {Property: "label", Values: ownerLabelStars()}, + {Property: "namespace", Values: []string{"openshift*", "open-cluster-management*"}}, + {Property: "cluster", Values: chunk}, + }, + RelatedKinds: []string{"Pod", "ReplicaSet", "StatefulSet"}, + Limit: searchQueryLimit, + }) +} + +func (e *Engine) nextClusterNameChunk() []string { + if len(e.clusterNameChunks) == 0 { + cm := e.clusterMap() + names := make([]string, 0, len(cm)) + for n := range cm { + names = append(names, n) + } + if len(names) > 0 { + var chunks [][]string + for i, n := range names { + cidx := i / remoteClusterChunks + for len(chunks) <= cidx { + chunks = append(chunks, nil) + } + chunks[cidx] = append(chunks[cidx], n) + } + e.clusterNameChunks = chunks + } else { + e.clusterNameChunks = [][]string{{e.hubClusterName()}} + } + b := e.cache[cacheRemoteSys] + if b.Resources != nil { + b.Resources = nil + b.ResourceMap = map[string][]App{} + } else if len(b.ResourceMap) > 0 { + for name := range b.ResourceMap { + if _, ok := cm[name]; !ok { + delete(b.ResourceMap, name) + } + } + } + } + ch := e.clusterNameChunks[0] + e.clusterNameChunks = e.clusterNameChunks[1:] + return ch +} + +func (e *Engine) cacheOCPApplications(search searchapi.ResultBucket, ocpArgoFilter map[string]struct{}, isSystem bool) { + helm := e.listKind("apps.open-cluster-management.io/v1", "HelmRelease") + hub := e.hubClusterName() + var localApps, remoteApps []map[string]any + var ocpApps []map[string]any + openShiftMap := map[string][]map[string]any{} + for _, ocpApp := range search.Items { + if searchStr(ocpApp, "_hostingSubscription") != "" { + continue + } + labels := parseSearchLabels(searchStr(ocpApp, "label")) + itemLabel, isHelm, argoInstance := ocpLabelValues(labels) + if itemLabel != "" && isHelm { + hosted := false + for _, hr := range helm { + if metaName(hr) == itemLabel && metaNamespace(hr) == searchStr(ocpApp, "namespace") { + if strVal(metaAnnotations(hr)["apps.open-cluster-management.io/hosting-subscription"]) != "" { + hosted = true + } + } + } + if hosted { + continue + } + } + if itemLabel == "" { + continue + } + key := itemLabel + "-" + searchStr(ocpApp, "namespace") + "-" + searchStr(ocpApp, "cluster") + argoKey := argoInstance + "-" + searchStr(ocpApp, "namespace") + "-" + searchStr(ocpApp, "cluster") + if _, skip := ocpArgoFilter[argoKey]; skip { + continue + } + openShiftMap[key] = append(openShiftMap[key], ocpApp) + } + for _, values := range openShiftMap { + value := values[0] + appLabel := getAppNameFromLabel(searchStr(value, "label"), searchStr(value, "name")) + api := searchStr(value, "apiversion") + if g := searchStr(value, "apigroup"); g != "" { + api = g + "/" + api + } + app := map[string]any{ + "apiVersion": api, + "kind": searchStr(value, "kind"), + "label": searchStr(value, "label"), + "metadata": map[string]any{ + "name": appLabel, + "namespace": searchStr(value, "namespace"), + "creationTimestamp": searchStr(value, "created"), + }, + "status": map[string]any{ + "cluster": searchStr(value, "cluster"), + "resourceName": searchStr(value, "name"), + }, + } + if searchStr(value, "cluster") == hub { + localApps = append(localApps, app) + } else { + remoteApps = append(remoteApps, app) + } + value["type"] = getApplicationType(app, e.systemPrefixes) + value["deployments"] = values + ocpApps = append(ocpApps, value) + } + statusMap := createOCPStatusMap(ocpApps, relatedKinds(search.Related)) + if !isSystem { + e.cache[cacheLocalOCP].Resources = e.transform(localApps, statusMap, false, nil, nil, nil) + e.cacheRemoteApps(statusMap, remoteApps, e.lastOCPChunk, cacheRemoteOCP) + return + } + if len(localApps) > 0 { + e.cache[cacheLocalSys].Resources = e.transform(localApps, statusMap, false, nil, nil, nil) + } + e.cacheRemoteSystemApps(statusMap, remoteApps, e.lastSystemChunk) +} + +func parseSearchLabels(label string) [][2]string { + var out [][2]string + clean := strings.ReplaceAll(strings.ReplaceAll(label, " ", ""), "\t", "") + for _, part := range strings.Split(clean, ";") { + ann, val, _ := strings.Cut(part, "=") + out = append(out, [2]string{ann, val}) + } + return out +} + +func ocpLabelValues(labels [][2]string) (itemLabel string, isManagedByHelm bool, argoInstance string) { + for _, p := range labels { + ann, value := p[0], p[1] + switch ann { + case "app": + itemLabel = value + case "app.kubernetes.io/part-of": + if itemLabel == "" { + itemLabel = value + } + } + if ann == "app.kubernetes.io/instance" { + argoInstance = value + } + if ann == "app.kubernetes.io/managed-by" && value == "Helm" { + isManagedByHelm = true + } + } + return itemLabel, isManagedByHelm, argoInstance +} + +func createOCPStatusMap(ocpApps []map[string]any, related []mapKind) map[string]StatusMap { + out := map[string]StatusMap{} + ids := map[string]*statusIDs{} + for _, app := range ocpApps { + appName := searchStr(app, "namespace") + "/" + getAppNameFromLabel(searchStr(app, "label"), searchStr(app, "name")) + appKey := searchStr(app, "type") + "/" + appName + if out[appKey] == nil { + out[appKey] = StatusMap{} + } + cluster := searchStr(app, "cluster") + st, ok := out[appKey][cluster] + if !ok { + st = emptyClusterStatuses() + } + idKey := statusIDKey(appKey, cluster) + id := ids[idKey] + if id == nil { + deps, _ := app["deployments"].([]map[string]any) + if idSlice, ok := app["deployments"].([]any); ok && deps == nil { + for _, d := range idSlice { + if m, ok := d.(map[string]any); ok { + deps = append(deps, m) + } + } + } + id = &statusIDs{appName: appName, deployments: deps} + ids[idKey] = id + } + id.uids = append(id.uids, searchStr(app, "_uid")) + out[appKey][cluster] = st + } + computeDeployedPodStatuses(related, out, ids, true) + return out +} + +func (e *Engine) cacheRemoteSystemApps(statusMap map[string]StatusMap, remote []map[string]any, clusterChunk []string) { + if e.cache[cacheRemoteSys].ResourceMap == nil { + e.cache[cacheRemoteSys].ResourceMap = map[string][]App{} + } + for _, name := range clusterChunk { + e.cache[cacheRemoteSys].ResourceMap[name] = []App{} + } + resources := e.transform(remote, statusMap, true, nil, nil, nil) + for _, resource := range resources { + clustername := joinKeys(resource.Transform.Clusters) + e.cache[cacheRemoteSys].ResourceMap[clustername] = append(e.cache[cacheRemoteSys].ResourceMap[clustername], resource) + } +} diff --git a/backend/internal/aggregate/pages.go b/backend/internal/aggregate/pages.go new file mode 100644 index 00000000000..2e48ca9b0b9 --- /dev/null +++ b/backend/internal/aggregate/pages.go @@ -0,0 +1,127 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import "strings" + +func (e *Engine) nextAppPageChunk(chunks *[]pageChunk, remoteKey string) *pageChunk { + if len(*chunks) == 0 { + b := e.cache[remoteKey] + var applications []App + if b.Resources != nil { + applications = b.Resources + } else if b.ResourceMap != nil { + for _, list := range b.ResourceMap { + applications = append(applications, list...) + } + } + if len(applications) > 0 { + a, z := int('a'), int('0') + sz := 26 + 10 + freq := make([]int, sz) + for _, app := range applications { + name := app.Transform.Name + if name == "" { + continue + } + ltr := int(name[0]) + index := ltr - a + if ltr < a { + index = ltr - z + 26 + } + if index >= 0 && index < sz { + freq[index]++ + } + } + current := pageChunk{Keys: []string{}} + limit := e.searchLimit() + for inx, n := range freq { + ch := byte(inx + a) + if inx >= 26 { + ch = byte(inx + z - 26) + } + current.Keys = append(current.Keys, string([]byte{ch})+"*") + current.Limit += n + next := 0 + if inx+1 < sz { + next = freq[inx+1] + } + if current.Limit+next > limit { + *chunks = append(*chunks, current) + current = pageChunk{Keys: []string{}} + } + } + if current.Limit == 0 && len(*chunks) == 1 { + *chunks = nil + } else if len(*chunks) > 0 { + *chunks = append(*chunks, current) + } + } + if len(*chunks) > 0 { + b.Resources = nil + needMap := b.ResourceMap == nil + if !needMap { + for _, ch := range *chunks { + if _, ok := b.ResourceMap[joinKeys(ch.Keys)]; !ok { + needMap = true + break + } + } + } + if needMap { + b.ResourceMap = map[string][]App{} + for _, ch := range *chunks { + b.ResourceMap[joinKeys(ch.Keys)] = []App{} + } + reverse := map[byte][]App{} + for key, list := range b.ResourceMap { + for _, k := range strings.Split(key, ",") { + if k != "" { + reverse[k[0]] = list + } + } + } + for _, app := range applications { + if app.Transform.Name == "" { + continue + } + ch := app.Transform.Name[0] + reverse[ch] = append(reverse[ch], app) + } + } + } else if b.Resources != nil { + b.Resources = applications + b.ResourceMap = nil + return nil + } + } + if len(*chunks) == 0 { + return nil + } + ch := (*chunks)[0] + *chunks = (*chunks)[1:] + return &ch +} + +func joinKeys(keys []string) string { + if len(keys) == 0 { + return "" + } + out := keys[0] + for i := 1; i < len(keys); i++ { + out += "," + keys[i] + } + return out +} + +func (e *Engine) cacheRemoteApps(statusMap map[string]StatusMap, remote []map[string]any, chunk *pageChunk, remoteKey string) { + resources := e.transform(remote, statusMap, true, nil, nil, nil) + if chunk == nil { + e.cache[remoteKey].Resources = resources + return + } + if e.cache[remoteKey].ResourceMap == nil { + e.cache[remoteKey].ResourceMap = map[string][]App{} + } + e.cache[remoteKey].ResourceMap[joinKeys(chunk.Keys)] = resources +} diff --git a/backend/internal/aggregate/pagination.go b/backend/internal/aggregate/pagination.go new file mode 100644 index 00000000000..69e2ac74d69 --- /dev/null +++ b/backend/internal/aggregate/pagination.go @@ -0,0 +1,101 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "encoding/json" + "net/http" +) + +// FilterSelections is filter id → selected values. +type FilterSelections map[string][]string + +// SortBy matches the frontend table sort payload. +type SortBy struct { + Index *int `json:"index,omitempty"` + Direction string `json:"direction,omitempty"` +} + +// RequestListView is POST /aggregate/applications body. +type RequestListView struct { + Page int `json:"page"` + PerPage int `json:"perPage"` + SortBy *SortBy `json:"sortBy,omitempty"` + Search string `json:"search,omitempty"` + Filters FilterSelections `json:"filters,omitempty"` +} + +// ResultListView is POST /aggregate/applications response. +type ResultListView struct { + Page int `json:"page"` + Items []App `json:"items"` + ProcessedItemCount int `json:"processedItemCount"` + EmptyResult bool `json:"emptyResult"` + IsPreProcessed bool `json:"isPreProcessed"` + Request RequestListView `json:"request"` +} + +func (h *Handler) paginate(w http.ResponseWriter, r *http.Request, token string) { + var req RequestListView + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + items := h.Engine.applications() + itemCount := len(items) + page, perPage := req.Page, req.PerPage + if perPage == -1 { + page = 1 + perPage = itemCount + } + rpage := page + emptyResult := false + isPreProcessed := itemCount == 0 + backendLimit := h.Engine.preprocessLimit() + startIndex, endIndex := 0, itemCount + if itemCount > backendLimit { + isPreProcessed = true + if len(req.Filters) > 0 { + items = filterApplications(req.Filters, items) + } + if req.Search != "" { + items = fuseFilter(items, req.Search) + } + if req.SortBy != nil && req.SortBy.Index != nil && *req.SortBy.Index >= 0 { + items = sortApplications(*req.SortBy.Index, req.SortBy.Direction == "desc", items) + } + if perPage <= 0 { + perPage = itemCount + } + start := 0 + if page > 0 { + start = (page - 1) * perPage + } + if start >= len(items) && perPage > 0 { + rpage = (len(items) + perPage - 1) / perPage + if rpage < 1 { + rpage = 1 + } + } + itemCount = len(items) + emptyResult = itemCount == 0 + startIndex = (rpage - 1) * perPage + if startIndex < 0 { + startIndex = 0 + } + endIndex = rpage * perPage + } + authorized := h.Access.Authorized(r.Context(), token, items, startIndex, endIndex) + authorized = h.Engine.addUIData(authorized) + if authorized == nil { + authorized = []App{} + } + writeJSON(w, ResultListView{ + Page: rpage, + Items: authorized, + ProcessedItemCount: itemCount, + EmptyResult: emptyResult, + IsPreProcessed: isPreProcessed, + Request: req, + }) +} diff --git a/backend/internal/aggregate/pushmodel.go b/backend/internal/aggregate/pushmodel.go new file mode 100644 index 00000000000..5293a2e7e33 --- /dev/null +++ b/backend/internal/aggregate/pushmodel.go @@ -0,0 +1,62 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import "github.com/stolostron/console/backend/internal/searchapi" + +func (e *Engine) addPushModelPodQueryInputs(q *searchapi.Query) (map[string]pushEntry, error) { + resourceMap := map[string]pushEntry{} + hub := e.hubClusterName() + allClusters := e.clusters() + deploymentNames := map[string]struct{}{} + clusterFilters := map[string]struct{}{} + e.mu.RLock() + appSetApps := e.appSetAppsMap + e.mu.RUnlock() + for appSetName, apps := range appSetApps { + e.collectPushModelWorkloads(apps, appSetName, allClusters, hub, resourceMap, deploymentNames, clusterFilters) + } + if len(deploymentNames) == 0 { + return resourceMap, nil + } + q.Variables.Input = append(q.Variables.Input, searchapi.Input{ + Filters: []searchapi.Filter{ + {Property: "kind", Values: []string{"Deployment", "StatefulSet"}}, + {Property: "name", Values: setKeys(deploymentNames)}, + {Property: "cluster", Values: setKeys(clusterFilters)}, + }, + RelatedKinds: []string{"Pod", "ReplicaSet"}, + Limit: searchQueryLimit, + }) + return resourceMap, nil +} + +func (e *Engine) collectPushModelWorkloads(apps []map[string]any, appSetName string, allClusters []Cluster, hub string, resourceMap map[string]pushEntry, deploymentNames, clusterFilters map[string]struct{}) { + for _, app := range apps { + dest := nestedMap(app, "spec", "destination") + target := e.argoDestinationCluster(dest, allClusters, "", hub) + if target == "" || target == hub { + continue + } + resources := nestedSlice(app, "status", "resources") + if len(resources) == 0 { + continue + } + appSetKey := "appset/" + metaNamespace(app) + "/" + appSetName + clusterFilters[target] = struct{}{} + for _, raw := range resources { + res, _ := raw.(map[string]any) + kind := strVal(res["kind"]) + if kind != "Deployment" && kind != "StatefulSet" { + continue + } + ns := strVal(res["namespace"]) + if ns == "" && dest != nil { + ns = strVal(dest["namespace"]) + } + name := strVal(res["name"]) + deploymentNames[name] = struct{}{} + resourceMap[target+"/"+ns+"/"+name] = pushEntry{appSetKey: appSetKey, targetCluster: target} + } + } +} diff --git a/backend/internal/aggregate/rbac.go b/backend/internal/aggregate/rbac.go new file mode 100644 index 00000000000..e7dbf7910d8 --- /dev/null +++ b/backend/internal/aggregate/rbac.go @@ -0,0 +1,246 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "sort" + "sync" + "time" + + authzv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" +) + +const ( + accessCacheTTL = 60 * time.Second + accessCleanupEvery = 90 * time.Second + accessCacheMaxTokens = 1000 +) + +// Access filters aggregated rows with SSAR (and ManagedClusterView create for remote apps). +type Access interface { + Authorized(ctx context.Context, token string, items []App, start, stop int) []App +} + +// AllowAll is for tests. +type AllowAll struct{} + +func (AllowAll) Authorized(_ context.Context, _ string, items []App, start, stop int) []App { + if start < 0 { + start = 0 + } + if stop > len(items) { + stop = len(items) + } + if start > stop { + return nil + } + return items[start:stop] +} + +type ssarKey struct { + kind, namespace, name, verb string +} + +type cacheEntry struct { + allowed bool + expiry time.Time +} + +type tokenState struct { + last time.Time + entries map[ssarKey]cacheEntry +} + +// SSARAccess ports Node getAuthorizedResources / canAccess. +type SSARAccess struct { + newClient func(userToken string) (kubernetes.Interface, error) + mu sync.Mutex + byToken map[string]*tokenState +} + +// NewSSARAccess builds a user-token SSAR client. +func NewSSARAccess(base *rest.Config) *SSARAccess { + return NewSSARAccessWithClient(func(userToken string) (kubernetes.Interface, error) { + return kubernetes.NewForConfig(auth.UserRESTConfig(base, userToken)) + }) +} + +// NewSSARAccessWithClient is for tests. +func NewSSARAccessWithClient(newClient func(userToken string) (kubernetes.Interface, error)) *SSARAccess { + return &SSARAccess{byToken: map[string]*tokenState{}, newClient: newClient} +} + +func hashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +func (a *SSARAccess) Authorized(ctx context.Context, token string, items []App, start, stop int) []App { + authorized := make([]App, 0, stop-start) + inx := 0 + chunkSize := 50 + if stop > 100 { + chunkSize = 100 + } + for inx < len(items) && len(authorized) < stop { + end := inx + chunkSize + if end > len(items) { + end = len(items) + } + for _, item := range items[inx:end] { + ok := false + var err error + if len(item.RemoteClusters) > 0 { + ok, err = a.canAccessRemote(ctx, token, item.RemoteClusters) + } else { + ok, err = a.canList(ctx, token, item.Object) + } + if err == nil && ok { + authorized = append(authorized, item) + } + } + inx += chunkSize + } + if start < 0 { + start = 0 + } + if stop > len(authorized) { + stop = len(authorized) + } + if start > stop { + return nil + } + return authorized[start:stop] +} + +func (a *SSARAccess) canList(ctx context.Context, token string, obj map[string]any) (bool, error) { + ok, err := a.ssar(ctx, token, obj, "list", "", "") + if err != nil || ok { + return ok, err + } + ns := metaNamespace(obj) + if ns == "" { + return false, nil + } + return a.ssar(ctx, token, obj, "list", "", ns) +} + +func (a *SSARAccess) canAccessRemote(ctx context.Context, token string, clusters []string) (bool, error) { + for _, ns := range clusters { + view := map[string]any{ + "kind": "ManagedClusterView", + "apiVersion": "view.open-cluster-management.io/v1beta1", + "metadata": map[string]any{"namespace": ns}, + } + ok, err := a.ssar(ctx, token, view, "create", "", ns) + if err == nil && ok { + return true, nil + } + } + return false, nil +} + +func (a *SSARAccess) ssar(ctx context.Context, token string, obj map[string]any, verb, name, namespace string) (bool, error) { + kind := kindOf(obj) + key := ssarKey{kind: kind, namespace: namespace, name: name, verb: verb} + now := time.Now() + th := hashToken(token) + a.mu.Lock() + if st, ok := a.byToken[th]; ok { + if e, hit := st.entries[key]; hit && e.expiry.After(now) { + st.last = now + allowed := e.allowed + a.mu.Unlock() + return allowed, nil + } + } + a.mu.Unlock() + + client, err := a.newClient(token) + if err != nil { + return false, err + } + review, err := client.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Group: apiGroup(apiVersionOf(obj)), + Resource: resourcePlural(kind), + Verb: verb, + Name: name, + Namespace: namespace, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + return false, err + } + allowed := review.Status.Allowed + a.mu.Lock() + st := a.byToken[th] + if st == nil { + st = &tokenState{entries: map[ssarKey]cacheEntry{}} + a.byToken[th] = st + } + st.last = now + st.entries[key] = cacheEntry{allowed: allowed, expiry: now.Add(accessCacheTTL)} + a.mu.Unlock() + return allowed, nil +} + +// StartCleanup expires SSAR cache entries. +func (a *SSARAccess) StartCleanup(ctx context.Context) { + if a == nil { + return + } + go func() { + tick := time.NewTicker(accessCleanupEvery) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + a.cleanup(time.Now()) + } + } + }() +} + +func (a *SSARAccess) cleanup(now time.Time) { + a.mu.Lock() + defer a.mu.Unlock() + for th, st := range a.byToken { + for k, e := range st.entries { + if !e.expiry.After(now) { + delete(st.entries, k) + } + } + if len(st.entries) == 0 { + delete(a.byToken, th) + } + } + if len(a.byToken) <= accessCacheMaxTokens { + return + } + type pair struct { + hash string + last time.Time + } + all := make([]pair, 0, len(a.byToken)) + for h, st := range a.byToken { + all = append(all, pair{h, st.last}) + } + sort.Slice(all, func(i, j int) bool { return all[i].last.Before(all[j].last) }) + extra := len(all) - accessCacheMaxTokens + for i := 0; i < extra; i++ { + delete(a.byToken, all[i].hash) + } +} diff --git a/backend/internal/aggregate/rbac_test.go b/backend/internal/aggregate/rbac_test.go new file mode 100644 index 00000000000..a6bdda6b2a6 --- /dev/null +++ b/backend/internal/aggregate/rbac_test.go @@ -0,0 +1,79 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "context" + "testing" + + authzv1 "k8s.io/api/authorization/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +func TestAllowAllWindow(t *testing.T) { + items := []App{{Transform: Transform{Name: "a"}}, {Transform: Transform{Name: "b"}}, {Transform: Transform{Name: "c"}}} + got := AllowAll{}.Authorized(context.Background(), "t", items, 1, 3) + if len(got) != 2 || got[0].Transform.Name != "b" { + t.Fatalf("%+v", got) + } +} + +func TestSSARListClusterThenNamespaced(t *testing.T) { + var verbs []string + var namespaces []string + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + review := create.GetObject().(*authzv1.SelfSubjectAccessReview) + attr := review.Spec.ResourceAttributes + verbs = append(verbs, attr.Verb) + namespaces = append(namespaces, attr.Namespace) + allowed := attr.Namespace == "ns" + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: allowed}, + }, nil + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + items := []App{{ + Object: map[string]any{ + "kind": "Application", + "apiVersion": "app.k8s.io/v1beta1", + "metadata": map[string]any{"name": "a", "namespace": "ns"}, + }, + }} + got := a.Authorized(context.Background(), "tok", items, 0, 1) + if len(got) != 1 { + t.Fatalf("authorized %d verbs %v ns %v", len(got), verbs, namespaces) + } + if len(verbs) < 2 || verbs[0] != "list" || verbs[1] != "list" { + t.Fatalf("verbs %v", verbs) + } + if namespaces[0] != "" || namespaces[1] != "ns" { + t.Fatalf("namespaces %v", namespaces) + } +} + +func TestSSARRemoteManagedClusterView(t *testing.T) { + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + create := action.(ktesting.CreateAction) + review := create.GetObject().(*authzv1.SelfSubjectAccessReview) + attr := review.Spec.ResourceAttributes + allowed := attr.Verb == "create" && attr.Resource == "managedclusterviews" + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: allowed}, + }, nil + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return client, nil }) + items := []App{{ + Object: map[string]any{"kind": "Application", "apiVersion": "argoproj.io/v1alpha1"}, + RemoteClusters: []string{"remote-1"}, + }} + got := a.Authorized(context.Background(), "tok", items, 0, 1) + if len(got) != 1 { + t.Fatal("remote app should pass MCV create") + } +} diff --git a/backend/internal/aggregate/status.go b/backend/internal/aggregate/status.go new file mode 100644 index 00000000000..6c0af988a3b --- /dev/null +++ b/backend/internal/aggregate/status.go @@ -0,0 +1,233 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import "strings" + +func extractMessages(entry *StatusEntry, item map[string]any, status string) { + if status != "" { + entry.Messages = append(entry.Messages, map[string]string{"key": "Status", "value": status}) + } + for k, v := range item { + if strings.HasPrefix(k, "_") && (strings.Contains(k, "condition") || strings.Contains(k, "missing")) { + exists := false + for _, msg := range entry.Messages { + if msg["key"] == k { + exists = true + break + } + } + if !exists { + entry.Messages = append(entry.Messages, map[string]string{"key": k, "value": strVal(v)}) + } + } + } +} + +func computeAppHealthStatus(health *StatusEntry, app map[string]any) { + switch searchStr(app, "healthStatus") { + case "Healthy": + health.Counts[scoreHealthy]++ + case "Degraded": + health.Counts[scoreDanger]++ + extractMessages(health, app, searchStr(app, "healthStatus")) + case "Progressing": + health.Counts[scoreProgress]++ + extractMessages(health, app, searchStr(app, "healthStatus")) + case "Unknown": + health.Counts[scoreUnknown]++ + extractMessages(health, app, searchStr(app, "healthStatus")) + default: + health.Counts[scoreWarning]++ + extractMessages(health, app, searchStr(app, "healthStatus")) + } +} + +func computeAppSyncStatus(synced *StatusEntry, app map[string]any) { + switch searchStr(app, "syncStatus") { + case "Synced": + synced.Counts[scoreHealthy]++ + case "OutOfSync": + synced.Counts[scoreWarning]++ + case "Unknown": + synced.Counts[scoreUnknown]++ + extractMessages(synced, app, searchStr(app, "syncStatus")) + default: + synced.Counts[scoreDanger]++ + extractMessages(synced, app, searchStr(app, "syncStatus")) + } +} + +func computePodStatus(deployed *StatusEntry, pods []map[string]any) { + for _, pod := range pods { + status := lower(searchStr(pod, "status")) + if status == "terminating" { + continue + } + if _, ok := resErrorStates[status]; ok { + deployed.Counts[scoreDanger]++ + extractMessages(deployed, pod, status) + } else if _, ok := resWarningStates[status]; ok { + deployed.Counts[scoreWarning]++ + extractMessages(deployed, pod, status) + } else { + deployed.Counts[scoreHealthy]++ + } + } +} + +type relatedMaps struct { + byName map[string][]map[string]any + byUID map[string][]map[string]any +} + +type statusIDs struct { + appName string + deployments []map[string]any + uids []string +} + +func relatedKindItems(related []mapKind, kind string) []map[string]any { + for _, r := range related { + if r.Kind == kind { + return r.Items + } + } + return nil +} + +func createResourceMap(related []mapKind, kind string) relatedMaps { + byName := map[string][]map[string]any{} + byUID := map[string][]map[string]any{} + for _, item := range relatedKindItems(related, kind) { + name := getAppNameFromLabel(searchStr(item, "label"), "") + if name != "" { + key := searchStr(item, "cluster") + "/" + searchStr(item, "namespace") + "/" + name + byName[key] = append(byName[key], item) + } + if uids, ok := item["_relatedUids"].([]any); ok { + for _, u := range uids { + uid := strVal(u) + byUID[uid] = append(byUID[uid], item) + } + } + } + return relatedMaps{byName: byName, byUID: byUID} +} + +func collectRelatedResources(cluster string, m relatedMaps, ids statusIDs) []map[string]any { + var items []map[string]any + if len(ids.deployments) > 1 { + for _, d := range ids.deployments { + key := cluster + "/" + searchStr(d, "namespace") + "/" + searchStr(d, "name") + if found := m.byName[key]; len(found) > 0 { + items = append(items, found...) + } else if found := m.byUID[searchStr(d, "_uid")]; len(found) > 0 { + items = append(items, found...) + } + } + } else { + items = append(items, m.byName[cluster+"/"+ids.appName]...) + } + if len(items) == 0 { + for _, uid := range ids.uids { + items = append(items, m.byUID[uid]...) + } + } + uniq := map[string]map[string]any{} + for _, item := range items { + if uid := searchStr(item, "_uid"); uid != "" { + uniq[uid] = item + } + } + out := make([]map[string]any, 0, len(uniq)) + for _, item := range uniq { + out = append(out, item) + } + return out +} + +type mapKind struct { + Kind string + Items []map[string]any +} + +func statusIDKey(appKey, cluster string) string { + return appKey + "\x00" + cluster +} + +func computeDeployedPodStatuses(related []mapKind, appStatusesMap map[string]StatusMap, ids map[string]*statusIDs, ignoreHealthCheck bool) { + deploymentMap := createResourceMap(related, "Deployment") + replicaSetMap := createResourceMap(related, "ReplicaSet") + podMap := createResourceMap(related, "Pod") + for appKey, clusterMap := range appStatusesMap { + for clusterKey, appStatuses := range clusterMap { + if !(appStatuses.Health.Counts[scoreHealthy] > 0 && appStatuses.Synced.Counts[scoreHealthy] > 0) && !ignoreHealthCheck { + continue + } + id := ids[statusIDKey(appKey, clusterKey)] + if id == nil { + continue + } + podItems := collectRelatedResources(clusterKey, podMap, *id) + replicaItems := collectRelatedResources(clusterKey, replicaSetMap, *id) + deploymentItems := collectRelatedResources(clusterKey, deploymentMap, *id) + computePodStatus(&appStatuses.Deployed, podItems) + currentPodCount := appStatuses.Deployed.Counts[scoreDanger] + appStatuses.Deployed.Counts[scoreWarning] + + appStatuses.Deployed.Counts[scoreHealthy] + appStatuses.Deployed.Counts[scoreProgress] + desiredPodCount := 0 + if len(replicaItems) > 0 { + for _, item := range replicaItems { + desiredPodCount += int(searchFloat(item, "desired")) + } + } + if len(deploymentItems) > 0 { + prod := 1 + for _, item := range deploymentItems { + d := searchFloat(item, "desired") + if d == 0 { + d = 1 + } + prod *= int(d) + } + desiredPodCount *= prod + } + if currentPodCount < desiredPodCount { + missingCount := desiredPodCount - currentPodCount + process := func(items []map[string]any) { + for _, item := range items { + if missingCount <= 0 { + break + } + available := searchFloat(item, "available") + if available == 0 { + available = searchFloat(item, "current") + } + desired := searchFloat(item, "desired") + if available == desired { + continue + } + if available < desired || desired <= 0 { + appStatuses.Deployed.Counts[scoreProgress]++ + extractMessages(&appStatuses.Deployed, item, "") + missingCount-- + } else if item["desired"] == nil || available == 0 { + appStatuses.Deployed.Counts[scoreDanger]++ + extractMessages(&appStatuses.Deployed, item, "") + missingCount-- + } + } + } + process(replicaItems) + process(deploymentItems) + if missingCount > 0 { + appStatuses.Deployed.Counts[scoreWarning] += missingCount + appStatuses.Deployed.Messages = []map[string]string{} + } + } else if currentPodCount == 0 && desiredPodCount == 0 { + appStatuses.Deployed.Counts = make([]int, scoreColumnSize) + } + clusterMap[clusterKey] = appStatuses + } + } +} diff --git a/backend/internal/aggregate/status_test.go b/backend/internal/aggregate/status_test.go new file mode 100644 index 00000000000..e606913e4e6 --- /dev/null +++ b/backend/internal/aggregate/status_test.go @@ -0,0 +1,59 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import "testing" + +func TestComputeAppHealthAndSync(t *testing.T) { + h := emptyStatusEntry() + computeAppHealthStatus(&h, map[string]any{"healthStatus": "Degraded"}) + if h.Counts[scoreDanger] != 1 { + t.Fatalf("health %+v", h.Counts) + } + s := emptyStatusEntry() + computeAppSyncStatus(&s, map[string]any{"syncStatus": "OutOfSync"}) + if s.Counts[scoreWarning] != 1 { + t.Fatalf("sync %+v", s.Counts) + } +} + +func TestComputePodStatusSkipsTerminating(t *testing.T) { + d := emptyStatusEntry() + computePodStatus(&d, []map[string]any{ + {"status": "Running"}, + {"status": "Terminating"}, + {"status": "CrashLoopBackOff"}, + }) + if d.Counts[scoreHealthy] != 1 || d.Counts[scoreDanger] != 1 { + t.Fatalf("%+v", d.Counts) + } +} + +func TestIncStatusCountsArgoOnly(t *testing.T) { + counts := map[string]map[string]int{"healthStatus": {}} + sub := App{Transform: Transform{Type: kindSubscriptionApp, Scores: Scores{colHealth: 0}, Statuses: StatusMap{"c": emptyClusterStatuses()}}} + incStatusCounts(counts, "healthStatus", sub, colHealth) + if len(counts["healthStatus"]) != 0 { + t.Fatal("subscription must not increment health") + } + argo := App{Transform: Transform{Type: kindArgo, Scores: Scores{colHealth: 0}, Statuses: StatusMap{"c": emptyClusterStatuses()}}} + incStatusCounts(counts, "healthStatus", argo, colHealth) + if counts["healthStatus"]["Healthy"] != 1 { + t.Fatalf("%+v", counts) + } +} + +func TestStatusEntryJSONArray(t *testing.T) { + e := emptyStatusEntry() + e.Counts[scoreHealthy] = 2 + raw, err := e.MarshalJSON() + if err != nil { + t.Fatal(err) + } + if string(raw) != `[[2,0,0,0,0],[]]` && string(raw) != `[[2,0,0,0,0],null]` { + // messages is empty slice → [] + if string(raw)[:2] != "[[" { + t.Fatalf("%s", raw) + } + } +} diff --git a/backend/internal/aggregate/transform.go b/backend/internal/aggregate/transform.go new file mode 100644 index 00000000000..65d935c6be7 --- /dev/null +++ b/backend/internal/aggregate/transform.go @@ -0,0 +1,412 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import "strings" + +func getApplicationType(obj map[string]any, prefixes []string) string { + api := apiVersionOf(obj) + kind := kindOf(obj) + if api == "app.k8s.io/v1beta1" && kind == "Application" { + return kindSubscriptionApp + } + if api == "argoproj.io/v1alpha1" { + if kind == "Application" { + return kindArgo + } + if kind == "ApplicationSet" { + return kindAppSet + } + } + if label, ok := obj["label"].(string); ok { + if isFluxApplication(label) { + return kindFlux + } + if isSystemApp(metaNamespace(obj), prefixes) { + return kindOpenShiftDefault + } + return kindOpenShift + } + return "-" +} + +func isFluxApplication(label string) bool { + for _, pair := range fluxAnnotations { + if strings.Contains(label, pair[0]) && strings.Contains(label, pair[1]) { + return true + } + } + return false +} + +func isSystemApp(namespace string, prefixes []string) bool { + if namespace == "" { + return false + } + for _, p := range prefixes { + if strings.HasPrefix(namespace, p) { + return true + } + } + return false +} + +func getAppNamespace(obj map[string]any) string { + ns := metaNamespace(obj) + if apiVersionOf(obj) == "argoproj.io/v1alpha1" && kindOf(obj) == "Application" { + if dest := nestedString(obj, "spec", "destination", "namespace"); dest != "" { + return dest + } + } + return ns +} + +func getAppNameFromLabel(label, defaultName string) string { + matching := "" + for _, p := range appOwnerLabels { + if strings.Contains(label, p) { + matching = p + break + } + } + if matching == "" { + return defaultName + } + start := strings.Index(label, matching) + len(matching) + rest := label[start:] + if i := strings.Index(rest, ";"); i >= 0 { + return rest[:i] + } + return rest +} + +func getTransform(obj map[string]any, typ string, clusterStatusMap map[string]StatusMap, clusters []string) Transform { + statusKey := typ + "/" + metaNamespace(obj) + "/" + metaName(obj) + statuses := getAppStatuses(typ, statusKey, clusterStatusMap, clusters) + return Transform{ + Name: metaName(obj), + Type: typ, + Namespace: getAppNamespace(obj), + Clusters: clusters, + Statuses: statuses, + Scores: getAppStatusScores(clusters, statuses), + Created: metaCreation(obj), + } +} + +func getAppStatuses(typ, statusKey string, clusterStatusMap map[string]StatusMap, clusters []string) StatusMap { + if appStatuses, ok := clusterStatusMap[statusKey]; ok && appStatuses != nil { + return appStatuses + } + if typ == kindAppSet { + if len(clusters) == 0 { + clusters = append(clusters, "-") + } + out := StatusMap{} + for _, cluster := range clusters { + out[cluster] = missingClusterStatuses() + } + return out + } + return StatusMap{} +} + +func getAppStatusScores(clusters []string, statuses StatusMap) Scores { + return Scores{ + colHealth: getAppStatusScore(clusters, statuses, colHealth), + colSynced: getAppStatusScore(clusters, statuses, colSynced), + colDeployed: getAppStatusScore(clusters, statuses, colDeployed), + } +} + +func getAppStatusScore(clusters []string, statuses StatusMap, index int) int { + score := 0 + for _, cluster := range clusters { + stats, ok := statuses[cluster] + if !ok { + continue + } + var column []int + switch index { + case colHealth: + column = stats.Health.Counts + case colSynced: + column = stats.Synced.Counts + case colDeployed: + column = stats.Deployed.Counts + } + if len(column) >= scoreColumnSize { + score = column[scoreDanger]*1000000 + + column[scoreWarning]*100000 + + column[scoreProgress]*10000 + + column[scoreUnknown]*1000 + + column[scoreHealthy] + } + } + return score +} + +func (e *Engine) transform(items []map[string]any, statusMap map[string]StatusMap, isRemote bool, local *Cluster, clusters []Cluster, uidMap map[string]App) []App { + subs := e.listKind("apps.open-cluster-management.io/v1", "Subscription") + placements := e.listKind("cluster.open-cluster-management.io/v1beta1", "PlacementDecision") + hub := e.hubClusterName() + out := make([]App, 0, len(items)) + for _, raw := range items { + app := cloneMap(raw) + typ := getApplicationType(app, e.systemPrefixes) + if typ == kindSubscriptionApp { + if ann := strVal(metaAnnotations(app)["apps.open-cluster-management.io/subscriptions"]); ann != "" { + allLabels := map[string]any{} + for _, ref := range strings.Split(ann, ",") { + parts := strings.Split(strings.TrimSpace(ref), "/") + if len(parts) != 2 { + continue + } + for _, s := range subs { + if metaNamespace(s) == parts[0] && metaName(s) == parts[1] { + for k, v := range metaLabels(s) { + allLabels[k] = v + } + } + } + } + if len(allLabels) > 0 { + meta := metaMap(app) + if meta == nil { + meta = map[string]any{} + app["metadata"] = meta + } + labels := metaLabels(app) + if labels == nil { + labels = map[string]any{} + } + for k, v := range allLabels { + labels[k] = v + } + meta["labels"] = labels + } + } + } + cls := e.applicationClusters(app, typ, subs, placements, local, clusters) + row := App{ + Object: app, + Transform: getTransform(app, typ, statusMap, cls), + } + remote := isRemote + if !remote && typ == kindSubscriptionApp { + for _, n := range cls { + if n != hub { + remote = true + break + } + } + } + if remote { + row.RemoteClusters = cls + } + if uidMap != nil { + uidMap[metaUID(app)] = row + } + out = append(out, row) + } + return out +} + +func getApplicationsHelper(cache map[string]*cacheBucket, keys []string) []App { + var items []App + for _, key := range keys { + b := cache[key] + if b == nil { + continue + } + if b.Resources != nil { + items = append(items, b.Resources...) + continue + } + if b.ResourceUIDMap != nil { + for _, a := range b.ResourceUIDMap { + items = append(items, a) + } + continue + } + if b.ResourceMap != nil { + for _, list := range b.ResourceMap { + items = append(items, list...) + } + } + } + return items +} + +func filterApplications(filters map[string][]string, items []App) []App { + if len(filters) == 0 { + return items + } + out := make([]App, 0, len(items)) + for _, item := range items { + ok := true + for filter, values := range filters { + match := false + switch filter { + case "type": + for _, v := range values { + if v == item.Transform.Type { + match = true + break + } + } + case "cluster": + for _, v := range values { + for _, c := range item.Transform.Clusters { + if c == v { + match = true + break + } + } + } + case "podStatuses": + key := statusFilterKey(item, colDeployed) + for _, v := range values { + if v == key { + match = true + break + } + } + case "healthStatus": + key := statusFilterKey(item, colHealth) + for _, v := range values { + if v == key { + match = true + break + } + } + case "syncStatus": + key := statusFilterKey(item, colSynced) + for _, v := range values { + if v == key { + match = true + break + } + } + default: + match = false + } + if !match { + ok = false + break + } + } + if ok { + out = append(out, item) + } + } + return out +} + +func statusFilterKey(item App, index int) string { + score := 0 + if item.Transform.Scores != nil { + score = item.Transform.Scores[index] + } + switch index { + case colHealth: + if score < 1000 { + return "Healthy" + } + return "Unhealthy" + case colSynced: + if score < 1000 { + return "Synced" + } + return "OutOfSync" + case colDeployed: + if score < 1000 { + return "Deployed" + } + return "Not Deployed" + default: + return "" + } +} + +func sortApplications(index int, desc bool, items []App) []App { + out := append([]App(nil), items...) + stringCols := map[int]struct{}{colName: {}, colNamespace: {}, colClusters: {}, colCreated: {}} + scoreCols := map[int]struct{}{colHealth: {}, colSynced: {}, colDeployed: {}} + less := func(i, j int) bool { return false } + if _, ok := stringCols[index]; ok { + less = func(i, j int) bool { + a := transformString(out[i], index) + b := transformString(out[j], index) + if a == "" || b == "" { + return false + } + return a < b + } + } else if _, ok := scoreCols[index]; ok { + less = func(i, j int) bool { + // Node comparator is bScore - aScore (higher score first). + return out[i].Transform.Scores[index] > out[j].Transform.Scores[index] + } + } + // insertion sort matching a stable-ish order + for i := 1; i < len(out); i++ { + for j := i; j > 0 && less(j, j-1); j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + if desc { + for i, k := 0, len(out)-1; i < k; i, k = i+1, k-1 { + out[i], out[k] = out[k], out[i] + } + } + return out +} + +func transformString(a App, index int) string { + switch index { + case colName: + return a.Transform.Name + case colNamespace: + return a.Transform.Namespace + case colClusters: + if len(a.Transform.Clusters) == 0 { + return "" + } + return a.Transform.Clusters[0] + case colCreated: + return a.Transform.Created + default: + return "" + } +} + +func (e *Engine) addUIData(items []App) []App { + e.mu.RLock() + defer e.mu.RUnlock() + argoAppSets := getApplicationsHelper(e.cache, []string{cacheAppSet}) + out := make([]App, len(items)) + for i, item := range items { + out[i] = item + apps := []string{} + placement := []any{"", []string{}} + if kindOf(item.Object) == "ApplicationSet" { + placement = e.appSetPlacementData(item.Object, argoAppSets) + if list := e.appSetAppsMap[metaName(item.Object)]; list != nil { + for _, app := range list { + apps = append(apps, metaName(app)) + } + } + } + out[i].UIData = &UIData{ + ClusterList: item.Transform.Clusters, + AppClusterStatuses: []StatusMap{item.Transform.Statuses}, + AppSetPlacementData: placement, + AppSetApps: apps, + } + if out[i].Transform.Clusters == nil { + out[i].UIData.ClusterList = []string{} + } + } + return out +} diff --git a/backend/internal/aggregate/transform_test.go b/backend/internal/aggregate/transform_test.go new file mode 100644 index 00000000000..07bb88092f0 --- /dev/null +++ b/backend/internal/aggregate/transform_test.go @@ -0,0 +1,110 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import "testing" + +func TestGetApplicationType(t *testing.T) { + if getApplicationType(map[string]any{"apiVersion": "app.k8s.io/v1beta1", "kind": "Application"}, nil) != kindSubscriptionApp { + t.Fatal("subscription") + } + if getApplicationType(map[string]any{"apiVersion": "argoproj.io/v1alpha1", "kind": "Application"}, nil) != kindArgo { + t.Fatal("argo") + } + if getApplicationType(map[string]any{"apiVersion": "argoproj.io/v1alpha1", "kind": "ApplicationSet"}, nil) != kindAppSet { + t.Fatal("appset") + } + flux := map[string]any{ + "label": "helm.toolkit.fluxcd.io/name=x;helm.toolkit.fluxcd.io/namespace=y", + "metadata": map[string]any{"namespace": "apps"}, + } + if getApplicationType(flux, nil) != kindFlux { + t.Fatal("flux") + } + ocp := map[string]any{ + "label": "app=nginx", + "metadata": map[string]any{"namespace": "openshift-gitops"}, + } + if getApplicationType(ocp, []string{"openshift"}) != kindOpenShiftDefault { + t.Fatal("system ocp") + } +} + +func TestFilterAndSortApplications(t *testing.T) { + items := []App{ + {Transform: Transform{Name: "b", Type: kindArgo, Namespace: "ns", Clusters: []string{"c1"}, Scores: Scores{colHealth: 0}}}, + {Transform: Transform{Name: "a", Type: kindSubscriptionApp, Namespace: "ns", Clusters: []string{"c2"}, Scores: Scores{colHealth: 2000}}}, + } + got := filterApplications(map[string][]string{"type": {kindSubscriptionApp}}, items) + if len(got) != 1 || got[0].Transform.Name != "a" { + t.Fatalf("%+v", got) + } + sorted := sortApplications(colName, false, items) + if sorted[0].Transform.Name != "a" { + t.Fatalf("asc %s", sorted[0].Transform.Name) + } + sorted = sortApplications(colName, true, items) + if sorted[0].Transform.Name != "b" { + t.Fatalf("desc %s", sorted[0].Transform.Name) + } + sorted = sortApplications(colHealth, false, items) + if sorted[0].Transform.Scores[colHealth] != 2000 { + t.Fatal("higher score first") + } +} + +func TestStatusFilterKey(t *testing.T) { + item := App{Transform: Transform{Scores: Scores{colHealth: 0, colSynced: 2000, colDeployed: 0}}} + if statusFilterKey(item, colHealth) != "Healthy" { + t.Fatal("health") + } + if statusFilterKey(item, colSynced) != "OutOfSync" { + t.Fatal("sync") + } + if statusFilterKey(item, colDeployed) != "Deployed" { + t.Fatal("pod") + } +} + +func TestPaginationPerPageAllAndBreakpoint(t *testing.T) { + lister := MapLister{ + "cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()}, + "app.k8s.io/v1beta1|Application": { + uObj("app.k8s.io/v1beta1", "Application", "zeta", "default", nil), + uObj("app.k8s.io/v1beta1", "Application", "alpha", "default", nil), + }, + } + h := testHandler(t, lister) + idx := 0 + resp := postAggregate(t, h, "/aggregate/applications", RequestListView{ + Page: 1, + PerPage: -1, + SortBy: &SortBy{Index: &idx, Direction: "asc"}, + }) + defer resp.Body.Close() + var all ResultListView + if err := decodeJSON(resp, &all); err != nil { + t.Fatal(err) + } + if all.ProcessedItemCount != 2 || len(all.Items) != 2 { + t.Fatalf("%+v", all) + } + + eng := NewEngine(lister, nil, nil) + limit := 500 + eng.PreLimit = &limit + h2 := NewHandler(eng, nil, AllowAll{}) + h2.Authn = testAuthOK + resp2 := postAggregate(t, h2, "/aggregate/applications", RequestListView{Page: 1, PerPage: 10, Search: "zzz"}) + defer resp2.Body.Close() + var small ResultListView + if err := decodeJSON(resp2, &small); err != nil { + t.Fatal(err) + } + if small.IsPreProcessed { + t.Fatal("<=500 should let the frontend filter") + } + if small.ProcessedItemCount != 2 { + t.Fatalf("unfiltered count %d", small.ProcessedItemCount) + } +} diff --git a/backend/internal/aggregate/types.go b/backend/internal/aggregate/types.go new file mode 100644 index 00000000000..8e57da3bbd6 --- /dev/null +++ b/backend/internal/aggregate/types.go @@ -0,0 +1,376 @@ +// Copyright Contributors to the Open Cluster Management project + +// Package aggregate serves POST /aggregate/{applications,statuses,appSetData} (ACM-42600). +package aggregate + +import ( + "encoding/json" + "strings" +) + +const ( + appSearchLimitDefault = 5000 + searchQueryLimit = 20000 + preprocessBreakpoint = 500 + scoreColumnSize = 5 + remoteClusterChunks = 10 + firstPassesFastInterval = 3 + appSearchIntervalDefault = 60 +) + +// AppColumns matches Node aggregators/applications.ts. +const ( + colName = 0 + colType = 1 + colNamespace = 2 + colClusters = 3 + colHealth = 4 + colSynced = 5 + colDeployed = 6 + colCreated = 7 +) + +const ( + scoreHealthy = 0 + scoreProgress = 1 + scoreWarning = 2 + scoreDanger = 3 + scoreUnknown = 4 +) + +const ( + kindSubscriptionApp = "subscription" + kindArgo = "argo" + kindAppSet = "appset" + kindFlux = "flux" + kindOpenShift = "openshift" + kindOpenShiftDefault = "openshift-default" +) + +const ( + cacheSubscription = "subscription" + cacheAppSet = "appset" + cacheLocalArgo = "localArgoApps" + cacheRemoteArgo = "remoteArgoApps" + cacheLocalOCP = "localOCPApps" + cacheRemoteOCP = "remoteOCPApps" + cacheLocalSys = "localSysApps" + cacheRemoteSys = "remoteSysApps" +) + +var cacheKeys = []string{ + cacheSubscription, cacheAppSet, cacheLocalArgo, cacheRemoteArgo, + cacheLocalOCP, cacheRemoteOCP, cacheLocalSys, cacheRemoteSys, +} + +var appOwnerLabels = []string{ + "kustomize.toolkit.fluxcd.io/name=", + "helm.toolkit.fluxcd.io/name=", + "app=", + "app.kubernetes.io/part-of=", +} + +var fluxAnnotations = [][2]string{ + {"helm.toolkit.fluxcd.io/name", "helm.toolkit.fluxcd.io/namespace"}, + {"kustomize.toolkit.fluxcd.io/name", "kustomize.toolkit.fluxcd.io/namespace"}, +} + +var resErrorStates = map[string]struct{}{ + "err": {}, "off": {}, "invalid": {}, "kill": {}, "propagationfailed": {}, + "imagepullbackoff": {}, "crashloopbackoff": {}, "lost": {}, +} + +var resWarningStates = map[string]struct{}{ + "pending": {}, "creating": {}, "terminating": {}, +} + +// StatusEntry is [counts[], messages[]]. +type StatusEntry struct { + Counts []int `json:"-"` + Messages []map[string]string `json:"-"` +} + +func emptyStatusEntry() StatusEntry { + return StatusEntry{Counts: make([]int, scoreColumnSize), Messages: []map[string]string{}} +} + +func missingStatusEntry() StatusEntry { + return StatusEntry{Counts: []int{0, 0, 0, 0, 1}, Messages: []map[string]string{{"key": "Status", "value": "Missing"}}} +} + +func emptyDeployedEntry() StatusEntry { + return StatusEntry{Counts: make([]int, scoreColumnSize), Messages: []map[string]string{}} +} + +func (s StatusEntry) MarshalJSON() ([]byte, error) { + return json.Marshal([]any{s.Counts, s.Messages}) +} + +func (c ClusterStatuses) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]StatusEntry{ + "health": c.Health, + "synced": c.Synced, + "deployed": c.Deployed, + }) +} + +// ClusterStatuses is health/synced/deployed for one cluster. +type ClusterStatuses struct { + Health StatusEntry + Synced StatusEntry + Deployed StatusEntry +} + +func emptyClusterStatuses() ClusterStatuses { + return ClusterStatuses{ + Health: emptyStatusEntry(), + Synced: emptyStatusEntry(), + Deployed: emptyStatusEntry(), + } +} + +func missingClusterStatuses() ClusterStatuses { + return ClusterStatuses{ + Health: missingStatusEntry(), + Synced: missingStatusEntry(), + Deployed: emptyDeployedEntry(), + } +} + +// StatusMap is cluster name → statuses. +type StatusMap map[string]ClusterStatuses + +// Scores is keyed by AppColumns health/synced/deployed. +type Scores map[int]int + +// Transform is the in-memory list/sort/filter projection. +type Transform struct { + Name string + Type string + Namespace string + Clusters []string + Statuses StatusMap + Scores Scores + Created string +} + +// App is a cached application row (no deflate). +type App struct { + Object map[string]any + Transform Transform + RemoteClusters []string + UIData *UIData +} + +// MarshalJSON emits the K8s object plus optional uidata. +func (a App) MarshalJSON() ([]byte, error) { + out := cloneMap(a.Object) + if out == nil { + out = map[string]any{} + } + if a.UIData != nil { + out["uidata"] = a.UIData + } + return json.Marshal(out) +} + +// UIData is attached for the Applications UI then returned in JSON. +type UIData struct { + ClusterList []string `json:"clusterList"` + AppClusterStatuses []StatusMap `json:"appClusterStatuses"` + AppSetPlacementData []any `json:"appSetPlacementData"` + AppSetApps []string `json:"appSetApps"` +} + +func cloneMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +type cacheBucket struct { + Resources []App + ResourceMap map[string][]App + ResourceUIDMap map[string]App +} + +func emptyCache() map[string]*cacheBucket { + out := make(map[string]*cacheBucket, len(cacheKeys)) + for _, k := range cacheKeys { + out[k] = &cacheBucket{Resources: []App{}} + } + return out +} + +func strVal(v any) string { + s, _ := v.(string) + return s +} + +func metaMap(obj map[string]any) map[string]any { + if obj == nil { + return nil + } + m, _ := obj["metadata"].(map[string]any) + return m +} + +func metaName(obj map[string]any) string { + return strVal(metaMap(obj)["name"]) +} + +func metaNamespace(obj map[string]any) string { + return strVal(metaMap(obj)["namespace"]) +} + +func metaUID(obj map[string]any) string { + return strVal(metaMap(obj)["uid"]) +} + +func metaCreation(obj map[string]any) string { + return strVal(metaMap(obj)["creationTimestamp"]) +} + +func metaLabels(obj map[string]any) map[string]any { + m, _ := metaMap(obj)["labels"].(map[string]any) + return m +} + +func metaAnnotations(obj map[string]any) map[string]any { + m, _ := metaMap(obj)["annotations"].(map[string]any) + return m +} + +func kindOf(obj map[string]any) string { + return strVal(obj["kind"]) +} + +func apiVersionOf(obj map[string]any) string { + return strVal(obj["apiVersion"]) +} + +func nestedMap(obj map[string]any, keys ...string) map[string]any { + cur := obj + for _, k := range keys { + if cur == nil { + return nil + } + n, _ := cur[k].(map[string]any) + cur = n + } + return cur +} + +func nestedString(obj map[string]any, keys ...string) string { + if len(keys) == 0 { + return "" + } + cur := obj + for i, k := range keys { + if cur == nil { + return "" + } + if i == len(keys)-1 { + return strVal(cur[k]) + } + n, _ := cur[k].(map[string]any) + cur = n + } + return "" +} + +func nestedSlice(obj map[string]any, keys ...string) []any { + if len(keys) == 0 { + return nil + } + cur := obj + for i, k := range keys { + if cur == nil { + return nil + } + if i == len(keys)-1 { + s, _ := cur[k].([]any) + return s + } + n, _ := cur[k].(map[string]any) + cur = n + } + return nil +} + +func searchStr(item map[string]any, key string) string { + return strVal(item[key]) +} + +func searchFloat(item map[string]any, key string) float64 { + switch v := item[key].(type) { + case float64: + return v + case int: + return float64(v) + case json.Number: + f, _ := v.Float64() + return f + case string: + return 0 + default: + return 0 + } +} + +func lower(s string) string { return strings.ToLower(s) } + +func findObjectWithKey(obj any, key string) map[string]any { + switch t := obj.(type) { + case map[string]any: + if _, ok := t[key]; ok { + return t + } + for _, v := range t { + if found := findObjectWithKey(v, key); found != nil { + return found + } + } + case []any: + for _, v := range t { + if found := findObjectWithKey(v, key); found != nil { + return found + } + } + } + return nil +} + +func placementNameFromSpec(spec map[string]any) string { + if spec == nil { + return "" + } + gen := findObjectWithKey(spec, "clusterDecisionResource") + if gen == nil { + return "" + } + return nestedString(gen, "clusterDecisionResource", "labelSelector", "matchLabels", "cluster.open-cluster-management.io/placement") +} + +func resourcePlural(kind string) string { + k := strings.ToLower(kind) + if k == "" { + return "" + } + if strings.HasSuffix(k, "s") { + return k + } + return k + "s" +} + +func apiGroup(apiVersion string) string { + if i := strings.Index(apiVersion, "/"); i >= 0 { + return apiVersion[:i] + } + return "" +} diff --git a/backend/internal/hubresources/hubresources.go b/backend/internal/hubresources/hubresources.go index 7c95121f638..fcb4b707af7 100644 --- a/backend/internal/hubresources/hubresources.go +++ b/backend/internal/hubresources/hubresources.go @@ -82,3 +82,18 @@ func MCHFineGrainedRBAC(ctx context.Context, client dynamic.Interface) (bool, er } return false, nil } + +// MCHNamespace returns metadata.namespace of the first MulticlusterHub. +func MCHNamespace(ctx context.Context, client dynamic.Interface) (string, error) { + if client == nil { + return "", fmt.Errorf("kubernetes dynamic client is required") + } + list, err := client.Resource(mchGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + return "", err + } + if len(list.Items) == 0 { + return "", nil + } + return list.Items[0].GetNamespace(), nil +} diff --git a/backend/internal/informers/specs.go b/backend/internal/informers/specs.go index d2e87e343fd..db7e1b8ce49 100644 --- a/backend/internal/informers/specs.go +++ b/backend/internal/informers/specs.go @@ -1,8 +1,9 @@ // Copyright Contributors to the Open Cluster Management project // Package informers watches hub resources with client-go (ACM-42597). -// GET /events SSE is served by internal/events/hub (ACM-42598). Node startWatching() -// still runs for aggregators until those routes migrate. +// GET /events SSE is served by internal/events/hub (ACM-42598). +// POST /aggregate/* reads this cache (ACM-42600). Node startWatching() still +// runs so hub.ts can use getKubeResources until ACM-42596 is wired in main.go. package informers import ( diff --git a/backend/internal/searchapi/searchapi.go b/backend/internal/searchapi/searchapi.go new file mode 100644 index 00000000000..a72960ee6a6 --- /dev/null +++ b/backend/internal/searchapi/searchapi.go @@ -0,0 +1,196 @@ +// Copyright Contributors to the Open Cluster Management project + +package searchapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + applog "github.com/stolostron/console/backend/internal/log" +) + +const ( + defaultSearchPort = "4010" + graphqlPath = "/searchapi/graphql" + federatedPath = "/federated" + searchTimeout = 2 * time.Minute + pingTimeout = 4 * time.Minute +) + +// Query is the GraphQL search payload used by application aggregation. +type Query struct { + OperationName string `json:"operationName"` + Variables struct { + Input []Input `json:"input"` + } `json:"variables"` + Query string `json:"query"` +} + +// Input is one Search API input block. +type Input struct { + Filters []Filter `json:"filters"` + RelatedKinds []string `json:"relatedKinds,omitempty"` + Limit int `json:"limit"` +} + +// Filter is a Search API property filter. +type Filter struct { + Property string `json:"property"` + Values []string `json:"values"` +} + +// Related is a related-kind bucket from searchResult. +type Related struct { + Kind string `json:"kind"` + Count int `json:"count,omitempty"` + Items []map[string]any `json:"items,omitempty"` +} + +// ResultBucket is one searchResult entry. +type ResultBucket struct { + Items []map[string]any `json:"items,omitempty"` + Count int `json:"count,omitempty"` + Related []Related `json:"related,omitempty"` +} + +// Response is the GraphQL envelope. +type Response struct { + Data *struct { + SearchResult []ResultBucket `json:"searchResult"` + } `json:"data"` + Message string `json:"message,omitempty"` +} + +const searchQuery = "query searchResult($input: [SearchInput]) {\n searchResult: search(input: $input) {\n items\n related {\n kind\n items\n }}\n}" + +const pingQuery = "query searchResult($input: [SearchInput]) {\n searchResult: search(input: $input) {\n items\n }\n}" + +// NewQuery returns the aggregator searchResult template. +func NewQuery() Query { + q := Query{OperationName: "searchResult", Query: searchQuery} + q.Variables.Input = []Input{} + return q +} + +// Client posts GraphQL search queries with the service-account token. +type Client struct { + HTTP *http.Client + Token string + SearchAPIURL string + Federated func() bool + Namespace string + MCHNamespace func(context.Context) string +} + +// Endpoint is SEARCH_API_URL or the in-cluster search-search-api service. +func (c *Client) Endpoint(ctx context.Context) string { + base := strings.TrimRight(c.SearchAPIURL, "/") + if base == "" { + ns := "" + if c.MCHNamespace != nil { + ns = c.MCHNamespace(ctx) + } + if ns == "" { + ns = c.Namespace + } + if ns == "" { + ns = "open-cluster-management" + } + base = fmt.Sprintf("https://search-search-api.%s.svc.cluster.local:%s", ns, defaultSearchPort) + } + path := graphqlPath + if c.Federated != nil && c.Federated() { + path = federatedPath + } + return base + path +} + +func (c *Client) httpClient(timeout time.Duration) *http.Client { + if c.HTTP != nil { + cp := *c.HTTP + cp.Timeout = timeout + return &cp + } + return &http.Client{Timeout: timeout} +} + +func (c *Client) post(ctx context.Context, timeout time.Duration, body any) (*Response, error) { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoint(ctx), bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient(timeout).Do(req) + if err != nil { + return nil, err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var out Response + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("parse search response: %w", err) + } + return &out, nil +} + +// Search runs the aggregator GraphQL query. +func (c *Client) Search(ctx context.Context, q Query) (*Response, error) { + out, err := c.post(ctx, searchTimeout, q) + if err != nil { + return nil, err + } + if out.Message != "" { + return nil, fmt.Errorf("%s", out.Message) + } + return out, nil +} + +// Ping returns true when search-api answers with data. +func (c *Client) Ping(ctx context.Context) (bool, error) { + body := map[string]any{ + "operationName": "searchResult", + "variables": map[string]any{ + "input": []map[string]any{ + { + "filters": []map[string]any{ + {"property": "kind", "values": []string{"Pod"}}, + {"property": "name", "values": []string{"search-api*"}}, + }, + "limit": 1, + }, + }, + }, + "query": pingQuery, + } + out, err := c.post(ctx, pingTimeout, body) + if err != nil { + return false, err + } + if out.Data == nil { + return false, fmt.Errorf("no data") + } + return true, nil +} + +// LogSearchError logs a search client failure. +func LogSearchError(op string, err error) { + if err == nil { + return + } + applog.Logger().Error(op, "error", err) +} diff --git a/backend/internal/searchapi/searchapi_test.go b/backend/internal/searchapi/searchapi_test.go new file mode 100644 index 00000000000..42c756e29e3 --- /dev/null +++ b/backend/internal/searchapi/searchapi_test.go @@ -0,0 +1,95 @@ +// Copyright Contributors to the Open Cluster Management project + +package searchapi_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stolostron/console/backend/internal/searchapi" +) + +func TestEndpointDefaultAndFederated(t *testing.T) { + c := &searchapi.Client{Namespace: "acm"} + got := c.Endpoint(context.Background()) + want := "https://search-search-api.acm.svc.cluster.local:4010/searchapi/graphql" + if got != want { + t.Fatalf("endpoint %q", got) + } + c.Federated = func() bool { return true } + got = c.Endpoint(context.Background()) + if !contains(got, "/federated") { + t.Fatalf("federated %q", got) + } + c.SearchAPIURL = "https://search.example:4010" + c.Federated = func() bool { return false } + got = c.Endpoint(context.Background()) + if got != "https://search.example:4010/searchapi/graphql" { + t.Fatalf("override %q", got) + } +} + +func TestSearchAndPing(t *testing.T) { + var lastPath string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lastPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + var payload map[string]any + _ = json.Unmarshal(body, &payload) + w.Header().Set("Content-Type", "application/json") + if payload["query"] == searchapi.NewQuery().Query || r.URL.Path != "" { + _, _ = w.Write([]byte(`{"data":{"searchResult":[{"items":[{"name":"a"}],"related":[]}]}}`)) + return + } + _, _ = w.Write([]byte(`{"data":{"searchResult":[]}}`)) + })) + defer ts.Close() + + c := &searchapi.Client{HTTP: ts.Client(), SearchAPIURL: ts.URL, Token: "tok"} + q := searchapi.NewQuery() + q.Variables.Input = append(q.Variables.Input, searchapi.Input{ + Filters: []searchapi.Filter{{Property: "kind", Values: []string{"Application"}}}, + Limit: 10, + }) + resp, err := c.Search(context.Background(), q) + if err != nil { + t.Fatal(err) + } + if resp.Data == nil || len(resp.Data.SearchResult) != 1 { + t.Fatalf("result %+v", resp) + } + ok, err := c.Ping(context.Background()) + if err != nil || !ok { + t.Fatalf("ping %v %v", ok, err) + } + if lastPath == "" { + t.Fatal("no request") + } +} + +func TestSearchMessageError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"message":"boom"}`)) + })) + defer ts.Close() + c := &searchapi.Client{HTTP: ts.Client(), SearchAPIURL: ts.URL} + _, err := c.Search(context.Background(), searchapi.NewQuery()) + if err == nil || err.Error() != "boom" { + t.Fatalf("err %v", err) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || (len(s) > 0 && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + }()))) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 3b8437db953..da42df6a510 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -41,6 +41,7 @@ type handlerOptions struct { user http.Handler clusterInfo http.Handler events http.Handler + aggregate http.Handler debugSnapshot http.Handler } @@ -54,6 +55,13 @@ func WithEvents(h http.Handler) Option { } } +// WithAggregate registers POST /aggregate/* (and /multicloud/aggregate/*). +func WithAggregate(h http.Handler) Option { + return func(o *handlerOptions) { + o.aggregate = h + } +} + // WithRBACEvents registers GET /events/rbac (and /multicloud/events/rbac). func WithRBACEvents(h http.Handler) Option { return func(o *handlerOptions) { @@ -188,6 +196,13 @@ func registerAliasedGet(r chi.Router, h http.Handler, patterns ...string) { } } +func registerAliasedPost(r chi.Router, h http.Handler, patterns ...string) { + for _, pattern := range patterns { + r.Post(pattern, h.ServeHTTP) + r.Post(multicloudPrefix+pattern, h.ServeHTTP) + } +} + func registerStatelessProxies(r chi.Router, o *handlerOptions) { if o.mcProxy != nil { registerAliased(r, o.mcProxy, "/managedclusterproxy/*") @@ -270,6 +285,9 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { r.Get("/events", o.events.ServeHTTP) r.Get(multicloudPrefix+"/events", o.events.ServeHTTP) } + if o.aggregate != nil { + registerAliasedPost(r, o.aggregate, "/aggregate/*") + } if o.k8sProxy != nil { registerK8sProxyRoutes(r, o.k8sProxy) } diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index bd6472413b9..6b9753cf4a6 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -760,3 +760,43 @@ func TestDebugSnapshotNotProxied(t *testing.T) { } } } + +func TestAggregateNotProxied(t *testing.T) { + var sidecarHit bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarHit = true + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + agg := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithAggregate(agg)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/aggregate/applications", "/multicloud/aggregate/statuses", "/aggregate/appSetData"} { + sidecarHit = false + req, _ := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + resp, getErr := ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if sidecarHit { + t.Fatalf("%s was proxied to sidecar", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 14b0ebb830d..b826bbde191 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,7 +41,7 @@ All REST calls use the token passed from the console frontend. Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. -The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). Node `startWatching()` still runs so aggregators can read `resourceCache` (dual-run). Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches and proxy `/events` to the sidecar. The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. +The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). Node `startWatching()` still runs so `hub.ts` can read `resourceCache` until ACM-42596 is wired. Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches and proxy `/events` and `/aggregate` to the sidecar (aggregate then 404s after the Node route cutover). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. DELETED resource events are sent to every SSE client without an access check (bug-compatible with Node). That is a known quirk to fix later. diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md index 5c7dd4145c9..a376a795981 100644 --- a/docs/RESOURCES.md +++ b/docs/RESOURCES.md @@ -1,7 +1,7 @@ # To add a new resource -1. Add a watch to `/backend-node/src/routes/events.ts` for the resource (still required for Node aggregators / `getKubeResources`). -2. Add the same watch to `/backend/internal/informers/specs.go` `DefaultWatchSpecs()` so Go `GET /events` and the informer cache include it. +1. Add a watch to `/backend-node/src/routes/events.ts` for the resource (still required for Node `getKubeResources` / `hub.ts` until ACM-42596). +2. Add the same watch to `/backend/internal/informers/specs.go` `DefaultWatchSpecs()` so Go `GET /events`, `POST /aggregate/*`, and the informer cache include it. 3. Add a resource definition in `/frontend/src/resources`. 4. Add recoil setup for the resource in `/frontend/src/atoms.tsx`. 5. In `frontend` use the resources by From 486236452b0102a8c12fc929666eae0be0e84581 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Fri, 11 Sep 2026 08:49:35 +0200 Subject: [PATCH 11/16] ACM-42601 Migrate search proxy and WebSocket relay to Go (#62) * ACM-42600 Signed-off-by: Enrique Mingorance Cano * ACM-42601 Migrate search proxy and WebSocket relay to Go Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- backend-node/AGENTS.md | 9 +- backend-node/src/app.ts | 2 - backend-node/src/lib/server.ts | 7 - backend-node/src/routes/search.ts | 249 ---------- backend-node/test/routes/search.test.ts | 41 -- .../test/routes/searchWebSocket.test.ts | 430 ------------------ backend/AGENTS.md | 6 +- backend/cmd/console/main.go | 32 +- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/aggregate/appset.go | 6 +- backend/internal/aggregate/argo.go | 39 +- backend/internal/aggregate/clusters.go | 12 +- backend/internal/aggregate/handler.go | 5 + backend/internal/aggregate/handler_test.go | 4 + backend/internal/aggregate/pages.go | 16 +- backend/internal/searchapi/discovery.go | 48 ++ backend/internal/searchapi/searchapi.go | 38 +- backend/internal/searchapi/searchapi_test.go | 15 + backend/internal/searchproxy/inject.go | 35 ++ backend/internal/searchproxy/proxy.go | 154 +++++++ backend/internal/searchproxy/proxy_test.go | 291 ++++++++++++ backend/internal/searchproxy/ws.go | 164 +++++++ backend/internal/server/server.go | 11 + backend/internal/server/server_test.go | 53 +++ docs/ARCHITECTURE.md | 2 +- 26 files changed, 882 insertions(+), 790 deletions(-) delete mode 100644 backend-node/src/routes/search.ts delete mode 100644 backend-node/test/routes/search.test.ts delete mode 100644 backend-node/test/routes/searchWebSocket.test.ts create mode 100644 backend/internal/searchapi/discovery.go create mode 100644 backend/internal/searchproxy/inject.go create mode 100644 backend/internal/searchproxy/proxy.go create mode 100644 backend/internal/searchproxy/proxy_test.go create mode 100644 backend/internal/searchproxy/ws.go diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md index c8ed77a0fe7..ec78b002748 100644 --- a/backend-node/AGENTS.md +++ b/backend-node/AGENTS.md @@ -9,14 +9,14 @@ Node.js ESM proxy server. Sits between the browser and the hub cluster API serve - **Proxy**: `node:https` + `pipeline` for main API proxy - **Logging**: Pino with structured JSON output (use `pino-zen` for dev formatting) - **HTTP Client**: `got` for outbound requests -- **WebSocket**: upgrade handler routes to search (bidirectional relay via `ws` with token injection) +- **WebSocket**: Search graphql-ws is served by the Go listener (`backend/internal/searchproxy`) ## Source Layout | Directory | Purpose | |-----------|---------| | `src/lib/` | Core server: `main.ts` entry, `server.ts`, auth, cookies, CORS, proxy, search, SSE, logging, config | -| `src/routes/` | HTTP route handlers: proxy, search, events, hub, etc. | +| `src/routes/` | HTTP route handlers: events, ansible, ROSA, placement-debug, remaining long-tail | | `src/resources/` | Backend resource watchers and handlers | | `test/` | Jest test files | | `config/` | Runtime configuration lives in `../backend/config` (Go backend) | @@ -40,20 +40,19 @@ Run from the `backend-node/` directory, or use the `npm run *:backend-node` vari The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated. OAuth login, logout, and `/configure` discovery are served by Go. ```text -Browser / plugin → Go :4000 (GET /events and POST /aggregate are native Go when CONSOLE_INFORMER_CACHE is on) +Browser / plugin → Go :4000 (GET /events, POST /aggregate, POST /proxy/search + Search WS) → Node sidecar (this package) → Hub Cluster API Server ↓ Watches resources via service account (hub.ts / dual-run) Enforces RBAC via user token + SubjectAccessReview Sidecar GET /events remains when Go cache is off - POST /proxy/search stays here until ACM-42601 ``` ## Route Handlers - Route handler signature: `(req: Http2ServerRequest, res: Http2ServerResponse): Promise` - Router uses `maxParamLength: 500` for long Kubernetes resource names -- URL rewriting: `/multicloud` prefix is stripped before routing in `app.ts` for HTTP and `server.ts` for WebSocket upgrades (e.g., `/multicloud/proxy/search` → `/proxy/search`) +- URL rewriting: `/multicloud` prefix is stripped before routing in `app.ts` - Use `pipeline()` from `node:stream` for proxy and streaming operations to ensure proper backpressure and cleanup - Use `getEncodeStream()` for SSE compression diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 708572181f3..65a68089b0d 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -14,7 +14,6 @@ import { ansibleTower } from './routes/ansibletower' import { events, startWatching, stopWatching } from './routes/events' import { liveness } from './routes/liveness' import { readiness } from './routes/readiness' -import { search } from './routes/search' import { placementDebug } from './routes/placementDebug' import { upgradeRiskPredictions } from './routes/upgrade-risks-prediction' import { watchTLSSecurityProfile } from './lib/tlsProfileWatch' @@ -49,7 +48,6 @@ if (eventsEnabled) { // This sidecar route remains for dual-run and when the Go cache is disabled. router.get('/events', events) } -router.post('/proxy/search', search) router.post('/placement-debug', placementDebug) router.post('/ansibletower', ansibleTower) router.post('/upgrade-risks-prediction', upgradeRiskPredictions) diff --git a/backend-node/src/lib/server.ts b/backend-node/src/lib/server.ts index fc78b06a7ec..e3b95d361fd 100644 --- a/backend-node/src/lib/server.ts +++ b/backend-node/src/lib/server.ts @@ -7,7 +7,6 @@ import type { Socket } from 'node:net' import type { TLSSocket } from 'node:tls' import { logger } from './logger' import { readFileSync } from 'node:fs' -import { searchWebSocket } from '../routes/search' import { certFile } from './paths' let server: Http2Server | undefined @@ -73,12 +72,6 @@ export function startServer(options: ServerOptions): Promise { - if (req.url.startsWith('/multicloud/proxy/search')) { - req.url = req.url.substring(11) - return searchWebSocket(req, socket, head) - } - }) .on('request', (req: Http2ServerRequest, res: Http2ServerResponse) => { if (isStopping) { res.setHeader(constants.HTTP2_HEADER_CONNECTION, 'close') diff --git a/backend-node/src/routes/search.ts b/backend-node/src/routes/search.ts deleted file mode 100644 index 7b73f04dc2c..00000000000 --- a/backend-node/src/routes/search.ts +++ /dev/null @@ -1,249 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { IncomingMessage } from 'node:http' -import type { Http2ServerRequest, Http2ServerResponse, OutgoingHttpHeaders } from 'node:http2' -import { constants } from 'node:http2' -import https, { request } from 'node:https' -import { pipeline } from 'node:stream' -import type { TLSSocket } from 'node:tls' -import WebSocket, { type RawData, WebSocketServer } from 'ws' -import { logger } from '../lib/logger' -import { notFound } from '../lib/respond' -import { getServiceCACertificate } from '../lib/serviceAccountToken' -import { getAuthenticatedToken } from '../lib/token' -import { getSearchRequestOptions } from '../lib/search' - -const proxyHeaders = [ - constants.HTTP2_HEADER_ACCEPT, - constants.HTTP2_HEADER_ACCEPT_ENCODING, - constants.HTTP2_HEADER_CONTENT_ENCODING, - constants.HTTP2_HEADER_CONTENT_LENGTH, - constants.HTTP2_HEADER_CONTENT_TYPE, -] - -/** Case-insensitive header lookup for HTTP/2 request pseudo-headers and normal headers. */ -function getHeaderValue(headers: Http2ServerRequest['headers'], name: string): string | undefined { - const lower = name.toLowerCase() - for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() !== lower) continue - if (value === undefined) return undefined - return Array.isArray(value) ? value[0] : String(value) - } - return undefined -} - -/** Adds `Authorization` to a graphql-ws `connection_init` message (exported for tests). */ -export function injectSearchWsConnectionInitAuthorization(connectionInitJson: string, bearerToken: string): string { - const bearer = bearerToken.startsWith('Bearer ') ? bearerToken : `Bearer ${bearerToken}` - try { - const msg = JSON.parse(connectionInitJson) as { type?: string; payload?: Record } - if (msg.type !== 'connection_init') return connectionInitJson - const prev = - msg.payload !== null && typeof msg.payload === 'object' && !Array.isArray(msg.payload) ? msg.payload : {} - return JSON.stringify({ - ...msg, - payload: { ...prev, Authorization: bearer }, - }) - } catch { - return connectionInitJson - } -} - -/** `Sec-WebSocket-Protocol` from the client, or default `graphql-transport-ws`. */ -function subprotocolsForUpstream(req: Http2ServerRequest): string | string[] { - const raw = getHeaderValue(req.headers, 'sec-websocket-protocol') - if (!raw) return ['graphql-transport-ws'] - const list = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return list.length ? list : ['graphql-transport-ws'] -} - -/** Decodes a `ws` text frame payload to UTF-8 (handles Buffer / fragment arrays). */ -function rawDataToUtf8(data: RawData): string { - if (typeof data === 'string') return data - if (Buffer.isBuffer(data)) return data.toString('utf8') - if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') - return Buffer.from(data).toString('utf8') -} - -/** Bidirectional relay; rewrites the first `connection_init` from the browser to include the bearer token. */ -function relayClientToUpstream(clientWs: WebSocket, upstreamWs: WebSocket, rawToken: string): void { - let connectionInitHandled = false - - clientWs.on('message', (data, isBinary) => { - if (!connectionInitHandled && !isBinary) { - const text = rawDataToUtf8(data) - try { - const parsed = JSON.parse(text) as { type?: string } - if (parsed.type === 'connection_init') { - connectionInitHandled = true - upstreamWs.send(injectSearchWsConnectionInitAuthorization(text, rawToken)) - return - } - } catch { - // fall through - } - connectionInitHandled = true - } else if (!connectionInitHandled) { - connectionInitHandled = true - } - upstreamWs.send(data, { binary: Boolean(isBinary) }) - }) - - upstreamWs.on('message', (data, isBinary) => { - if (clientWs.readyState === WebSocket.OPEN) { - clientWs.send(data, { binary: Boolean(isBinary) }) - } - }) - - const closeBoth = () => { - try { - clientWs.close() - } catch { - /* ignore */ - } - try { - upstreamWs.close() - } catch { - /* ignore */ - } - } - - clientWs.on('close', closeBoth) - upstreamWs.on('close', () => { - try { - clientWs.close() - } catch { - /* ignore */ - } - }) - clientWs.on('error', (err) => { - logger.error({ msg: 'search websocket relay: client error', err }) - closeBoth() - }) - upstreamWs.on('error', (err) => { - logger.error({ msg: 'search websocket relay: upstream error after open', err }) - closeBoth() - }) -} - -/** Proxies search GraphQL POST to search-api (HTTPS). */ -export async function search(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - const headers: OutgoingHttpHeaders = { authorization: `Bearer ${token}` } - for (const header of proxyHeaders) { - if (req.headers[header]) headers[header] = req.headers[header] - } - const options = await getSearchRequestOptions(headers) - pipeline( - req, - request(options, (response) => { - if (!response) return notFound(req, res) - res.writeHead(response.statusCode, response.headers) - pipeline(response, res as unknown as NodeJS.WritableStream, () => logger.error) - }), - (err) => { - if (err) { - logger.error(err) - } - } - ) - } -} - -/** - * WS relay: opens `wss` to search-api with the session bearer, completes the browser upgrade, injects - * `Authorization` into graphql-ws `connection_init` (search-v2-api expects the token in that payload). - */ -export async function searchWebSocket(req: Http2ServerRequest, socket: TLSSocket, head: Buffer): Promise { - let clientUpgradeCompleted = false - - /** Sends an HTTP error on the TCP socket if the WS upgrade to the browser has not completed yet. */ - const failClientUpgrade = (statusCode: number, statusText: string) => { - if (clientUpgradeCompleted) return - try { - socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\n\r\n`) - } catch { - /* ignore */ - } - socket.destroy() - } - - try { - const token = await getAuthenticatedToken(req, socket) - const headers: OutgoingHttpHeaders = { authorization: `Bearer ${token}` } - const options = await getSearchRequestOptions(headers) - - const upstreamHost = String(options.hostname) - const upstreamPort = options.port ? Number(options.port) : 443 - const upstreamPath = String(options.path ?? '') - const upstreamWsUrl = - upstreamPort === 443 - ? `wss://${upstreamHost}${upstreamPath}` - : `wss://${upstreamHost}:${upstreamPort}${upstreamPath}` - const bearerHeader = `Bearer ${token}` - const hostHeader = upstreamPort === 443 ? upstreamHost : `${upstreamHost}:${upstreamPort}` - - logger.info({ - msg: 'search websocket relay: opening upstream', - clientUrl: req.url, - upstreamWsUrl, - }) - - const httpsAgent = new https.Agent({ - ca: getServiceCACertificate(), - keepAlive: true, - }) - - const upstreamWs = new WebSocket(upstreamWsUrl, subprotocolsForUpstream(req), { - agent: httpsAgent, - headers: { - Authorization: bearerHeader, - Host: hostHeader, - }, - }) - - let upstreamOpen = false - - const connectTimeout = setTimeout(() => { - logger.error({ msg: 'search websocket relay: upstream connect timeout', upstreamWsUrl }) - upstreamWs.terminate() - if (!upstreamOpen) { - failClientUpgrade(504, 'Gateway Timeout') - } - }, 60_000) - - upstreamWs.on('error', (err) => { - clearTimeout(connectTimeout) - if (!upstreamOpen) { - logger.error({ msg: 'search websocket relay: upstream connect failed', err, upstreamWsUrl }) - failClientUpgrade(502, 'Bad Gateway') - } - }) - - upstreamWs.once('open', () => { - upstreamOpen = true - clearTimeout(connectTimeout) - const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false }) - wss.handleUpgrade(req as unknown as IncomingMessage, socket, head, (clientWs) => { - clientUpgradeCompleted = true - relayClientToUpstream(clientWs, upstreamWs, token) - }) - }) - } catch (err: unknown) { - const message = - err instanceof Error ? err.message : typeof err === 'string' ? err : 'search websocket relay: unknown error' - logger.error({ msg: 'search websocket relay: handler error', error: message }) - if (!clientUpgradeCompleted) { - failClientUpgrade(500, 'Internal Server Error') - } else { - try { - socket.destroy() - } catch { - /* ignore */ - } - } - } -} diff --git a/backend-node/test/routes/search.test.ts b/backend-node/test/routes/search.test.ts deleted file mode 100644 index f4fc879f29f..00000000000 --- a/backend-node/test/routes/search.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import nock from 'nock' - -describe(`search Route`, function () { - it(`uses search-api in the namespace of the MultiClusterHub`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL) - .get('/apis/operator.open-cluster-management.io/v1/multiclusterhubs') - .reply(200, { - items: [ - { - metadata: { - namespace: 'ocm', - }, - status: { - currentVersion: '2.5.1', - }, - }, - ], - }) - nock('https://search-search-api.ocm.svc.cluster.local:4010').post('/searchapi/graphql').reply(200) - await request('POST', '/proxy/search') - // TODO - pipeline is not writing response - //expect(res.statusCode).toEqual(200) - }) - it(`uses search-api in namespace of pod if no MultiClusterHub`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200, { - status: 200, - }) - nock(process.env.CLUSTER_API_URL).get('/apis/operator.open-cluster-management.io/v1/multiclusterhubs').reply(200, { - items: [], - }) - nock('https://search-search-api.undefined.svc.cluster.local:4010').post('/searchapi/graphql').reply(200) - await request('POST', '/proxy/search') - // TODO - pipeline is not writing response - //expect(res.statusCode).toEqual(200) - }) -}) diff --git a/backend-node/test/routes/searchWebSocket.test.ts b/backend-node/test/routes/searchWebSocket.test.ts deleted file mode 100644 index d9a0b50873a..00000000000 --- a/backend-node/test/routes/searchWebSocket.test.ts +++ /dev/null @@ -1,430 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals' -import EventEmitter from 'node:events' -import type { Http2ServerRequest } from 'node:http2' -import type { TLSSocket } from 'node:tls' -import { getSearchRequestOptions } from '../../src/lib/search' -import { getAuthenticatedToken } from '../../src/lib/token' -import { injectSearchWsConnectionInitAuthorization, searchWebSocket } from '../../src/routes/search' - -// var avoids temporal dead zone issues when jest.mock factories are hoisted above imports -/* eslint-disable no-var */ -var mockWsInstance: EventEmitter & { readyState: number; send: jest.Mock; close: jest.Mock; terminate: jest.Mock } -var mockWssInstance: EventEmitter & { handleUpgrade: jest.Mock } -/* eslint-enable no-var */ - -jest.mock('ws', () => { - const WsMock = jest.fn().mockImplementation(() => mockWsInstance) - ;(WsMock as unknown as Record).OPEN = 1 - return { - __esModule: true, - default: WsMock, - WebSocketServer: jest.fn().mockImplementation(() => mockWssInstance), - } -}) - -jest.mock('../../src/lib/token', () => ({ - getAuthenticatedToken: jest.fn(), -})) - -jest.mock('../../src/lib/search', () => ({ - getSearchRequestOptions: jest.fn(), -})) - -jest.mock('../../src/lib/serviceAccountToken', () => ({ - getServiceCACertificate: jest.fn(() => undefined), -})) - -jest.mock('../../src/lib/logger', () => ({ - logger: { info: jest.fn(), error: jest.fn() }, -})) - -jest.mock('node:https', () => ({ - Agent: jest.fn(() => ({})), -})) - -const mockGetAuthToken = getAuthenticatedToken as jest.MockedFunction -const mockGetSearchOpts = getSearchRequestOptions as jest.MockedFunction - -const DEFAULT_OPTIONS = { - hostname: 'search-api.ocm.svc.cluster.local', - port: 4010, - path: '/searchapi/graphql', -} - -type MockSocket = TLSSocket & { write: jest.Mock; destroy: jest.Mock } - -function makeMockSocket(): MockSocket { - return { write: jest.fn(), destroy: jest.fn() } as unknown as MockSocket -} - -function makeMockReq(headers: Record = {}) { - return { url: '/proxy/search', headers } as unknown as Http2ServerRequest -} - -function makeMockClientWs() { - const ws = Object.assign(new EventEmitter(), { - readyState: 1, - send: jest.fn(), - close: jest.fn(), - }) - return ws -} - -/** - * Runs through the happy-path setup: resolves auth + search options, awaits - * searchWebSocket, fires the upstream 'open' event (triggering handleUpgrade), - * then calls the upgrade callback with a fresh mock client WebSocket. - */ -async function triggerSuccessfulUpgrade(token = 'mytoken', opts = DEFAULT_OPTIONS) { - const socket = makeMockSocket() - const req = makeMockReq() - const head = Buffer.alloc(0) - - mockGetAuthToken.mockResolvedValue(token) - mockGetSearchOpts.mockResolvedValue(opts) - - await searchWebSocket(req, socket, head) - - // Emit upstream 'open' → handler creates WebSocketServer and calls handleUpgrade - mockWsInstance.emit('open') - - // handleUpgrade was called synchronously inside the 'open' handler; extract its callback - const upgradeCallback = mockWssInstance.handleUpgrade.mock.calls[0][3] as ( - ws: ReturnType - ) => void - const clientWs = makeMockClientWs() - upgradeCallback(clientWs) - - return { socket, req, head, clientWs, upstreamWs: mockWsInstance } -} - -// ───────────────────────────────────────────────────────────────────────────── -// injectSearchWsConnectionInitAuthorization -// ───────────────────────────────────────────────────────────────────────────── - -describe('injectSearchWsConnectionInitAuthorization', () => { - it('adds Authorization to connection_init payload', () => { - const input = JSON.stringify({ type: 'connection_init', payload: { foo: 'bar' } }) - const out = injectSearchWsConnectionInitAuthorization(input, 'mytoken') - const msg = JSON.parse(out) as { type: string; payload: { foo: string; Authorization: string } } - expect(msg.type).toBe('connection_init') - expect(msg.payload.foo).toBe('bar') - expect(msg.payload.Authorization).toBe('Bearer mytoken') - }) - - it('accepts token that already includes Bearer prefix', () => { - const input = JSON.stringify({ type: 'connection_init', payload: {} }) - const out = injectSearchWsConnectionInitAuthorization(input, 'Bearer x') - const msg = JSON.parse(out) as { payload: { Authorization: string } } - expect(msg.payload.Authorization).toBe('Bearer x') - }) - - it('leaves non-connection_init messages unchanged', () => { - const input = JSON.stringify({ type: 'subscribe', id: '1', payload: {} }) - expect(injectSearchWsConnectionInitAuthorization(input, 't')).toBe(input) - }) - - it('handles null payload by defaulting to empty object', () => { - const input = JSON.stringify({ type: 'connection_init', payload: null }) - const out = injectSearchWsConnectionInitAuthorization(input, 'tok') - const msg = JSON.parse(out) as { payload: { Authorization: string } } - expect(msg.payload.Authorization).toBe('Bearer tok') - }) - - it('handles array payload by defaulting to empty object', () => { - const input = JSON.stringify({ type: 'connection_init', payload: [1, 2, 3] }) - const out = injectSearchWsConnectionInitAuthorization(input, 'tok') - const msg = JSON.parse(out) as { payload: { Authorization: string } } - expect(msg.payload.Authorization).toBe('Bearer tok') - }) - - it('handles missing payload by defaulting to empty object', () => { - const input = JSON.stringify({ type: 'connection_init' }) - const out = injectSearchWsConnectionInitAuthorization(input, 'tok') - const msg = JSON.parse(out) as { payload: { Authorization: string } } - expect(msg.payload.Authorization).toBe('Bearer tok') - }) - - it('returns original string on invalid JSON', () => { - const input = 'not-json' - expect(injectSearchWsConnectionInitAuthorization(input, 'tok')).toBe(input) - }) -}) - -// ───────────────────────────────────────────────────────────────────────────── -// searchWebSocket -// ───────────────────────────────────────────────────────────────────────────── - -describe('searchWebSocket', () => { - beforeEach(() => { - jest.clearAllMocks() - - mockWsInstance = Object.assign(new EventEmitter(), { - readyState: 1, - send: jest.fn(), - close: jest.fn(), - terminate: jest.fn(), - }) - - mockWssInstance = Object.assign(new EventEmitter(), { - handleUpgrade: jest.fn(), - }) - }) - - afterEach(() => { - jest.useRealTimers() - }) - - // ── Error handling ────────────────────────────────────────────────────────── - - describe('error handling before upstream connection', () => { - /* eslint-disable @typescript-eslint/unbound-method */ - it('sends HTTP 500 and destroys socket when getAuthenticatedToken rejects', async () => { - const socket = makeMockSocket() - mockGetAuthToken.mockRejectedValue(new Error('auth failure')) - - await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0)) - - expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('500')) - expect(socket.destroy).toHaveBeenCalled() - }) - - it('sends HTTP 500 and destroys socket when getSearchRequestOptions rejects', async () => { - const socket = makeMockSocket() - mockGetAuthToken.mockResolvedValue('tok') - mockGetSearchOpts.mockRejectedValue(new Error('options failure')) - - await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0)) - - expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('500')) - expect(socket.destroy).toHaveBeenCalled() - }) - - it('sends HTTP 502 and destroys socket when upstream WS emits error before open', async () => { - const socket = makeMockSocket() - mockGetAuthToken.mockResolvedValue('tok') - mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS) - - await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0)) - mockWsInstance.emit('error', new Error('connect ECONNREFUSED')) - - expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('502')) - expect(socket.destroy).toHaveBeenCalled() - }) - - it('terminates upstream and sends HTTP 504 when connection times out', async () => { - jest.useFakeTimers() - const socket = makeMockSocket() - mockGetAuthToken.mockResolvedValue('tok') - mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS) - - await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0)) - jest.advanceTimersByTime(60_001) - - expect(mockWsInstance.terminate).toHaveBeenCalled() - expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('504')) - expect(socket.destroy).toHaveBeenCalled() - }) - /* eslint-enable @typescript-eslint/unbound-method */ - }) - - // ── Successful upstream connection ────────────────────────────────────────── - - describe('successful upstream connection', () => { - it('calls handleUpgrade with the original req, socket and head when upstream opens', async () => { - const socket = makeMockSocket() - const head = Buffer.from('head') - const req = makeMockReq() - - mockGetAuthToken.mockResolvedValue('tok') - mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS) - - await searchWebSocket(req, socket, head) - mockWsInstance.emit('open') - - expect(mockWssInstance.handleUpgrade).toHaveBeenCalledWith(req, socket, head, expect.any(Function)) - }) - - it('constructs upstream URL without port when port is 443', async () => { - const socket = makeMockSocket() - mockGetAuthToken.mockResolvedValue('tok') - mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 443, path: '/graphql' }) - - await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0)) - - // If upstream was created without throwing we verify it exists; URL is wss://host/path (no port) - expect(mockWsInstance).toBeDefined() - }) - - it('constructs upstream URL with port when port is not 443', async () => { - const socket = makeMockSocket() - mockGetAuthToken.mockResolvedValue('tok') - mockGetSearchOpts.mockResolvedValue({ hostname: 'search.example.com', port: 4010, path: '/graphql' }) - - await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0)) - - expect(mockWsInstance).toBeDefined() - }) - - it('uses the sec-websocket-protocol header from the browser request for the upstream', async () => { - const socket = makeMockSocket() - mockGetAuthToken.mockResolvedValue('tok') - mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS) - - await searchWebSocket(makeMockReq({ 'sec-websocket-protocol': 'graphql-ws' }), socket, Buffer.alloc(0)) - - // Upstream WS was created; the test verifies the function completes without error - expect(mockWsInstance).toBeDefined() - }) - - it('does not call failClientUpgrade after upgrade is complete even if socket errors', async () => { - const socket = makeMockSocket() - mockGetAuthToken.mockResolvedValue('tok') - mockGetSearchOpts.mockResolvedValue(DEFAULT_OPTIONS) - - await searchWebSocket(makeMockReq(), socket, Buffer.alloc(0)) - mockWsInstance.emit('open') - - // After upgrade completed, an upstream error should not write to the socket - const writeCallsBefore = (socket.write as jest.Mock).mock.calls.length - mockWsInstance.emit('error', new Error('post-open error')) - expect((socket.write as jest.Mock).mock.calls.length).toBe(writeCallsBefore) - }) - }) - - // ── Relay: client → upstream ──────────────────────────────────────────────── - - describe('relay: client → upstream', () => { - it('injects Authorization into connection_init message from client', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade('secret-token') - - const initMsg = JSON.stringify({ type: 'connection_init', payload: { extra: true } }) - clientWs.emit('message', Buffer.from(initMsg), false) - - expect(upstreamWs.send).toHaveBeenCalledWith(expect.stringContaining('"Authorization":"Bearer secret-token"')) - }) - - it('does not forward connection_init message as-is (only injected version is sent)', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade('tok') - - const initMsg = JSON.stringify({ type: 'connection_init', payload: {} }) - clientWs.emit('message', Buffer.from(initMsg), false) - - // send is called exactly once for connection_init (with injected auth) - expect(upstreamWs.send).toHaveBeenCalledTimes(1) - const sentArg = upstreamWs.send.mock.calls[0][0] as string - expect(JSON.parse(sentArg)).toMatchObject({ payload: { Authorization: 'Bearer tok' } }) - }) - - it('forwards subsequent text messages directly after connection_init is handled', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - - const initMsg = JSON.stringify({ type: 'connection_init', payload: {} }) - const subscribeMsg = JSON.stringify({ type: 'subscribe', id: '1', payload: {} }) - - clientWs.emit('message', Buffer.from(initMsg), false) - clientWs.emit('message', Buffer.from(subscribeMsg), false) - - expect(upstreamWs.send).toHaveBeenNthCalledWith(2, Buffer.from(subscribeMsg), { binary: false }) - }) - - it('forwards a non-connection_init text message to upstream (sets connectionInitHandled)', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - - const subscribeMsg = JSON.stringify({ type: 'subscribe', id: '1', payload: {} }) - clientWs.emit('message', Buffer.from(subscribeMsg), false) - - expect(upstreamWs.send).toHaveBeenCalledWith(Buffer.from(subscribeMsg), { binary: false }) - }) - - it('forwards binary frames directly to upstream', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - - const binaryData = Buffer.from([0x01, 0x02, 0x03]) - clientWs.emit('message', binaryData, true) - - expect(upstreamWs.send).toHaveBeenCalledWith(binaryData, { binary: true }) - }) - - it('handles string message data (rawDataToUtf8 string path)', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade('tok') - - const initMsg = JSON.stringify({ type: 'connection_init', payload: {} }) - clientWs.emit('message', initMsg, false) - - expect(upstreamWs.send).toHaveBeenCalledWith(expect.stringContaining('"Authorization":"Bearer tok"')) - }) - - it('handles fragment array message data (rawDataToUtf8 array path)', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade('tok') - - const part1 = Buffer.from('{"type":"connection_init"') - const part2 = Buffer.from(',"payload":{}}') - clientWs.emit('message', [part1, part2], false) - - expect(upstreamWs.send).toHaveBeenCalledWith(expect.stringContaining('"Authorization":"Bearer tok"')) - }) - }) - - // ── Relay: upstream → client ──────────────────────────────────────────────── - - describe('relay: upstream → client', () => { - it('forwards messages from upstream to the client', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - - const responseData = Buffer.from(JSON.stringify({ type: 'next', id: '1', payload: {} })) - upstreamWs.emit('message', responseData, false) - - expect(clientWs.send).toHaveBeenCalledWith(responseData, { binary: false }) - }) - - it('does not forward to client when client readyState is not OPEN', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - clientWs.readyState = 3 // CLOSED - - upstreamWs.emit('message', Buffer.from('data'), false) - - expect(clientWs.send).not.toHaveBeenCalled() - }) - }) - - // ── Relay: connection lifecycle ───────────────────────────────────────────── - - describe('relay: connection lifecycle', () => { - it('closes both sockets when the client closes', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - - clientWs.emit('close') - - expect(clientWs.close).toHaveBeenCalled() - expect(upstreamWs.close).toHaveBeenCalled() - }) - - it('closes the client socket when the upstream closes', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - - upstreamWs.emit('close') - - expect(clientWs.close).toHaveBeenCalled() - }) - - it('closes both sockets on client-side error', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - - clientWs.emit('error', new Error('client error')) - - expect(clientWs.close).toHaveBeenCalled() - expect(upstreamWs.close).toHaveBeenCalled() - }) - - it('closes both sockets on upstream error after the connection is open', async () => { - const { clientWs, upstreamWs } = await triggerSuccessfulUpgrade() - - upstreamWs.emit('error', new Error('upstream post-open error')) - - expect(clientWs.close).toHaveBeenCalled() - expect(upstreamWs.close).toHaveBeenCalled() - }) - }) -}) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index c81e1a9b8ea..44ba26d5ed9 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -34,6 +34,7 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/events/hub` | `GET /events` SSE: informer fan-out, snapshot packets, per-user SSAR (60s TTL). DELETED is not RBAC-filtered (bug-compatible with Node). `CONSOLE_INFORMER_CACHE=0` proxies `/events` to Node | | `internal/aggregate` | `POST /aggregate/{applications,statuses,appSetData}`: informer cache + Search SA GraphQL, Fuse.js-compatible filter, windowed SSAR. `CONSOLE_INFORMER_CACHE=0` does not register the route | | `internal/searchapi` | Search GraphQL client used by the aggregator (`/searchapi/graphql` or `/federated`) | +| `internal/searchproxy` | `POST /proxy/search` and graphql-ws relay to search-api with the **user** token (`connection_init` Authorization injection) | | `internal/informers` | Hub resource cache (~67 watch specs, dual-run with Node). Dev: `GET /debug/informer-snapshot` | | `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | @@ -65,6 +66,7 @@ Go backend :4000 (TLS / HTTP/2) ├─ GET /events (resource watch SSE + per-user SSAR; also /multicloud/events) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) ├─ POST /aggregate/{applications,statuses,appSetData} (application inventory; also /multicloud/…) + ├─ POST /proxy/search and WebSocket graphql-ws (user token; also /multicloud/proxy/search) ├─ GET /debug/informer-snapshot (dev only; Go informer cache dump) ├─ SA informers (~67 specs) feed GET /events and POST /aggregate; Node startWatching() still runs for hub.ts ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) @@ -89,7 +91,9 @@ Go backend :4000 (TLS / HTTP/2) During ACM-42597/42598 the Go process watches the same specs as Node `startWatching()` **after** the public listener is bound. Startup is capped at 8 concurrent list/watch setups; the informer client uses QPS 20 / Burst 40; resync is disabled. Set `CONSOLE_INFORMER_CACHE=0` (or `false`/`off`) to skip Go watches and keep proxying `GET /events` to Node (and not register `POST /aggregate`). Node `startWatching()` still runs for `hub.ts` (`getKubeResources`). After informers sync, logs `informer cache memory` with `heapAlloc` — compare that to the sidecar deflate cache, not combined RSS. -`POST /aggregate/*` rebuilds ACM/Argo Application rows from `InformerCache.ListByKind` and refreshes remote OCP/Flux/Argo status from Search (15s for the first three passes, then `APP_SEARCH_INTERVAL` or 60s). Pagination uses Fuse.js 6.6.2 options (`ignoreLocation`, threshold 0.3) when there are more than 500 items; `itemCount` in `/aggregate/statuses` is a JSON string. `POST /proxy/search` stays on Node until ACM-42601. +`POST /aggregate/*` rebuilds ACM/Argo Application rows from `InformerCache.ListByKind` and refreshes remote OCP/Flux/Argo status from Search (15s for the first three passes, then `APP_SEARCH_INTERVAL` or 60s). Pagination uses Fuse.js 6.6.2 options (`ignoreLocation`, threshold 0.3) when there are more than 500 items; `itemCount` in `/aggregate/statuses` is a JSON string. + +`POST /proxy/search` and the Search WebSocket are served by Go (`backend/internal/searchproxy`). Auth is GET `/api`. GraphQL POST injects the user Bearer token and forwards the Node header allowlist (`accept`, `accept-encoding`, `content-encoding`, `content-length`, `content-type`). The graphql-ws relay opens `wss` to the same Search URL, sends `Authorization` on the upgrade, and rewrites the first `connection_init` payload with `Authorization: Bearer `. Upstream connect timeout 60s → 504; connect failure → 502. Discovery matches the aggregator: `SEARCH_API_URL` or `search-search-api..svc.cluster.local:4010` plus `/searchapi/graphql` (or `/federated` when `globalSearchFeatureFlag=enabled`). `GET /events` framing matches Node `server-side-events.ts`: `id:` + `data:` (no space), gzip when `Accept-Encoding` includes gzip, keepalive `:\n\n` every 10s, snapshot `START` → `SETTINGS` → priority packets with `EOP` → `LOADED`, live `MODIFIED`/`DELETED` then `LOADED`. Creates and updates are both `MODIFIED` (not `ADDED`). **DELETED events are broadcast without per-user SSAR** — the same known gap as Node; do not “fix” it in this stream without a follow-up. diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index 10af794f4ca..c3e10451f31 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -32,6 +32,7 @@ import ( "github.com/stolostron/console/backend/internal/metricsproxy" "github.com/stolostron/console/backend/internal/oauth" "github.com/stolostron/console/backend/internal/searchapi" + "github.com/stolostron/console/backend/internal/searchproxy" "github.com/stolostron/console/backend/internal/server" "github.com/stolostron/console/backend/internal/static" "github.com/stolostron/console/backend/internal/user" @@ -125,6 +126,18 @@ func run() error { }) var opts []server.Option opts = append(opts, server.WithRBACEvents(rbacHandler), server.WithOAuth(oauthH)) + searchDiscovery := searchapi.Discovery{ + SearchAPIURL: os.Getenv("SEARCH_API_URL"), + Federated: func() bool { return os.Getenv("globalSearchFeatureFlag") == "enabled" }, + Namespace: serviceAccountNamespace(), + MCHNamespace: func(reqCtx context.Context) string { + ns, nsErr := hubresources.MCHNamespace(reqCtx, dyn) + if nsErr != nil { + return "" + } + return ns + }, + } var aggEng *aggregate.Engine if cfg.InformerCache { opts = append(opts, server.WithEvents(eventsHandler)) @@ -135,16 +148,10 @@ func run() error { searchClient := &searchapi.Client{ HTTP: auth.HTTPClient(ca, 0), Token: sa.Token, - SearchAPIURL: os.Getenv("SEARCH_API_URL"), - Federated: func() bool { return os.Getenv("globalSearchFeatureFlag") == "enabled" }, - Namespace: serviceAccountNamespace(), - MCHNamespace: func(reqCtx context.Context) string { - ns, nsErr := hubresources.MCHNamespace(reqCtx, dyn) - if nsErr != nil { - return "" - } - return ns - }, + SearchAPIURL: searchDiscovery.SearchAPIURL, + Federated: searchDiscovery.Federated, + Namespace: searchDiscovery.Namespace, + MCHNamespace: searchDiscovery.MCHNamespace, } aggEng = aggregate.NewEngine(infCache, searchClient, dyn) aggAccess := aggregate.NewSSARAccess(restCfg) @@ -207,6 +214,11 @@ func run() error { Dynamic: dyn, Discovery: disc, })), + server.WithSearchProxy(searchproxy.New(searchproxy.Options{ + RESTConfig: restCfg, + TLSConfig: serviceTLS, + Endpoint: searchDiscovery.Endpoint, + })), ) handler, err := server.Handler(cfg, opts...) diff --git a/backend/go.mod b/backend/go.mod index 13ed34f4073..5d4f4d4f481 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/fsnotify/fsnotify v1.8.0 github.com/go-chi/chi/v5 v5.2.1 + github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 golang.org/x/oauth2 v0.23.0 k8s.io/api v0.32.3 diff --git a/backend/go.sum b/backend/go.sum index a2ee2bbbf30..3196b3fcc2d 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -39,6 +39,8 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= diff --git a/backend/internal/aggregate/appset.go b/backend/internal/aggregate/appset.go index adcd4c3e041..7fdff40cf51 100644 --- a/backend/internal/aggregate/appset.go +++ b/backend/internal/aggregate/appset.go @@ -120,14 +120,12 @@ func incStatusCounts(m map[string]map[string]int, id string, item App, index int func (h *Handler) appSetData(w http.ResponseWriter, r *http.Request, token string) { var stub map[string]any if err := json.NewDecoder(r.Body).Decode(&stub); err != nil { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "Invalid request body"}) + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) return } appset, err := h.fetchAppSet(r.Context(), token, stub) if err != nil { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "Failed to fetch resource"}) + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "Failed to fetch resource"}) return } h.Engine.mu.RLock() diff --git a/backend/internal/aggregate/argo.go b/backend/internal/aggregate/argo.go index 671fa4b91d3..38c15079136 100644 --- a/backend/internal/aggregate/argo.go +++ b/backend/internal/aggregate/argo.go @@ -162,7 +162,7 @@ func (e *Engine) remoteArgoApps(remote []map[string]any) []map[string]any { e.ocpArgoFilter[searchStr(argoApp, "name")+"-"+searchStr(argoApp, "destinationNamespace")+"-"+searchStr(argoApp, "cluster")] = struct{}{} hosting := searchStr(argoApp, "_hostingResource") if hosting != "" { - parts := strings.Split(hosting, "/") + parts := splitSlash(hosting) if len(parts) >= 3 && parts[0] == "ApplicationSet" { appSetName := parts[2] pulled := e.tempPulled[appSetName] @@ -212,6 +212,18 @@ func (e *Engine) remoteArgoApps(remote []map[string]any) []map[string]any { return apps } +func splitSlash(s string) []string { + var out []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == '/' { + out = append(out, s[start:i]) + start = i + 1 + } + } + return out +} + func (e *Engine) appSetPlacementData(appSet map[string]any, applicationSets []App) []any { current := placementFromAppSet(appSet) if current == "" { @@ -266,14 +278,14 @@ func (e *Engine) createArgoStatusMap(search searchapi.ResultBucket, clusters []C appCluster := searchStr(app, "cluster") appNamespace = searchStr(app, "namespace") if hosting := searchStr(app, "_hostingResource"); hosting != "" { - parts := strings.Split(hosting, "/") + parts := splitSlash(hosting) if len(parts) >= 3 { appNamespace, appSetName = parts[1], parts[2] appName = appNamespace + "/" + appSetName appKey = "appset/" + appName } } else if aset := searchStr(app, "applicationSet"); aset != "" { - if !strings.Contains(searchStr(app, "label"), "apps.open-cluster-management.io/pull-to-ocm-managed-cluster=true") { + if !containsLabel(searchStr(app, "label"), "apps.open-cluster-management.io/pull-to-ocm-managed-cluster=true") { appName = searchStr(app, "namespace") + "/" + aset appKey = "appset/" + appName namePart := searchStr(app, "name") @@ -329,6 +341,10 @@ func (e *Engine) createArgoStatusMap(search searchapi.ResultBucket, clusters []C return out } +func containsLabel(label, needle string) bool { + return strings.Contains(label, needle) +} + type pushEntry struct { appSetKey string targetCluster string @@ -390,13 +406,22 @@ func mergePushModelPodStatuses(search searchapi.ResultBucket, pushMap map[string statusPtr[entryKey] = matched.appSetKey + "\x00" + matched.targetCluster } for entryKey, plist := range buckets { - appSetKey, targetCluster, ok := strings.Cut(statusPtr[entryKey], "\x00") - if !ok { + parts := splitOnce(statusPtr[entryKey], "\x00") + if len(parts) != 2 { continue } - st := argo[appSetKey][targetCluster] + st := argo[parts[0]][parts[1]] computePodStatus(&st.Deployed, plist) - argo[appSetKey][targetCluster] = st + argo[parts[0]][parts[1]] = st _ = entryKey } } + +func splitOnce(s, sep string) []string { + for i := 0; i+len(sep) <= len(s); i++ { + if s[i:i+len(sep)] == sep { + return []string{s[:i], s[i+len(sep):]} + } + } + return []string{s} +} diff --git a/backend/internal/aggregate/clusters.go b/backend/internal/aggregate/clusters.go index 9d9dfa8683c..4763f5bc799 100644 --- a/backend/internal/aggregate/clusters.go +++ b/backend/internal/aggregate/clusters.go @@ -192,18 +192,20 @@ func (e *Engine) argoPushModelClusters(resources []map[string]any, local *Cluste localName = local.Name } for _, resource := range resources { - clusterHint := nestedString(resource, "status", "cluster") - isRemote := clusterHint != "" - + isRemote := nestedString(resource, "status", "cluster") != "" dest := nestedMap(resource, "spec", "destination") destName := strVal(dest["name"]) destServer := strVal(dest["server"]) - if (destName == "in-cluster" || destName == localName || isLocalClusterURL(destServer, local)) && !isRemote { set[localName] = struct{}{} continue } - set[e.argoDestinationCluster(dest, managed, clusterHint, localName)] = struct{}{} + clusterHint := nestedString(resource, "status", "cluster") + if isRemote { + set[e.argoDestinationCluster(dest, managed, clusterHint, localName)] = struct{}{} + continue + } + set[e.argoDestinationCluster(dest, managed, "", localName)] = struct{}{} } return setKeys(set) } diff --git a/backend/internal/aggregate/handler.go b/backend/internal/aggregate/handler.go index 97f1a41bfb1..4d2df45962d 100644 --- a/backend/internal/aggregate/handler.go +++ b/backend/internal/aggregate/handler.go @@ -80,7 +80,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func writeJSON(w http.ResponseWriter, v any) { + writeJSONStatus(w, http.StatusOK, v) +} + +func writeJSONStatus(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) enc := json.NewEncoder(w) enc.SetEscapeHTML(false) _ = enc.Encode(v) diff --git a/backend/internal/aggregate/handler_test.go b/backend/internal/aggregate/handler_test.go index 233332f6653..9cb85f3c938 100644 --- a/backend/internal/aggregate/handler_test.go +++ b/backend/internal/aggregate/handler_test.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -105,6 +106,9 @@ func TestAppSetDataInvalidJSON400(t *testing.T) { if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status %d", resp.StatusCode) } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "json") { + t.Fatalf("content-type %q", ct) + } var out map[string]string if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatal(err) diff --git a/backend/internal/aggregate/pages.go b/backend/internal/aggregate/pages.go index 2e48ca9b0b9..ceb1231735f 100644 --- a/backend/internal/aggregate/pages.go +++ b/backend/internal/aggregate/pages.go @@ -2,8 +2,6 @@ package aggregate -import "strings" - func (e *Engine) nextAppPageChunk(chunks *[]pageChunk, remoteKey string) *pageChunk { if len(*chunks) == 0 { b := e.cache[remoteKey] @@ -75,7 +73,7 @@ func (e *Engine) nextAppPageChunk(chunks *[]pageChunk, remoteKey string) *pageCh } reverse := map[byte][]App{} for key, list := range b.ResourceMap { - for _, k := range strings.Split(key, ",") { + for _, k := range splitComma(key) { if k != "" { reverse[k[0]] = list } @@ -114,6 +112,18 @@ func joinKeys(keys []string) string { return out } +func splitComma(s string) []string { + var out []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == ',' { + out = append(out, s[start:i]) + start = i + 1 + } + } + return out +} + func (e *Engine) cacheRemoteApps(statusMap map[string]StatusMap, remote []map[string]any, chunk *pageChunk, remoteKey string) { resources := e.transform(remote, statusMap, true, nil, nil, nil) if chunk == nil { diff --git a/backend/internal/searchapi/discovery.go b/backend/internal/searchapi/discovery.go new file mode 100644 index 00000000000..5eb9721dee1 --- /dev/null +++ b/backend/internal/searchapi/discovery.go @@ -0,0 +1,48 @@ +// Copyright Contributors to the Open Cluster Management project + +package searchapi + +import ( + "context" + "fmt" + "strings" +) + +const ( + defaultSearchPort = "4010" + graphqlPath = "/searchapi/graphql" + federatedPath = "/federated" + defaultNamespace = "open-cluster-management" +) + +// Discovery resolves the Search GraphQL HTTP(S) URL (user proxy and SA client). +type Discovery struct { + SearchAPIURL string + Federated func() bool + Namespace string + MCHNamespace func(context.Context) string +} + +// Endpoint is SEARCH_API_URL or https://search-search-api..svc.cluster.local:4010 +// plus /searchapi/graphql, or /federated when globalSearchFeatureFlag is enabled. +func (d Discovery) Endpoint(ctx context.Context) string { + base := strings.TrimRight(d.SearchAPIURL, "/") + if base == "" { + ns := "" + if d.MCHNamespace != nil { + ns = d.MCHNamespace(ctx) + } + if ns == "" { + ns = d.Namespace + } + if ns == "" { + ns = defaultNamespace + } + base = fmt.Sprintf("https://search-search-api.%s.svc.cluster.local:%s", ns, defaultSearchPort) + } + path := graphqlPath + if d.Federated != nil && d.Federated() { + path = federatedPath + } + return base + path +} diff --git a/backend/internal/searchapi/searchapi.go b/backend/internal/searchapi/searchapi.go index a72960ee6a6..69e295276c3 100644 --- a/backend/internal/searchapi/searchapi.go +++ b/backend/internal/searchapi/searchapi.go @@ -9,18 +9,14 @@ import ( "fmt" "io" "net/http" - "strings" "time" applog "github.com/stolostron/console/backend/internal/log" ) const ( - defaultSearchPort = "4010" - graphqlPath = "/searchapi/graphql" - federatedPath = "/federated" - searchTimeout = 2 * time.Minute - pingTimeout = 4 * time.Minute + searchTimeout = 2 * time.Minute + pingTimeout = 4 * time.Minute ) // Query is the GraphQL search payload used by application aggregation. @@ -88,27 +84,19 @@ type Client struct { MCHNamespace func(context.Context) string } +// Discovery returns the shared Search API URL resolver (SA client and user proxy). +func (c *Client) Discovery() Discovery { + return Discovery{ + SearchAPIURL: c.SearchAPIURL, + Federated: c.Federated, + Namespace: c.Namespace, + MCHNamespace: c.MCHNamespace, + } +} + // Endpoint is SEARCH_API_URL or the in-cluster search-search-api service. func (c *Client) Endpoint(ctx context.Context) string { - base := strings.TrimRight(c.SearchAPIURL, "/") - if base == "" { - ns := "" - if c.MCHNamespace != nil { - ns = c.MCHNamespace(ctx) - } - if ns == "" { - ns = c.Namespace - } - if ns == "" { - ns = "open-cluster-management" - } - base = fmt.Sprintf("https://search-search-api.%s.svc.cluster.local:%s", ns, defaultSearchPort) - } - path := graphqlPath - if c.Federated != nil && c.Federated() { - path = federatedPath - } - return base + path + return c.Discovery().Endpoint(ctx) } func (c *Client) httpClient(timeout time.Duration) *http.Client { diff --git a/backend/internal/searchapi/searchapi_test.go b/backend/internal/searchapi/searchapi_test.go index 42c756e29e3..4f377f793ff 100644 --- a/backend/internal/searchapi/searchapi_test.go +++ b/backend/internal/searchapi/searchapi_test.go @@ -33,6 +33,21 @@ func TestEndpointDefaultAndFederated(t *testing.T) { } } +func TestDiscoveryMCHNamespaceAndDefault(t *testing.T) { + d := searchapi.Discovery{ + MCHNamespace: func(context.Context) string { return "ocm" }, + } + got := d.Endpoint(context.Background()) + if got != "https://search-search-api.ocm.svc.cluster.local:4010/searchapi/graphql" { + t.Fatalf("mch %q", got) + } + d.MCHNamespace = func(context.Context) string { return "" } + got = d.Endpoint(context.Background()) + if got != "https://search-search-api.open-cluster-management.svc.cluster.local:4010/searchapi/graphql" { + t.Fatalf("default ns %q", got) + } +} + func TestSearchAndPing(t *testing.T) { var lastPath string ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/searchproxy/inject.go b/backend/internal/searchproxy/inject.go new file mode 100644 index 00000000000..7965ad86dff --- /dev/null +++ b/backend/internal/searchproxy/inject.go @@ -0,0 +1,35 @@ +// Copyright Contributors to the Open Cluster Management project + +package searchproxy + +import ( + "encoding/json" + "strings" +) + +// InjectConnectionInitAuthorization adds Authorization to a graphql-ws connection_init payload. +func InjectConnectionInitAuthorization(connectionInitJSON string, bearerToken string) string { + bearer := bearerToken + if !strings.HasPrefix(bearer, "Bearer ") { + bearer = "Bearer " + bearerToken + } + var msg map[string]any + if err := json.Unmarshal([]byte(connectionInitJSON), &msg); err != nil { + return connectionInitJSON + } + typ, _ := msg["type"].(string) + if typ != "connection_init" { + return connectionInitJSON + } + payload, ok := msg["payload"].(map[string]any) + if !ok { + payload = map[string]any{} + } + payload["Authorization"] = bearer + msg["payload"] = payload + out, err := json.Marshal(msg) + if err != nil { + return connectionInitJSON + } + return string(out) +} diff --git a/backend/internal/searchproxy/proxy.go b/backend/internal/searchproxy/proxy.go new file mode 100644 index 00000000000..2802a17a21b --- /dev/null +++ b/backend/internal/searchproxy/proxy.go @@ -0,0 +1,154 @@ +// Copyright Contributors to the Open Cluster Management project + +package searchproxy + +import ( + "context" + "crypto/tls" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/gorilla/websocket" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + applog "github.com/stolostron/console/backend/internal/log" +) + +var requestHeaders = []string{ + "Accept", + "Accept-Encoding", + "Content-Encoding", + "Content-Length", + "Content-Type", +} + +const defaultDialTimeout = 60 * time.Second + +type tokenCtxKey struct{} + +// Options configure the user-token Search GraphQL proxy and WebSocket relay. +type Options struct { + RESTConfig *rest.Config + TLSConfig *tls.Config + Endpoint func(ctx context.Context) string + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + DialTimeout time.Duration + Dialer *websocket.Dialer +} + +// Handler proxies POST /proxy/search and graphql-ws upgrades to search-api. +type Handler struct { + TLSConfig *tls.Config + Endpoint func(ctx context.Context) string + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + DialTimeout time.Duration + Dialer *websocket.Dialer + proxy *httputil.ReverseProxy +} + +// New returns a handler for POST GraphQL and WebSocket search-api relay. +func New(opts Options) *Handler { + h := &Handler{ + TLSConfig: opts.TLSConfig, + Endpoint: opts.Endpoint, + Authn: opts.Authn, + DialTimeout: opts.DialTimeout, + Dialer: opts.Dialer, + } + if h.DialTimeout <= 0 { + h.DialTimeout = defaultDialTimeout + } + if h.Authn == nil && opts.RESTConfig != nil { + h.Authn = func(w http.ResponseWriter, r *http.Request) (string, bool) { + return auth.AuthenticateRequest(r.Context(), opts.RESTConfig, w, r) + } + } + h.proxy = &httputil.ReverseProxy{ + Rewrite: h.rewrite, + ErrorHandler: proxyError, + Transport: h.transport(), + FlushInterval: -1 * time.Millisecond, + } + return h +} + +func (h *Handler) transport() http.RoundTripper { + return &http.Transport{ + TLSClientConfig: h.TLSConfig, + ForceAttemptHTTP2: false, + ResponseHeaderTimeout: 0, + } +} + +func proxyError(w http.ResponseWriter, _ *http.Request, err error) { + applog.Logger().Error("search proxy", "error", err) + w.WriteHeader(http.StatusBadGateway) +} + +func (h *Handler) rewrite(pr *httputil.ProxyRequest) { + token, _ := pr.In.Context().Value(tokenCtxKey{}).(string) + if token == "" { + token = auth.TokenFromRequest(pr.In) + } + endpoint := "" + if h.Endpoint != nil { + endpoint = h.Endpoint(pr.In.Context()) + } + target, err := url.Parse(endpoint) + if err != nil || target.Scheme == "" || target.Host == "" { + return + } + pr.SetURL(target) + pr.Out.URL.Path = target.Path + pr.Out.URL.RawPath = target.RawPath + pr.Out.URL.RawQuery = pr.In.URL.RawQuery + pr.Out.Host = target.Host + pr.Out.Header = http.Header{} + for _, name := range requestHeaders { + if v := pr.In.Header.Get(name); v != "" { + pr.Out.Header.Set(name, v) + } + } + pr.Out.Header.Set("Authorization", "Bearer "+token) +} + +func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { + if h.Authn != nil { + return h.Authn(w, r) + } + w.WriteHeader(http.StatusUnauthorized) + return "", false +} + +// ServeHTTP authenticates, then ReverseProxy POST or relays a WebSocket upgrade. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + token, ok := h.authenticate(w, r) + if !ok { + return + } + r = r.WithContext(context.WithValue(r.Context(), tokenCtxKey{}, token)) + if websocket.IsWebSocketUpgrade(r) { + h.serveWebSocket(w, r, token) + return + } + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + h.proxy.ServeHTTP(w, r) +} + +func httpToWebSocketURL(endpoint string) string { + switch { + case strings.HasPrefix(endpoint, "https://"): + return "wss://" + strings.TrimPrefix(endpoint, "https://") + case strings.HasPrefix(endpoint, "http://"): + return "ws://" + strings.TrimPrefix(endpoint, "http://") + default: + return endpoint + } +} diff --git a/backend/internal/searchproxy/proxy_test.go b/backend/internal/searchproxy/proxy_test.go new file mode 100644 index 00000000000..62d5c6134ed --- /dev/null +++ b/backend/internal/searchproxy/proxy_test.go @@ -0,0 +1,291 @@ +// Copyright Contributors to the Open Cluster Management project + +package searchproxy + +import ( + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func authOK(_ http.ResponseWriter, _ *http.Request) (string, bool) { + return "user-token", true +} + +func TestUnauthorizedEmptyBody(t *testing.T) { + h := New(Options{ + Endpoint: func(context.Context) string { return "http://example.invalid" }, + }) + req := httptest.NewRequest(http.MethodPost, "/proxy/search", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } +} + +func TestGetWithoutUpgradeNotFound(t *testing.T) { + h := New(Options{Authn: authOK, Endpoint: func(context.Context) string { return "http://example.invalid" }}) + req := httptest.NewRequest(http.MethodGet, "/proxy/search", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } +} + +func TestPostProxiesUserTokenAndStripsCookie(t *testing.T) { + var ( + gotAuth, gotCookie, gotPath, gotCT, gotXFF string + gotBody []byte + ) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotCookie = r.Header.Get("Cookie") + gotPath = r.URL.Path + gotCT = r.Header.Get("Content-Type") + gotXFF = r.Header.Get("X-Forwarded-For") + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"searchResult":[]}}`)) + })) + defer upstream.Close() + + h := New(Options{ + Authn: authOK, + Endpoint: func(context.Context) string { return upstream.URL + "/searchapi/graphql" }, + }) + req := httptest.NewRequest(http.MethodPost, "/proxy/search", strings.NewReader(`{"query":"{ __typename }"}`)) + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "session=secret") + req.Header.Set("X-Forwarded-For", "1.2.3.4") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if gotAuth != "Bearer user-token" { + t.Fatalf("auth %q", gotAuth) + } + if gotCookie != "" { + t.Fatalf("cookie forwarded %q", gotCookie) + } + if gotXFF != "" { + t.Fatalf("x-forwarded-for forwarded %q", gotXFF) + } + if gotPath != "/searchapi/graphql" { + t.Fatalf("path %q", gotPath) + } + if gotCT != "application/json" { + t.Fatalf("content-type %q", gotCT) + } + if string(gotBody) != `{"query":"{ __typename }"}` { + t.Fatalf("body %s", gotBody) + } + if rec.Header().Get("Content-Type") != "application/json" { + t.Fatalf("resp content-type %q", rec.Header().Get("Content-Type")) + } +} + +func TestInjectConnectionInitAuthorization(t *testing.T) { + out := InjectConnectionInitAuthorization(`{"type":"connection_init","payload":{"foo":"bar"}}`, "mytoken") + var msg struct { + Type string `json:"type"` + Payload struct { + Foo string `json:"foo"` + Authorization string `json:"Authorization"` + } `json:"payload"` + } + if err := json.Unmarshal([]byte(out), &msg); err != nil { + t.Fatal(err) + } + if msg.Type != "connection_init" || msg.Payload.Foo != "bar" || msg.Payload.Authorization != "Bearer mytoken" { + t.Fatalf("%+v", msg) + } + + out = InjectConnectionInitAuthorization(`{"type":"connection_init","payload":{}}`, "Bearer x") + if err := json.Unmarshal([]byte(out), &msg); err != nil { + t.Fatal(err) + } + if msg.Payload.Authorization != "Bearer x" { + t.Fatalf("%q", msg.Payload.Authorization) + } + + sub := `{"type":"subscribe","id":"1","payload":{}}` + if InjectConnectionInitAuthorization(sub, "t") != sub { + t.Fatal("subscribe rewritten") + } + out = InjectConnectionInitAuthorization(`{"type":"connection_init","payload":null}`, "tok") + if err := json.Unmarshal([]byte(out), &msg); err != nil { + t.Fatal(err) + } + if msg.Payload.Authorization != "Bearer tok" { + t.Fatalf("null payload %q", msg.Payload.Authorization) + } + if InjectConnectionInitAuthorization("not-json", "tok") != "not-json" { + t.Fatal("invalid json") + } +} + +func TestWebSocketUnauthorizedBeforeUpgrade(t *testing.T) { + h := New(Options{Endpoint: func(context.Context) string { return "http://example.invalid" }}) + req := httptest.NewRequest(http.MethodGet, "/proxy/search", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } + if rec.Header().Get("Upgrade") != "" { + t.Fatal("upgraded without auth") + } +} + +func TestWebSocketInjectsConnectionInit(t *testing.T) { + gotInitCh := make(chan string, 1) + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }, EnableCompression: false} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer user-token" { + t.Errorf("upstream auth %q", r.Header.Get("Authorization")) + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + _, data, err := conn.ReadMessage() + if err != nil { + return + } + gotInitCh <- string(data) + _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"connection_ack"}`)) + })) + defer upstream.Close() + + h := New(Options{ + Authn: authOK, + Endpoint: func(context.Context) string { return upstream.URL + "/searchapi/graphql" }, + }) + ts := httptest.NewServer(h) + defer ts.Close() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/proxy/search" + client, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{"Authorization": []string{"Bearer user-token"}}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + initMsg := `{"type":"connection_init","payload":{"extra":true}}` + if err = client.WriteMessage(websocket.TextMessage, []byte(initMsg)); err != nil { + t.Fatal(err) + } + _, ack, err := client.ReadMessage() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(ack), "connection_ack") { + t.Fatalf("ack %s", ack) + } + var gotInit string + select { + case gotInit = <-gotInitCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for upstream connection_init") + } + var parsed map[string]any + if err = json.Unmarshal([]byte(gotInit), &parsed); err != nil { + t.Fatalf("init %s: %v", gotInit, err) + } + payload, _ := parsed["payload"].(map[string]any) + if payload["Authorization"] != "Bearer user-token" { + t.Fatalf("init payload %+v", payload) + } + if payload["extra"] != true { + t.Fatalf("lost extra %+v", payload) + } +} + +func TestWebSocketUpstreamTimeout504(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + c, err := ln.Accept() + if err != nil { + return + } + time.Sleep(500 * time.Millisecond) + _ = c.Close() + }() + + h := New(Options{ + Authn: authOK, + DialTimeout: 50 * time.Millisecond, + Endpoint: func(context.Context) string { return "http://" + ln.Addr().String() + "/searchapi/graphql" }, + }) + req := httptest.NewRequest(http.MethodGet, "/proxy/search", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusGatewayTimeout { + t.Fatalf("status %d", rec.Code) + } +} + +func TestWebSocketUpstreamRefused502(t *testing.T) { + h := New(Options{ + Authn: authOK, + DialTimeout: 200 * time.Millisecond, + Endpoint: func(context.Context) string { return "http://127.0.0.1:1/searchapi/graphql" }, + }) + req := httptest.NewRequest(http.MethodGet, "/proxy/search", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status %d", rec.Code) + } +} + +func TestSubprotocolsDefault(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/proxy/search", nil) + got := subprotocolsForUpstream(req) + if len(got) != 1 || got[0] != "graphql-transport-ws" { + t.Fatalf("%v", got) + } + req.Header.Set("Sec-WebSocket-Protocol", "graphql-ws, graphql-transport-ws") + got = subprotocolsForUpstream(req) + if len(got) != 2 || got[0] != "graphql-ws" { + t.Fatalf("%v", got) + } +} diff --git a/backend/internal/searchproxy/ws.go b/backend/internal/searchproxy/ws.go new file mode 100644 index 00000000000..f1b9dfe035a --- /dev/null +++ b/backend/internal/searchproxy/ws.go @@ -0,0 +1,164 @@ +// Copyright Contributors to the Open Cluster Management project + +package searchproxy + +import ( + "encoding/json" + "errors" + "net" + "net/http" + "strings" + "sync" + + "github.com/gorilla/websocket" + + applog "github.com/stolostron/console/backend/internal/log" +) + +func (h *Handler) serveWebSocket(w http.ResponseWriter, r *http.Request, token string) { + endpoint := "" + if h.Endpoint != nil { + endpoint = h.Endpoint(r.Context()) + } + wsURL := httpToWebSocketURL(endpoint) + if wsURL == "" { + failBeforeUpgrade(w, http.StatusBadGateway) + return + } + + dialer := h.websocketDialer(r) + hdr := http.Header{} + hdr.Set("Authorization", "Bearer "+token) + + upstream, resp, err := dialer.Dial(wsURL, hdr) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + applog.Logger().Error("search websocket relay: upstream connect failed", "error", err, "url", wsURL) + status := http.StatusBadGateway + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + status = http.StatusGatewayTimeout + } + failBeforeUpgrade(w, status) + return + } + + upgrader := websocket.Upgrader{ + CheckOrigin: func(*http.Request) bool { return true }, + EnableCompression: false, + Subprotocols: subprotocolsForUpstream(r), + } + client, err := upgrader.Upgrade(w, r, nil) + if err != nil { + applog.Logger().Error("search websocket relay: client upgrade failed", "error", err) + _ = upstream.Close() + return + } + relay(client, upstream, token) +} + +func (h *Handler) websocketDialer(r *http.Request) *websocket.Dialer { + if h.Dialer != nil { + cp := *h.Dialer + if cp.HandshakeTimeout == 0 { + cp.HandshakeTimeout = h.DialTimeout + } + if len(cp.Subprotocols) == 0 { + cp.Subprotocols = subprotocolsForUpstream(r) + } + return &cp + } + return &websocket.Dialer{ + TLSClientConfig: h.TLSConfig, + HandshakeTimeout: h.DialTimeout, + EnableCompression: false, + Subprotocols: subprotocolsForUpstream(r), + } +} + +func failBeforeUpgrade(w http.ResponseWriter, status int) { + w.Header().Set("Connection", "close") + w.WriteHeader(status) +} + +func subprotocolsForUpstream(r *http.Request) []string { + raw := r.Header.Get("Sec-WebSocket-Protocol") + if raw == "" { + return []string{"graphql-transport-ws"} + } + var list []string + for _, s := range strings.Split(raw, ",") { + s = strings.TrimSpace(s) + if s != "" { + list = append(list, s) + } + } + if len(list) == 0 { + return []string{"graphql-transport-ws"} + } + return list +} + +func relay(client, upstream *websocket.Conn, token string) { + var ( + once sync.Once + wg sync.WaitGroup + ) + closeBoth := func() { + once.Do(func() { + _ = client.Close() + _ = upstream.Close() + }) + } + + wg.Add(2) + go func() { + defer wg.Done() + defer closeBoth() + injected := false + for { + mt, data, err := client.ReadMessage() + if err != nil { + return + } + if !injected && mt == websocket.TextMessage { + injected = true + if messageType(data) == "connection_init" { + data = []byte(InjectConnectionInitAuthorization(string(data), token)) + } + } else if !injected { + injected = true + } + if err = upstream.WriteMessage(mt, data); err != nil { + return + } + } + }() + + go func() { + defer wg.Done() + defer closeBoth() + for { + mt, data, err := upstream.ReadMessage() + if err != nil { + return + } + if err = client.WriteMessage(mt, data); err != nil { + return + } + } + }() + wg.Wait() +} + +func messageType(data []byte) string { + var peek struct { + Type string `json:"type"` + } + if err := json.Unmarshal(data, &peek); err != nil { + return "" + } + return peek.Type +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index da42df6a510..90edfa6d270 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -42,6 +42,7 @@ type handlerOptions struct { clusterInfo http.Handler events http.Handler aggregate http.Handler + searchProxy http.Handler debugSnapshot http.Handler } @@ -62,6 +63,13 @@ func WithAggregate(h http.Handler) Option { } } +// WithSearchProxy registers POST /proxy/search and WebSocket upgrades (also /multicloud/proxy/search). +func WithSearchProxy(h http.Handler) Option { + return func(o *handlerOptions) { + o.searchProxy = h + } +} + // WithRBACEvents registers GET /events/rbac (and /multicloud/events/rbac). func WithRBACEvents(h http.Handler) Option { return func(o *handlerOptions) { @@ -288,6 +296,9 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { if o.aggregate != nil { registerAliasedPost(r, o.aggregate, "/aggregate/*") } + if o.searchProxy != nil { + registerAliased(r, o.searchProxy, "/proxy/search") + } if o.k8sProxy != nil { registerK8sProxyRoutes(r, o.k8sProxy) } diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index 6b9753cf4a6..d8341b8b795 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -800,3 +800,56 @@ func TestAggregateNotProxied(t *testing.T) { } } } + +func TestSearchNotProxied(t *testing.T) { + var sidecarHit bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarHit = true + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + searchH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, server.WithSearchProxy(searchH)) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + for _, path := range []string{"/proxy/search", "/multicloud/proxy/search"} { + sidecarHit = false + req, _ := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + resp, getErr := ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if sidecarHit { + t.Fatalf("%s was proxied to sidecar", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) + } + + sidecarHit = false + req, _ = http.NewRequest(http.MethodGet, ts.URL+path, nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + resp, getErr = ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if sidecarHit { + t.Fatalf("%s websocket was proxied to sidecar", path) + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b826bbde191..d0ec0b321b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,7 +41,7 @@ All REST calls use the token passed from the console frontend. Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. -The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). Node `startWatching()` still runs so `hub.ts` can read `resourceCache` until ACM-42596 is wired. Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches and proxy `/events` and `/aggregate` to the sidecar (aggregate then 404s after the Node route cutover). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. +The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). `POST /proxy/search` and the Search graphql-ws relay are served by Go (`backend/internal/searchproxy`) with the user token. Node `startWatching()` still runs so `hub.ts` can read `resourceCache` until ACM-42596 is wired. Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches and proxy `/events` and `/aggregate` to the sidecar (aggregate then 404s after the Node route cutover). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. DELETED resource events are sent to every SSE client without an access check (bug-compatible with Node). That is a known quirk to fix later. From 7d793c9a3580195861663f92e67633b687cbaa18 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Mon, 14 Sep 2026 10:51:08 +0200 Subject: [PATCH 12/16] ACM-42602 Migrate long-tail routes to Go (#61) * ACM-42600 Signed-off-by: Enrique Mingorance Cano * ACM-42601 Migrate search proxy and WebSocket relay to Go Signed-off-by: Enrique Mingorance Cano * ACM-42602 Migrate long-tail routes to Go Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- backend-node/AGENTS.md | 10 +- backend-node/src/app.ts | 39 - backend-node/src/routes/ansibletower.ts | 128 ---- backend-node/src/routes/placementDebug.ts | 78 -- backend-node/src/routes/rosaWizardApi.ts | 507 ------------- .../src/routes/upgrade-risks-prediction.ts | 93 --- backend-node/test/routes/ansibletower.test.ts | 236 ------ .../test/routes/placementDebug.test.ts | 69 -- .../test/routes/rosaWizardApi.test.ts | 676 ------------------ .../routes/upgrade-risks-prediction.test.ts | 110 --- backend/AGENTS.md | 10 +- backend/cmd/console/main.go | 26 + backend/internal/ansibletower/ansibletower.go | 194 +++++ .../ansibletower/ansibletower_test.go | 200 ++++++ backend/internal/auth/tls.go | 9 +- backend/internal/config/config.go | 1 + backend/internal/config/config_test.go | 4 + backend/internal/k8sproxy/k8sproxy.go | 7 +- backend/internal/mcproxy/mcproxy.go | 7 +- backend/internal/metricsproxy/metricsproxy.go | 7 +- backend/internal/outbound/transport.go | 35 + backend/internal/placementdebug/ca.go | 158 ++++ backend/internal/placementdebug/ca_test.go | 47 ++ .../internal/placementdebug/placementdebug.go | 177 +++++ .../placementdebug/placementdebug_test.go | 95 +++ backend/internal/proxy/proxy.go | 8 +- backend/internal/rosa/rosa.go | 540 ++++++++++++++ backend/internal/rosa/rosa_test.go | 192 +++++ backend/internal/searchproxy/proxy.go | 7 +- backend/internal/server/server.go | 86 ++- backend/internal/server/server_test.go | 51 ++ backend/internal/upgraderisks/upgraderisks.go | 215 ++++++ .../upgraderisks/upgraderisks_test.go | 139 ++++ backend/internal/vmproxy/handler.go | 8 +- docs/ARCHITECTURE.md | 4 +- .../src/resources/utils/resource-request.ts | 2 +- 36 files changed, 2181 insertions(+), 1994 deletions(-) delete mode 100644 backend-node/src/routes/ansibletower.ts delete mode 100644 backend-node/src/routes/placementDebug.ts delete mode 100644 backend-node/src/routes/rosaWizardApi.ts delete mode 100644 backend-node/src/routes/upgrade-risks-prediction.ts delete mode 100644 backend-node/test/routes/ansibletower.test.ts delete mode 100644 backend-node/test/routes/placementDebug.test.ts delete mode 100644 backend-node/test/routes/rosaWizardApi.test.ts delete mode 100644 backend-node/test/routes/upgrade-risks-prediction.test.ts create mode 100644 backend/internal/ansibletower/ansibletower.go create mode 100644 backend/internal/ansibletower/ansibletower_test.go create mode 100644 backend/internal/outbound/transport.go create mode 100644 backend/internal/placementdebug/ca.go create mode 100644 backend/internal/placementdebug/ca_test.go create mode 100644 backend/internal/placementdebug/placementdebug.go create mode 100644 backend/internal/placementdebug/placementdebug_test.go create mode 100644 backend/internal/rosa/rosa.go create mode 100644 backend/internal/rosa/rosa_test.go create mode 100644 backend/internal/upgraderisks/upgraderisks.go create mode 100644 backend/internal/upgraderisks/upgraderisks_test.go diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md index ec78b002748..57a597bce1f 100644 --- a/backend-node/AGENTS.md +++ b/backend-node/AGENTS.md @@ -16,7 +16,7 @@ Node.js ESM proxy server. Sits between the browser and the hub cluster API serve | Directory | Purpose | |-----------|---------| | `src/lib/` | Core server: `main.ts` entry, `server.ts`, auth, cookies, CORS, proxy, search, SSE, logging, config | -| `src/routes/` | HTTP route handlers: events, ansible, ROSA, placement-debug, remaining long-tail | +| `src/routes/` | Sidecar handlers still dual-run: `events`, probes, aggregators (`hub.ts`). Long-tail HTTP (ROSA, ansibletower, placement-debug, upgrade-risks) is served by Go | | `src/resources/` | Backend resource watchers and handlers | | `test/` | Jest test files | | `config/` | Runtime configuration lives in `../backend/config` (Go backend) | @@ -37,10 +37,10 @@ Run from the `backend-node/` directory, or use the `npm run *:backend-node` vari ## Architecture -The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated. OAuth login, logout, and `/configure` discovery are served by Go. +The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated (ACM-42603 decommissions it). OAuth login, logout, `/configure`, Search proxy, and long-tail HTTP (ROSA wizard, Ansible Tower, placement-debug, upgrade-risks) are served by Go. ```text -Browser / plugin → Go :4000 (GET /events, POST /aggregate, POST /proxy/search + Search WS) +Browser / plugin → Go :4000 (GET /events, POST /aggregate, POST /proxy/search + Search WS, long-tail HTTP) → Node sidecar (this package) → Hub Cluster API Server ↓ Watches resources via service account (hub.ts / dual-run) @@ -84,7 +84,7 @@ Generated by `npm run setup` from the repo root into **`../backend/.env`**. The | `OIDC_ISSUER_URL` | OIDC issuer URL (when using external OIDC instead of OpenShift OAuth) | | `FRONTEND_URL` | Frontend URL for post-login redirect | | `SEARCH_API_URL` | Search API route URL | -| `PLACEMENT_DEBUG_URL` | Placement debug service route URL | +| `PLACEMENT_DEBUG_URL` | Consumed by the Go listener (`backend/internal/placementdebug`) | Optional development/debug variables (not in `.env` by default): @@ -103,7 +103,7 @@ Files in the Go backend `config/` directory are loaded at startup and watched fo - `LOG_*` keys (`LOG_LEVEL`, `LOG_ACCESS`, `LOG_EVENTS`, `LOG_MEMORY`, `LOG_WATCH`) — control logging behavior - `APP_SEARCH_*` keys (`APP_SEARCH_INTERVAL`, `APP_SEARCH_LIMIT`) — application search tuning - `globalSearchFeatureFlag` — enables federated search endpoint -- `UPGRADE_RISKS_PREDICTION_URL` — override for upgrade risk prediction service +- `UPGRADE_RISKS_PREDICTION_URL` — override for upgrade risk prediction (consumed by the Go listener) Other config files (e.g., `singleNodeOpenshift`, `ansibleIntegration`, `awsPrivateWizardStep`) are sent to the frontend as settings but not promoted to backend env vars. The frontend uses these to toggle UI features like single-node cluster creation and Ansible automation options. diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 65a68089b0d..119110bb057 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -10,28 +10,10 @@ import { startLoggingMemory } from './lib/memory' import { notFound, respondInternalServerError, respondOK } from './lib/respond' import { startServer, stopServer } from './lib/server' import { ServerSideEvents } from './lib/server-side-events' -import { ansibleTower } from './routes/ansibletower' import { events, startWatching, stopWatching } from './routes/events' import { liveness } from './routes/liveness' import { readiness } from './routes/readiness' -import { placementDebug } from './routes/placementDebug' -import { upgradeRiskPredictions } from './routes/upgrade-risks-prediction' import { watchTLSSecurityProfile } from './lib/tlsProfileWatch' -import { watchPlacementDebugCA } from './lib/placementDebugCAWatch' -import { invalidatePlacementDebugAgent } from './lib/agent' -import { - getAwsAccountIds, - getAwsBillingAccountIds, - getWizardOIDCConfigs, - getWizardCloudProviders, - getClusterNameCheck, - getOCMRoleARN, - getRoleARNs, - getUserRole, - getWizardVersions, - getWizardVPCs, - getWizardMachineTypes, -} from './routes/rosaWizardApi' const isProduction = process.env.NODE_ENV === 'production' const isDevelopment = process.env.NODE_ENV === 'development' @@ -48,22 +30,6 @@ if (eventsEnabled) { // This sidecar route remains for dual-run and when the Go cache is disabled. router.get('/events', events) } -router.post('/placement-debug', placementDebug) -router.post('/ansibletower', ansibleTower) -router.post('/upgrade-risks-prediction', upgradeRiskPredictions) - -// rosa wizard routes -router.post('/aws-account-ids', getAwsAccountIds) -router.post('/aws-billing-accounts', getAwsBillingAccountIds) -router.post('/oidc-configs', getWizardOIDCConfigs) -router.post('/regions', getWizardCloudProviders) -router.post('/cluster-name-check', getClusterNameCheck) -router.post('/sts-role-arns', getRoleARNs) -router.post('/vpcs', getWizardVPCs) -router.post('/sts-ocm-role', getOCMRoleARN) -router.post('/sts-user-role', getUserRole) -router.post('/openshift-versions', getWizardVersions) -router.post('/machine-types', getWizardMachineTypes) export async function requestHandler(req: Http2ServerRequest, res: Http2ServerResponse): Promise { if (!isProduction) { @@ -92,15 +58,11 @@ export async function requestHandler(req: Http2ServerRequest, res: Http2ServerRe } let stopTLSProfileWatch: (() => void) | undefined -let stopPlacementDebugCAWatch: (() => void) | undefined export async function start() { await loadSettings() if (eventsEnabled) { startWatching() } - stopPlacementDebugCAWatch = watchPlacementDebugCA(() => { - invalidatePlacementDebugAgent() - }) stopTLSProfileWatch = watchTLSSecurityProfile(async (options) => { try { await stopServer() @@ -124,7 +86,6 @@ export async function stop(): Promise { stopFileWatches() await ServerSideEvents.dispose() stopWatching() - stopPlacementDebugCAWatch?.() stopTLSProfileWatch?.() await stopServer() stopLogger() diff --git a/backend-node/src/routes/ansibletower.ts b/backend-node/src/routes/ansibletower.ts deleted file mode 100644 index d8a23348448..00000000000 --- a/backend-node/src/routes/ansibletower.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import type { RequestOptions } from 'node:https' -import { request } from 'node:https' -import { pipeline } from 'node:stream' -import { URL } from 'node:url' -import { jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { catchInternalServerError, notFound, respond, respondBadRequest } from '../lib/respond' -import { getAuthenticatedToken } from '../lib/token' - -interface AnsibleTowerRequest { - // Reference to the Ansible credential Secret. The backend reads it with the - // caller's bearer token so kube-apiserver enforces RBAC; the tower host and - // token are derived server-side and never accepted from the request body. - secretNamespace: string - secretName: string - // Allow-listed AAP API path (optionally with query string for pagination). - ansiblePath: string -} - -interface AnsibleSecret { - data?: { host?: string; token?: string } -} - -// must match ansiblePaths in frontend/src/resources/utils/resource-request.ts -// 2.5 and later ansible operator version only support Gateway URL. Gateway URL need below paths. -// '/api/controller/v2/job_templates/', '/api/controller/v2/workflow_job_templates/', '/api/controller/v2/inventories/'' -export const ansiblePaths = [ - '/api/v2/job_templates/', - '/api/v2/workflow_job_templates/', - '/api/v2/inventories/', - '/api/controller/v2/job_templates/', - '/api/controller/v2/workflow_job_templates/', - '/api/controller/v2/inventories/', -] - -export function ansibleTower(req: Http2ServerRequest, res: Http2ServerResponse): void { - getAuthenticatedToken(req, res) - .then((userToken) => { - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - req.on('end', () => { - let body: AnsibleTowerRequest - try { - body = JSON.parse(chucks.join('')) as AnsibleTowerRequest - } catch (err) { - return respondBadRequest(req, res) - } - if ( - typeof body.secretNamespace !== 'string' || - typeof body.secretName !== 'string' || - typeof body.ansiblePath !== 'string' - ) { - return respondBadRequest(req, res) - } - - // Resolve the tower host + token from the credential Secret using the - // caller's own token. A 401/403 from kube-apiserver means the caller - // is not authorized for this credential; never proxy in that case. - const secretPath = - process.env.CLUSTER_API_URL + - `/api/v1/namespaces/${encodeURIComponent(body.secretNamespace)}/secrets/${encodeURIComponent(body.secretName)}` - jsonRequest(secretPath, userToken, 0) - .then((secret) => { - const host = secret?.data?.host ? Buffer.from(secret.data.host, 'base64').toString('utf8') : '' - const token = secret?.data?.token ? Buffer.from(secret.data.token, 'base64').toString('utf8') : '' - if (!host || !token) { - return respondBadRequest(req, res) - } - - let hostUrl: URL - let towerUrl: URL - try { - hostUrl = new URL(host) - towerUrl = new URL(body.ansiblePath, hostUrl) - } catch (err) { - return respondBadRequest(req, res) - } - - // The ansiblePath is caller-supplied and only meant to be a relative - // API path. Reject absolute or network-path references that would - // point the proxy (and the Secret-derived token) at any origin other - // than the host configured in the credential Secret. - if (towerUrl.origin !== hostUrl.origin) { - return respondBadRequest(req, res) - } - - // allow list of apis our ui calls - if (!ansiblePaths.includes(towerUrl.pathname)) { - return respondBadRequest(req, res) - } - - const options: RequestOptions = { - protocol: towerUrl.protocol, - hostname: towerUrl.hostname, - port: towerUrl.port, - path: `${towerUrl.pathname}${towerUrl.search ? towerUrl.search : ''}`, - method: 'GET', - headers: { - Authorization: `Bearer ${token}`, - }, - rejectUnauthorized: false, // NOSONAR - AAP connects insecurely by default - } - - const towerReq = request(options, (response) => { - if (!response) return notFound(req, res) - res.writeHead(response.statusCode ?? 500, response.headers) - pipeline(response, res as unknown as NodeJS.WritableStream, (err) => { - if (err) { - logger.error(err) - } - }) - }) - towerReq.on('error', (e) => { - logger.error(e) - respond(res, JSON.stringify(e.message), constants.HTTP_STATUS_INTERNAL_SERVER_ERROR) - }) - towerReq.end() - }) - .catch(catchInternalServerError(res)) - }) - }) - .catch(catchInternalServerError(res)) -} diff --git a/backend-node/src/routes/placementDebug.ts b/backend-node/src/routes/placementDebug.ts deleted file mode 100644 index 22b54ae04f3..00000000000 --- a/backend-node/src/routes/placementDebug.ts +++ /dev/null @@ -1,78 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse, OutgoingHttpHeaders } from 'node:http2' -import { constants } from 'node:http2' -import type { RequestOptions } from 'node:https' -import { request } from 'node:https' -import { pipeline } from 'node:stream' -import { URL } from 'node:url' -import { getPlacementDebugAgent } from '../lib/agent' -import { logger } from '../lib/logger' -import { notFound, respond, respondInternalServerError } from '../lib/respond' -import { getAuthenticatedToken } from '../lib/token' - -const proxyHeaders = [ - constants.HTTP2_HEADER_ACCEPT, - constants.HTTP2_HEADER_ACCEPT_ENCODING, - constants.HTTP2_HEADER_CONTENT_ENCODING, - constants.HTTP2_HEADER_CONTENT_LENGTH, - constants.HTTP2_HEADER_CONTENT_TYPE, -] -const proxyResponseHeaders = [ - constants.HTTP2_HEADER_CACHE_CONTROL, - constants.HTTP2_HEADER_CONTENT_LENGTH, - constants.HTTP2_HEADER_CONTENT_ENCODING, - constants.HTTP2_HEADER_ETAG, -] - -const defaultServiceHost = 'cluster-manager-placement.open-cluster-management-hub.svc.cluster.local' -const defaultPlacementDebugUrl = `https://${defaultServiceHost}:9443/debug/placements/` - -export async function placementDebug(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (!token) return - - const agent = getPlacementDebugAgent() - if (!agent) { - return respond(res, { error: 'Placement debug service unavailable — OCM CA bundle not configured' }, 503) - } - - const headers: OutgoingHttpHeaders = { authorization: `Bearer ${token}` } - for (const header of proxyHeaders) { - if (req.headers[header]) headers[header] = req.headers[header] - } - headers['content-type'] = 'application/json' - - const url = new URL(process.env.PLACEMENT_DEBUG_URL || defaultPlacementDebugUrl) - headers.host = url.hostname - - const options: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port, - path: url.pathname, - method: 'POST', - headers, - agent, - } - - pipeline( - req, - request(options, (response) => { - if (!response) return notFound(req, res) - const responseHeaders: OutgoingHttpHeaders = { 'content-type': 'application/json' } - for (const header of proxyResponseHeaders) { - if (response.headers[header]) responseHeaders[header] = response.headers[header] - } - res.writeHead(response.statusCode ?? 500, responseHeaders) - pipeline(response, res as unknown as NodeJS.WritableStream, (err) => { - if (err) logger.error({ msg: 'placement debug response pipeline error', error: err.message }) - }) - }), - (err) => { - if (err) { - logger.error({ msg: 'placement debug upstream error', error: err.message }) - if (!res.headersSent) respondInternalServerError(req, res) - } - } - ) -} diff --git a/backend-node/src/routes/rosaWizardApi.ts b/backend-node/src/routes/rosaWizardApi.ts deleted file mode 100644 index 1c1988630ce..00000000000 --- a/backend-node/src/routes/rosaWizardApi.ts +++ /dev/null @@ -1,507 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { jsonPost, jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { respondInternalServerError } from '../lib/respond' -import { getOcmServiceToken } from '../lib/getServiceToken' -import { getAuthenticatedToken } from '../lib/token' - -const API_URL = 'https://api.openshift.com' - -type OrgType = { - organization: { - created_at: string - ebs_account_id: string - external_id: string - id: string - kind: string - name: string - } - service_account: boolean - username: string - id: string -} - -type Payload = { - service_account_id: string - service_account_secret: string -} - -type WithAwsAccount = Payload & { - aws_account_id: string -} - -type MachineTypesPayload = Payload & { - region: string - role_arn: string - availability_zones: string[] -} - -type ClusterNameCheck = Payload & { - cluster_name: string -} - -type VPCPayload = Payload & { - aws: { - account_id: string - sts: { - role_arn: string - } - } - region: { - id: string - } -} - -export async function getAwsAccountIds(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body: Payload = JSON.parse(data) as Payload - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - const orgPath = `${API_URL}/api/accounts_mgmt/v1/current_account` - const getOrg = (await jsonRequest(orgPath, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Error gettting account info', error: err.message }) - })) as OrgType - const orgId = getOrg.organization.id - const accountPath = `${API_URL}/api/accounts_mgmt/v1/organizations/${orgId}/labels` - - const accReq = await jsonRequest(accountPath, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Error gettting account info', error: err.message }) - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(accReq)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getAwsBillingAccountIds(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as Payload - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - const orgPath = `${API_URL}/api/accounts_mgmt/v1/current_account` - const getOrgID = (await jsonRequest(orgPath, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Error gettting account info', error: err.message }) - })) as OrgType - const accountPath = `${API_URL}/api/accounts_mgmt/v1/organizations/${getOrgID.organization.id}/quota_cost?fetchRelatedResources=true&fetchCloudAccounts=true` - - const accReq = await jsonRequest(accountPath, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Error gettting account info', error: err.message }) - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(accReq)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getWizardOIDCConfigs(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as WithAwsAccount - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - - const accountPath = `${API_URL}/api/clusters_mgmt/v1/oidc_configs?search=aws.account_id=${body.aws_account_id} or aws.account_id=''` - const request = await jsonRequest(accountPath, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Failed to fetch account', error: err.message }) - return { error: err.message } - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(request)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getWizardCloudProviders(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as Payload - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - - const cloudProvidersPath = `${API_URL}/api/clusters_mgmt/v1/cloud_providers?size=-1&fetchRegions=true` - const request = await jsonRequest(cloudProvidersPath, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Failed to fetch regions', error: err.message }) - return { error: err.message } - }) - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(request)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getClusterNameCheck(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as ClusterNameCheck - - const clusterNameRegex = /^[a-z]([a-z0-9-]*[a-z0-9])?$/ - if (!body.cluster_name || !clusterNameRegex.test(body.cluster_name)) { - res.setHeader('Content-Type', 'application/json') - res.writeHead(400) - res.end(JSON.stringify({ error: 'Invalid cluster name format' })) - return - } - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - const accountPath = `${API_URL}/api/clusters_mgmt/v1/clusters?method=get` - const accReq = await jsonPost( - accountPath, - { - size: 1, - search: `name = '${body.cluster_name}'`, - }, - accessTokenSSO - ).catch((err: Error) => { - logger.error({ msg: 'Error getting account info', error: err.message }) - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(accReq)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getWizardVPCs(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as VPCPayload - - const payload = { - aws: body.aws, - region: body.region, - } - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - - const accountPath = `${API_URL}/api/clusters_mgmt/v1/aws_inquiries/vpcs?fetchSecurityGroups=true` - const request = await jsonPost(accountPath, payload, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Failed to fetch account', error: err.message }) - return { error: err.message } - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(request)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getRoleARNs(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as WithAwsAccount - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - - const accountPath = `${API_URL}/api/clusters_mgmt/v1/aws_inquiries/sts_account_roles` - - const requestBody = { - account_id: body.aws_account_id, - } - const accReq = await jsonPost(accountPath, requestBody, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Error gettting account info', error: err.message }) - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(accReq)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getOCMRoleARN(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as WithAwsAccount - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - - const accountPath = `${API_URL}/api/clusters_mgmt/v1/aws_inquiries/sts_ocm_role` - - const requestBody = { - account_id: body.aws_account_id, - } - const accReq = await jsonPost(accountPath, requestBody, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Error gettting account info', error: err.message }) - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(accReq)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getUserRole(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as Payload - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - - // get current account and ID - const accountPath = `${API_URL}/api/accounts_mgmt/v1/current_account` - let request: OrgType - try { - request = await jsonRequest(accountPath, accessTokenSSO) - } catch (err) { - logger.error({ msg: 'Failed to fetch account', error: (err as Error).message }) - respondInternalServerError(req, res) - return - } - - const userRolesPath = `${API_URL}/api/accounts_mgmt/v1/accounts/${request.id}/labels/sts_user_role` - - const accReq = await jsonRequest(userRolesPath, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Error gettting account info', error: err.message }) - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(accReq)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getWizardMachineTypes(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as MachineTypesPayload - - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - - const machineTypesPath = `${API_URL}/api/clusters_mgmt/v1/aws_inquiries/machine_types?size=-1` - - const requestBody = { - aws: { sts: { role_arn: body.role_arn } }, - region: { id: body.region }, - availability_zones: body.availability_zones ?? [], - } - - const accReq = await jsonPost(machineTypesPath, requestBody, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Error getting machine types', error: err.message }) - return { error: err.message } - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(accReq)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} - -export async function getWizardVersions(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - try { - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - - req.on('end', async () => { - try { - data = chucks.join('') - const body = JSON.parse(data) as Payload - const accessTokenSSO = await getOcmServiceToken(body.service_account_id, body.service_account_secret) - - const versionsPath = `${API_URL}/api/clusters_mgmt/v1/versions/?order=end_of_life_timestamp desc&product=hcp&search=enabled='t' AND (channel_group='stable' OR channel_group='eus' OR channel_group='candidate' OR channel_group='fast' OR channel_group='nightly') AND rosa_enabled='t'&size=-1` - const request = await jsonRequest(versionsPath, accessTokenSSO).catch((err: Error) => { - logger.error({ msg: 'Failed to fetch versions', error: err.message }) - return { error: err.message } - }) - - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(request)) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} diff --git a/backend-node/src/routes/upgrade-risks-prediction.ts b/backend-node/src/routes/upgrade-risks-prediction.ts deleted file mode 100644 index 7dfe1f63493..00000000000 --- a/backend-node/src/routes/upgrade-risks-prediction.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { getInsightsAgent, getProxyAgent } from '../lib/agent' -import { jsonPost, jsonRequest } from '../lib/json-request' -import { logger } from '../lib/logger' -import { respondInternalServerError } from '../lib/respond' -import { getServiceAccountToken } from '../lib/serviceAccountToken' -import { getAuthenticatedToken } from '../lib/token' -import type { ResourceList } from '../resources/resource-list' -import type { Secret } from '../resources/secret' -interface Credential { - auths: { - 'cloud.openshift.com': { - auth: string - } - } -} - -interface UpgradeRiskBody { - clusterIds: string[] -} - -export async function upgradeRiskPredictions(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - const serviceAccountToken = getServiceAccountToken() - - try { - // console-mce ClusterRole does not allow for GET on secrets. Have to list in a namespace - const secretPath = process.env.CLUSTER_API_URL + '/api/v1/namespaces/openshift-config/secrets' - const crcToken: string = await jsonRequest(secretPath, serviceAccountToken) - .then((response: ResourceList) => { - const pullSecret = response.items.find((secret) => secret.metadata.name === 'pull-secret') - const dockerconfigjson = pullSecret.data['.dockerconfigjson'] ?? '' - const decodedToken = JSON.parse(Buffer.from(dockerconfigjson, 'base64').toString('ascii')) as Credential - return decodedToken?.auths?.['cloud.openshift.com']?.auth ?? '' - }) - .catch((err: Error): undefined => { - logger.error({ msg: 'Error getting pull-secret in namespace openshift-config', error: err.message }) - return undefined - }) - - let data: string = undefined - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - req.on('end', async () => { - data = chucks.join('') - const body = JSON.parse(data) as UpgradeRiskBody - - // acm-operator version in User-Agent header doesn't matter - CCX only uses the 'acm-operator' string to identify the product initiating the req - // https://github.com/RedHatInsights/insights-results-smart-proxy/blob/master/server/router_utils.go#L168 - const userAgent = 'acm-operator/v2.10.0 cluster/acm-hub' - const insightsPath = - process.env.UPGRADE_RISKS_PREDICTION_URL || - 'https://console.redhat.com/api/insights-results-aggregator/v2/upgrade-risks-prediction' - - // create array of clusterIds with length of 100 - const clusterIds = body.clusterIds.reduce((resultArray: string[][], item, index) => { - const chunkIndex = Math.floor(index / 100) - if (!resultArray[chunkIndex]) { - resultArray[chunkIndex] = [] // start a new chunk - } - resultArray[chunkIndex].push(item) - return resultArray - }, []) - - // Create req for each 100 id chunk - const reqs = clusterIds.map((idChunk: string[]) => { - return jsonPost( - insightsPath, - { clusters: idChunk }, - crcToken, - userAgent, - getProxyAgent() ?? getInsightsAgent() - ).catch((err: Error) => { - logger.error({ msg: 'Error getting cluster upgrade risk predictions', error: err.message }) - return { error: err.message } - }) - }) - - await Promise.all(reqs).then((results) => { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(results)) - }) - }) - } catch (err) { - logger.error(err) - respondInternalServerError(req, res) - } - } -} diff --git a/backend-node/test/routes/ansibletower.test.ts b/backend-node/test/routes/ansibletower.test.ts deleted file mode 100644 index daa7409adab..00000000000 --- a/backend-node/test/routes/ansibletower.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import { parsePipedJsonBody } from '../../src/lib/body-parser' -import { ansiblePaths } from '../../src/routes/ansibletower' -import nock from 'nock' - -const TOWER_HOST = 'https://ansible-tower.com' -const SECRET_NS = 'app-team' -const SECRET_NAME = 'tower-cred' - -function nockCredentialSecret(host: string) { - return nock(process.env.CLUSTER_API_URL) - .get(`/api/v1/namespaces/${SECRET_NS}/secrets/${SECRET_NAME}`) - .reply(200, { - kind: 'Secret', - apiVersion: 'v1', - metadata: { name: SECRET_NAME, namespace: SECRET_NS }, - data: { - host: Buffer.from(host).toString('base64'), - token: Buffer.from('12345').toString('base64'), - }, - }) -} - -describe(`ansibletower Route`, function () { - it(`should list Ansible Automation controller Jobs`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nockCredentialSecret(TOWER_HOST) - nock(TOWER_HOST).get(ansiblePaths[0]).reply(200, response) - const res = await request('POST', '/ansibletower', { - secretNamespace: SECRET_NS, - secretName: SECRET_NAME, - ansiblePath: ansiblePaths[0], - }) - expect(res.statusCode).toEqual(200) - expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual(JSON.stringify(response)) - }) - - it(`should reject body-supplied tower hostname`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - const res = await request('POST', '/ansibletower', { - towerHost: TOWER_HOST + ansiblePaths[0], - token: '12345', - }) - expect(res.statusCode).toEqual(400) - }) - - it(`should preserve the query string for paginated requests`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nockCredentialSecret(TOWER_HOST) - nock(TOWER_HOST).get(ansiblePaths[0]).query({ page: '2', page_size: '20' }).reply(200, response) - const res = await request('POST', '/ansibletower', { - secretNamespace: SECRET_NS, - secretName: SECRET_NAME, - ansiblePath: `${ansiblePaths[0]}?page=2&page_size=20`, - }) - expect(res.statusCode).toEqual(200) - expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual(JSON.stringify(response)) - }) - - it(`should reject an external absolute URL in ansiblePath`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nockCredentialSecret(TOWER_HOST) - const res = await request('POST', '/ansibletower', { - secretNamespace: SECRET_NS, - secretName: SECRET_NAME, - ansiblePath: `https://evil.example.com${ansiblePaths[0]}`, - }) - expect(res.statusCode).toEqual(400) - }) - - it(`should reject a network-path reference in ansiblePath`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nockCredentialSecret(TOWER_HOST) - const res = await request('POST', '/ansibletower', { - secretNamespace: SECRET_NS, - secretName: SECRET_NAME, - ansiblePath: `//evil.example.com${ansiblePaths[0]}`, - }) - expect(res.statusCode).toEqual(400) - }) - - it(`should fail closed when caller cannot read the credential secret`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .get(`/api/v1/namespaces/${SECRET_NS}/secrets/${SECRET_NAME}`) - .reply(403, { kind: 'Status', apiVersion: 'v1', status: 'Failure', reason: 'Forbidden', code: 403 }) - const res = await request('POST', '/ansibletower', { - secretNamespace: SECRET_NS, - secretName: SECRET_NAME, - ansiblePath: ansiblePaths[0], - }) - expect(res.statusCode).toEqual(400) - }) - - it(`when bad things happen to Ansible Automation controller Jobs 1`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nockCredentialSecret(TOWER_HOST) - nock(TOWER_HOST).get(ansiblePaths[0]).reply(200, response) - const res = await request('POST', '/ansibletower', { - secretNamespace: SECRET_NS, - secretName: SECRET_NAME, - ansiblePath: '/badPath', - }) - expect(res.statusCode).toEqual(400) - expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual(JSON.stringify({})) - }) - - it(`when bad things happen to Ansible Automation controller Jobs 2`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nockCredentialSecret(TOWER_HOST) - nock(TOWER_HOST).get(ansiblePaths[0]).reply(200, response) - const res = await request('POST', '/ansibletower', { - secretNamespace: SECRET_NS, - secretName: SECRET_NAME, - ansiblePath: '/badPath', - }) - expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual(JSON.stringify({})) - }) - - it(`when bad things happen to Ansible Automation controller Jobs 3`, async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(401) - const res = await request('POST', '/ansibletower') - expect(res.statusCode).toEqual(401) - expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual(JSON.stringify({})) - }) -}) - -const response = { - count: 1, - next: {}, - previous: {}, - results: [ - { - id: 70, - type: 'workflow_job_template', - url: '/api/v2/workflow_job_templates/70/', - related: { - created_by: '/api/v2/users/2/', - modified_by: '/api/v2/users/2/', - last_job: '/api/v2/workflow_jobs/75010/', - workflow_jobs: '/api/v2/workflow_job_templates/70/workflow_jobs/', - schedules: '/api/v2/workflow_job_templates/70/schedules/', - launch: '/api/v2/workflow_job_templates/70/launch/', - webhook_key: '/api/v2/workflow_job_templates/70/webhook_key/', - webhook_receiver: '', - workflow_nodes: '/api/v2/workflow_job_templates/70/workflow_nodes/', - labels: '/api/v2/workflow_job_templates/70/labels/', - activity_stream: '/api/v2/workflow_job_templates/70/activity_stream/', - notification_templates_started: '/api/v2/workflow_job_templates/70/notification_templates_started/', - notification_templates_success: '/api/v2/workflow_job_templates/70/notification_templates_success/', - notification_templates_error: '/api/v2/workflow_job_templates/70/notification_templates_error/', - notification_templates_approvals: '/api/v2/workflow_job_templates/70/notification_templates_approvals/', - access_list: '/api/v2/workflow_job_templates/70/access_list/', - object_roles: '/api/v2/workflow_job_templates/70/object_roles/', - survey_spec: '/api/v2/workflow_job_templates/70/survey_spec/', - copy: '/api/v2/workflow_job_templates/70/copy/', - }, - summary_fields: { - last_job: { - id: 75010, - name: 'Demo Workflow Template', - description: '', - finished: '2023-01-03T19:57:48.114586Z', - status: 'successful', - failed: false, - }, - last_update: { - id: 75010, - name: 'Demo Workflow Template', - description: '', - status: 'successful', - failed: false, - }, - created_by: { id: 2, username: 'admin', first_name: '', last_name: '' }, - modified_by: { id: 2, username: 'admin', first_name: '', last_name: '' }, - object_roles: { - admin_role: { - description: 'Can manage all aspects of the workflow job template', - name: 'Admin', - id: 275, - }, - execute_role: { description: 'May run the workflow job template', name: 'Execute', id: 276 }, - read_role: { description: 'May view settings for the workflow job template', name: 'Read', id: 277 }, - approval_role: { description: 'Can approve or deny a workflow approval node', name: 'Approve', id: 278 }, - }, - user_capabilities: { edit: true, delete: true, start: true, schedule: true, copy: true }, - labels: { count: 0, results: {} }, - recent_jobs: [ - { - id: 75010, - status: 'successful', - finished: '2023-01-03T19:57:48.114586Z', - canceled_on: {}, - type: 'workflow_job', - }, - { - id: 75004, - status: 'successful', - finished: '2023-01-03T19:50:27.542857Z', - canceled_on: {}, - type: 'workflow_job', - }, - { - id: 74998, - status: 'successful', - finished: '2023-01-03T19:29:12.585016Z', - canceled_on: {}, - type: 'workflow_job', - }, - ], - }, - created: '2022-11-17T18:28:50.547286Z', - modified: '2022-11-23T20:30:36.652164Z', - name: 'Demo Workflow Template', - description: '', - last_job_run: '2023-01-03T19:57:48.114586Z', - last_job_failed: false, - next_job_run: {}, - status: 'successful', - extra_vars: '', - organization: {}, - survey_enabled: false, - allow_simultaneous: false, - ask_variables_on_launch: true, - inventory: {}, - limit: {}, - scm_branch: {}, - ask_inventory_on_launch: true, - ask_scm_branch_on_launch: false, - ask_limit_on_launch: false, - webhook_service: '', - webhook_credential: {}, - }, - ], -} diff --git a/backend-node/test/routes/placementDebug.test.ts b/backend-node/test/routes/placementDebug.test.ts deleted file mode 100644 index c8f586d26a6..00000000000 --- a/backend-node/test/routes/placementDebug.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import nock from 'nock' - -const upstreamHost = 'https://cluster-manager-placement.open-cluster-management-hub.svc.cluster.local:9443' - -jest.mock('../../src/lib/placementDebugCAWatch', () => ({ - getPlacementDebugCA: jest.fn(() => 'mock-ca-cert'), - watchPlacementDebugCA: jest.fn(() => jest.fn()), -})) - -function nockAuth(status = 200) { - nock(process.env.CLUSTER_API_URL).get('/api').reply(status, { status }) -} - -describe(`placementDebug Route`, function () { - it(`proxies placement debug request to upstream service`, async function () { - nockAuth() - nock(upstreamHost).post('/debug/placements/').reply(200, { aggregatedScores: [] }) - const res = await request('POST', '/placement-debug', { placement: 'test' }) - expect(res.statusCode).toEqual(200) - }) - - it(`handles upstream errors`, async function () { - nockAuth() - nock(upstreamHost).post('/debug/placements/').reply(500, { error: 'internal server error' }) - const res = await request('POST', '/placement-debug', { placement: 'test' }) - expect(res.statusCode).toEqual(500) - }) - - it(`rejects unauthenticated requests`, async function () { - nockAuth(401) - const res = await request('POST', '/placement-debug', { placement: 'test' }) - expect(res.statusCode).toEqual(401) - }) - - it(`uses custom URL when PLACEMENT_DEBUG_URL is set`, async function () { - const original = process.env.PLACEMENT_DEBUG_URL - process.env.PLACEMENT_DEBUG_URL = 'https://localhost:9443/debug/placements/' - try { - nockAuth() - nock('https://localhost:9443').post('/debug/placements/').reply(200, { aggregatedScores: [] }) - const res = await request('POST', '/placement-debug', { placement: 'test' }) - expect(res.statusCode).toEqual(200) - } finally { - if (original === undefined) { - delete process.env.PLACEMENT_DEBUG_URL - } else { - process.env.PLACEMENT_DEBUG_URL = original - } - } - }) - - it(`returns 503 when CA bundle is not available`, async function () { - const { getPlacementDebugCA } = await import('../../src/lib/placementDebugCAWatch') - const { invalidatePlacementDebugAgent } = await import('../../src/lib/agent') - const mockedGetCA = getPlacementDebugCA as jest.MockedFunction - mockedGetCA.mockReturnValueOnce(undefined) - invalidatePlacementDebugAgent() - - nockAuth() - const res = await request('POST', '/placement-debug', { placement: 'test' }) - expect(res.statusCode).toEqual(503) - }) - - // Connection errors are handled by the pipeline error callback in placementDebug.ts. - // The mock-request test infrastructure doesn't reliably capture pipeline-level errors - // (same limitation as proxy.ts, which also omits connection error tests). -}) diff --git a/backend-node/test/routes/rosaWizardApi.test.ts b/backend-node/test/routes/rosaWizardApi.test.ts deleted file mode 100644 index 86e84f34bf9..00000000000 --- a/backend-node/test/routes/rosaWizardApi.test.ts +++ /dev/null @@ -1,676 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import { parsePipedJsonBody } from '../../src/lib/body-parser' -import nock from 'nock' - -const SSO_HOST = 'https://sso.redhat.com' -const SSO_PATH = '/auth/realms/redhat-external/protocol/openid-connect/token' -const API_HOST = 'https://api.openshift.com' - -const mockPayload = { - service_account_id: Buffer.from('test-client-id').toString('base64'), - service_account_secret: Buffer.from('test-client-secret').toString('base64'), -} - -const mockOrg = { - organization: { - created_at: '2024-01-01T00:00:00Z', - ebs_account_id: 'ebs-123', - external_id: 'ext-123', - id: 'org-abc-123', - kind: 'Organization', - name: 'Test Org', - }, - service_account: true, - username: 'test-user', -} - -function nockAuth() { - return nock(process.env.CLUSTER_API_URL).get('/api').reply(200) -} - -function nockSsoToken() { - return nock(SSO_HOST).post(SSO_PATH).reply(200, { access_token: 'mock-ocm-token' }) -} - -function nockCurrentAccount() { - return nock(API_HOST).get('/api/accounts_mgmt/v1/current_account').reply(200, mockOrg) -} - -describe('rosaWizardApi routes', () => { - beforeEach(() => { - nock.cleanAll() - }) - - describe('POST /aws-account-ids', () => { - test('should return organization labels', async () => { - const labelsResponse = { - items: [ - { - id: '1', - key: 'sts_ocm_role', - value: 'arn:aws:iam::123456789012:role/OCM-Role', - }, - ], - } - - nockAuth() - nockSsoToken() - nockCurrentAccount() - nock(API_HOST).get('/api/accounts_mgmt/v1/organizations/org-abc-123/labels').reply(200, labelsResponse) - - const res = await request('POST', '/aws-account-ids', mockPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual(labelsResponse) - }) - - test('should return 401 when not authenticated', async () => { - nock(process.env.CLUSTER_API_URL).get('/api').reply(401) - - const res = await request('POST', '/aws-account-ids', mockPayload) - expect(res.statusCode).toEqual(401) - }) - }) - - describe('POST /aws-billing-accounts', () => { - test('should return organization quota cost', async () => { - const quotaResponse = { - items: [ - { - quota_id: 'cluster|byoc|moa|marketplace', - cloud_accounts: [{ cloud_account_id: '111111111111', cloud_provider_id: 'aws' }], - }, - ], - } - - nockAuth() - nockSsoToken() - nockCurrentAccount() - nock(API_HOST) - .get( - '/api/accounts_mgmt/v1/organizations/org-abc-123/quota_cost?fetchRelatedResources=true&fetchCloudAccounts=true' - ) - .reply(200, quotaResponse) - - const res = await request('POST', '/aws-billing-accounts', mockPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual(quotaResponse) - }) - - test('should return 401 when not authenticated', async () => { - nock(process.env.CLUSTER_API_URL).get('/api').reply(401) - - const res = await request('POST', '/aws-billing-accounts', mockPayload) - expect(res.statusCode).toEqual(401) - }) - }) - - describe('POST /cluster-name-check', () => { - const clusterNamePayload = { - ...mockPayload, - cluster_name: 'my-rosa-cluster', - } - - test('should return cluster search results when name is unique', async () => { - const clusterSearchBody = { - kind: 'ClusterList', - page: 1, - size: 0, - total: 0, - items: [] as unknown[], - } - - nockAuth() - nockSsoToken() - nock(API_HOST) - .post('/api/clusters_mgmt/v1/clusters?method=get', { - size: 1, - search: "name = 'my-rosa-cluster'", - }) - .reply(200, clusterSearchBody) - - const res = await request('POST', '/cluster-name-check', clusterNamePayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 200, body: clusterSearchBody }) - }) - - test('should return results when cluster name already exists', async () => { - const clusterSearchBody = { - kind: 'ClusterList', - page: 1, - size: 1, - total: 1, - items: [{ id: 'cluster-123', name: 'my-rosa-cluster' }], - } - - nockAuth() - nockSsoToken() - nock(API_HOST) - .post('/api/clusters_mgmt/v1/clusters?method=get', { - size: 1, - search: "name = 'my-rosa-cluster'", - }) - .reply(200, clusterSearchBody) - - const res = await request('POST', '/cluster-name-check', clusterNamePayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 200, body: clusterSearchBody }) - }) - - test('should return 401 when not authenticated', async () => { - nock(process.env.CLUSTER_API_URL).get('/api').reply(401) - - const res = await request('POST', '/cluster-name-check', clusterNamePayload) - expect(res.statusCode).toEqual(401) - }) - - test('should return 400 when cluster_name is missing', async () => { - nockAuth() - - const res = await request('POST', '/cluster-name-check', mockPayload) - expect(res.statusCode).toEqual(400) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ error: 'Invalid cluster name format' }) - }) - - test('should return 400 when cluster_name contains special characters', async () => { - nockAuth() - - const res = await request('POST', '/cluster-name-check', { - ...mockPayload, - cluster_name: "my-cluster'; DROP TABLE clusters--", - }) - expect(res.statusCode).toEqual(400) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ error: 'Invalid cluster name format' }) - }) - - test('should return 400 when cluster_name contains uppercase letters', async () => { - nockAuth() - - const res = await request('POST', '/cluster-name-check', { - ...mockPayload, - cluster_name: 'MyCluster', - }) - expect(res.statusCode).toEqual(400) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ error: 'Invalid cluster name format' }) - }) - }) - - describe('POST /oidc-configs', () => { - const oidcPayload = { - ...mockPayload, - aws_account_id: '123456789012', - } - - test('should return OIDC configs for given AWS account', async () => { - const oidcResponse = { - items: [ - { - id: 'oidc-config-1', - href: '/api/clusters_mgmt/v1/oidc_configs/oidc-config-1', - managed: false, - installer_role_arn: 'arn:aws:iam::123456789012:role/Installer', - }, - ], - } - - nockAuth() - nockSsoToken() - nock(API_HOST) - .get('/api/clusters_mgmt/v1/oidc_configs') - .query({ search: "aws.account_id=123456789012 or aws.account_id=''" }) - .reply(200, oidcResponse) - - const res = await request('POST', '/oidc-configs', oidcPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual(oidcResponse) - }) - - test('should return error object when OIDC API call fails', async () => { - nockAuth() - nockSsoToken() - nock(API_HOST) - .get('/api/clusters_mgmt/v1/oidc_configs') - .query({ search: "aws.account_id=123456789012 or aws.account_id=''" }) - .replyWithError('connection refused') - - const res = await request('POST', '/oidc-configs', oidcPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody<{ error: string }>(res) - expect(body).toEqual({ error: expect.stringContaining('connection refused') as string }) - }) - - test('should return 500 when SSO token request fails', async () => { - nockAuth() - nock(SSO_HOST).post(SSO_PATH).replyWithError('SSO unavailable') - - const res = await request('POST', '/oidc-configs', oidcPayload) - expect(res.statusCode).toEqual(500) - }) - }) - - describe('POST /regions', () => { - test('should return cloud providers with regions', async () => { - const cloudProvidersResponse = { - kind: 'CloudProviderList', - items: [ - { - id: 'aws', - name: 'AWS', - regions: { - items: [ - { id: 'us-east-1', name: 'US East (N. Virginia)' }, - { id: 'eu-west-1', name: 'EU (Ireland)' }, - ], - }, - }, - ], - } - - nockAuth() - nockSsoToken() - nock(API_HOST) - .get('/api/clusters_mgmt/v1/cloud_providers?size=-1&fetchRegions=true') - .reply(200, cloudProvidersResponse) - - const res = await request('POST', '/regions', mockPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual(cloudProvidersResponse) - }) - - test('should return error object when cloud providers request fails', async () => { - nockAuth() - nockSsoToken() - nock(API_HOST) - .get('/api/clusters_mgmt/v1/cloud_providers?size=-1&fetchRegions=true') - .replyWithError('Connection refused') - - const res = await request('POST', '/regions', mockPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody<{ error: string }>(res) - expect(body.error).toContain('Connection refused') - }) - - test('should return 401 when not authenticated', async () => { - nock(process.env.CLUSTER_API_URL).get('/api').reply(401) - - const res = await request('POST', '/regions', mockPayload) - expect(res.statusCode).toEqual(401) - }) - }) - - describe('POST /sts-role-arns', () => { - const payloadWithAccount = { - ...mockPayload, - aws_account_id: '720424066366', - } - - test('should return STS account roles', async () => { - const rolesResponse = { - statusCode: 200, - body: { - kind: 'AccountRoleList', - items: [ - { - prefix: 'ManagedOpenShift', - kind: 'AccountRole', - items: [ - { arn: 'arn:aws:iam::720424066366:role/Installer', type: 'Installer' }, - { arn: 'arn:aws:iam::720424066366:role/Support', type: 'Support' }, - ], - }, - ], - }, - } - - nockAuth() - nockSsoToken() - nock(API_HOST).post('/api/clusters_mgmt/v1/aws_inquiries/sts_account_roles').reply(200, rolesResponse.body) - - const res = await request('POST', '/sts-role-arns', payloadWithAccount) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 200, body: rolesResponse.body }) - }) - - test('should return 401 when not authenticated', async () => { - nock(process.env.CLUSTER_API_URL).get('/api').reply(401) - - const res = await request('POST', '/sts-role-arns', payloadWithAccount) - expect(res.statusCode).toEqual(401) - }) - }) - - describe('POST /sts-ocm-role', () => { - const payloadWithAccount = { - ...mockPayload, - aws_account_id: '720424066366', - } - - test('should return OCM role ARN', async () => { - const ocmRoleResponse = { - arn: 'arn:aws:iam::720424066366:role/ManagedOpenShift-OCM-Role', - type: 'OCM', - isAdmin: true, - profile: 'default', - roleVersion: '4.14', - managedPolicies: true, - hcpManagedPolicies: true, - } - - nockAuth() - nockSsoToken() - nock(API_HOST).post('/api/clusters_mgmt/v1/aws_inquiries/sts_ocm_role').reply(200, ocmRoleResponse) - - const res = await request('POST', '/sts-ocm-role', payloadWithAccount) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 200, body: ocmRoleResponse }) - }) - - test('should handle 403 error from OCM API', async () => { - const errorResponse = { - kind: 'Error', - id: '403', - reason: 'Organization is not authorized to access AWS Account', - } - - nockAuth() - nockSsoToken() - nock(API_HOST).post('/api/clusters_mgmt/v1/aws_inquiries/sts_ocm_role').reply(403, errorResponse) - - const res = await request('POST', '/sts-ocm-role', payloadWithAccount) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 403, body: errorResponse }) - }) - }) - - describe('POST /sts-user-role', () => { - test('should return user role label', async () => { - const userRoleResponse = { - account_id: 'account-1', - id: 'label-1', - internal: false, - key: 'sts_user_role', - kind: 'AccountLabel', - value: 'arn:aws:iam::720424066366:role/User-Role', - } - - nockAuth() - nockSsoToken() - nock(API_HOST).get('/api/accounts_mgmt/v1/current_account').reply(200, { id: 'account-1' }) - nock(API_HOST) - .get(/\/api\/accounts_mgmt\/v1\/accounts\/account-1\/labels\/sts_user_role/) - .reply(200, userRoleResponse) - - const res = await request('POST', '/sts-user-role', mockPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual(userRoleResponse) - }) - - test('should return 401 when not authenticated', async () => { - nock(process.env.CLUSTER_API_URL).get('/api').reply(401) - - const res = await request('POST', '/sts-user-role', mockPayload) - expect(res.statusCode).toEqual(401) - }) - }) - - describe('POST /openshift-versions', () => { - const versionsPath = - "/api/clusters_mgmt/v1/versions/?order=end_of_life_timestamp desc&product=hcp&search=enabled='t' AND (channel_group='stable' OR channel_group='eus' OR channel_group='candidate' OR channel_group='fast' OR channel_group='nightly') AND rosa_enabled='t'&size=-1" - - test('should return OpenShift versions list', async () => { - const versionsResponse = { - kind: 'VersionList', - page: 1, - size: 3, - total: 3, - items: [ - { - id: 'openshift-v4.14.10', - kind: 'Version', - raw_id: '4.14.10', - channel_group: 'stable', - rosa_enabled: true, - hosted_control_plane_enabled: true, - end_of_life_timestamp: '2025-10-31T00:00:00Z', - }, - { - id: 'openshift-v4.14.9', - kind: 'Version', - raw_id: '4.14.9', - channel_group: 'stable', - rosa_enabled: true, - hosted_control_plane_enabled: true, - end_of_life_timestamp: '2025-10-31T00:00:00Z', - }, - { - id: 'openshift-v4.13.25', - kind: 'Version', - raw_id: '4.13.25', - channel_group: 'eus', - rosa_enabled: true, - hosted_control_plane_enabled: true, - end_of_life_timestamp: '2025-04-17T00:00:00Z', - }, - ], - } - - nockAuth() - nockSsoToken() - nock(API_HOST).get(versionsPath).reply(200, versionsResponse) - - const res = await request('POST', '/openshift-versions', mockPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual(versionsResponse) - }) - - test('should return error object when versions API call fails', async () => { - nockAuth() - nockSsoToken() - nock(API_HOST).get(versionsPath).replyWithError('connection timeout') - - const res = await request('POST', '/openshift-versions', mockPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody<{ error: string }>(res) - expect(body).toEqual({ error: expect.stringContaining('connection timeout') as string }) - }) - - test('should return 401 when not authenticated', async () => { - nock(process.env.CLUSTER_API_URL).get('/api').reply(401) - - const res = await request('POST', '/openshift-versions', mockPayload) - expect(res.statusCode).toEqual(401) - }) - }) - - describe('POST /vpcs', () => { - const vpcsPayload = { - ...mockPayload, - aws: { account_id: '720424066366', sts: { role_arn: 'arn:aws:iam::720424066366:role/Installer' } }, - region: { id: 'us-east-2' }, - } - - test('should return VPCs for given AWS account and region', async () => { - const vpcsResponse = { - kind: 'VPCList', - items: [ - { vpc_id: 'vpc-123', name: 'my-vpc' }, - { vpc_id: 'vpc-456', name: 'other-vpc' }, - ], - } - - nockAuth() - nockSsoToken() - nock(API_HOST) - .post('/api/clusters_mgmt/v1/aws_inquiries/vpcs?fetchSecurityGroups=true', { - aws: vpcsPayload.aws, - region: vpcsPayload.region, - }) - .reply(200, vpcsResponse) - - const res = await request('POST', '/vpcs', vpcsPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 200, body: vpcsResponse }) - }) - - test('should return error object when VPCs API call fails', async () => { - nockAuth() - nockSsoToken() - nock(API_HOST) - .post('/api/clusters_mgmt/v1/aws_inquiries/vpcs?fetchSecurityGroups=true') - .replyWithError('connection refused') - - const res = await request('POST', '/vpcs', vpcsPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody<{ error: string }>(res) - expect(body).toEqual({ error: expect.stringContaining('connection refused') as string }) - }) - - test('should return 500 when SSO token request fails', async () => { - nockAuth() - nock(SSO_HOST).post(SSO_PATH).replyWithError('SSO unavailable') - - const res = await request('POST', '/vpcs', vpcsPayload) - expect(res.statusCode).toEqual(500) - }) - - test('should forward error response from upstream API', async () => { - const errorResponse = { - kind: 'Error', - id: '400', - reason: 'The role ARN is not valid', - } - - nockAuth() - nockSsoToken() - nock(API_HOST).post('/api/clusters_mgmt/v1/aws_inquiries/vpcs?fetchSecurityGroups=true').reply(400, errorResponse) - - const res = await request('POST', '/vpcs', vpcsPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 400, body: errorResponse }) - }) - }) - - describe('POST /machine-types', () => { - const machineTypesPayload = { - ...mockPayload, - region: 'us-east-1', - role_arn: 'arn:aws:iam::720424066366:role/Installer', - availability_zones: ['us-east-1a', 'us-east-1b'], - } - - test('should return machine types for the given region', async () => { - const machineTypesResponse = { - kind: 'MachineTypeList', - page: 1, - size: 2, - total: 2, - items: [ - { - kind: 'MachineType', - id: 'm5.xlarge', - name: 'm5.xlarge - General Purpose', - category: 'general_purpose', - cpu: { value: 4, unit: 'vCPU' }, - memory: { value: 17179869184, unit: 'B' }, - cloud_provider: { id: 'aws' }, - }, - { - kind: 'MachineType', - id: 'm6a.xlarge', - name: 'm6a.xlarge - General Purpose', - category: 'general_purpose', - cpu: { value: 4, unit: 'vCPU' }, - memory: { value: 17179869184, unit: 'B' }, - cloud_provider: { id: 'aws' }, - }, - ], - } - - nockAuth() - nockSsoToken() - nock(API_HOST) - .post('/api/clusters_mgmt/v1/aws_inquiries/machine_types', { - aws: { sts: { role_arn: 'arn:aws:iam::720424066366:role/Installer' } }, - region: { id: 'us-east-1' }, - availability_zones: ['us-east-1a', 'us-east-1b'], - }) - .query({ size: '-1' }) - .reply(200, machineTypesResponse) - - const res = await request('POST', '/machine-types', machineTypesPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 200, body: machineTypesResponse }) - }) - - test('should return error response from OCM API', async () => { - const errorResponse = { - kind: 'Error', - id: '400', - reason: 'Invalid region', - } - - nockAuth() - nockSsoToken() - nock(API_HOST).post('/api/clusters_mgmt/v1/aws_inquiries/machine_types').query(true).reply(400, errorResponse) - - const res = await request('POST', '/machine-types', machineTypesPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody(res) - expect(body).toEqual({ statusCode: 400, body: errorResponse }) - }) - - test('should return error object when machine types request fails', async () => { - nockAuth() - nockSsoToken() - nock(API_HOST) - .post('/api/clusters_mgmt/v1/aws_inquiries/machine_types') - .query(true) - .replyWithError('connection refused') - - const res = await request('POST', '/machine-types', machineTypesPayload) - expect(res.statusCode).toEqual(200) - - const body = await parsePipedJsonBody<{ error: string }>(res) - expect(body).toEqual({ error: expect.stringContaining('connection refused') as string }) - }) - }) -}) diff --git a/backend-node/test/routes/upgrade-risks-prediction.test.ts b/backend-node/test/routes/upgrade-risks-prediction.test.ts deleted file mode 100644 index da4dde8083c..00000000000 --- a/backend-node/test/routes/upgrade-risks-prediction.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import nock from 'nock' -import { parsePipedJsonBody } from '../../src/lib/body-parser' -import { request } from '../mock-request' - -describe('Upgrade risks prediction Route', function () { - afterEach(() => { - delete process.env.UPGRADE_RISKS_PREDICTION_URL - }) - - it('should use UPGRADE_RISKS_PREDICTION_URL env var when set', async function () { - process.env.UPGRADE_RISKS_PREDICTION_URL = - 'https://on-prem.example.com/api/insights-results-aggregator/v2/upgrade-risks-prediction' - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/openshift-config/secrets') - .reply(200, { - statusCode: 200, - body: { - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { name: 'pull-secret', namespace: 'openshift-config' }, - data: { '.dockerconfigjson': 'test' }, - type: 'kubernetes.io/dockerconfigjson', - }, - ], - }, - }) - nock('https://on-prem.example.com') - .post('/api/insights-results-aggregator/v2/upgrade-risks-prediction') - .reply(200, { predictions: [] }) - const res = await request('POST', '/upgrade-risks-prediction', { clusterIds: ['id-1234-abcd'] }) - expect(res.statusCode).toEqual(200) - }) - - it('should return the upgrade risks', async function () { - nock(process.env.CLUSTER_API_URL).get('/api').reply(200) - nock(process.env.CLUSTER_API_URL) - .get('/api/v1/namespaces/openshift-config/secrets') - .reply(200, { - statusCode: 200, - body: { - apiVersion: 'v1', - kind: 'SecretList', - items: [ - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'pull-secret', - namespace: 'openshift-config', - }, - data: { - '.dockerconfigjson': 'test', - }, - type: 'kubernetes.io/dockerconfigjson', - }, - ], - }, - }) - nock('https://console.redhat.com') - .post('/api/insights-results-aggregator/v2/upgrade-risks-prediction') - .reply(200, { - statusCode: 200, - body: { - predictions: [ - { - cluster_id: 'id-1234-abcd', - prediction_status: 'ok', - upgrade_recommended: true, - upgrade_risks_predictors: { - alerts: [], - operator_conditions: [], - }, - last_checked_at: '2024-03-25T20:33:03.156633+00:00', - }, - ], - status: 'ok', - }, - }) - const res = await request('POST', '/upgrade-risks-prediction', { clusterIds: ['id-1234-abcd'] }) - expect(res.statusCode).toEqual(200) - expect(JSON.stringify(await parsePipedJsonBody(res))).toEqual( - JSON.stringify([ - { - statusCode: 200, - body: { - statusCode: 200, - body: { - predictions: [ - { - cluster_id: 'id-1234-abcd', - prediction_status: 'ok', - upgrade_recommended: true, - upgrade_risks_predictors: { alerts: [], operator_conditions: [] }, - last_checked_at: '2024-03-25T20:33:03.156633+00:00', - }, - ], - status: 'ok', - }, - }, - }, - ]) - ) - }) -}) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 44ba26d5ed9..d5001933670 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -35,6 +35,10 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/aggregate` | `POST /aggregate/{applications,statuses,appSetData}`: informer cache + Search SA GraphQL, Fuse.js-compatible filter, windowed SSAR. `CONSOLE_INFORMER_CACHE=0` does not register the route | | `internal/searchapi` | Search GraphQL client used by the aggregator (`/searchapi/graphql` or `/federated`) | | `internal/searchproxy` | `POST /proxy/search` and graphql-ws relay to search-api with the **user** token (`connection_init` Authorization injection) | +| `internal/rosa` | ROSA HCP wizard POSTs to `sso.redhat.com` + `api.openshift.com` (OCM service-account token) | +| `internal/ansibletower` | `POST /ansibletower`: user-token Secret GET, AAP path allowlist, TLS skip-verify | +| `internal/placementdebug` | `POST /placement-debug` reverse proxy + independent watch of OCM CA ConfigMap | +| `internal/upgraderisks` | `POST /upgrade-risks-prediction`: SA list `pull-secret`, chunked Insights POSTs | | `internal/informers` | Hub resource cache (~67 watch specs, dual-run with Node). Dev: `GET /debug/informer-snapshot` | | `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | @@ -67,6 +71,8 @@ Go backend :4000 (TLS / HTTP/2) ├─ GET /events/rbac (ClusterRole watch; also /multicloud/events/rbac) ├─ POST /aggregate/{applications,statuses,appSetData} (application inventory; also /multicloud/…) ├─ POST /proxy/search and WebSocket graphql-ws (user token; also /multicloud/proxy/search) + ├─ POST ROSA wizard (/aws-account-ids, /regions, /vpcs, …) → OCM + ├─ POST /ansibletower, /placement-debug, /upgrade-risks-prediction ├─ GET /debug/informer-snapshot (dev only; Go informer cache dump) ├─ SA informers (~67 specs) feed GET /events and POST /aggregate; Node startWatching() still runs for hub.ts ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) @@ -95,6 +101,8 @@ During ACM-42597/42598 the Go process watches the same specs as Node `startWatch `POST /proxy/search` and the Search WebSocket are served by Go (`backend/internal/searchproxy`). Auth is GET `/api`. GraphQL POST injects the user Bearer token and forwards the Node header allowlist (`accept`, `accept-encoding`, `content-encoding`, `content-length`, `content-type`). The graphql-ws relay opens `wss` to the same Search URL, sends `Authorization` on the upgrade, and rewrites the first `connection_init` payload with `Authorization: Bearer `. Upstream connect timeout 60s → 504; connect failure → 502. Discovery matches the aggregator: `SEARCH_API_URL` or `search-search-api..svc.cluster.local:4010` plus `/searchapi/graphql` (or `/federated` when `globalSearchFeatureFlag=enabled`). +Long-tail HTTP is always registered in Go (not gated on `CONSOLE_INFORMER_CACHE`). Auth is GET `/api` (401 empty body). ROSA wizard POSTs exchange OCM client credentials at SSO then call `api.openshift.com`. `POST /ansibletower` reads the credential Secret with the **user** token, allow-lists AAP pathnames, and GETs the tower with `InsecureSkipVerify`. `POST /placement-debug` reverse-proxies to `PLACEMENT_DEBUG_URL` (or the in-cluster placement service) with the OCM CA ConfigMap `open-cluster-management-hub/ca-bundle-configmap`; missing CA → 503. `POST /upgrade-risks-prediction` lists `openshift-config` secrets with the **SA**, extracts `pull-secret` `cloud.openshift.com` auth, and POSTs Insights in chunks of 100 (`UPGRADE_RISKS_PREDICTION_URL` or console.redhat.com). The Node sidecar still serves `GET /events` when the Go cache is off, plus leftover aggregators/`startWatching` until ACM-42603. + `GET /events` framing matches Node `server-side-events.ts`: `id:` + `data:` (no space), gzip when `Accept-Encoding` includes gzip, keepalive `:\n\n` every 10s, snapshot `START` → `SETTINGS` → priority packets with `EOP` → `LOADED`, live `MODIFIED`/`DELETED` then `LOADED`. Creates and updates are both `MODIFIED` (not `ADDED`). **DELETED events are broadcast without per-user SSAR** — the same known gap as Node; do not “fix” it in this stream without a follow-up. ## Shared artifacts @@ -103,6 +111,6 @@ During ACM-42597/42598 the Go process watches the same specs as Node `startWatch Go exits 1 at startup if the service-account token is missing (`TOKEN` or `/var/run/secrets/kubernetes.io/serviceaccount/token`). -Migrated proxy routes also read `CLUSTER_PROXY_ADDON_USER_HOST` / `CLUSTER_PROXY_ADDON_USER_ROUTE`, `PROMETHEUS_ROUTE`, `OBSERVABILITY_ROUTE`, and `SERVICE_CA_CERT` from the same `.env`. +Migrated proxy routes also read `CLUSTER_PROXY_ADDON_USER_HOST` / `CLUSTER_PROXY_ADDON_USER_ROUTE`, `PROMETHEUS_ROUTE`, `OBSERVABILITY_ROUTE`, `SERVICE_CA_CERT`, `PLACEMENT_DEBUG_URL`, and `UPGRADE_RISKS_PREDICTION_URL` from the same `.env` / `config/` directory. `PUBLIC_FOLDER` (default `public`) is the on-disk plugin/SPA tree. Production images copy `frontend/plugins/{acm|mce}/dist` to `/app/public/plugin`. diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index c3e10451f31..50a215e03ae 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -18,6 +18,7 @@ import ( "k8s.io/client-go/kubernetes" "github.com/stolostron/console/backend/internal/aggregate" + "github.com/stolostron/console/backend/internal/ansibletower" "github.com/stolostron/console/backend/internal/auth" "github.com/stolostron/console/backend/internal/clusterinfo" "github.com/stolostron/console/backend/internal/clusterproxy" @@ -31,10 +32,13 @@ import ( "github.com/stolostron/console/backend/internal/mcproxy" "github.com/stolostron/console/backend/internal/metricsproxy" "github.com/stolostron/console/backend/internal/oauth" + "github.com/stolostron/console/backend/internal/placementdebug" + "github.com/stolostron/console/backend/internal/rosa" "github.com/stolostron/console/backend/internal/searchapi" "github.com/stolostron/console/backend/internal/searchproxy" "github.com/stolostron/console/backend/internal/server" "github.com/stolostron/console/backend/internal/static" + "github.com/stolostron/console/backend/internal/upgraderisks" "github.com/stolostron/console/backend/internal/user" "github.com/stolostron/console/backend/internal/vmproxy" ) @@ -177,6 +181,12 @@ func run() error { }))) serviceTLS := auth.ServiceTLSConfig(sa) + insightsCA := sa.ServiceCACert + if len(insightsCA) == 0 { + insightsCA = sa.CACert + } + placementCA := &placementdebug.CAWatch{Kube: kube} + placementCA.Start(ctx) addonResolver := &clusterproxy.Resolver{ HostOverride: cfg.ClusterProxyAddonUserHost, RouteOverride: cfg.ClusterProxyAddonUserRoute, @@ -219,6 +229,22 @@ func run() error { TLSConfig: serviceTLS, Endpoint: searchDiscovery.Endpoint, })), + server.WithRosa(rosa.New(rosa.Options{ + RESTConfig: restCfg, + Client: auth.HTTPClient(nil, 0), + })), + server.WithAnsibleTower(ansibletower.New(ansibletower.Options{ + RESTConfig: restCfg, + })), + server.WithPlacementDebug(placementdebug.New(placementdebug.Options{ + RESTConfig: restCfg, + GetCA: placementCA.Get, + })), + server.WithUpgradeRisks(upgraderisks.New(upgraderisks.Options{ + RESTConfig: restCfg, + Kube: kube, + Client: auth.HTTPClient(insightsCA, 0), + })), ) handler, err := server.Handler(cfg, opts...) diff --git a/backend/internal/ansibletower/ansibletower.go b/backend/internal/ansibletower/ansibletower.go new file mode 100644 index 00000000000..d5da3ec973a --- /dev/null +++ b/backend/internal/ansibletower/ansibletower.go @@ -0,0 +1,194 @@ +// Copyright Contributors to the Open Cluster Management project + +package ansibletower + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/outbound" + applog "github.com/stolostron/console/backend/internal/log" +) + +// Paths is the AAP pathname allowlist (must match frontend ansiblePaths). +var Paths = []string{ + "/api/v2/job_templates/", + "/api/v2/workflow_job_templates/", + "/api/v2/inventories/", + "/api/controller/v2/job_templates/", + "/api/controller/v2/workflow_job_templates/", + "/api/controller/v2/inventories/", +} + +type requestBody struct { + SecretNamespace string `json:"secretNamespace"` + SecretName string `json:"secretName"` + AnsiblePath string `json:"ansiblePath"` +} + +// Options configure the Ansible Tower proxy. +type Options struct { + RESTConfig *rest.Config + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + KubeForUser func(token string) (kubernetes.Interface, error) + Tower *http.Client +} + +// Handler serves POST /ansibletower. +type Handler struct { + RESTConfig *rest.Config + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + KubeForUser func(token string) (kubernetes.Interface, error) + Tower *http.Client +} + +// New returns an Ansible Tower proxy handler. +func New(opts Options) *Handler { + h := &Handler{ + RESTConfig: opts.RESTConfig, + Authn: opts.Authn, + KubeForUser: opts.KubeForUser, + Tower: opts.Tower, + } + if h.Authn == nil && opts.RESTConfig != nil { + h.Authn = func(w http.ResponseWriter, r *http.Request) (string, bool) { + return auth.AuthenticateRequest(r.Context(), opts.RESTConfig, w, r) + } + } + if h.KubeForUser == nil && opts.RESTConfig != nil { + h.KubeForUser = func(token string) (kubernetes.Interface, error) { + return kubernetes.NewForConfig(auth.UserRESTConfig(opts.RESTConfig, token)) + } + } + if h.Tower == nil { + h.Tower = &http.Client{ + Timeout: 30 * time.Second, + Transport: outbound.Transport(&tls.Config{InsecureSkipVerify: true}, true), //nolint:gosec // Node rejectUnauthorized: false + } + } + return h +} + +func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { + if h.Authn != nil { + return h.Authn(w, r) + } + w.WriteHeader(http.StatusUnauthorized) + return "", false +} + +// ServeHTTP proxies an allow-listed AAP GET using credentials from a user-readable Secret. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + token, ok := h.authenticate(w, r) + if !ok { + return + } + raw, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + var body requestBody + if err = json.Unmarshal(raw, &body); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if body.SecretNamespace == "" || body.SecretName == "" || body.AnsiblePath == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + host, towerToken, err := h.credential(r.Context(), token, body.SecretNamespace, body.SecretName) + if err != nil || host == "" || towerToken == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + hostURL, err := url.Parse(host) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + towerURL, err := url.Parse(body.AnsiblePath) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if !towerURL.IsAbs() { + towerURL = hostURL.ResolveReference(towerURL) + } + if towerURL.Scheme == "" || towerURL.Host == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + if towerURL.Scheme != hostURL.Scheme || towerURL.Host != hostURL.Host { + w.WriteHeader(http.StatusBadRequest) + return + } + if !allowedPath(towerURL.Path) { + w.WriteHeader(http.StatusBadRequest) + return + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, towerURL.String(), nil) + if err != nil { + applog.Logger().Error("ansibletower request", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + req.Header.Set("Authorization", "Bearer "+towerToken) + resp, err := h.Tower.Do(req) + if err != nil { + applog.Logger().Error("ansibletower upstream", "error", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(err.Error()) + return + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func (h *Handler) credential(ctx context.Context, userToken, ns, name string) (string, string, error) { + if h.KubeForUser == nil { + return "", "", errNoKube + } + kube, err := h.KubeForUser(userToken) + if err != nil { + return "", "", err + } + secret, err := kube.CoreV1().Secrets(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return "", "", err + } + return string(secret.Data["host"]), string(secret.Data["token"]), nil +} + +var errNoKube = errors.New("ansibletower kube client missing") + +func allowedPath(path string) bool { + for _, p := range Paths { + if path == p { + return true + } + } + return false +} diff --git a/backend/internal/ansibletower/ansibletower_test.go b/backend/internal/ansibletower/ansibletower_test.go new file mode 100644 index 00000000000..192a1e27922 --- /dev/null +++ b/backend/internal/ansibletower/ansibletower_test.go @@ -0,0 +1,200 @@ +// Copyright Contributors to the Open Cluster Management project + +package ansibletower + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +func authOK(_ http.ResponseWriter, _ *http.Request) (string, bool) { + return "user-token", true +} + +func post(h http.Handler, body any) *httptest.ResponseRecorder { + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/ansibletower", strings.NewReader(string(raw))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestUnauthorized(t *testing.T) { + h := New(Options{}) + rec := post(h, map[string]string{}) + if rec.Code != http.StatusUnauthorized || rec.Body.Len() != 0 { + t.Fatalf("status %d body %q", rec.Code, rec.Body.String()) + } +} + +func TestBadBody(t *testing.T) { + h := New(Options{Authn: authOK}) + rec := post(h, map[string]string{"towerHost": "https://evil"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} + +func TestProxiesAllowlistedPath(t *testing.T) { + var gotPath, gotAuth string + tower := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.RequestURI() + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"count":1}`)) + })) + defer tower.Close() + + kube := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "tower-cred", Namespace: "app-team"}, + Data: map[string][]byte{"host": []byte(tower.URL), "token": []byte("12345")}, + }) + h := New(Options{ + Authn: authOK, + KubeForUser: func(string) (kubernetes.Interface, error) { return kube, nil }, + Tower: tower.Client(), + }) + rec := post(h, map[string]string{ + "secretNamespace": "app-team", + "secretName": "tower-cred", + "ansiblePath": Paths[0] + "?page=2&page_size=20", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if gotAuth != "Bearer 12345" { + t.Fatalf("auth %q", gotAuth) + } + if gotPath != "/api/v2/job_templates/?page=2&page_size=20" { + t.Fatalf("path %q", gotPath) + } + if rec.Body.String() != `{"count":1}` { + t.Fatalf("body %s", rec.Body.String()) + } +} + +func TestRejectsAbsoluteURL(t *testing.T) { + tower := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("should not reach tower") + })) + defer tower.Close() + kube := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "tower-cred", Namespace: "app-team"}, + Data: map[string][]byte{"host": []byte(tower.URL), "token": []byte("12345")}, + }) + h := New(Options{ + Authn: authOK, + KubeForUser: func(string) (kubernetes.Interface, error) { return kube, nil }, + Tower: tower.Client(), + }) + rec := post(h, map[string]string{ + "secretNamespace": "app-team", + "secretName": "tower-cred", + "ansiblePath": "https://evil.example.com" + Paths[0], + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} + +func TestRejectsNetworkPath(t *testing.T) { + tower := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("should not reach tower") + })) + defer tower.Close() + kube := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "tower-cred", Namespace: "app-team"}, + Data: map[string][]byte{"host": []byte(tower.URL), "token": []byte("12345")}, + }) + h := New(Options{ + Authn: authOK, + KubeForUser: func(string) (kubernetes.Interface, error) { return kube, nil }, + Tower: tower.Client(), + }) + rec := post(h, map[string]string{ + "secretNamespace": "app-team", + "secretName": "tower-cred", + "ansiblePath": "//evil.example.com" + Paths[0], + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} + +func TestSecretForbiddenIs400(t *testing.T) { + kube := fake.NewSimpleClientset() + h := New(Options{ + Authn: authOK, + KubeForUser: func(string) (kubernetes.Interface, error) { return kube, nil }, + }) + rec := post(h, map[string]string{ + "secretNamespace": "app-team", + "secretName": "tower-cred", + "ansiblePath": Paths[0], + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} + +func TestRejectsBadPath(t *testing.T) { + tower := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("should not reach tower") + })) + defer tower.Close() + kube := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "tower-cred", Namespace: "app-team"}, + Data: map[string][]byte{"host": []byte(tower.URL), "token": []byte("12345")}, + }) + h := New(Options{ + Authn: authOK, + KubeForUser: func(string) (kubernetes.Interface, error) { return kube, nil }, + Tower: tower.Client(), + }) + rec := post(h, map[string]string{ + "secretNamespace": "app-team", + "secretName": "tower-cred", + "ansiblePath": "/badPath", + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } +} + +func TestGatewayPath(t *testing.T) { + var gotPath string + tower := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{"count":0}`)) + })) + defer tower.Close() + kube := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "tower-cred", Namespace: "app-team"}, + Data: map[string][]byte{"host": []byte(tower.URL), "token": []byte("12345")}, + }) + h := New(Options{ + Authn: authOK, + KubeForUser: func(string) (kubernetes.Interface, error) { return kube, nil }, + Tower: tower.Client(), + }) + rec := post(h, map[string]string{ + "secretNamespace": "app-team", + "secretName": "tower-cred", + "ansiblePath": "/api/controller/v2/job_templates/", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if gotPath != "/api/controller/v2/job_templates/" { + t.Fatalf("path %q", gotPath) + } +} diff --git a/backend/internal/auth/tls.go b/backend/internal/auth/tls.go index c41b0139963..533693b97ba 100644 --- a/backend/internal/auth/tls.go +++ b/backend/internal/auth/tls.go @@ -8,6 +8,8 @@ import ( "net/http" "os" "time" + + "github.com/stolostron/console/backend/internal/outbound" ) // TLSConfigFromCA builds a TLS config from a PEM CA bundle. @@ -42,11 +44,8 @@ func HTTPClient(ca []byte, timeout time.Duration) *http.Client { timeout = 30 * time.Second } return &http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - TLSClientConfig: TLSConfigFromCA(ca, true), - Proxy: http.ProxyFromEnvironment, - }, + Timeout: timeout, + Transport: outbound.Transport(TLSConfigFromCA(ca, true), true), } } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index b737d57e104..06ab4cc404d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -157,6 +157,7 @@ func (c *Config) ReloadSettings() error { } promote("globalSearchFeatureFlag") promote("UPGRADE_RISKS_PREDICTION_URL") + promote("PLACEMENT_DEBUG_URL") if lvl, ok := next["LOG_LEVEL"]; ok { c.LogLevel = lvl diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index b94adff7238..3d9cd3721c8 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -22,6 +22,7 @@ func TestReloadSettings_PromotesKeys(t *testing.T) { write("APP_SEARCH_LIMIT", "50") write("globalSearchFeatureFlag", "enabled") write("UPGRADE_RISKS_PREDICTION_URL", "https://example.invalid") + write("PLACEMENT_DEBUG_URL", "https://placement.example/debug/placements/") write("ansibleIntegration", "available") t.Setenv("LOG_LEVEL", "") @@ -41,6 +42,9 @@ func TestReloadSettings_PromotesKeys(t *testing.T) { if os.Getenv("UPGRADE_RISKS_PREDICTION_URL") != "https://example.invalid" { t.Fatalf("upgrade url=%q", os.Getenv("UPGRADE_RISKS_PREDICTION_URL")) } + if os.Getenv("PLACEMENT_DEBUG_URL") != "https://placement.example/debug/placements/" { + t.Fatalf("placement url=%q", os.Getenv("PLACEMENT_DEBUG_URL")) + } if os.Getenv("ansibleIntegration") != "" { t.Fatal("ansibleIntegration must not be promoted to env") } diff --git a/backend/internal/k8sproxy/k8sproxy.go b/backend/internal/k8sproxy/k8sproxy.go index 590d3023335..b3e7aab89c9 100644 --- a/backend/internal/k8sproxy/k8sproxy.go +++ b/backend/internal/k8sproxy/k8sproxy.go @@ -11,6 +11,7 @@ import ( "time" "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/outbound" "github.com/stolostron/console/backend/internal/server" ) @@ -50,11 +51,7 @@ func TLSConfigFromCA(caCert []byte) *tls.Config { // New returns a handler that proxies hub K8s API requests (/api, /apis, /version) with the user's token. func New(clusterURL *url.URL, tlsConfig *tls.Config) http.Handler { - transport := &http.Transport{ - TLSClientConfig: tlsConfig, - ForceAttemptHTTP2: true, - ResponseHeaderTimeout: 0, - } + transport := outbound.Transport(tlsConfig, true) rp := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { token := auth.TokenFromRequest(pr.In) diff --git a/backend/internal/mcproxy/mcproxy.go b/backend/internal/mcproxy/mcproxy.go index 0e50b1e3dbd..77368e08389 100644 --- a/backend/internal/mcproxy/mcproxy.go +++ b/backend/internal/mcproxy/mcproxy.go @@ -15,6 +15,7 @@ import ( "github.com/stolostron/console/backend/internal/auth" "github.com/stolostron/console/backend/internal/clusterproxy" + "github.com/stolostron/console/backend/internal/outbound" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/server" ) @@ -30,11 +31,7 @@ type Options struct { // New proxies /managedclusterproxy// to the cluster-proxy addon. func New(opts Options) http.Handler { - transport := &http.Transport{ - TLSClientConfig: opts.TLSConfig, - ForceAttemptHTTP2: false, // HTTP/1.1 so WebSocket upgrades work - ResponseHeaderTimeout: 0, - } + transport := outbound.Transport(opts.TLSConfig, false) // HTTP/1.1 so WebSocket upgrades work rp := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { target, err := opts.Resolver.ProxyURL(pr.In.Context()) diff --git a/backend/internal/metricsproxy/metricsproxy.go b/backend/internal/metricsproxy/metricsproxy.go index 91f97e89c55..74659c7b14a 100644 --- a/backend/internal/metricsproxy/metricsproxy.go +++ b/backend/internal/metricsproxy/metricsproxy.go @@ -11,6 +11,7 @@ import ( "time" "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/outbound" "github.com/stolostron/console/backend/internal/server" ) @@ -37,11 +38,7 @@ const ( // New returns a ReverseProxy that rewrites /prometheus or /observability to /api/v1 on target. func New(target *url.URL, tlsConfig *tls.Config, prefix string) http.Handler { - transport := &http.Transport{ - TLSClientConfig: tlsConfig, - ForceAttemptHTTP2: true, - ResponseHeaderTimeout: 0, - } + transport := outbound.Transport(tlsConfig, true) rp := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { token := auth.TokenFromRequest(pr.In) diff --git a/backend/internal/outbound/transport.go b/backend/internal/outbound/transport.go new file mode 100644 index 00000000000..758fbb41e0e --- /dev/null +++ b/backend/internal/outbound/transport.go @@ -0,0 +1,35 @@ +// Copyright Contributors to the Open Cluster Management project + +package outbound + +import ( + "crypto/tls" + "net" + "net/http" + "time" +) + +const ( + // DialTimeout bounds TCP connect to hub services and in-cluster DNS names during local dev. + DialTimeout = 10 * time.Second + // ResponseHeaderTimeout bounds time waiting for upstream response headers on reverse proxies. + ResponseHeaderTimeout = 60 * time.Second +) + +// Transport builds an http.Transport for outbound reverse-proxy and API clients. +// Dial and response-header timeouts prevent orphaned goroutines when a client +// disconnects while the backend is still connecting to *.svc.cluster.local. +func Transport(tlsCfg *tls.Config, forceHTTP2 bool) *http.Transport { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: DialTimeout, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: forceHTTP2, + TLSClientConfig: tlsCfg, + ResponseHeaderTimeout: ResponseHeaderTimeout, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } +} diff --git a/backend/internal/placementdebug/ca.go b/backend/internal/placementdebug/ca.go new file mode 100644 index 00000000000..50dcf5e56fb --- /dev/null +++ b/backend/internal/placementdebug/ca.go @@ -0,0 +1,158 @@ +// Copyright Contributors to the Open Cluster Management project + +package placementdebug + +import ( + "context" + "errors" + "sync" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + + applog "github.com/stolostron/console/backend/internal/log" +) + +const ( + hubNamespace = "open-cluster-management-hub" + configMapName = "ca-bundle-configmap" + caBundleKey = "ca-bundle.crt" +) + +// CAWatch lists and watches the OCM placement CA ConfigMap. +type CAWatch struct { + Kube kubernetes.Interface + + mu sync.RWMutex + ca []byte +} + +// Get returns the current CA PEM, or nil when the bundle is missing. +func (c *CAWatch) Get() []byte { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + if len(c.ca) == 0 { + return nil + } + out := make([]byte, len(c.ca)) + copy(out, c.ca) + return out +} + +func (c *CAWatch) set(ca []byte) { + c.mu.Lock() + defer c.mu.Unlock() + c.ca = append([]byte(nil), ca...) +} + +// Start runs list+watch until ctx is canceled. +func (c *CAWatch) Start(ctx context.Context) { + if c == nil || c.Kube == nil { + return + } + go c.loop(ctx) +} + +func (c *CAWatch) loop(ctx context.Context) { + for ctx.Err() == nil { + rv, err := c.list(ctx) + if err != nil { + c.handleErr(ctx, err) + continue + } + if err = c.watch(ctx, rv); err != nil && ctx.Err() == nil { + c.handleErr(ctx, err) + } + } +} + +func (c *CAWatch) list(ctx context.Context) (string, error) { + list, err := c.Kube.CoreV1().ConfigMaps(hubNamespace).List(ctx, metav1.ListOptions{ + FieldSelector: "metadata.name=" + configMapName, + }) + if err != nil { + return "", err + } + ca := []byte(nil) + for i := range list.Items { + if list.Items[i].Name != configMapName { + continue + } + ca = []byte(list.Items[i].Data[caBundleKey]) + break + } + if len(ca) == 0 { + if c.Get() != nil { + applog.Logger().Info("placement debug CA bundle removed") + } + c.set(nil) + } else { + c.set(ca) + applog.Logger().Info("placement debug CA bundle updated") + } + return list.ResourceVersion, nil +} + +func (c *CAWatch) watch(ctx context.Context, resourceVersion string) error { + w, err := c.Kube.CoreV1().ConfigMaps(hubNamespace).Watch(ctx, metav1.ListOptions{ + FieldSelector: "metadata.name=" + configMapName, + ResourceVersion: resourceVersion, + Watch: true, + }) + if err != nil { + return err + } + defer w.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case ev, ok := <-w.ResultChan(): + if !ok { + return nil + } + switch ev.Type { + case watch.Added, watch.Modified: + cm, _ := ev.Object.(*corev1.ConfigMap) + if cm == nil || cm.Name != configMapName { + continue + } + ca := []byte(cm.Data[caBundleKey]) + if len(ca) == 0 { + continue + } + c.set(ca) + applog.Logger().Info("placement debug CA bundle updated") + case watch.Deleted: + cm, _ := ev.Object.(*corev1.ConfigMap) + if cm != nil && cm.Name != configMapName { + continue + } + if c.Get() != nil { + applog.Logger().Info("placement debug CA bundle removed") + } + c.set(nil) + case watch.Error: + return errWatch + } + } + } +} + +var errWatch = errors.New("placement debug CA watch error event") + +func (c *CAWatch) handleErr(ctx context.Context, err error) { + applog.Logger().Error("placement debug CA watch", "error", err) + timer := time.NewTimer(60*time.Second + time.Duration(time.Now().UnixNano()%10_000)*time.Millisecond) + defer timer.Stop() + select { + case <-ctx.Done(): + case <-timer.C: + } +} diff --git a/backend/internal/placementdebug/ca_test.go b/backend/internal/placementdebug/ca_test.go new file mode 100644 index 00000000000..b693620583f --- /dev/null +++ b/backend/internal/placementdebug/ca_test.go @@ -0,0 +1,47 @@ +// Copyright Contributors to the Open Cluster Management project + +package placementdebug + +import ( + "context" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestCAWatchLoadsBundle(t *testing.T) { + pem := "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----" + kube := fake.NewSimpleClientset(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: hubNamespace}, + Data: map[string]string{caBundleKey: pem}, + }) + w := &CAWatch{Kube: kube} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + w.Start(ctx) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if string(w.Get()) == pem { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("got %q", w.Get()) +} + +func TestCAWatchMissingIsNil(t *testing.T) { + w := &CAWatch{Kube: fake.NewSimpleClientset()} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + w.Start(ctx) + deadline := time.Now().Add(200 * time.Millisecond) + for time.Now().Before(deadline) { + if w.Get() != nil { + t.Fatalf("got %q", w.Get()) + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/backend/internal/placementdebug/placementdebug.go b/backend/internal/placementdebug/placementdebug.go new file mode 100644 index 00000000000..08a75a440eb --- /dev/null +++ b/backend/internal/placementdebug/placementdebug.go @@ -0,0 +1,177 @@ +// Copyright Contributors to the Open Cluster Management project + +package placementdebug + +import ( + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httputil" + "net/url" + "os" + "sync" + "time" + + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/outbound" + applog "github.com/stolostron/console/backend/internal/log" +) + +const defaultPlacementDebugURL = "https://cluster-manager-placement.open-cluster-management-hub.svc.cluster.local:9443/debug/placements/" + +var requestHeaders = []string{ + "Accept", + "Accept-Encoding", + "Content-Encoding", + "Content-Length", + "Content-Type", +} + +var responseHeaders = []string{ + "Cache-Control", + "Content-Length", + "Content-Encoding", + "Etag", +} + +// Options configure the placement-debug reverse proxy. +type Options struct { + RESTConfig *rest.Config + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + GetCA func() []byte + Endpoint func() string +} + +// Handler proxies POST /placement-debug. +type Handler struct { + RESTConfig *rest.Config + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + GetCA func() []byte + Endpoint func() string + + mu sync.Mutex + proxy *httputil.ReverseProxy + ca string +} + +// New returns a placement-debug proxy handler. +func New(opts Options) *Handler { + h := &Handler{ + RESTConfig: opts.RESTConfig, + Authn: opts.Authn, + GetCA: opts.GetCA, + Endpoint: opts.Endpoint, + } + if h.Authn == nil && opts.RESTConfig != nil { + h.Authn = func(w http.ResponseWriter, r *http.Request) (string, bool) { + return auth.AuthenticateRequest(r.Context(), opts.RESTConfig, w, r) + } + } + if h.Endpoint == nil { + h.Endpoint = func() string { + if v := os.Getenv("PLACEMENT_DEBUG_URL"); v != "" { + return v + } + return defaultPlacementDebugURL + } + } + return h +} + +func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { + if h.Authn != nil { + return h.Authn(w, r) + } + w.WriteHeader(http.StatusUnauthorized) + return "", false +} + +func (h *Handler) caPEM() []byte { + if h.GetCA != nil { + return h.GetCA() + } + return nil +} + +func (h *Handler) reverseProxy(target *url.URL, ca []byte) *httputil.ReverseProxy { + pem := string(ca) + h.mu.Lock() + defer h.mu.Unlock() + if h.proxy != nil && h.ca == pem { + return h.proxy + } + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: nil} + if len(ca) > 0 { + tlsCfg = auth.TLSConfigFromCA(ca, false) + } + rp := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + token := auth.TokenFromRequest(pr.In) + pr.SetURL(target) + pr.Out.URL.Path = target.Path + pr.Out.URL.RawPath = target.RawPath + pr.Out.URL.RawQuery = pr.In.URL.RawQuery + pr.Out.Host = target.Hostname() + pr.Out.Header = http.Header{} + for _, name := range requestHeaders { + if v := pr.In.Header.Get(name); v != "" { + pr.Out.Header.Set(name, v) + } + } + pr.Out.Header.Set("Content-Type", "application/json") + pr.Out.Header.Set("Authorization", "Bearer "+token) + pr.Out.Header.Set("Host", target.Hostname()) + }, + ModifyResponse: func(resp *http.Response) error { + filtered := http.Header{"Content-Type": []string{"application/json"}} + for _, name := range responseHeaders { + for _, v := range resp.Header.Values(name) { + filtered.Add(name, v) + } + } + resp.Header = filtered + return nil + }, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + applog.Logger().Error("placement debug upstream error", "error", err) + w.WriteHeader(http.StatusInternalServerError) + }, + Transport: outbound.Transport(tlsCfg, false), + FlushInterval: -1 * time.Millisecond, + } + h.proxy = rp + h.ca = pem + return rp +} + +// ServeHTTP authenticates and reverse-proxies to the placement debug service. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + if _, ok := h.authenticate(w, r); !ok { + return + } + ca := h.caPEM() + if len(ca) == 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _ = json.NewEncoder(w).Encode(map[string]string{ + "error": "Placement debug service unavailable — OCM CA bundle not configured", + }) + return + } + endpoint := "" + if h.Endpoint != nil { + endpoint = h.Endpoint() + } + target, err := url.Parse(endpoint) + if err != nil || target.Scheme == "" || target.Host == "" { + w.WriteHeader(http.StatusInternalServerError) + return + } + h.reverseProxy(target, ca).ServeHTTP(w, r) +} diff --git a/backend/internal/placementdebug/placementdebug_test.go b/backend/internal/placementdebug/placementdebug_test.go new file mode 100644 index 00000000000..9e8872c61a7 --- /dev/null +++ b/backend/internal/placementdebug/placementdebug_test.go @@ -0,0 +1,95 @@ +// Copyright Contributors to the Open Cluster Management project + +package placementdebug + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func authOK(_ http.ResponseWriter, _ *http.Request) (string, bool) { + return "user-token", true +} + +func TestUnauthorized(t *testing.T) { + h := New(Options{GetCA: func() []byte { return []byte("ca") }}) + req := httptest.NewRequest(http.MethodPost, "/placement-debug", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized || rec.Body.Len() != 0 { + t.Fatalf("status %d body %q", rec.Code, rec.Body.String()) + } +} + +func TestUnavailableWithoutCA(t *testing.T) { + h := New(Options{Authn: authOK, GetCA: func() []byte { return nil }}) + req := httptest.NewRequest(http.MethodPost, "/placement-debug", strings.NewReader(`{"placement":"test"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status %d", rec.Code) + } + var out map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out["error"], "OCM CA bundle") { + t.Fatalf("%v", out) + } +} + +func TestProxiesUpstream(t *testing.T) { + var gotAuth, gotHost, gotCT, gotCookie string + var gotBody []byte + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotHost = r.Host + gotCT = r.Header.Get("Content-Type") + gotCookie = r.Header.Get("Cookie") + gotBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("X-Secret", "nope") + _, _ = w.Write([]byte(`{"aggregatedScores":[]}`)) + })) + defer upstream.Close() + + h := New(Options{ + Authn: authOK, + GetCA: func() []byte { return []byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----") }, + Endpoint: func() string { return upstream.URL + "/debug/placements/" }, + }) + req := httptest.NewRequest(http.MethodPost, "/placement-debug", strings.NewReader(`{"placement":"test"}`)) + req.Header.Set("Authorization", "Bearer user-token") + req.Header.Set("Cookie", "session=secret") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if gotAuth != "Bearer user-token" { + t.Fatalf("auth %q", gotAuth) + } + if gotCookie != "" { + t.Fatalf("cookie forwarded %q", gotCookie) + } + if gotCT != "application/json" { + t.Fatalf("content-type %q", gotCT) + } + if string(gotBody) != `{"placement":"test"}` { + t.Fatalf("body %s", gotBody) + } + if rec.Header().Get("Content-Type") != "application/json" { + t.Fatalf("resp ct %q", rec.Header().Get("Content-Type")) + } + if rec.Header().Get("X-Secret") != "" { + t.Fatal("non-allowlisted response header forwarded") + } + if gotHost == "" { + t.Fatal("missing host") + } +} diff --git a/backend/internal/proxy/proxy.go b/backend/internal/proxy/proxy.go index c6cfe32bd65..4dfacb03c51 100644 --- a/backend/internal/proxy/proxy.go +++ b/backend/internal/proxy/proxy.go @@ -8,16 +8,14 @@ import ( "net/http/httputil" "net/url" "time" + + "github.com/stolostron/console/backend/internal/outbound" ) // New returns a reverse proxy to the Node sidecar. HTTP/1.1 only so WebSocket // upgrades succeed. Original request paths (including /multicloud) are kept. func New(target *url.URL, tlsConfig *tls.Config) http.Handler { - transport := &http.Transport{ - ForceAttemptHTTP2: false, - TLSClientConfig: tlsConfig, - ResponseHeaderTimeout: 0, - } + transport := outbound.Transport(tlsConfig, false) rp := &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(target) diff --git a/backend/internal/rosa/rosa.go b/backend/internal/rosa/rosa.go new file mode 100644 index 00000000000..5f925d8f9e3 --- /dev/null +++ b/backend/internal/rosa/rosa.go @@ -0,0 +1,540 @@ +// Copyright Contributors to the Open Cluster Management project + +package rosa + +import ( + "context" + "encoding/json" + "io" + "net/http" + "regexp" + "strings" + + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + applog "github.com/stolostron/console/backend/internal/log" +) + +const defaultAPIURL = "https://api.openshift.com" + +// Routes are the ROSA wizard POST paths (also registered under /multicloud). +var Routes = []string{ + "/aws-account-ids", + "/aws-billing-accounts", + "/oidc-configs", + "/regions", + "/cluster-name-check", + "/sts-role-arns", + "/vpcs", + "/sts-ocm-role", + "/sts-user-role", + "/openshift-versions", + "/machine-types", +} + +var clusterNameRE = regexp.MustCompile(`^[a-z]([a-z0-9-]*[a-z0-9])?$`) + +// Options configure the ROSA wizard proxy. +type Options struct { + RESTConfig *rest.Config + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + Client *http.Client + APIURL string +} + +// Handler serves the 11 POST ROSA wizard routes. +type Handler struct { + RESTConfig *rest.Config + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + Client *http.Client + APIURL string +} + +// New returns a ROSA wizard handler. +func New(opts Options) *Handler { + h := &Handler{ + RESTConfig: opts.RESTConfig, + Authn: opts.Authn, + Client: opts.Client, + APIURL: strings.TrimRight(opts.APIURL, "/"), + } + if h.APIURL == "" { + h.APIURL = defaultAPIURL + } + if h.Authn == nil && opts.RESTConfig != nil { + h.Authn = func(w http.ResponseWriter, r *http.Request) (string, bool) { + return auth.AuthenticateRequest(r.Context(), opts.RESTConfig, w, r) + } + } + return h +} + +func (h *Handler) client() *http.Client { + if h.Client != nil { + return h.Client + } + return auth.HTTPClient(nil, 0) +} + +func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) bool { + if h.Authn != nil { + _, ok := h.Authn(w, r) + return ok + } + w.WriteHeader(http.StatusUnauthorized) + return false +} + +func stripPath(path string) string { + const prefix = "/multicloud" + if path == prefix { + return "/" + } + if strings.HasPrefix(path, prefix+"/") || strings.HasPrefix(path, prefix) { + return path[len(prefix):] + } + return path +} + +// ServeHTTP dispatches POST ROSA wizard endpoints. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + if !h.authenticate(w, r) { + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + applog.Logger().Error("rosa wizard read body", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + path := strings.Trim(stripPath(r.URL.Path), "/") + switch path { + case "aws-account-ids": + h.awsAccountIds(w, r, body) + case "aws-billing-accounts": + h.awsBillingAccounts(w, r, body) + case "oidc-configs": + h.oidcConfigs(w, r, body) + case "regions": + h.regions(w, r, body) + case "cluster-name-check": + h.clusterNameCheck(w, r, body) + case "sts-role-arns": + h.stsRoleARNs(w, r, body) + case "vpcs": + h.vpcs(w, r, body) + case "sts-ocm-role": + h.stsOCMRole(w, r, body) + case "sts-user-role": + h.userRole(w, r, body) + case "openshift-versions": + h.versions(w, r, body) + case "machine-types": + h.machineTypes(w, r, body) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +type payload struct { + ServiceAccountID string `json:"service_account_id"` + ServiceAccountSecret string `json:"service_account_secret"` + AWSAccountID string `json:"aws_account_id"` + ClusterName string `json:"cluster_name"` + Region string `json:"region"` + RoleARN string `json:"role_arn"` + AvailabilityZones []any `json:"availability_zones"` +} + +type orgAccount struct { + ID string `json:"id"` + Organization struct { + ID string `json:"id"` + } `json:"organization"` +} + +type postResult struct { + StatusCode int `json:"statusCode"` + Body any `json:"body"` +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) +} + +func writeJSONStatus(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(v) +} + +func encodeRequestURL(raw string) string { + return strings.ReplaceAll(raw, " ", "%20") +} + +func parsePayload(body []byte) (payload, error) { + var p payload + err := json.Unmarshal(body, &p) + return p, err +} + +func (h *Handler) ssoToken(ctx context.Context, p payload) (string, error) { + return auth.OCMServiceToken(ctx, h.client(), p.ServiceAccountID, p.ServiceAccountSecret) +} + +func (h *Handler) getJSON(ctx context.Context, token, rawURL string) (any, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, encodeRequestURL(rawURL), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + resp, err := h.client().Do(req) + if err != nil { + return nil, err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + var out any + if err = json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out, nil +} + +func (h *Handler) postJSON(ctx context.Context, token, rawURL string, body any) (postResult, error) { + raw, err := json.Marshal(body) + if err != nil { + return postResult{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, encodeRequestURL(rawURL), strings.NewReader(string(raw))) + if err != nil { + return postResult{}, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err := h.client().Do(req) + if err != nil { + return postResult{}, err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + var out any + if err = json.NewDecoder(resp.Body).Decode(&out); err != nil { + return postResult{}, err + } + return postResult{StatusCode: resp.StatusCode, Body: out}, nil +} + +func (h *Handler) getOrg(ctx context.Context, token string) (orgAccount, error) { + raw, err := h.getJSON(ctx, token, h.APIURL+"/api/accounts_mgmt/v1/current_account") + if err != nil { + return orgAccount{}, err + } + b, err := json.Marshal(raw) + if err != nil { + return orgAccount{}, err + } + var org orgAccount + if err = json.Unmarshal(b, &org); err != nil { + return orgAccount{}, err + } + return org, nil +} + +func (h *Handler) awsAccountIds(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa aws-account-ids", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa aws-account-ids sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + org, err := h.getOrg(r.Context(), tok) + if err != nil { + applog.Logger().Error("rosa aws-account-ids org", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + out, err := h.getJSON(r.Context(), tok, h.APIURL+"/api/accounts_mgmt/v1/organizations/"+org.Organization.ID+"/labels") + if err != nil { + applog.Logger().Error("rosa aws-account-ids labels", "error", err) + writeJSON(w, nil) + return + } + writeJSON(w, out) +} + +func (h *Handler) awsBillingAccounts(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa aws-billing-accounts", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa aws-billing-accounts sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + org, err := h.getOrg(r.Context(), tok) + if err != nil { + applog.Logger().Error("rosa aws-billing-accounts org", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + out, err := h.getJSON(r.Context(), tok, h.APIURL+"/api/accounts_mgmt/v1/organizations/"+org.Organization.ID+"/quota_cost?fetchRelatedResources=true&fetchCloudAccounts=true") + if err != nil { + applog.Logger().Error("rosa aws-billing-accounts quota", "error", err) + writeJSON(w, nil) + return + } + writeJSON(w, out) +} + +func (h *Handler) oidcConfigs(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa oidc-configs", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa oidc-configs sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + rawURL := h.APIURL + "/api/clusters_mgmt/v1/oidc_configs?search=aws.account_id=" + p.AWSAccountID + " or aws.account_id=''" + out, err := h.getJSON(r.Context(), tok, rawURL) + if err != nil { + applog.Logger().Error("rosa oidc-configs", "error", err) + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, out) +} + +func (h *Handler) regions(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa regions", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa regions sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + out, err := h.getJSON(r.Context(), tok, h.APIURL+"/api/clusters_mgmt/v1/cloud_providers?size=-1&fetchRegions=true") + if err != nil { + applog.Logger().Error("rosa regions", "error", err) + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, out) +} + +func (h *Handler) clusterNameCheck(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa cluster-name-check", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if p.ClusterName == "" || !clusterNameRE.MatchString(p.ClusterName) { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "Invalid cluster name format"}) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa cluster-name-check sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + out, err := h.postJSON(r.Context(), tok, h.APIURL+"/api/clusters_mgmt/v1/clusters?method=get", map[string]any{ + "size": 1, + "search": "name = '" + p.ClusterName + "'", + }) + if err != nil { + applog.Logger().Error("rosa cluster-name-check", "error", err) + writeJSON(w, nil) + return + } + writeJSON(w, out) +} + +func (h *Handler) vpcs(w http.ResponseWriter, r *http.Request, body []byte) { + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + applog.Logger().Error("rosa vpcs", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa vpcs", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa vpcs sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + payloadBody := map[string]any{"aws": raw["aws"], "region": raw["region"]} + out, err := h.postJSON(r.Context(), tok, h.APIURL+"/api/clusters_mgmt/v1/aws_inquiries/vpcs?fetchSecurityGroups=true", payloadBody) + if err != nil { + applog.Logger().Error("rosa vpcs", "error", err) + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, out) +} + +func (h *Handler) stsRoleARNs(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa sts-role-arns", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa sts-role-arns sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + out, err := h.postJSON(r.Context(), tok, h.APIURL+"/api/clusters_mgmt/v1/aws_inquiries/sts_account_roles", map[string]any{ + "account_id": p.AWSAccountID, + }) + if err != nil { + applog.Logger().Error("rosa sts-role-arns", "error", err) + writeJSON(w, nil) + return + } + writeJSON(w, out) +} + +func (h *Handler) stsOCMRole(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa sts-ocm-role", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa sts-ocm-role sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + out, err := h.postJSON(r.Context(), tok, h.APIURL+"/api/clusters_mgmt/v1/aws_inquiries/sts_ocm_role", map[string]any{ + "account_id": p.AWSAccountID, + }) + if err != nil { + applog.Logger().Error("rosa sts-ocm-role", "error", err) + writeJSON(w, nil) + return + } + writeJSON(w, out) +} + +func (h *Handler) userRole(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa sts-user-role", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa sts-user-role sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + org, err := h.getOrg(r.Context(), tok) + if err != nil { + applog.Logger().Error("rosa sts-user-role org", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + out, err := h.getJSON(r.Context(), tok, h.APIURL+"/api/accounts_mgmt/v1/accounts/"+org.ID+"/labels/sts_user_role") + if err != nil { + applog.Logger().Error("rosa sts-user-role", "error", err) + writeJSON(w, nil) + return + } + writeJSON(w, out) +} + +func (h *Handler) machineTypes(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa machine-types", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa machine-types sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + zones := p.AvailabilityZones + if zones == nil { + zones = []any{} + } + out, err := h.postJSON(r.Context(), tok, h.APIURL+"/api/clusters_mgmt/v1/aws_inquiries/machine_types?size=-1", map[string]any{ + "aws": map[string]any{"sts": map[string]any{"role_arn": p.RoleARN}}, + "region": map[string]any{"id": p.Region}, + "availability_zones": zones, + }) + if err != nil { + applog.Logger().Error("rosa machine-types", "error", err) + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, out) +} + +func (h *Handler) versions(w http.ResponseWriter, r *http.Request, body []byte) { + p, err := parsePayload(body) + if err != nil { + applog.Logger().Error("rosa openshift-versions", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + tok, err := h.ssoToken(r.Context(), p) + if err != nil { + applog.Logger().Error("rosa openshift-versions sso", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + rawURL := h.APIURL + "/api/clusters_mgmt/v1/versions/?order=end_of_life_timestamp desc&product=hcp&search=enabled='t' AND (channel_group='stable' OR channel_group='eus' OR channel_group='candidate' OR channel_group='fast' OR channel_group='nightly') AND rosa_enabled='t'&size=-1" + out, err := h.getJSON(r.Context(), tok, rawURL) + if err != nil { + applog.Logger().Error("rosa openshift-versions", "error", err) + writeJSON(w, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, out) +} diff --git a/backend/internal/rosa/rosa_test.go b/backend/internal/rosa/rosa_test.go new file mode 100644 index 00000000000..13bee75604c --- /dev/null +++ b/backend/internal/rosa/rosa_test.go @@ -0,0 +1,192 @@ +// Copyright Contributors to the Open Cluster Management project + +package rosa + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stolostron/console/backend/internal/auth" +) + +func authOK(_ http.ResponseWriter, _ *http.Request) (string, bool) { + return "user-token", true +} + +func b64(s string) string { + return base64.StdEncoding.EncodeToString([]byte(s)) +} + +func testHandler(t *testing.T, api http.Handler) (*Handler, *httptest.Server) { + t.Helper() + sso := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"mock-ocm-token"}`)) + })) + t.Cleanup(sso.Close) + restore := auth.SetOCMTokenURL(sso.URL) + t.Cleanup(restore) + upstream := httptest.NewServer(api) + t.Cleanup(upstream.Close) + return New(Options{Authn: authOK, Client: http.DefaultClient, APIURL: upstream.URL}), upstream +} + +func post(h http.Handler, path string, body any) *httptest.ResponseRecorder { + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(raw))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestUnauthorized(t *testing.T) { + h := New(Options{}) + rec := post(h, "/aws-account-ids", map[string]string{}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status %d", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body %q", rec.Body.String()) + } +} + +func TestAwsAccountIds(t *testing.T) { + h, _ := testHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer mock-ocm-token" { + t.Errorf("auth %q", r.Header.Get("Authorization")) + } + switch r.URL.Path { + case "/api/accounts_mgmt/v1/current_account": + _, _ = w.Write([]byte(`{"id":"acct-1","organization":{"id":"org-abc"}}`)) + case "/api/accounts_mgmt/v1/organizations/org-abc/labels": + _, _ = w.Write([]byte(`{"items":[{"id":"1"}]}`)) + default: + http.NotFound(w, r) + } + })) + rec := post(h, "/aws-account-ids", map[string]string{ + "service_account_id": b64("id"), + "service_account_secret": b64("secret"), + }) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"items"`) { + t.Fatalf("body %s", rec.Body.String()) + } +} + +func TestClusterNameCheckInvalid(t *testing.T) { + h, _ := testHandler(t, http.NotFoundHandler()) + rec := post(h, "/cluster-name-check", map[string]string{ + "service_account_id": b64("id"), + "service_account_secret": b64("secret"), + "cluster_name": "BAD", + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Invalid cluster name format") { + t.Fatalf("body %s", rec.Body.String()) + } +} + +func TestClusterNameCheckWrapsPost(t *testing.T) { + h, _ := testHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/clusters_mgmt/v1/clusters" { + http.NotFound(w, r) + return + } + if r.URL.RawQuery != "method=get" { + t.Errorf("query %q", r.URL.RawQuery) + } + b, _ := io.ReadAll(r.Body) + if !strings.Contains(string(b), "my-rosa-cluster") { + t.Errorf("body %s", b) + } + _, _ = w.Write([]byte(`{"kind":"ClusterList","items":[]}`)) + })) + rec := post(h, "/cluster-name-check", map[string]string{ + "service_account_id": b64("id"), + "service_account_secret": b64("secret"), + "cluster_name": "my-rosa-cluster", + }) + if rec.Code != http.StatusOK { + t.Fatalf("status %d %s", rec.Code, rec.Body.String()) + } + var out postResult + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.StatusCode != 200 { + t.Fatalf("%+v", out) + } +} + +func TestRegionsErrorObject(t *testing.T) { + h, _ := testHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + // 500 with non-JSON body causes decode error → {error: ...} + rec := post(h, "/regions", map[string]string{ + "service_account_id": b64("id"), + "service_account_secret": b64("secret"), + }) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + var out map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out["error"] == "" { + t.Fatalf("%v", out) + } +} + +func TestRouteCount(t *testing.T) { + if len(Routes) != 11 { + t.Fatalf("got %d routes", len(Routes)) + } +} + +func TestOpenshiftVersionsQuery(t *testing.T) { + var gotPath, gotQuery string + h, _ := testHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"items":[]}`)) + })) + rec := post(h, "/openshift-versions", map[string]string{ + "service_account_id": b64("id"), + "service_account_secret": b64("secret"), + }) + if rec.Code != http.StatusOK { + t.Fatalf("status %d %s", rec.Code, rec.Body.String()) + } + if gotPath != "/api/clusters_mgmt/v1/versions/" { + t.Fatalf("path %q", gotPath) + } + if !strings.Contains(gotQuery, "product=hcp") || !strings.Contains(gotQuery, "rosa_enabled=") { + t.Fatalf("query %q", gotQuery) + } +} + +func TestMulticloudPath(t *testing.T) { + h, _ := testHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + rec := post(h, "/multicloud/openshift-versions", map[string]string{ + "service_account_id": b64("id"), + "service_account_secret": b64("secret"), + }) + if rec.Code != http.StatusOK { + t.Fatalf("status %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/internal/searchproxy/proxy.go b/backend/internal/searchproxy/proxy.go index 2802a17a21b..f5a364824cf 100644 --- a/backend/internal/searchproxy/proxy.go +++ b/backend/internal/searchproxy/proxy.go @@ -15,6 +15,7 @@ import ( "k8s.io/client-go/rest" "github.com/stolostron/console/backend/internal/auth" + "github.com/stolostron/console/backend/internal/outbound" applog "github.com/stolostron/console/backend/internal/log" ) @@ -77,11 +78,7 @@ func New(opts Options) *Handler { } func (h *Handler) transport() http.RoundTripper { - return &http.Transport{ - TLSClientConfig: h.TLSConfig, - ForceAttemptHTTP2: false, - ResponseHeaderTimeout: 0, - } + return outbound.Transport(h.TLSConfig, false) } func proxyError(w http.ResponseWriter, _ *http.Request, err error) { diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 90edfa6d270..8cd23c94407 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -29,21 +29,25 @@ import ( const multicloudPrefix = "/multicloud" type handlerOptions struct { - rbacEvents http.Handler - k8sProxy http.Handler - oauth *oauth.Handler - oauthLogin bool - mcProxy http.Handler - prometheus http.Handler - observability http.Handler - vmProxy http.Handler - staticH http.Handler - user http.Handler - clusterInfo http.Handler - events http.Handler - aggregate http.Handler - searchProxy http.Handler - debugSnapshot http.Handler + rbacEvents http.Handler + k8sProxy http.Handler + oauth *oauth.Handler + oauthLogin bool + mcProxy http.Handler + prometheus http.Handler + observability http.Handler + vmProxy http.Handler + staticH http.Handler + user http.Handler + clusterInfo http.Handler + events http.Handler + aggregate http.Handler + searchProxy http.Handler + debugSnapshot http.Handler + rosa http.Handler + ansibleTower http.Handler + placementDebug http.Handler + upgradeRisks http.Handler } // Option configures Handler. @@ -70,6 +74,34 @@ func WithSearchProxy(h http.Handler) Option { } } +// WithRosa registers the ROSA wizard POST routes (also /multicloud/...). +func WithRosa(h http.Handler) Option { + return func(o *handlerOptions) { + o.rosa = h + } +} + +// WithAnsibleTower registers POST /ansibletower (also /multicloud/ansibletower). +func WithAnsibleTower(h http.Handler) Option { + return func(o *handlerOptions) { + o.ansibleTower = h + } +} + +// WithPlacementDebug registers POST /placement-debug (also /multicloud/placement-debug). +func WithPlacementDebug(h http.Handler) Option { + return func(o *handlerOptions) { + o.placementDebug = h + } +} + +// WithUpgradeRisks registers POST /upgrade-risks-prediction (also /multicloud/upgrade-risks-prediction). +func WithUpgradeRisks(h http.Handler) Option { + return func(o *handlerOptions) { + o.upgradeRisks = h + } +} + // WithRBACEvents registers GET /events/rbac (and /multicloud/events/rbac). func WithRBACEvents(h http.Handler) Option { return func(o *handlerOptions) { @@ -299,6 +331,30 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { if o.searchProxy != nil { registerAliased(r, o.searchProxy, "/proxy/search") } + if o.rosa != nil { + registerAliasedPost(r, o.rosa, + "/aws-account-ids", + "/aws-billing-accounts", + "/oidc-configs", + "/regions", + "/cluster-name-check", + "/sts-role-arns", + "/vpcs", + "/sts-ocm-role", + "/sts-user-role", + "/openshift-versions", + "/machine-types", + ) + } + if o.ansibleTower != nil { + registerAliasedPost(r, o.ansibleTower, "/ansibletower") + } + if o.placementDebug != nil { + registerAliasedPost(r, o.placementDebug, "/placement-debug") + } + if o.upgradeRisks != nil { + registerAliasedPost(r, o.upgradeRisks, "/upgrade-risks-prediction") + } if o.k8sProxy != nil { registerK8sProxyRoutes(r, o.k8sProxy) } diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index d8341b8b795..d27990ddb59 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -12,6 +12,7 @@ import ( "github.com/stolostron/console/backend/internal/config" "github.com/stolostron/console/backend/internal/oauth" + "github.com/stolostron/console/backend/internal/rosa" "github.com/stolostron/console/backend/internal/server" ) @@ -853,3 +854,53 @@ func TestSearchNotProxied(t *testing.T) { } } } + +func TestLongTailNotProxied(t *testing.T) { + var sidecarHit bool + sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sidecarHit = true + w.WriteHeader(http.StatusTeapot) + })) + defer sidecar.Close() + + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} + h, err := server.Handler(cfg, + server.WithRosa(ok), + server.WithAnsibleTower(ok), + server.WithPlacementDebug(ok), + server.WithUpgradeRisks(ok), + ) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + paths := []string{ + "/ansibletower", "/multicloud/ansibletower", + "/placement-debug", "/multicloud/placement-debug", + "/upgrade-risks-prediction", "/multicloud/upgrade-risks-prediction", + } + for _, p := range rosa.Routes { + paths = append(paths, p, "/multicloud"+p) + } + for _, path := range paths { + sidecarHit = false + req, _ := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + resp, getErr := ts.Client().Do(req) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if sidecarHit { + t.Fatalf("%s was proxied to sidecar", path) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + } +} diff --git a/backend/internal/upgraderisks/upgraderisks.go b/backend/internal/upgraderisks/upgraderisks.go new file mode 100644 index 00000000000..f175e7c9a2c --- /dev/null +++ b/backend/internal/upgraderisks/upgraderisks.go @@ -0,0 +1,215 @@ +// Copyright Contributors to the Open Cluster Management project + +package upgraderisks + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "sync" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + + "github.com/stolostron/console/backend/internal/auth" + applog "github.com/stolostron/console/backend/internal/log" +) + +const ( + defaultInsightsURL = "https://console.redhat.com/api/insights-results-aggregator/v2/upgrade-risks-prediction" + userAgent = "acm-operator/v2.10.0 cluster/acm-hub" + chunkSize = 100 + pullSecretName = "pull-secret" + configNamespace = "openshift-config" +) + +type requestBody struct { + ClusterIDs []string `json:"clusterIds"` +} + +type pullAuth struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` +} + +type postResult struct { + StatusCode int `json:"statusCode"` + Body any `json:"body"` +} + +// Options configure upgrade-risks-prediction. +type Options struct { + RESTConfig *rest.Config + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + Kube kubernetes.Interface + Client *http.Client + Endpoint func() string +} + +// Handler serves POST /upgrade-risks-prediction. +type Handler struct { + RESTConfig *rest.Config + Authn func(w http.ResponseWriter, r *http.Request) (string, bool) + Kube kubernetes.Interface + Client *http.Client + Endpoint func() string +} + +// New returns an Insights upgrade-risks handler. +func New(opts Options) *Handler { + h := &Handler{ + RESTConfig: opts.RESTConfig, + Authn: opts.Authn, + Kube: opts.Kube, + Client: opts.Client, + Endpoint: opts.Endpoint, + } + if h.Authn == nil && opts.RESTConfig != nil { + h.Authn = func(w http.ResponseWriter, r *http.Request) (string, bool) { + return auth.AuthenticateRequest(r.Context(), opts.RESTConfig, w, r) + } + } + if h.Client == nil { + h.Client = auth.HTTPClient(nil, 0) + } + if h.Endpoint == nil { + h.Endpoint = func() string { + if v := os.Getenv("UPGRADE_RISKS_PREDICTION_URL"); v != "" { + return v + } + return defaultInsightsURL + } + } + return h +} + +func (h *Handler) authenticate(w http.ResponseWriter, r *http.Request) bool { + if h.Authn != nil { + _, ok := h.Authn(w, r) + return ok + } + w.WriteHeader(http.StatusUnauthorized) + return false +} + +// ServeHTTP posts cluster IDs to Insights in chunks of 100. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + if !h.authenticate(w, r) { + return + } + crcToken := h.crcToken(r.Context()) + raw, err := io.ReadAll(r.Body) + if err != nil { + applog.Logger().Error("upgrade-risks-prediction", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + var body requestBody + if err = json.Unmarshal(raw, &body); err != nil { + applog.Logger().Error("upgrade-risks-prediction", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + chunks := chunkIDs(body.ClusterIDs, chunkSize) + results := make([]any, len(chunks)) + var wg sync.WaitGroup + for i, ids := range chunks { + wg.Add(1) + go func(i int, ids []string) { + defer wg.Done() + out, err := h.postChunk(r.Context(), crcToken, ids) + if err != nil { + applog.Logger().Error("Error getting cluster upgrade risk predictions", "error", err) + results[i] = map[string]string{"error": err.Error()} + return + } + results[i] = out + }(i, ids) + } + wg.Wait() + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + _ = enc.Encode(results) +} + +func (h *Handler) crcToken(ctx context.Context) string { + if h.Kube == nil { + return "" + } + list, err := h.Kube.CoreV1().Secrets(configNamespace).List(ctx, metav1.ListOptions{}) + if err != nil { + applog.Logger().Error("Error getting pull-secret in namespace openshift-config", "error", err) + return "" + } + for i := range list.Items { + if list.Items[i].Name != pullSecretName { + continue + } + raw := list.Items[i].Data[".dockerconfigjson"] + if len(raw) == 0 { + return "" + } + var cred pullAuth + if err = json.Unmarshal(raw, &cred); err != nil { + return "" + } + return cred.Auths["cloud.openshift.com"].Auth + } + return "" +} + +func (h *Handler) postChunk(ctx context.Context, crcToken string, ids []string) (postResult, error) { + endpoint := defaultInsightsURL + if h.Endpoint != nil { + endpoint = h.Endpoint() + } + raw, err := json.Marshal(map[string]any{"clusters": ids}) + if err != nil { + return postResult{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(raw))) + if err != nil { + return postResult{}, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", userAgent) + if crcToken != "" { + req.Header.Set("Authorization", "Bearer "+crcToken) + } + resp, err := h.Client.Do(req) + if err != nil { + return postResult{}, err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + var out any + if err = json.NewDecoder(resp.Body).Decode(&out); err != nil { + return postResult{}, err + } + return postResult{StatusCode: resp.StatusCode, Body: out}, nil +} + +func chunkIDs(ids []string, size int) [][]string { + if len(ids) == 0 { + return nil + } + var out [][]string + for i := 0; i < len(ids); i += size { + end := i + size + if end > len(ids) { + end = len(ids) + } + out = append(out, ids[i:end]) + } + return out +} diff --git a/backend/internal/upgraderisks/upgraderisks_test.go b/backend/internal/upgraderisks/upgraderisks_test.go new file mode 100644 index 00000000000..d65db7d3556 --- /dev/null +++ b/backend/internal/upgraderisks/upgraderisks_test.go @@ -0,0 +1,139 @@ +// Copyright Contributors to the Open Cluster Management project + +package upgraderisks + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func authOK(_ http.ResponseWriter, _ *http.Request) (string, bool) { + return "user-token", true +} + +func TestUnauthorized(t *testing.T) { + h := New(Options{}) + req := httptest.NewRequest(http.MethodPost, "/upgrade-risks-prediction", strings.NewReader(`{"clusterIds":[]}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized || rec.Body.Len() != 0 { + t.Fatalf("status %d body %q", rec.Code, rec.Body.String()) + } +} + +func TestPostsChunksAndWrapsJSON(t *testing.T) { + var ( + mu sync.Mutex + gotUA, gotAuth string + bodies []string + ) + insights := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUA = r.Header.Get("User-Agent") + gotAuth = r.Header.Get("Authorization") + b, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, string(b)) + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + defer insights.Close() + + docker := []byte(`{"auths":{"cloud.openshift.com":{"auth":"crc-token"}}}`) + kube := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "pull-secret", Namespace: "openshift-config"}, + Data: map[string][]byte{".dockerconfigjson": docker}, + }) + h := New(Options{ + Authn: authOK, + Kube: kube, + Client: insights.Client(), + Endpoint: func() string { return insights.URL }, + }) + ids := make([]string, 101) + for i := range ids { + ids[i] = "c" + strconv.Itoa(i) + } + raw, _ := json.Marshal(map[string]any{"clusterIds": ids}) + req := httptest.NewRequest(http.MethodPost, "/upgrade-risks-prediction", strings.NewReader(string(raw))) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d %s", rec.Code, rec.Body.String()) + } + if gotUA != userAgent { + t.Fatalf("ua %q", gotUA) + } + if gotAuth != "Bearer crc-token" { + t.Fatalf("auth %q", gotAuth) + } + mu.Lock() + n := len(bodies) + mu.Unlock() + if n != 2 { + t.Fatalf("chunks %d", n) + } + var out []postResult + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if len(out) != 2 || out[0].StatusCode != 200 { + t.Fatalf("%+v", out) + } +} + +func TestEndpointOverride(t *testing.T) { + var gotPath string + insights := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + _, _ = w.Write([]byte(`{}`)) + })) + defer insights.Close() + h := New(Options{ + Authn: authOK, + Client: insights.Client(), + Endpoint: func() string { return insights.URL + "/api/insights-results-aggregator/v2/upgrade-risks-prediction" }, + }) + req := httptest.NewRequest(http.MethodPost, "/upgrade-risks-prediction", strings.NewReader(`{"clusterIds":["id-1"]}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if gotPath != "/api/insights-results-aggregator/v2/upgrade-risks-prediction" { + t.Fatalf("path %q", gotPath) + } +} + +func TestEmptyClusterIDs(t *testing.T) { + h := New(Options{Authn: authOK, Client: http.DefaultClient}) + req := httptest.NewRequest(http.MethodPost, "/upgrade-risks-prediction", strings.NewReader(`{"clusterIds":[]}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d %s", rec.Code, rec.Body.String()) + } + if strings.TrimSpace(rec.Body.String()) != "[]" { + t.Fatalf("body %s", rec.Body.String()) + } +} + +func TestChunkIDs(t *testing.T) { + got := chunkIDs([]string{"a", "b", "c"}, 2) + if len(got) != 2 || len(got[0]) != 2 || len(got[1]) != 1 { + t.Fatalf("%v", got) + } + if chunkIDs(nil, 100) != nil { + t.Fatal("expected nil") + } +} diff --git a/backend/internal/vmproxy/handler.go b/backend/internal/vmproxy/handler.go index 43ee4372061..8fe087bc031 100644 --- a/backend/internal/vmproxy/handler.go +++ b/backend/internal/vmproxy/handler.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "strings" + "time" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" @@ -17,6 +18,7 @@ import ( "github.com/stolostron/console/backend/internal/auth" "github.com/stolostron/console/backend/internal/clusterproxy" + "github.com/stolostron/console/backend/internal/outbound" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/server" ) @@ -50,10 +52,8 @@ func New(opts Options) *Handler { } } h.addonClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: opts.TLSConfig, - ForceAttemptHTTP2: false, - }, + Timeout: 30 * time.Second, + Transport: outbound.Transport(opts.TLSConfig, false), } return h } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d0ec0b321b1..5cf736e4235 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,7 @@ The frontend has two builds. One for the stand alone version and one for the dyn ## Console Backend -The public listener is a Go process (`backend/`). Hub kube-apiserver passthrough routes (`/api`, `/apis`, `/version`), managed-cluster, metrics, and VirtualMachine proxy routes are served natively in Go. Routes that have not been migrated yet are reverse-proxied to the Node sidecar (`backend-node/`). The plugin and browser keep talking to the same Service and paths. +The public listener is a Go process (`backend/`). Hub kube-apiserver passthrough routes (`/api`, `/apis`, `/version`), managed-cluster, metrics, VirtualMachine proxy, Search proxy, and long-tail HTTP (ROSA wizard, Ansible Tower, placement-debug, upgrade-risks) are served natively in Go. Routes that have not been migrated yet are reverse-proxied to the Node sidecar (`backend-node/`). The plugin and browser keep talking to the same Service and paths. The console backend uses a service account to `list` and `watch` kubernetes cluster resources. Resource events are streamed to the console frontend. @@ -41,7 +41,7 @@ All REST calls use the token passed from the console frontend. Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. -The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). `POST /proxy/search` and the Search graphql-ws relay are served by Go (`backend/internal/searchproxy`) with the user token. Node `startWatching()` still runs so `hub.ts` can read `resourceCache` until ACM-42596 is wired. Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches and proxy `/events` and `/aggregate` to the sidecar (aggregate then 404s after the Node route cutover). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. +The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). `POST /proxy/search` and the Search graphql-ws relay are served by Go (`backend/internal/searchproxy`) with the user token. ROSA wizard, `POST /ansibletower`, `POST /placement-debug`, and `POST /upgrade-risks-prediction` are served by Go (`backend/internal/rosa`, `ansibletower`, `placementdebug`, `upgraderisks`) and are not gated on `CONSOLE_INFORMER_CACHE`. Node `startWatching()` still runs so `hub.ts` can read `resourceCache` until ACM-42596 is wired. Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches and proxy `/events` and `/aggregate` to the sidecar (aggregate then 404s after the Node route cutover). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. DELETED resource events are sent to every SSE client without an access check (bug-compatible with Node). That is a known quirk to fix later. diff --git a/frontend/src/resources/utils/resource-request.ts b/frontend/src/resources/utils/resource-request.ts index cf7aed1ad3e..d8ae5d2fd8d 100644 --- a/frontend/src/resources/utils/resource-request.ts +++ b/frontend/src/resources/utils/resource-request.ts @@ -12,7 +12,7 @@ import { getResourceApiPath, getResourceName, getResourceNameApiPath, IResource, import { Status, StatusKind } from '../status' import { AnsibleTowerInventory, AnsibleTowerInventoryList } from '../ansible-inventory' -// must match ansiblePaths in backend-node/src/routes/ansibletower.ts +// must match ansibletower.Paths in backend/internal/ansibletower const ansibleControllerPaths = ['/api/v2/job_templates/', '/api/v2/workflow_job_templates/'] // Ansible Automation Platform Operator v2.5 and later only supports the Gateway URL. // For Gateway URLs, use the following path prefixes: From fe442733568ab9ab6a9b19978c6d5a062dba4fba Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Mon, 14 Sep 2026 16:48:44 +0200 Subject: [PATCH 13/16] ACM-42603 Decommission Node.js backend (#63) * ACM-42600 Signed-off-by: Enrique Mingorance Cano * ACM-42601 Migrate search proxy and WebSocket relay to Go Signed-off-by: Enrique Mingorance Cano * ACM-42602 Migrate long-tail routes to Go Signed-off-by: Enrique Mingorance Cano * ACM-42603 Decommission Node.js backend Signed-off-by: Enrique Mingorance Cano * tektone gomod path Signed-off-by: Enrique Mingorance Cano * config.DisableEvents Signed-off-by: Enrique Mingorance Cano * Change Containerfile.[a|e]cm file go image to registry.ci.openshift.org/stolostron/builder:go1.26-linux and move line to the top Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- .dockerignore | 10 - .github/workflows/backend-upgrade.yml | 29 - .tekton/console-acm-51-pull-request.yaml | 2 +- .tekton/console-acm-51-push.yaml | 2 +- .tekton/console-acm-52-pull-request.yaml | 2 +- .tekton/console-acm-52-push.yaml | 2 +- .tekton/console-mce-mce-51-pull-request.yaml | 2 +- .tekton/console-mce-mce-51-push.yaml | 2 +- .tekton/console-mce-mce-52-pull-request.yaml | 2 +- .tekton/console-mce-mce-52-push.yaml | 2 +- .vscode/launch.json | 21 +- AGENTS.md | 14 +- CONTRIBUTING.md | 2 +- Containerfile.acm | 23 +- Containerfile.mce | 25 +- Makefile.prow | 8 +- README.md | 14 +- backend-node/.gitignore | 5 - backend-node/.vscode/launch.json | 31 - backend-node/AGENTS.md | 125 - backend-node/CLAUDE.md | 1 - backend-node/eslint.config.mjs | 58 - backend-node/package-lock.json | 7820 ----------------- backend-node/package.json | 127 - backend-node/src/app.ts | 96 - backend-node/src/lib/agent.ts | 83 - backend-node/src/lib/batch-promise-all.ts | 20 - backend-node/src/lib/body-parser.ts | 81 - backend-node/src/lib/compression.ts | 374 - backend-node/src/lib/config.ts | 71 - backend-node/src/lib/cookies.ts | 49 - backend-node/src/lib/cors.ts | 26 - backend-node/src/lib/delay.ts | 21 - backend-node/src/lib/fetch-retry.ts | 96 - backend-node/src/lib/fileWatch.ts | 77 - backend-node/src/lib/getServiceToken.ts | 49 - backend-node/src/lib/gigantic.ts | 1991 ----- backend-node/src/lib/json-request.ts | 116 - backend-node/src/lib/logger.ts | 9 - backend-node/src/lib/main.ts | 66 - backend-node/src/lib/memory.ts | 17 - backend-node/src/lib/multi-cluster-engine.ts | 68 - backend-node/src/lib/multi-cluster-hub.ts | 56 - backend-node/src/lib/noop.ts | 6 - backend-node/src/lib/pagination.ts | 153 - backend-node/src/lib/paths.ts | 18 - backend-node/src/lib/placementDebugCAWatch.ts | 218 - backend-node/src/lib/random-string.ts | 12 - backend-node/src/lib/request-retry.ts | 145 - backend-node/src/lib/respond.ts | 71 - backend-node/src/lib/search.ts | 161 - backend-node/src/lib/server-side-events.ts | 462 - backend-node/src/lib/server.ts | 197 - backend-node/src/lib/serviceAccountToken.ts | 119 - backend-node/src/lib/tlsProfileWatch.ts | 356 - backend-node/src/lib/token.ts | 79 - backend-node/src/resources/resource-list.ts | 16 - backend-node/src/resources/resource.ts | 237 - backend-node/src/resources/route.ts | 23 - backend-node/src/resources/secret.ts | 9 - backend-node/src/resources/status.ts | 10 - backend-node/src/resources/watch-options.ts | 19 - backend-node/src/routes/aggregator.ts | 53 - .../src/routes/aggregators/appSetData.ts | 150 - .../src/routes/aggregators/applications.ts | 445 - .../routes/aggregators/applicationsArgo.ts | 639 -- .../src/routes/aggregators/applicationsOCP.ts | 329 - .../aggregators/applicationsPushModel.ts | 98 - .../src/routes/aggregators/statuses.ts | 102 - backend-node/src/routes/aggregators/utils.ts | 1135 --- backend-node/src/routes/events.ts | 1011 --- backend-node/src/routes/liveness.ts | 57 - backend-node/src/routes/readiness.ts | 8 - backend-node/test/app.test.ts | 18 - backend-node/test/jest-setup.ts | 12 - backend-node/test/lib/agent.test.ts | 172 - .../test/lib/batch-promise-all.test.ts | 80 - backend-node/test/lib/compression.test.ts | 185 - backend-node/test/lib/fileWatch.test.ts | 169 - backend-node/test/lib/getServiceToken.test.ts | 69 - backend-node/test/lib/paths.test.ts | 35 - .../test/lib/placementDebugCAWatch.test.ts | 490 -- backend-node/test/lib/tlsProfileWatch.test.ts | 1016 --- backend-node/test/mock-request.ts | 148 - .../routes/aggregators/applications.test.ts | 649 -- .../applicationsArgoMergePush.test.ts | 467 - .../aggregators/applicationsPushModel.test.ts | 286 - .../test/routes/aggregators/utils.test.ts | 1401 --- backend-node/test/routes/events.test.ts | 1498 ---- backend-node/test/routes/liveness.test.ts | 17 - backend-node/test/routes/ping.test.ts | 9 - backend-node/test/routes/readiness.test.ts | 17 - backend-node/test/tsconfig.json | 4 - backend-node/tsconfig.build.json | 4 - backend-node/tsconfig.json | 30 - backend/AGENTS.md | 39 +- backend/README.md | 4 +- backend/cmd/console/main.go | 52 +- backend/internal/auth/auth.go | 2 +- backend/internal/config/config.go | 15 +- backend/internal/config/config_test.go | 26 - backend/internal/cors/cors.go | 5 +- backend/internal/health/health.go | 33 +- backend/internal/health/health_test.go | 32 +- backend/internal/informers/factory.go | 1 - backend/internal/informers/specs.go | 5 +- backend/internal/informers/specs_test.go | 134 - backend/internal/proxy/proxy.go | 28 - backend/internal/proxy/proxy_test.go | 242 - backend/internal/server/server.go | 34 +- backend/internal/server/server_test.go | 465 +- backend/internal/static/static.go | 4 +- console.code-workspace | 3 - docs/ARCHITECTURE.md | 8 +- docs/RESOURCES.md | 9 +- lint-staged.config.js | 1 - package.json | 21 +- port-defaults.sh | 1 - scripts/check-hub-alignment.sh | 2 +- scripts/console-entrypoint.sh | 4 - setup.sh | 2 - sonar-project.properties | 8 +- 122 files changed, 193 insertions(+), 25796 deletions(-) delete mode 100644 .github/workflows/backend-upgrade.yml delete mode 100644 backend-node/.gitignore delete mode 100644 backend-node/.vscode/launch.json delete mode 100644 backend-node/AGENTS.md delete mode 100644 backend-node/CLAUDE.md delete mode 100644 backend-node/eslint.config.mjs delete mode 100644 backend-node/package-lock.json delete mode 100644 backend-node/package.json delete mode 100644 backend-node/src/app.ts delete mode 100644 backend-node/src/lib/agent.ts delete mode 100644 backend-node/src/lib/batch-promise-all.ts delete mode 100644 backend-node/src/lib/body-parser.ts delete mode 100644 backend-node/src/lib/compression.ts delete mode 100644 backend-node/src/lib/config.ts delete mode 100644 backend-node/src/lib/cookies.ts delete mode 100644 backend-node/src/lib/cors.ts delete mode 100644 backend-node/src/lib/delay.ts delete mode 100644 backend-node/src/lib/fetch-retry.ts delete mode 100644 backend-node/src/lib/fileWatch.ts delete mode 100644 backend-node/src/lib/getServiceToken.ts delete mode 100644 backend-node/src/lib/gigantic.ts delete mode 100644 backend-node/src/lib/json-request.ts delete mode 100644 backend-node/src/lib/logger.ts delete mode 100644 backend-node/src/lib/main.ts delete mode 100644 backend-node/src/lib/memory.ts delete mode 100644 backend-node/src/lib/multi-cluster-engine.ts delete mode 100644 backend-node/src/lib/multi-cluster-hub.ts delete mode 100644 backend-node/src/lib/noop.ts delete mode 100644 backend-node/src/lib/pagination.ts delete mode 100644 backend-node/src/lib/paths.ts delete mode 100644 backend-node/src/lib/placementDebugCAWatch.ts delete mode 100644 backend-node/src/lib/random-string.ts delete mode 100644 backend-node/src/lib/request-retry.ts delete mode 100644 backend-node/src/lib/respond.ts delete mode 100644 backend-node/src/lib/search.ts delete mode 100644 backend-node/src/lib/server-side-events.ts delete mode 100644 backend-node/src/lib/server.ts delete mode 100644 backend-node/src/lib/serviceAccountToken.ts delete mode 100644 backend-node/src/lib/tlsProfileWatch.ts delete mode 100644 backend-node/src/lib/token.ts delete mode 100644 backend-node/src/resources/resource-list.ts delete mode 100644 backend-node/src/resources/resource.ts delete mode 100644 backend-node/src/resources/route.ts delete mode 100644 backend-node/src/resources/secret.ts delete mode 100644 backend-node/src/resources/status.ts delete mode 100644 backend-node/src/resources/watch-options.ts delete mode 100644 backend-node/src/routes/aggregator.ts delete mode 100644 backend-node/src/routes/aggregators/appSetData.ts delete mode 100644 backend-node/src/routes/aggregators/applications.ts delete mode 100644 backend-node/src/routes/aggregators/applicationsArgo.ts delete mode 100644 backend-node/src/routes/aggregators/applicationsOCP.ts delete mode 100644 backend-node/src/routes/aggregators/applicationsPushModel.ts delete mode 100644 backend-node/src/routes/aggregators/statuses.ts delete mode 100644 backend-node/src/routes/aggregators/utils.ts delete mode 100644 backend-node/src/routes/events.ts delete mode 100644 backend-node/src/routes/liveness.ts delete mode 100644 backend-node/src/routes/readiness.ts delete mode 100644 backend-node/test/app.test.ts delete mode 100644 backend-node/test/jest-setup.ts delete mode 100644 backend-node/test/lib/agent.test.ts delete mode 100644 backend-node/test/lib/batch-promise-all.test.ts delete mode 100644 backend-node/test/lib/compression.test.ts delete mode 100644 backend-node/test/lib/fileWatch.test.ts delete mode 100644 backend-node/test/lib/getServiceToken.test.ts delete mode 100644 backend-node/test/lib/paths.test.ts delete mode 100644 backend-node/test/lib/placementDebugCAWatch.test.ts delete mode 100644 backend-node/test/lib/tlsProfileWatch.test.ts delete mode 100644 backend-node/test/mock-request.ts delete mode 100644 backend-node/test/routes/aggregators/applications.test.ts delete mode 100644 backend-node/test/routes/aggregators/applicationsArgoMergePush.test.ts delete mode 100644 backend-node/test/routes/aggregators/applicationsPushModel.test.ts delete mode 100644 backend-node/test/routes/aggregators/utils.test.ts delete mode 100644 backend-node/test/routes/events.test.ts delete mode 100644 backend-node/test/routes/liveness.test.ts delete mode 100644 backend-node/test/routes/ping.test.ts delete mode 100644 backend-node/test/routes/readiness.test.ts delete mode 100644 backend-node/test/tsconfig.json delete mode 100644 backend-node/tsconfig.build.json delete mode 100644 backend-node/tsconfig.json delete mode 100644 backend/internal/proxy/proxy.go delete mode 100644 backend/internal/proxy/proxy_test.go diff --git a/.dockerignore b/.dockerignore index 28d464b388a..d2c17aff3eb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,16 +8,6 @@ /backend/bin/ /backend/.env -/backend-node/.vscode/ -/backend-node/coverage/ -/backend-node/build/ -/backend-node/node_modules/ -/backend-node/public/ -/backend-node/.dockerignore -/backend-node/.gitignore -/backend-node/.Dockerfile -/backend-node/*.md - /frontend/.vscode/ /frontend/build/ /frontend/node_modules/ diff --git a/.github/workflows/backend-upgrade.yml b/.github/workflows/backend-upgrade.yml deleted file mode 100644 index 01a3e55d91f..00000000000 --- a/.github/workflows/backend-upgrade.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Backend Upgrade -on: - workflow_dispatch: - schedule: - - cron: '0 9 * * 0' -jobs: - npm-check-updates: - if: (github.event_name == 'schedule' && github.repository == 'stolostron/console') || (github.event_name != 'schedule') - runs-on: ubuntu-latest - timeout-minutes: 10 - defaults: - run: - working-directory: backend-node - steps: - - uses: actions/checkout@v7 - with: - token: ${{ secrets.GH_TOKEN }} - - uses: actions/setup-node@v7 - with: - node-version: '24' - - run: npm i -g npm-check-updates - - name: npm-check-updates --target minor - run: ncu -e 2 -t minor > /dev/null 2>&1 || ncu --doctor -u -t minor - - run: npm audit fix - - run: npm test - - uses: EndBug/add-and-commit@v11 - with: - default_author: github_actions - message: Upgraded package dependencies diff --git a/.tekton/console-acm-51-pull-request.yaml b/.tekton/console-acm-51-pull-request.yaml index 85530873ba1..980adbdab16 100644 --- a/.tekton/console-acm-51-pull-request.yaml +++ b/.tekton/console-acm-51-pull-request.yaml @@ -41,7 +41,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"gomod"}]' - name: enable-cache-proxy value: "true" pipelineRef: diff --git a/.tekton/console-acm-51-push.yaml b/.tekton/console-acm-51-push.yaml index 038b73201f4..2246354362a 100644 --- a/.tekton/console-acm-51-push.yaml +++ b/.tekton/console-acm-51-push.yaml @@ -38,7 +38,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"gomod"}]' - name: send-slack-notification value: true - name: konflux-application-name diff --git a/.tekton/console-acm-52-pull-request.yaml b/.tekton/console-acm-52-pull-request.yaml index c5de1f55015..9abb1f3aee5 100644 --- a/.tekton/console-acm-52-pull-request.yaml +++ b/.tekton/console-acm-52-pull-request.yaml @@ -41,7 +41,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"gomod"}]' - name: enable-cache-proxy value: "true" pipelineRef: diff --git a/.tekton/console-acm-52-push.yaml b/.tekton/console-acm-52-push.yaml index 9a54bdde8c8..ec258ff86c2 100644 --- a/.tekton/console-acm-52-push.yaml +++ b/.tekton/console-acm-52-push.yaml @@ -38,7 +38,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"gomod"}]' - name: send-slack-notification value: true - name: konflux-application-name diff --git a/.tekton/console-mce-mce-51-pull-request.yaml b/.tekton/console-mce-mce-51-pull-request.yaml index b50d8bf88bf..c943d30e0f4 100644 --- a/.tekton/console-mce-mce-51-pull-request.yaml +++ b/.tekton/console-mce-mce-51-pull-request.yaml @@ -41,7 +41,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"gomod"}]' - name: enable-cache-proxy value: "true" pipelineRef: diff --git a/.tekton/console-mce-mce-51-push.yaml b/.tekton/console-mce-mce-51-push.yaml index d7a570f3b00..5d16bc6a236 100644 --- a/.tekton/console-mce-mce-51-push.yaml +++ b/.tekton/console-mce-mce-51-push.yaml @@ -38,7 +38,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"gomod"}]' - name: send-slack-notification value: true - name: konflux-application-name diff --git a/.tekton/console-mce-mce-52-pull-request.yaml b/.tekton/console-mce-mce-52-pull-request.yaml index c6220ff1991..34c5badb482 100644 --- a/.tekton/console-mce-mce-52-pull-request.yaml +++ b/.tekton/console-mce-mce-52-pull-request.yaml @@ -41,7 +41,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"gomod"}]' - name: enable-cache-proxy value: "true" pipelineRef: diff --git a/.tekton/console-mce-mce-52-push.yaml b/.tekton/console-mce-mce-52-push.yaml index c3ccd7edd46..cb35933f4a9 100644 --- a/.tekton/console-mce-mce-52-push.yaml +++ b/.tekton/console-mce-mce-52-push.yaml @@ -38,7 +38,7 @@ spec: - name: build-source-image value: "true" - name: prefetch-input - value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend-node","type":"npm"}]' + value: '[{"path":".","type":"npm"},{"path":"./frontend","type":"npm"},{"path":"./backend","type":"gomod"}]' - name: send-slack-notification value: true - name: konflux-application-name diff --git a/.vscode/launch.json b/.vscode/launch.json index c9820c675c2..50ea3f4d42b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -36,24 +36,6 @@ "internalConsoleOptions": "neverOpen", "program": "${workspaceFolder}/frontend/node_modules/.bin/jest" }, - { - "type": "node", - "name": "vscode-jest-tests.v2.backend-node", - "request": "launch", - "args": [ - "--runInBand", - "--watchAll=false", - "--coverage=false", - "--testNamePattern", - "${jest.testNamePattern}", - "--runTestsByPath", - "${jest.testFile}" - ], - "cwd": "${workspaceFolder}/backend-node", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "program": "${workspaceFolder}/backend-node/node_modules/.bin/jest" - }, { "name": "Go backend", "type": "go", @@ -62,8 +44,7 @@ "program": "${workspaceFolder}/backend/cmd/console", "cwd": "${workspaceFolder}/backend", "env": { - "PORT": "4000", - "NODE_BACKEND_URL": "https://127.0.0.1:4001" + "PORT": "4000" } } ] diff --git a/AGENTS.md b/AGENTS.md index 2682e718ce1..a12f9950885 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,6 @@ console/ │ ├── eslint-config/ # @stolostron/eslint-config │ └── prettier-config/ # @stolostron/prettier-config ├── backend/ # Go console backend (public listener) -├── backend-node/ # Node sidecar for routes not yet migrated to Go ├── docs/ # Architecture documentation ├── scripts/ # Build and development scripts └── resources/ # Sample K8s YAML fixtures @@ -33,7 +32,7 @@ console/ ## Setup ```bash -npm ci # installs frontend, backend-node; go mod download when Go is installed +npm ci # installs frontend; go mod download when Go is installed npm run setup # writes backend/.env and backend/certs/ from the current oc context ``` @@ -63,9 +62,9 @@ rm -rf backend/.env backend/certs/ && npm run setup && npm run ci:backend Run checks against only one side of the monorepo: -- `npm run test:frontend` / `npm run test:backend` / `npm run test:backend-node` -- `npm run check:frontend` / `npm run check:backend` / `npm run check:backend-node` -- `npm run lint:frontend` / `npm run lint:backend` / `npm run lint:backend-node` +- `npm run test:frontend` / `npm run test:backend` +- `npm run check:frontend` / `npm run check:backend` +- `npm run lint:frontend` / `npm run lint:backend` ### Port Configuration @@ -75,7 +74,6 @@ Ports are customizable via environment variables defined in `port-defaults.sh`: |----------|---------|---------| | `FRONTEND_PORT` | 3000 | Standalone console | | `BACKEND_PORT` | 4000 | Backend APIs (Go listener) | -| `NODE_BACKEND_PORT` | 4001 | Node sidecar (unmigrated routes) | | `CONSOLE_PORT` | 9000 | OpenShift console | | `MCE_PORT` | 3001 | MCE plugin | | `ACM_PORT` | 3002 | ACM plugin | @@ -90,7 +88,7 @@ Use `npm run plugins` for development; it matches the production deployment mode ## Code Quality Standards -- TypeScript strict mode in frontend; `backend-node` uses `noImplicitAny` but not full strict mode +- TypeScript strict mode in frontend - Go backend: `gofmt`, `golangci-lint`, and `go test ./...` (`npm run check:backend`) - ESLint with `@stolostron/eslint-config` (flat config) - Prettier with `@stolostron/prettier-config` (120 char width, no semicolons, single quotes) @@ -148,4 +146,4 @@ Features can be enabled/disabled via the `console-config` ConfigMap in the insta - **Module resolution errors** — Verify Node.js and npm versions match `.nvmrc` / `.tool-versions`; version mismatches break ESM resolution - **Missing `.env`** — Run `npm run setup` (or `npm run setup:hub` after `oc login` to a new cluster) to generate `backend/.env` - **Plugin UI redirects to `/dashboards`** — `oc whoami --show-server` must match `CLUSTER_API_URL` in `backend/.env`. After `oc login` to a new hub, run `npm run setup:hub` and restart `npm run plugins`. `start-ocp-console.sh` runs `scripts/check-hub-alignment.sh` to catch this early. -- **Console `tls: first record does not look like a TLS handshake`** — `backend/certs/` is missing or backends were started before certs existed. Run `npm run generate-certs` and restart `npm run plugins` (both Go and Node sidecar read certs only at startup). +- **Console `tls: first record does not look like a TLS handshake`** — `backend/certs/` is missing or the backend was started before certs existed. Run `npm run generate-certs` and restart `npm run plugins` (the Go listener reads certs only at startup). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7792ee16765..e7d8a931505 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,7 +67,7 @@ npm ci npm run setup ``` -`npm ci` runs a `postinstall` that installs `frontend`, `backend-node`, and (when Go is installed) `go mod download` in `backend/`. `npm run setup` writes `backend/.env` and creates `backend/certs/` when missing; `npm run ci:backend` also ensures certs exist. +`npm ci` runs a `postinstall` that installs `frontend` and (when Go is installed) `go mod download` in `backend/`. `npm run setup` writes `backend/.env` and creates `backend/certs/` when missing; `npm run ci:backend` also ensures certs exist. After `oc login` to a different hub: diff --git a/Containerfile.acm b/Containerfile.acm index 39a1076937d..c57a9fb7882 100644 --- a/Containerfile.acm +++ b/Containerfile.acm @@ -1,5 +1,7 @@ # Copyright Contributors to the Open Cluster Management project ARG NODE_BASE=registry.redhat.io/ubi9/nodejs-24-minimal:latest +ARG RUNTIME_BASE=registry.redhat.io/ubi9/ubi-minimal:latest +ARG GO_BASE=registry.ci.openshift.org/stolostron/builder:go1.26-linux FROM registry.redhat.io/ubi9/ubi:latest AS crypto-policy RUN update-crypto-policies --set DEFAULT:PQ @@ -11,20 +13,10 @@ ENV NPM_CONFIG_IGNORE_SCRIPTS=true FROM build-base as dynamic-plugin WORKDIR /app/frontend COPY ./frontend . -# Optimization of copying only package.json and package-lock.json does not work because of workspace packages +# Optimization of copying only package.json and package-lock.json does not work because of workspace packages RUN npm ci --legacy-peer-deps RUN npm run build:plugin:acm -FROM build-base as backend -WORKDIR /app/backend-node -# Copy only package.json and package-lock.json so that the docker layer cache only changes if those change -# This will cause the npm ci to only rerun if the package.json or package-lock.json changes -COPY ./backend-node/package.json ./backend-node/package-lock.json ./ -RUN npm ci --omit=optional -COPY ./backend-node . -RUN npm run build - -ARG GO_BASE=golang:1.26 FROM ${GO_BASE} as go-backend WORKDIR /src COPY ./backend/go.mod ./backend/go.sum ./ @@ -32,18 +24,11 @@ RUN go mod download COPY ./backend . RUN CGO_ENABLED=0 GOOS=linux go build -o /console ./cmd/console -FROM build-base as production -WORKDIR /app/backend-node -COPY ./backend-node/package-lock.json ./backend-node/package.json ./ -RUN npm ci --omit=optional --only=production - -FROM ${NODE_BASE} +FROM ${RUNTIME_BASE} COPY --from=crypto-policy /etc/crypto-policies /etc/crypto-policies WORKDIR /app ENV NODE_ENV production ENV PUBLIC_FOLDER=/app/public -COPY --from=production /app/backend-node/node_modules ./node_modules -COPY --from=backend /app/backend-node/backend.mjs ./ COPY --from=go-backend /console ./console COPY --from=dynamic-plugin /app/frontend/plugins/acm/dist ./public/plugin COPY ./scripts/console-entrypoint.sh ./console-entrypoint.sh diff --git a/Containerfile.mce b/Containerfile.mce index e5d6e5167fa..2cbf256b688 100644 --- a/Containerfile.mce +++ b/Containerfile.mce @@ -1,5 +1,7 @@ # Copyright Contributors to the Open Cluster Management project ARG NODE_BASE=registry.redhat.io/ubi9/nodejs-24-minimal:latest +ARG RUNTIME_BASE=registry.redhat.io/ubi9/ubi-minimal:latest +ARG GO_BASE=registry.ci.openshift.org/stolostron/builder:go1.26-linux FROM registry.redhat.io/ubi9/ubi:latest AS crypto-policy RUN update-crypto-policies --set DEFAULT:PQ @@ -11,20 +13,10 @@ ENV NPM_CONFIG_IGNORE_SCRIPTS=true FROM build-base as dynamic-plugin WORKDIR /app/frontend COPY ./frontend . -# Optimization of copying only package.json and package-lock.json does not work because of workspace packages +# Optimization of copying only package.json and package-lock.json does not work because of workspace packages RUN npm ci --legacy-peer-deps RUN npm run build:plugin:mce -FROM build-base as backend -WORKDIR /app/backend-node -# Copy only package.json and package-lock.json so that the docker layer cache only changes if those change -# This will cause the npm ci to only rerun if the package.json or package-lock.json changes -COPY ./backend-node/package.json ./backend-node/package-lock.json ./ -RUN npm ci --omit=optional -COPY ./backend-node . -RUN npm run build - -ARG GO_BASE=golang:1.26 FROM ${GO_BASE} as go-backend WORKDIR /src COPY ./backend/go.mod ./backend/go.sum ./ @@ -32,18 +24,11 @@ RUN go mod download COPY ./backend . RUN CGO_ENABLED=0 GOOS=linux go build -o /console ./cmd/console -FROM build-base as production -WORKDIR /app/backend-node -COPY ./backend-node/package-lock.json ./backend-node/package.json ./ -RUN npm ci --omit=optional --only=production - -FROM ${NODE_BASE} +FROM ${RUNTIME_BASE} COPY --from=crypto-policy /etc/crypto-policies /etc/crypto-policies WORKDIR /app ENV NODE_ENV production ENV PUBLIC_FOLDER=/app/public -COPY --from=production /app/backend-node/node_modules ./node_modules -COPY --from=backend /app/backend-node/backend.mjs ./ COPY --from=go-backend /console ./console COPY --from=dynamic-plugin /app/frontend/plugins/mce/dist ./public/plugin COPY ./scripts/console-entrypoint.sh ./console-entrypoint.sh @@ -61,4 +46,4 @@ LABEL com.redhat.component="multicluster-engine-console-mce-container" \ io.k8s.display-name="multicluster-engine-console-mce" \ maintainer="['acm-component-maintainers@redhat.com']" \ description="multicluster-engine-console-mce" \ - io.k8s.description="multicluster-engine-console-mce" \ No newline at end of file + io.k8s.description="multicluster-engine-console-mce" diff --git a/Makefile.prow b/Makefile.prow index 2d4778656de..327199f3f98 100644 --- a/Makefile.prow +++ b/Makefile.prow @@ -10,15 +10,15 @@ install: .PHONY: build build: npm run build:frontend - npm run build:backend-node + npm run build:backend .PHONY: check check: - npx concurrently --kill-others-on-fail npm:copyright:check npm:check:frontend npm:check:backend npm:check:backend-node -c green,blue,magenta + npx concurrently --kill-others-on-fail npm:copyright:check npm:check:frontend npm:check:backend -c green,blue,magenta .PHONY: lint lint: - npx concurrently --kill-others-on-fail npm:lint:frontend npm:lint:backend npm:lint:backend-node -c green,blue + npx concurrently --kill-others-on-fail npm:lint:frontend npm:lint:backend -c green,blue .PHONY: unit-tests unit-tests: @@ -26,4 +26,4 @@ unit-tests: mkdir test-output; \ fi npm run test:frontend -- --maxWorkers=2 - npm run test:backend-node -- --maxWorkers=2 + npm run test:backend diff --git a/README.md b/README.md index b31abeebb06..981e536d1c0 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ The recommended way to run the console for development is as OpenShift Console d npm ci ``` - The root `postinstall` installs `frontend`, `backend-node`, and (when Go is installed) runs `go mod download` in `backend/`. You may see `[backend] ci:backend` in the output — that is expected. + The root `postinstall` installs `frontend` and (when Go is installed) runs `go mod download` in `backend/`. You may see `[backend] ci:backend` in the output — that is expected. 3. Configure environment @@ -107,7 +107,7 @@ The recommended way to run the console for development is as OpenShift Console d npm run plugins ``` - This concurrently starts the Go backend (reverse-proxying unmigrated routes to a Node sidecar), the frontend webpack development server (serving both ACM and MCE plugins), and a local OpenShift Console container. The console will be available at **http://localhost:9000**. + This concurrently starts the Go backend, the frontend webpack development server (serving both ACM and MCE plugins), and a local OpenShift Console container. The console will be available at **http://localhost:9000**. ### Options @@ -168,7 +168,6 @@ All ports are customizable via environment variables. The default values are def | -------------- | ------- | ----------------------------------------------------------------------------------- | ------------------------------- | | FRONTEND_PORT | 3000 | Port for standalone console (access at https://localhost:FRONTEND_PORT) | `npm run setup`, `npm start` | | BACKEND_PORT | 4000 | Port for the Go backend APIs used by both standalone and plugin modes | `npm run setup`, `npm start`, `npm run plugins` | -| NODE_BACKEND_PORT | 4001 | Port for the Node sidecar (unmigrated routes; not used by the browser) | `npm start`, `npm run plugins` | | CONSOLE_PORT | 9000 | Port for OpenShift Console (access at http://localhost:CONSOLE_PORT) | `npm run setup`, `npm run plugins` | | MCE_PORT | 3001 | Port on which the `mce` dynamic plugin is served to OpenShift Console | `npm run plugins` | | ACM_PORT | 3002 | Port on which the `acm` dynamic plugin is served to OpenShift Console | `npm run plugins` | @@ -215,10 +214,9 @@ Enabling this feature will allow the user to create a cluster that only contains ### Testing ```bash -npm test # Run all tests (frontend + Go backend + Node sidecar) +npm test # Run all tests (frontend + Go backend) npm run test:frontend # Run frontend tests only npm run test:backend # Run Go backend tests only -npm run test:backend-node # Run Node sidecar tests only npm test -- # Run tests matching a file pattern ``` @@ -322,12 +320,6 @@ After executing the `npm start` command an error on the backend is produced like [go] service account token missing ``` -or on the sidecar: - -```text -[sidecar] ERROR:Error reading service account token -``` - `./backend/.env` is missing or stale. Run `npm run setup` or `npm run setup:hub` after `oc login`. ### Certs issues diff --git a/backend-node/.gitignore b/backend-node/.gitignore deleted file mode 100644 index c0da78ff4c2..00000000000 --- a/backend-node/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Copyright Contributors to the Open Cluster Management project -build -coverage -backend.mjs -test-report.xml \ No newline at end of file diff --git a/backend-node/.vscode/launch.json b/backend-node/.vscode/launch.json deleted file mode 100644 index 1ae14f936f6..00000000000 --- a/backend-node/.vscode/launch.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "Debug", - "type": "node", - "request": "launch", - "args": ["${workspaceFolder}/src/main.ts"], - "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], - "sourceMaps": true, - "cwd": "${workspaceRoot}", - "disableOptimisticBPs": true, - "protocol": "inspector", - "console": "integratedTerminal" - }, - { - "name": "vscode-jest-tests", - "type": "node", - "request": "launch", - "args": ["--runInBand", "--coverage", "false"], - "cwd": "${workspaceFolder}", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "disableOptimisticBPs": true, - "program": "${workspaceFolder}/node_modules/jest/bin/jest" - } - ] -} diff --git a/backend-node/AGENTS.md b/backend-node/AGENTS.md deleted file mode 100644 index 57a597bce1f..00000000000 --- a/backend-node/AGENTS.md +++ /dev/null @@ -1,125 +0,0 @@ -# Backend - -Node.js ESM proxy server. Sits between the browser and the hub cluster API server, handling authentication, RBAC enforcement, resource watching, and API proxying. - -## Key Technologies - -- **Runtime**: Node.js with native ESM (`"type": "module"`) -- **Router**: `find-my-way` for HTTP/2 route matching -- **Proxy**: `node:https` + `pipeline` for main API proxy -- **Logging**: Pino with structured JSON output (use `pino-zen` for dev formatting) -- **HTTP Client**: `got` for outbound requests -- **WebSocket**: Search graphql-ws is served by the Go listener (`backend/internal/searchproxy`) - -## Source Layout - -| Directory | Purpose | -|-----------|---------| -| `src/lib/` | Core server: `main.ts` entry, `server.ts`, auth, cookies, CORS, proxy, search, SSE, logging, config | -| `src/routes/` | Sidecar handlers still dual-run: `events`, probes, aggregators (`hub.ts`). Long-tail HTTP (ROSA, ansibletower, placement-debug, upgrade-risks) is served by Go | -| `src/resources/` | Backend resource watchers and handlers | -| `test/` | Jest test files | -| `config/` | Runtime configuration lives in `../backend/config` (Go backend) | -| `certs/` | TLS certificates live in `../backend/certs` (created by `npm run setup` / `npm run ci:backend` when missing) | - -## Commands - -Run from the `backend-node/` directory, or use the `npm run *:backend-node` variants from the repo root. - -| Command | Purpose | -|---------|---------| -| `npm start` | Start dev server with nodemon + inspector | -| `npm test` | Run Jest tests | -| `npm run lint` | ESLint check | -| `npm run tsc` | TypeScript type check | -| `npm run check` | Run lint + prettier + tsc together | -| `npm run build` | Production build via tsc + rollup → `backend.mjs` | - -## Architecture - -The Go process in `../backend` is the public listener. This Node process is a sidecar for routes not yet migrated (ACM-42603 decommissions it). OAuth login, logout, `/configure`, Search proxy, and long-tail HTTP (ROSA wizard, Ansible Tower, placement-debug, upgrade-risks) are served by Go. - -```text -Browser / plugin → Go :4000 (GET /events, POST /aggregate, POST /proxy/search + Search WS, long-tail HTTP) - → Node sidecar (this package) → Hub Cluster API Server - ↓ - Watches resources via service account (hub.ts / dual-run) - Enforces RBAC via user token + SubjectAccessReview - Sidecar GET /events remains when Go cache is off -``` - -## Route Handlers - -- Route handler signature: `(req: Http2ServerRequest, res: Http2ServerResponse): Promise` -- Router uses `maxParamLength: 500` for long Kubernetes resource names -- URL rewriting: `/multicloud` prefix is stripped before routing in `app.ts` -- Use `pipeline()` from `node:stream` for proxy and streaming operations to ensure proper backpressure and cleanup -- Use `getEncodeStream()` for SSE compression - -## Security - -- Never log sensitive data (tokens, passwords, credentials) -- Validate and sanitize all inputs -- Guard against injection vulnerabilities (command injection, path traversal) -- Ensure proper authentication and authorization checks on all routes -- Use `SelfSubjectAccessReview` for permission checks -- Log at appropriate levels with Pino (error, warn, info, debug) — include relevant context but never sensitive data - -## Configuration - -### Environment Variables (`.env`) - -Generated by `npm run setup` from the repo root into **`../backend/.env`**. The sidecar loads it via `ENV_FILE` (default `../backend/.env`). These are cluster-specific: - -| Variable | Purpose | -|----------|---------| -| `PORT` | Sidecar listen port (`NODE_BACKEND_PORT`, default 4001). Public `PORT` in `.env` is the Go listener (4000). | -| `ENV_FILE` / `CONFIG_DIR` / `CERTS_DIR` | Shared artifacts owned by the Go backend (`../backend/.env`, `config`, `certs`) | -| `NODE_ENV` | `development` or `production` — controls CORS, caching, logging, cert behavior | -| `CLUSTER_API_URL` | Hub cluster API server URL — used extensively for all K8s API calls | -| `TOKEN` | Service account token for backend-initiated cluster requests | -| `CA_CERT` / `SERVICE_CA_CERT` | Cluster CA certificates for TLS verification | -| `OAUTH2_CLIENT_ID` / `OAUTH2_CLIENT_SECRET` | OAuth client credentials for login flow | -| `OAUTH2_REDIRECT_URL` | OAuth callback URL | -| `OIDC_ISSUER_URL` | OIDC issuer URL (when using external OIDC instead of OpenShift OAuth) | -| `FRONTEND_URL` | Frontend URL for post-login redirect | -| `SEARCH_API_URL` | Search API route URL | -| `PLACEMENT_DEBUG_URL` | Consumed by the Go listener (`backend/internal/placementdebug`) | - -Optional development/debug variables (not in `.env` by default): - -| Variable | Purpose | -|----------|---------| -| `HTTPS_PROXY` | HTTP proxy for outbound requests | -| `DELAY` / `RANDOM_DELAY` | Artificial delay for dev testing (development mode only) | -| `MOCK_CLUSTERS` | Number of mock clusters to generate for testing | -| `DISABLE_EVENTS` | Set to `true` to disable SSE event streams | -| `DISABLE_STREAM_COMPRESSION` | Set to `true` to disable SSE compression | - -### Settings (`../backend/config/` directory) - -Files in the Go backend `config/` directory are loaded at startup and watched for dynamic updates. - -- `LOG_*` keys (`LOG_LEVEL`, `LOG_ACCESS`, `LOG_EVENTS`, `LOG_MEMORY`, `LOG_WATCH`) — control logging behavior -- `APP_SEARCH_*` keys (`APP_SEARCH_INTERVAL`, `APP_SEARCH_LIMIT`) — application search tuning -- `globalSearchFeatureFlag` — enables federated search endpoint -- `UPGRADE_RISKS_PREDICTION_URL` — override for upgrade risk prediction (consumed by the Go listener) - -Other config files (e.g., `singleNodeOpenshift`, `ansibleIntegration`, `awsPrivateWizardStep`) are sent to the frontend as settings but not promoted to backend env vars. The frontend uses these to toggle UI features like single-node cluster creation and Ansible automation options. - -### Feature Flags - -Feature flags come from two mechanisms: -- **MultiClusterHub components** — the `/multiclusterhub/components` route exposes MCH component status, used by the frontend to determine which features are installed -- **Config settings** — files in `config/` act as feature toggles (e.g., `singleNodeOpenshift`, `ansibleIntegration`), pushed to the frontend via SSE and checked with `settings. === 'enabled'` - -## Testing - -- Test files are in `test/` -- Tests should meaningfully cover behavior, not just achieve coverage metrics -- Properly mock and isolate dependencies -- Async tests must handle promises correctly - -## Environment - -The sidecar requires `../backend/.env` (generated by `npm run setup` from the repo root). Key variables include the cluster API URL, OAuth credentials, and service account token. diff --git a/backend-node/CLAUDE.md b/backend-node/CLAUDE.md deleted file mode 100644 index 43c994c2d36..00000000000 --- a/backend-node/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/backend-node/eslint.config.mjs b/backend-node/eslint.config.mjs deleted file mode 100644 index a0ff302229a..00000000000 --- a/backend-node/eslint.config.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import { defineConfig } from "eslint/config"; -import prettier from "eslint-plugin-prettier"; -import tsParser from "@typescript-eslint/parser"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import js from "@eslint/js"; -import { FlatCompat } from "@eslint/eslintrc"; -import unicorn from 'eslint-plugin-unicorn'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all -}); - -export default defineConfig([{ - extends: compat.extends( - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking", - "prettier", - ), - - plugins: { - prettier, - unicorn - }, - - languageOptions: { - parser: tsParser, - ecmaVersion: 2022, - sourceType: "module", - - parserOptions: { - project: ["./tsconfig.json", "./test/tsconfig.json"], - }, - }, - - rules: { - "prettier/prettier": "error", - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/no-use-before-define": "off", - "@typescript-eslint/no-unused-vars": "off", - - "@typescript-eslint/no-floating-promises": ["error", { - ignoreVoid: true, - }], - - "@typescript-eslint/no-misused-promises": ["error", { - checksVoidReturn: false, - }], - "unicorn/new-for-builtins": "error", - "unicorn/prefer-node-protocol": "error" - }, -}]); \ No newline at end of file diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json deleted file mode 100644 index c1bd73d5c81..00000000000 --- a/backend-node/package-lock.json +++ /dev/null @@ -1,7820 +0,0 @@ -{ - "name": "backend", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "backend", - "version": "0.0.1", - "hasInstallScript": true, - "dependencies": { - "abort-controller": "3.0.0", - "dotenv": "16.6.1", - "find-my-way": "^9.9.0", - "fuse.js": "6.6.2", - "get-value": "3.0.1", - "got": "^12.6.1", - "http2-proxy": "^5.0.53", - "https-proxy-agent": "^7.0.6", - "node-fetch": "2.7.0", - "node-localstorage": "^3.0.5", - "object-sizeof": "^2.6.5", - "pino": "7.11.0", - "pluralize": "8.0.0", - "prom-client": "^14.2.0", - "raw-body": "2.5.3", - "ws": "^8.21.3" - }, - "devDependencies": { - "@types/eslint": "9.6.1", - "@types/get-value": "3.0.5", - "@types/jest": "27.5.2", - "@types/node": "^24.13.3", - "@types/node-fetch": "2.6.13", - "@types/node-localstorage": "^1.3.3", - "@types/pluralize": "^0.0.33", - "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^8.68.0", - "@typescript-eslint/parser": "^8.68.0", - "concurrently": "9.2.4", - "eslint": "^9.39.5", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.6", - "eslint-plugin-unicorn": "^62.0.0", - "extensionless": "^2.0.6", - "got-11.8.2": "npm:got@11.8.6", - "jest": "^29.7.0", - "jest-sonar-reporter": "^2.0.0", - "nock": "13.5.6", - "nodemon": "^3.1.14", - "pino-zen": "2.0.8", - "prettier": "^3.9.6", - "rollup": "2.80.0", - "ts-jest": "^29.4.12", - "ts-node": "^10.9.2", - "typescript": "^5.9.3" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", - "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.24.2", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", - "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", - "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", - "dev": true, - "peer": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.4", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", - "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.24.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", - "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", - "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", - "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", - "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.2", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", - "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", - "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", - "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz", - "integrity": "sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", - "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.24.1", - "@babel/generator": "^7.24.1", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.1", - "@babel/types": "^7.24.0", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/core/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/core/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", - "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", - "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true - }, - "node_modules/@sindresorhus/is": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.3.0.tgz", - "integrity": "sha512-CX6t4SYQ37lzxicAqsBtxA3OseeoVrh9cSJ5PFYam0GksYlupRfy1A+Q4aYD3zvcfECLc0zO2u+ZnR2UYKvCrw==", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", - "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true - }, - "node_modules/@types/args": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/args/-/args-5.0.0.tgz", - "integrity": "sha512-3fNb8ja/wQWFrHf5SQC5S3n0iBXdnT3PTPEJni2tBQRuv0BnAsz5u12U5gPRBSR7xdY6fI6QjWoTK/8ysuTt0w==", - "dev": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", - "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/get-value": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/get-value/-/get-value-3.0.5.tgz", - "integrity": "sha512-+o8nw0TId5cDwtdVrhlc8rvzaxbCU+JksFeu8ZunY9vUaODxngXiNceTFj2gkSwGWNRpe3PtaSWt1y0VB71PvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "27.5.2", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.5.2.tgz", - "integrity": "sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==", - "dev": true, - "dependencies": { - "jest-matcher-utils": "^27.0.0", - "pretty-format": "^27.0.0" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/node-localstorage": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@types/node-localstorage/-/node-localstorage-1.3.3.tgz", - "integrity": "sha512-Wkn5g4eM5x10UNV9Xvl9K6y6m0zorocuJy4WjB5muUdyMZuPbZpSJG3hlhjGHe1HGxbOQO7RcB+jlHcNwkh+Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/pluralize": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/@types/pluralize/-/pluralize-0.0.33.tgz", - "integrity": "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", - "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.68.0", - "@typescript-eslint/type-utils": "8.68.0", - "@typescript-eslint/utils": "8.68.0", - "@typescript-eslint/visitor-keys": "8.68.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.68.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", - "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.68.0", - "@typescript-eslint/types": "8.68.0", - "@typescript-eslint/typescript-estree": "8.68.0", - "@typescript-eslint/visitor-keys": "8.68.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", - "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.68.0", - "@typescript-eslint/types": "^8.68.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", - "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.68.0", - "@typescript-eslint/visitor-keys": "8.68.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", - "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", - "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.68.0", - "@typescript-eslint/typescript-estree": "8.68.0", - "@typescript-eslint/utils": "8.68.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", - "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", - "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.68.0", - "@typescript-eslint/tsconfig-utils": "8.68.0", - "@typescript-eslint/types": "8.68.0", - "@typescript-eslint/visitor-keys": "8.68.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", - "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.68.0", - "@typescript-eslint/types": "8.68.0", - "@typescript-eslint/typescript-estree": "8.68.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.68.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", - "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.68.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/args": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/args/-/args-5.0.3.tgz", - "integrity": "sha512-h6k/zfFgusnv3i5TU08KQkVKuCPBtL/PWQbWkHUxvJrZ2nAyeaUupneemcrgn1xmqxPQsPIzwkUhOpoqPDRZuA==", - "dev": true, - "dependencies": { - "camelcase": "5.0.0", - "chalk": "2.4.2", - "leven": "2.1.0", - "mri": "1.1.4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/args/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/args/node_modules/camelcase": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz", - "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/args/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/args/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/args/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/args/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/args/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/args/node_modules/leven": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", - "integrity": "sha512-nvVPLpIHUxCUoRLrFqTgSxXJ614d8AgQoWl7zPe/2VadE8+1dpU3LBhowRuBAcuwruWtOdD8oYC9jDNJjXDPyA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/args/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dev": true, - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.17", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", - "integrity": "sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bintrees": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", - "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" - }, - "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/brace-expansion/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "node_modules/builtin-modules": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-5.0.0.tgz", - "integrity": "sha512-bkXY9WsVpY7CvMhKSR6pZilZu9Ln5WDrKVBUXf2S443etkmEO4V58heTecXcUIsNsi4Rx8JUO4NfX1IcQl4deg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", - "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/change-case": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", - "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", - "dev": true - }, - "node_modules/clean-regexp": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", - "integrity": "sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/clean-regexp/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/clone-response/node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/concurrently": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", - "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.9.0", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/core-js-compat": { - "version": "3.48.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", - "integrity": "sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dedent": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", - "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", - "dev": true, - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "engines": { - "node": ">=10" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/diff-sequences": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", - "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", - "dev": true, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexify": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.2.tgz", - "integrity": "sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==", - "dependencies": { - "end-of-stream": "^1.4.1", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1", - "stream-shift": "^1.0.0" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.278", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", - "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", - "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.13" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-unicorn": { - "version": "62.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-62.0.0.tgz", - "integrity": "sha512-HIlIkGLkvf29YEiS/ImuDZQbP12gWyx5i3C6XrRxMvVdqMroCI9qoVYCoIl17ChN+U89pn9sVwLxhIWj5nEc7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "@eslint-community/eslint-utils": "^4.9.0", - "@eslint/plugin-kit": "^0.4.0", - "change-case": "^5.4.4", - "ci-info": "^4.3.1", - "clean-regexp": "^1.0.0", - "core-js-compat": "^3.46.0", - "esquery": "^1.6.0", - "find-up-simple": "^1.0.1", - "globals": "^16.4.0", - "indent-string": "^5.0.0", - "is-builtin-module": "^5.0.0", - "jsesc": "^3.1.0", - "pluralize": "^8.0.0", - "regexp-tree": "^0.1.27", - "regjsparser": "^0.13.0", - "semver": "^7.7.3", - "strip-indent": "^4.1.1" - }, - "engines": { - "node": "^20.10.0 || >=21.0.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" - }, - "peerDependencies": { - "eslint": ">=9.38.0" - } - }, - "node_modules/eslint-plugin-unicorn/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint-plugin-unicorn/node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-plugin-unicorn/node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "engines": { - "node": ">=6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/expect/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/expect/node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/expect/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/expect/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/expect/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/expect/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/expect/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/extensionless": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/extensionless/-/extensionless-2.0.6.tgz", - "integrity": "sha512-Kri4UehTAnQzYpM2gySya2nsCBEChxstgtKtOqR1nqZpF0HYM+BZXaeua9uUhE4Z0CE4kfZp+0JFhKL3SsuKFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-decode-uri-component": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", - "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fast-querystring": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", - "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", - "license": "MIT", - "dependencies": { - "fast-decode-uri-component": "^1.0.1" - } - }, - "node_modules/fast-redact": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.1.2.tgz", - "integrity": "sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==", - "engines": { - "node": ">=6" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-my-way": { - "version": "9.9.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.9.0.tgz", - "integrity": "sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-querystring": "^1.0.0", - "safe-regex2": "^5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/find-up-simple": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", - "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", - "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", - "engines": { - "node": ">= 14.17" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fuse.js": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz", - "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-value": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/get-value/-/get-value-3.0.1.tgz", - "integrity": "sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA==", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/got": { - "version": "12.6.1", - "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", - "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/got-11.8.2": { - "name": "got", - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, - "node_modules/got-11.8.2/node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/got-11.8.2/node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/got-11.8.2/node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/got-11.8.2/node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/got-11.8.2/node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/got-11.8.2/node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/got-11.8.2/node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/got-11.8.2/node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/got-11.8.2/node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/got-11.8.2/node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http2-proxy": { - "version": "5.0.53", - "resolved": "https://registry.npmjs.org/http2-proxy/-/http2-proxy-5.0.53.tgz", - "integrity": "sha512-k9OUKrPWau/YeViJGv5peEFgSGPE2n8CDyk/G3f+JfaaJzbFMPAK5PJTd99QYSUvgUwVBGNbZJCY/BEb+kUZNQ==", - "license": "MIT" - }, - "node_modules/http2-wrapper": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz", - "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-builtin-module": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-5.0.0.tgz", - "integrity": "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "builtin-modules": "^5.0.0" - }, - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz", - "integrity": "sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "peer": true, - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-circus/node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-config/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-config/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-config/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-config/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/jest-diff": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", - "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-each/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", - "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", - "dev": true, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-leak-detector/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-leak-detector/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-leak-detector/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/jest-matcher-utils": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", - "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^27.5.1", - "jest-get-type": "^27.5.1", - "pretty-format": "^27.5.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-message-util/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-snapshot/node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/jest-sonar-reporter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jest-sonar-reporter/-/jest-sonar-reporter-2.0.0.tgz", - "integrity": "sha512-ZervDCgEX5gdUbdtWsjdipLN3bKJwpxbvhkYNXTAYvAckCihobSLr9OT/IuyNIRT1EZMDDwR6DroWtrq+IL64w==", - "dev": true, - "dependencies": { - "xml": "^1.0.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-validate/node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mri": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", - "integrity": "sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nock": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", - "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "json-stringify-safe": "^5.0.1", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">= 10.13" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true - }, - "node_modules/node-localstorage": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-3.0.5.tgz", - "integrity": "sha512-GCwtK33iwVXboZWYcqQHu3aRvXEBwmPkAMRBLeaX86ufhqslyUkLGsi4aW3INEfdQYpUB5M9qtYf3eHvAk2VBg==", - "license": "MIT", - "dependencies": { - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/node-localstorage/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/node-localstorage/node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nodemon": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", - "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^10.2.1", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/nodemon/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/nopt": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", - "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", - "dev": true, - "dependencies": { - "abbrev": "1" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", - "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-sizeof": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/object-sizeof/-/object-sizeof-2.6.5.tgz", - "integrity": "sha512-Mu3udRqIsKpneKjIEJ2U/s1KmEgpl+N6cEX1o+dDl2aZ+VW5piHqNgomqAk5YMsDoSkpcA8HnIKx1eqGTKzdfw==", - "dependencies": { - "buffer": "^6.0.3" - } - }, - "node_modules/on-exit-leak-free": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-0.2.0.tgz", - "integrity": "sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==" - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", - "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pino": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-7.11.0.tgz", - "integrity": "sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==", - "dependencies": { - "atomic-sleep": "^1.0.0", - "fast-redact": "^3.0.0", - "on-exit-leak-free": "^0.2.0", - "pino-abstract-transport": "v0.5.0", - "pino-std-serializers": "^4.0.0", - "process-warning": "^1.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.1.0", - "safe-stable-stringify": "^2.1.0", - "sonic-boom": "^2.2.1", - "thread-stream": "^0.15.1" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-0.5.0.tgz", - "integrity": "sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ==", - "dependencies": { - "duplexify": "^4.1.2", - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz", - "integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==" - }, - "node_modules/pino-zen": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/pino-zen/-/pino-zen-2.0.8.tgz", - "integrity": "sha512-p0yRYiaKNtxdpLTgb2M2Z4zegak86rN7LAJFhoY+fxgHMzpwlihcckV0nCkr+UbwXgiSL22Zmuo/zfl10ZDUow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/args": "^5.0.0", - "args": "^5.0.3", - "chalk": "^5.2.0", - "pino-abstract-transport": "^1.0.0", - "sonic-boom": "^3.2.1", - "split2": "^4.1.0" - }, - "bin": { - "pino-zen": "lib/pino-zen-cli.mjs" - } - }, - "node_modules/pino-zen/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/pino-zen/node_modules/pino-abstract-transport": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", - "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "readable-stream": "^4.0.0", - "split2": "^4.0.0" - } - }, - "node_modules/pino-zen/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/pino-zen/node_modules/sonic-boom": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.1.tgz", - "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "engines": { - "node": ">=4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/process-warning": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-1.0.0.tgz", - "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==" - }, - "node_modules/prom-client": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.2.0.tgz", - "integrity": "sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==", - "dependencies": { - "tdigest": "^0.1.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true - }, - "node_modules/pump": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", - "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ] - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" - }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true - }, - "node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/real-require": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.1.0.tgz", - "integrity": "sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/regexp-tree": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", - "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", - "dev": true, - "license": "MIT", - "bin": { - "regexp-tree": "bin/regexp-tree" - } - }, - "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "jsesc": "~3.1.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/responselike": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", - "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ret": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", - "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/rollup": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", - "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safe-regex2": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", - "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "ret": "~0.5.0" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.1.tgz", - "integrity": "sha512-dVHE6bMtS/bnL2mwualjc6IxEv1F+OCUpA46pKUj6F8uDbUM0jCCulPqRNPSnWwGNKx5etqMjZYdXtrm5KJZGA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", - "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/sonic-boom": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz", - "integrity": "sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/split2": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", - "integrity": "sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/stream-shift": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", - "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-indent": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", - "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/synckit": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", - "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.3.6" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/tdigest": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", - "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", - "dependencies": { - "bintrees": "1.0.2" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/thread-stream": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-0.15.2.tgz", - "integrity": "sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==", - "dependencies": { - "real-require": "^0.1.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", - "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", - "dev": true, - "dependencies": { - "nopt": "~1.0.10" - }, - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-jest": { - "version": "29.4.12", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", - "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.9", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.8.5", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <7" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/ts-node/node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true - }, - "node_modules/v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/ws": { - "version": "8.21.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", - "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", - "dev": true - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/backend-node/package.json b/backend-node/package.json deleted file mode 100644 index 608375e7fe0..00000000000 --- a/backend-node/package.json +++ /dev/null @@ -1,127 +0,0 @@ -{ - "name": "backend", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "start": "NODE_ENV=development nodemon --watch './**/*.ts' --exec 'node --inspect --experimental-transform-types --import extensionless/register src/lib/main.ts' | pino-zen -r msg=6 -d fields -d labels -d apiVersion -e error", - "build": "tsc -p tsconfig.build.json --sourceMap false --declaration false && npx rollup --format es --file backend.mjs -- build/lib/main.js", - "tsc": "tsc --noEmit", - "clean": "rm -rf coverage build", - "check": "npm run prettier && npm run lint && npm run tsc", - "check:fix": "npm run prettier:fix && npm run lint:fix && npm run tsc", - "test": "npm run jest --", - "jest": "node --experimental-vm-modules node_modules/.bin/jest --testResultsProcessor jest-sonar-reporter", - "lint": "eslint src test --max-warnings=0", - "lint:fix": "eslint src test --fix", - "prettier": "prettier --check src test", - "prettier:fix": "prettier --write src test", - "update": "npx npm-check-updates --doctor --upgrade && npm audit fix && npm dedup" - }, - "dependencies": { - "abort-controller": "3.0.0", - "dotenv": "16.6.1", - "find-my-way": "^9.9.0", - "fuse.js": "6.6.2", - "get-value": "3.0.1", - "got": "^12.6.1", - "http2-proxy": "^5.0.53", - "https-proxy-agent": "^7.0.6", - "node-fetch": "2.7.0", - "node-localstorage": "^3.0.5", - "object-sizeof": "^2.6.5", - "pino": "7.11.0", - "pluralize": "8.0.0", - "prom-client": "^14.2.0", - "raw-body": "2.5.3", - "ws": "^8.21.3" - }, - "devDependencies": { - "@types/eslint": "9.6.1", - "@types/get-value": "3.0.5", - "@types/jest": "27.5.2", - "@types/node": "^24.13.3", - "@types/node-fetch": "2.6.13", - "@types/node-localstorage": "^1.3.3", - "@types/pluralize": "^0.0.33", - "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^8.68.0", - "@typescript-eslint/parser": "^8.68.0", - "concurrently": "9.2.4", - "eslint": "^9.39.5", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.6", - "eslint-plugin-unicorn": "^62.0.0", - "extensionless": "^2.0.6", - "got-11.8.2": "npm:got@11.8.6", - "jest": "^29.7.0", - "jest-sonar-reporter": "^2.0.0", - "nock": "13.5.6", - "nodemon": "^3.1.14", - "pino-zen": "2.0.8", - "prettier": "^3.9.6", - "rollup": "2.80.0", - "ts-jest": "^29.4.12", - "ts-node": "^10.9.2", - "typescript": "^5.9.3" - }, - "prettier": { - "printWidth": 120, - "tabWidth": 2, - "semi": false, - "singleQuote": true, - "trailingComma": "es5" - }, - "jest": { - "transform": { - "^.+\\.[jt]sx?$": [ - "ts-jest", - { - "useESM": true, - "tsconfig": { - "verbatimModuleSyntax": false - } - } - ] - }, - "setupFiles": [ - "/test/jest-setup.ts" - ], - "moduleNameMapper": { - "(\\.{1,2}/.*)\\.js$": "$1", - "got(.*)$": "/node_modules/got-11.8.2/dist/source/index.js" - }, - "testTimeout": 60000, - "testEnvironment": "node", - "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", - "collectCoverage": true, - "coverageThreshold": { - "global": { - "branches": 0, - "functions": 0, - "lines": 0, - "statements": 0 - } - }, - "coverageReporters": [ - "text-summary", - "html", - "lcov" - ], - "collectCoverageFrom": [ - "src/**/*.ts", - "!**/node_modules/**" - ], - "moduleFileExtensions": [ - "ts", - "tsx", - "js", - "jsx", - "json", - "node" - ], - "watchPathIgnorePatterns": [ - "coverage" - ] - } -} diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts deleted file mode 100644 index 119110bb057..00000000000 --- a/backend-node/src/app.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import Router from 'find-my-way' -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { loadSettings } from './lib/config' -import { stopFileWatches } from './lib/fileWatch' -import { cors } from './lib/cors' -import { delay } from './lib/delay' -import { logger, stopLogger } from './lib/logger' -import { startLoggingMemory } from './lib/memory' -import { notFound, respondInternalServerError, respondOK } from './lib/respond' -import { startServer, stopServer } from './lib/server' -import { ServerSideEvents } from './lib/server-side-events' -import { events, startWatching, stopWatching } from './routes/events' -import { liveness } from './routes/liveness' -import { readiness } from './routes/readiness' -import { watchTLSSecurityProfile } from './lib/tlsProfileWatch' - -const isProduction = process.env.NODE_ENV === 'production' -const isDevelopment = process.env.NODE_ENV === 'development' -const eventsEnabled = process.env.DISABLE_EVENTS !== 'true' - -// Router defaults to max param length of 100 - We need to override to 500 to handle resources with very long names -// If the route exceeds 500 chars the route will not be found from this fn: router.find() -export const router = Router({ maxParamLength: 500 }) -router.get('/readinessProbe', readiness) -router.get('/livenessProbe', liveness) -router.get('/ping', respondOK) -if (eventsEnabled) { - // Public GET /events is served by the Go listener when CONSOLE_INFORMER_CACHE is on (ACM-42598). - // This sidecar route remains for dual-run and when the Go cache is disabled. - router.get('/events', events) -} - -export async function requestHandler(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - if (!isProduction) { - if (cors(req, res)) return - await delay(req, res) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - if (req.url === '/multicloud') (req as any).url = '/' - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - else if (req.url.startsWith('/multicloud')) (req as any).url = req.url.substring(11) - - const route = router.find(req.method as Router.HTTPMethod, req.url) - if (!route) { - logger.warn({ msg: 'route not found', url: req.url }) - return notFound(req, res) - } - - try { - const result: unknown = route.handler(req, res, route.params, route.store, route.searchParams) - if (result instanceof Promise) await result - } catch (err) { - logger.error(err) - if (!res.headersSent) return respondInternalServerError(req, res) - } -} - -let stopTLSProfileWatch: (() => void) | undefined -export async function start() { - await loadSettings() - if (eventsEnabled) { - startWatching() - } - stopTLSProfileWatch = watchTLSSecurityProfile(async (options) => { - try { - await stopServer() - await startServer({ requestHandler, ...options }) - } catch (err) { - logger.error({ - msg: 'server restart failed on TLS profile change', - error: err instanceof Error ? err.message : String(err), - }) - } - }) -} - -export async function stop(): Promise { - if (isDevelopment) { - setTimeout(() => { - logger.warn('process stop timeout. exiting...') - process.exit(1) - }, 0.5 * 1000).unref() - } - stopFileWatches() - await ServerSideEvents.dispose() - stopWatching() - stopTLSProfileWatch?.() - await stopServer() - stopLogger() -} - -if (process.env.LOG_MEMORY === 'true') { - startLoggingMemory() -} diff --git a/backend-node/src/lib/agent.ts b/backend-node/src/lib/agent.ts deleted file mode 100644 index eee941cba33..00000000000 --- a/backend-node/src/lib/agent.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { AgentOptions } from 'node:https' -import { Agent } from 'node:https' -import { getCACertificates } from 'node:tls' -import { getCACertificate, getServiceCACertificate } from './serviceAccountToken' -import { getPlacementDebugCA } from './placementDebugCAWatch' -import { HttpsProxyAgent } from 'https-proxy-agent' - -const COMMON_AGENT_OPTIONS: Partial = { - keepAlive: true, // Reuse connections - keepAliveMsecs: 30000, // 30 seconds keep alive - timeout: 30000, // 30 second socket timeout -} - -let defaultAgent: Agent -export function getDefaultAgent() { - if (!defaultAgent) { - defaultAgent = new Agent({ - ca: getCACertificate(() => { - defaultAgent = undefined - }), - ...COMMON_AGENT_OPTIONS, - }) - } - return defaultAgent -} - -let serviceAgent: Agent -export function getServiceAgent() { - if (!serviceAgent) { - serviceAgent = new Agent({ - ca: getServiceCACertificate(() => { - serviceAgent = undefined - }), - ...COMMON_AGENT_OPTIONS, - }) - } - return serviceAgent -} - -let placementDebugAgent: Agent | undefined -export function getPlacementDebugAgent(): Agent | undefined { - if (!placementDebugAgent) { - const ca = getPlacementDebugCA() - if (!ca) return undefined - placementDebugAgent = new Agent({ ca, ...COMMON_AGENT_OPTIONS }) - } - return placementDebugAgent -} - -export function invalidatePlacementDebugAgent(): void { - placementDebugAgent = undefined -} - -let proxyAgent: HttpsProxyAgent -export function getProxyAgent() { - if (!proxyAgent && process.env.HTTPS_PROXY) { - proxyAgent = new HttpsProxyAgent(process.env.HTTPS_PROXY, COMMON_AGENT_OPTIONS) - } - return proxyAgent -} - -// Insights upgrade-risk-prediction requests may target either the public console.redhat.com -// (default) or an on-cluster gateway like the Insights Operator proxy service (service-ca signed) -// or an externally hosted on-prem instance (trusted via NODE_EXTRA_CA_CERTS), so this agent trusts -// all three rather than just the public roots used by getDefaultAgent(). -let insightsAgent: Agent -export function getInsightsAgent() { - if (!insightsAgent) { - insightsAgent = new Agent({ - ca: [ - ...getCACertificates('default'), - ...([] as string[]).concat( - getServiceCACertificate(() => { - insightsAgent = undefined - }) - ), - ], - ...COMMON_AGENT_OPTIONS, - }) - } - return insightsAgent -} diff --git a/backend-node/src/lib/batch-promise-all.ts b/backend-node/src/lib/batch-promise-all.ts deleted file mode 100644 index f2623ade754..00000000000 --- a/backend-node/src/lib/batch-promise-all.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -export const BATCH_SIZE = 100 - -/** - * Process an array of items through an async mapper in batches, yielding the - * event loop between batches to allow HTTP requests (e.g. liveness probes) to - * be served. - */ -export async function batchPromiseAll(items: T[], mapper: (item: T) => Promise): Promise { - const results: R[] = [] - for (let i = 0; i < items.length; i += BATCH_SIZE) { - const batch = await Promise.all(items.slice(i, i + BATCH_SIZE).map(mapper)) - results.push(...batch) - if (i + BATCH_SIZE < items.length) { - await new Promise((resolve) => setImmediate(resolve)) - } - } - return results -} diff --git a/backend-node/src/lib/body-parser.ts b/backend-node/src/lib/body-parser.ts deleted file mode 100644 index 9bedec52096..00000000000 --- a/backend-node/src/lib/body-parser.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { IncomingMessage } from 'node:http' -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import rawBody from 'raw-body' -import { getDecodeStream } from './compression' - -export const APPLICATION_JSON = 'application/json' - -export async function parseJsonBody(req: Http2ServerRequest | IncomingMessage): Promise { - const contentType = req.headers[constants.HTTP2_HEADER_CONTENT_TYPE] - if (typeof contentType === 'string') { - if (contentType.includes(':')) { - const found = contentType.split(':').find((part) => part === APPLICATION_JSON) - if (!found) throw new Error('Content type header not set to application/json') - } else { - if (contentType !== APPLICATION_JSON) throw new Error('Content type header not set to application/json') - } - } else { - throw new Error('Content type header not set') - } - - const bodyString = await rawBody(getDecodeStream(req, req.headers[constants.HTTP2_HEADER_CONTENT_ENCODING]), { - length: req.headers['content-length'], - limit: 1 * 1024 * 1024, - encoding: true, - }) - - return JSON.parse(bodyString) as T -} - -export async function parseBody(req: Http2ServerRequest | IncomingMessage): Promise { - const contentType = req.headers[constants.HTTP2_HEADER_CONTENT_TYPE] - if (typeof contentType === 'string') { - if (contentType.includes(':')) { - const found = contentType.split(':').find((part) => part === APPLICATION_JSON) - if (!found) throw new Error('Content type header not set to application/json') - } else { - if (contentType !== APPLICATION_JSON) throw new Error('Content type header not set to application/json') - } - } else { - throw new Error('Content type header not set') - } - - const buffer = await rawBody(getDecodeStream(req, req.headers[constants.HTTP2_HEADER_CONTENT_ENCODING]), { - length: req.headers['content-length'], - limit: 1 * 1024 * 1024, - }) - - return buffer -} - -export async function parseResponseJsonBody>>(r: Http2ServerResponse): Promise { - const contentType = r.getHeader(constants.HTTP2_HEADER_CONTENT_TYPE) - if (typeof contentType === 'string') { - if (contentType.includes(':')) { - const found = contentType.split(':').find((part) => part === APPLICATION_JSON) - if (!found) throw new Error('Content type header not set to application/json') - } else { - if (contentType !== APPLICATION_JSON) throw new Error('Content type header not set to application/json') - } - } else { - throw new Error('Content type header not set') - } - - const bodyString = await rawBody(getDecodeStream(r.stream, r.getHeader(constants.HTTP2_HEADER_CONTENT_ENCODING)), { - length: r.getHeader('content-length'), - limit: 1 * 1024 * 1024, - encoding: true, - }) - return JSON.parse(bodyString) as T -} - -export async function parsePipedJsonBody>>(r: Http2ServerResponse): Promise { - const bodyString = await rawBody(getDecodeStream(r.stream, r.getHeader(constants.HTTP2_HEADER_CONTENT_ENCODING)), { - length: r.getHeader('content-length'), - limit: 1 * 1024 * 1024, - encoding: true, - }) - return JSON.parse(bodyString || '{}') as T -} diff --git a/backend-node/src/lib/compression.ts b/backend-node/src/lib/compression.ts deleted file mode 100644 index cdf8113d271..00000000000 --- a/backend-node/src/lib/compression.ts +++ /dev/null @@ -1,374 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Readable, Transform } from 'node:stream' -import { pipeline } from 'node:stream' -import { promisify } from 'node:util' -import type { Zlib } from 'node:zlib' -import { - createBrotliCompress, - createBrotliDecompress, - createDeflate, - createGunzip, - createGzip, - createInflate, - deflateRaw, - inflateRaw, -} from 'node:zlib' -import { getAppDict, type ICompressedResource, type ITransformedResource } from '../routes/aggregators/applications' -import { getEventDict } from '../routes/events' -import type { IResource } from './../resources/resource' -import { logger } from './logger' -import type { ServerSideEvent, WatchEvent } from './server-side-events' - -const MAX_RECENTLY_ADDED = 200 - -type Dictionary = { - arr: string[] - map: Record - add: (key: string) => string - get: (inx: number) => string - has: (key: string) => string - recentlyAdded: string[] - snapshotSize: () => number - drainRecentlyAdded: () => string[] -} - -export function createDictionary(): Dictionary { - const arr: string[] = [] - const map: Record = {} - const recentlyAdded: string[] = [] - const add = (key: string): string => { - if (!(key in map)) { - map[key] = `${arr.length}` - arr.push(key) - if (logger.isLevelEnabled('debug') && recentlyAdded.length < MAX_RECENTLY_ADDED) { - recentlyAdded.push(key) - } - } - return map[key] - } - const get = (inx: number) => { - return arr[inx] - } - const has = (key: string) => { - return map[key] - } - const snapshotSize = () => arr.length - const drainRecentlyAdded = () => recentlyAdded.splice(0) - return { - arr, - map, - add, - get, - has, - recentlyAdded, - snapshotSize, - drainRecentlyAdded, - } -} - -// keys that point to unique values (don't index) -const valueAsIsKeys = new Set(['uid', 'name', 'resourceVersion', 'generation']) -// keys that point to values that are likely to be repeated (we should index) -const valueInDictionaryKeys = new Set([ - 'apiVersion', - 'kind', - 'namespace', - 'pathname', - 'group', - 'webConsoleURL', - 'ocpClusterId', -]) - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type UncompressedResourceType = Record | Record | string | number - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type CompressedResourceType = Record | Record | string | number - -const NUMBER_MARKER = '#!%' -const JSON_MARKER = '#!&' - -// Detects ISO 8601 timestamps to avoid permanently indexing unique time values. -// Covers: "2026-05-27T20:18:12Z" (20), "2026-05-27T20:18:12.000Z" (24), "2026-05-27T20:18:12+05:30" (25) -export function isTimestamp(s: string): boolean { - return ( - (s.length === 20 || s.length === 24 || s.length === 25) && - s[4] === '-' && - s[7] === '-' && - s[10] === 'T' && - s[13] === ':' && - (s.endsWith('Z') || s[19] === '+' || s[19] === '-') - ) -} - -export class FifoSet { - private readonly values: T[] = [] - private readonly membership: Set = new Set() - private readonly capacity?: number - - constructor(capacity?: number) { - this.capacity = capacity - } - - has(value: T): boolean { - return this.membership.has(value) - } - - add(value: T): void { - if (!this.membership.has(value)) { - this.values.push(value) - this.membership.add(value) - - if (this.capacity !== undefined && this.values.length > this.capacity) { - const evicted = this.values.shift() - if (evicted !== undefined) this.membership.delete(evicted) - } - } - } - - delete(value: T): void { - if (this.membership.has(value)) { - this.membership.delete(value) - const index = this.values.indexOf(value) - if (index >= 0) this.values.splice(index, 1) - } - } -} - -const bigStrings: FifoSet = new FifoSet(200) - -export async function deflateResource(resource: IResource, dictionary: Dictionary): Promise { - const res = compressResource(resource, dictionary) - let buffer - try { - buffer = await promisify(deflateRaw)(JSON.stringify(res)) - } catch (err: unknown) { - logger.error({ - msg: 'Error from deflateRaw during deflateResource', - error: err instanceof Error ? err.message : err, - }) - throw err - } - return buffer -} - -function compressResource(resource: UncompressedResourceType, dictionary: Dictionary): CompressedResourceType { - if (resource) { - if (Array.isArray(resource)) { - return resource.map((item: UncompressedResourceType) => compressResource(item, dictionary)) - } else if (typeof resource === 'object') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const res: Record = {} - for (const key in resource) { - if (Object.prototype.hasOwnProperty.call(resource, key)) { - // filter out these key/values - // dont try to index the values pointed to by key - if ( - valueAsIsKeys.has(key) || - (key === 'message' && - 'message' in resource && - typeof resource[key] === 'string' && - resource[key].length > 32) || - key.includes('Time') - ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - res[dictionary.add(key)] = resource[key] - } else { - const inx = dictionary.add(key) - // Guard against non-string values (e.g. nested CRD OpenAPI schema objects) corrupting the shared dictionary. - if (valueInDictionaryKeys.has(key) && typeof resource[key] === 'string') { - res[inx] = dictionary.add(resource[key]) - } else { - res[inx] = compressResource(resource[key] as UncompressedResourceType, dictionary) - } - } - } - } - return res - } else if (typeof resource === 'string') { - if ( - (resource.length > 128 && resource.startsWith('{') && !resource.startsWith('{{')) || - resource.startsWith('[') - ) { - // if the resource is a large json string, compress the inner json - try { - const innerJson = JSON.parse(resource) as UncompressedResourceType - return `${JSON_MARKER}${JSON.stringify(compressResource(innerJson, dictionary))}` - } catch (error) { - // drop thru - } - } - if (resource.length < 32 && !resource.endsWith('=')) { - // skip indexing of all timestamps - if (isTimestamp(resource)) { - return resource - } - // index short strings that aren't a base64 - return dictionary.add(resource) - } - // if already in dictionary, return the index - const exists = dictionary.has(resource) - if (exists) { - return exists - } - // if the string is not in the dictionary, add it to the bigStrings set - if (!bigStrings.has(resource)) { - bigStrings.add(resource) - } else { - // if we've seen this string, add to the dictionary - bigStrings.delete(resource) - return dictionary.add(resource) - } - } else if (typeof resource === 'number' && Number.isInteger(resource)) { - // to differentiate between an index and a value that is actually a number - return `${NUMBER_MARKER}${resource}` - } - } - return resource -} - -export async function inflateResource(buffer: Buffer, dictionary: Dictionary): Promise { - let inflated - try { - inflated = (await promisify(inflateRaw)(new Uint8Array(buffer))).toString() - } catch (err: unknown) { - logger.error({ - msg: 'Error from inflateRaw during inflateResource', - error: err instanceof Error ? err.message : err, - }) - throw err - } - const res = JSON.parse(inflated) as CompressedResourceType - return decompressResource(res, dictionary) as IResource -} - -export async function inflateEvent(event: ServerSideEvent): Promise { - const { id, data } = event - const { type, object } = data as WatchEvent - return !object - ? event - : { id, data: { type, object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object } } -} - -export async function inflateApps(apps: ICompressedResource[]): Promise { - return await Promise.all(apps.map(async (app) => await inflateApp(app))) -} - -export async function inflateApp(app: ITransformedResource | ICompressedResource): Promise { - const capp = app as ICompressedResource - if (capp.compressed) { - return { - ...(await inflateResource(capp.compressed, getAppDict())), - transform: capp.transform, - remoteClusters: capp.remoteClusters, - } - } - return app as ITransformedResource -} - -function decompressResource(resource: CompressedResourceType, dictionary: Dictionary): UncompressedResourceType { - if (typeof resource === 'boolean') { - return resource - } else if (resource) { - if (Array.isArray(resource)) { - return resource.map((item: CompressedResourceType) => decompressResource(item, dictionary)) - } else if (typeof resource === 'object') { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const res: Record = {} - for (const inx in resource) { - if (Object.prototype.hasOwnProperty.call(resource, inx)) { - const key = dictionary.get(Number(inx)) - // Dictionary corruption would produce a non-string key; skip rather than crashing on key.includes(). - if (typeof key !== 'string') continue - if ( - valueAsIsKeys.has(key) || - (key === 'message' && inx in resource && !Number.isInteger(Number(resource[inx]))) || - key.includes('Time') - ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - res[key] = resource[inx] - } else { - if (valueInDictionaryKeys.has(key)) { - res[key] = dictionary.get(resource[inx] as number) - } else { - res[key] = decompressResource(resource[inx] as CompressedResourceType, dictionary) - } - } - } - } - return res - } else if (Number.isInteger(Number(resource))) { - return dictionary.get(Number(resource)) - } else if (typeof resource === 'string') { - if (resource.startsWith(NUMBER_MARKER)) { - return Number(resource.substring(NUMBER_MARKER.length)) - } - if (resource.startsWith(JSON_MARKER)) { - const innerJson = JSON.parse(resource.substring(JSON_MARKER.length)) as CompressedResourceType - return JSON.stringify(decompressResource(innerJson, dictionary)) - } - } - } - return resource -} - -export function getDecodeStream(stream: Readable, contentEncoding?: string | string[]): Readable { - switch (contentEncoding) { - case undefined: - case 'identity': - return stream - case 'deflate': - return pipeline(stream, createDeflate(), (err) => { - if (err) logger.warn(err) - }) - case 'br': - return pipeline(stream, createBrotliDecompress(), (err) => { - if (err) logger.warn(err) - }) - case 'gzip': - return pipeline(stream, createGunzip(), (err) => { - if (err) logger.warn(err) - }) - default: - throw new Error('Unknown content encoding') - } -} - -export function getEncodeStream( - stream: NodeJS.WritableStream, - acceptEncoding?: string | string[], - disabled = false -): [NodeJS.WritableStream, (Transform & Zlib) | undefined, string] { - let encoding = 'identity' - - if (!disabled) { - // Firefox tells us it supports 'br' but it does not... disabling - // if (acceptEncoding?.includes('br')) encoding = 'br' else - if (acceptEncoding?.includes('gzip')) encoding = 'gzip' - else if (acceptEncoding?.includes('deflate')) encoding = 'deflate' - } - - let compressionStream: (Transform & Zlib) | undefined - switch (encoding) { - case 'br': - compressionStream = createBrotliCompress() - break - case 'gzip': - compressionStream = createGzip() - break - case 'deflate': - compressionStream = createInflate() - break - } - - if (compressionStream) { - pipeline(compressionStream, stream, (_err) => { - // Client might close stream while we are still writing to it - // ignore it for now as there is no issue here - // TODO - long term should we close the compression stream - // when client request ends? - // if (err) logger.warn(err) - }) - } - return [stream, compressionStream, encoding] -} diff --git a/backend-node/src/lib/config.ts b/backend-node/src/lib/config.ts deleted file mode 100644 index f09d077f3ce..00000000000 --- a/backend-node/src/lib/config.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -/* istanbul ignore file */ -import { readdir, readFile, stat } from 'node:fs/promises' -import { join } from 'node:path' -import type { SettingsEvent } from '../routes/events' -import { watchFile } from './fileWatch' -import { logger } from './logger' -import { configDir } from './paths' -import { ServerSideEvents } from './server-side-events' - -let settingsEventID = 0 - -export async function loadSettings(): Promise { - const paths = await loadConfigSettings() - for (const filePath of paths) { - watchFile( - filePath, - () => { - void loadConfigSettings() - }, - true - ) - } -} - -export async function loadConfigSettings(): Promise { - const settings: Record = {} - const readPaths: string[] = [] - try { - const filenames = await readdir(configDir()) - for (const filename of filenames) { - try { - const filePath = join(configDir(), filename) - const stats = await stat(filePath) - if (stats.isDirectory()) continue - const contents = await readFile(filePath) - settings[filename] = contents.toString() - readPaths.push(filePath) - } catch (err) { - // Do Nothing - } - } - for (const key in settings) { - if (key.startsWith('LOG_') || key.startsWith('APP_SEARCH_')) { - process.env[key] = settings[key] - } else if (key === 'globalSearchFeatureFlag') { - // Global search tech-preview requires feature flag toggle (2.11) - process.env[key] = settings[key] - } else if (key === 'UPGRADE_RISKS_PREDICTION_URL') { - process.env[key] = settings[key] - } - } - if (process.env['globalSearchFeatureFlag'] && !settings['globalSearchFeatureFlag']) { - // If globalSearchFeatureFlag is set but has been removed from config settings -> removing env var. - delete process.env['globalSearchFeatureFlag'] - } - if (process.env['UPGRADE_RISKS_PREDICTION_URL'] && !settings['UPGRADE_RISKS_PREDICTION_URL']) { - delete process.env['UPGRADE_RISKS_PREDICTION_URL'] - } - if (settings.LOG_LEVEL) { - logger.level = settings.LOG_LEVEL - } - const data: SettingsEvent = { type: 'SETTINGS', settings } - if (settingsEventID) ServerSideEvents.removeEvent(settingsEventID) - settingsEventID = await ServerSideEvents.pushEvent({ data }) - logger.info({ msg: 'loaded settings', settings }) - } catch (err) { - // Do Nothing - } - return readPaths -} diff --git a/backend-node/src/lib/cookies.ts b/backend-node/src/lib/cookies.ts deleted file mode 100644 index f5d73ffc948..00000000000 --- a/backend-node/src/lib/cookies.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' - -export function parseCookies(req: Http2ServerRequest): Record { - const cookieHeader = req.headers.cookie - if (cookieHeader !== undefined) { - const cookies: { [key: string]: string } = {} - const cookieArray = cookieHeader.split(';').map((cookie) => cookie.trim().split('=')) - for (const cookie of cookieArray) { - if (cookie.length === 2) { - cookies[cookie[0]] = cookie[1] - } - } - return cookies - } - return {} -} - -export function setCookie(res: Http2ServerResponse, cookie: string, value: string, path?: string): void { - const cookieString = `${cookie}=${value}; Secure; HttpOnly; Path=${path ? path : '/'}` - const cookieHeader = res.getHeader('Set-Cookie') - if (cookieHeader) { - if (Array.isArray(cookieHeader)) { - res.setHeader('Set-Cookie', [...cookieHeader, cookieString]) - } else { - res.setHeader('Set-Cookie', [cookieHeader, cookieString]) - } - } else { - res.setHeader('Set-Cookie', cookieString) - } -} - -export function deleteCookie( - res: Http2ServerResponse, - options: { cookie: string; path?: string; domain?: string } -): void { - let cookieString = `${options.cookie}=; Secure; HttpOnly; Path=${options.path ? options.path : '/'}` + `; max-age=0` - if (options.domain) cookieString += `; Domain=${options.domain}` - const cookieHeader = res.getHeader('Set-Cookie') - if (cookieHeader) { - if (Array.isArray(cookieHeader)) { - res.setHeader('Set-Cookie', [...cookieHeader, cookieString]) - } else { - res.setHeader('Set-Cookie', [cookieHeader, cookieString]) - } - } else { - res.setHeader('Set-Cookie', cookieString) - } -} diff --git a/backend-node/src/lib/cors.ts b/backend-node/src/lib/cors.ts deleted file mode 100644 index 313a247b0fa..00000000000 --- a/backend-node/src/lib/cors.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -/* istanbul ignore file */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' - -export function cors(req: Http2ServerRequest, res: Http2ServerResponse): boolean { - if (process.env.NODE_ENV !== 'production') { - if (req.headers['origin']) { - res.setHeader('Access-Control-Allow-Origin', req.headers['origin']) - res.setHeader('Vary', 'Origin, Access-Control-Allow-Origin') - } - res.setHeader('Access-Control-Allow-Credentials', 'true') - switch (req.method) { - case 'OPTIONS': - if (req.headers['access-control-request-method']) { - res.setHeader('Access-Control-Allow-Methods', req.headers['access-control-request-method']) - } - if (req.headers['access-control-request-headers']) { - res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers']) - } - res.writeHead(200).end() - return true - } - } - return false -} diff --git a/backend-node/src/lib/delay.ts b/backend-node/src/lib/delay.ts deleted file mode 100644 index 5864ff5ddc1..00000000000 --- a/backend-node/src/lib/delay.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -/* istanbul ignore file */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' - -function getRandomInt(min: number, max: number) { - min = Math.ceil(min) - max = Math.floor(max) - return Math.floor(Math.random() * (max - min + 1)) + min -} - -export async function delay(_req: Http2ServerRequest, _res: Http2ServerResponse): Promise { - if (process.env.NODE_ENV === 'development') { - if (process.env.DELAY) { - await new Promise((resolve) => setTimeout(resolve, Number(process.env.DELAY))) - } - if (process.env.RANDOM_DELAY) { - await new Promise((resolve) => setTimeout(resolve, getRandomInt(0, Number(process.env.RANDOM_DELAY)))) - } - } -} diff --git a/backend-node/src/lib/fetch-retry.ts b/backend-node/src/lib/fetch-retry.ts deleted file mode 100644 index 9c5ebe50e9f..00000000000 --- a/backend-node/src/lib/fetch-retry.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { RequestInfo, RequestInit, Response } from 'node-fetch' -import fetch from 'node-fetch' -import { getDefaultAgent } from './agent' - -export function fetchRetry(url: RequestInfo, init?: RequestInit, retry?: number): Promise { - let retries: number - switch (init?.method) { - case undefined: - case 'GET': - retries = retry ?? 4 - break - default: - retries = 0 - } - - const requestInit = { ...(init ?? {}) } - if (!requestInit.agent) { - requestInit.agent = getDefaultAgent() - } - - let delay = 1000 - - return new Promise(function (resolve, reject) { - async function fetchAttempt() { - try { - const response = await fetch(url, requestInit) - switch (response.status) { - case 429: // Too Many Requests - { - const retryAfter = Number(response.headers.get('retry-after')) - if (!Number.isInteger(retryAfter)) delay = retryAfter - setTimeout(fetchAttempt, delay) - } - break - - case 408: // Request Timeout - case 500: // Internal Server Error - case 502: // Bad Gateway - case 503: // Service Unavailable - case 504: // Gateway Timeout - case 522: // Connection timed out - case 524: // A Timeout Occurred - { - const retryAfter = Number(response.headers.get('retry-after')) - if (!Number.isInteger(retryAfter)) delay = retryAfter - if (retries > 0) { - retries-- - setTimeout(fetchAttempt, delay) - } else { - resolve(response) - } - } - break - - default: - resolve(response) - } - } catch (err) { - if (err instanceof Error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - switch ((err as any).code) { - case 'ETIMEDOUT': - case 'ECONNRESET': - case 'ENOTFOUND': - if (retries > 0) { - retries-- - setTimeout(fetchAttempt, delay) - } else { - reject(err) - } - break - default: - if (err.message === 'Network Error') { - if (retries > 0) { - retries-- - setTimeout(fetchAttempt, delay) - } else { - reject(err) - } - } else { - reject(err) - } - break - } - } else { - reject(new Error(String(err))) - } - } finally { - if (delay === 0) delay = 100 - else delay *= 2 - } - } - void fetchAttempt() - }) -} diff --git a/backend-node/src/lib/fileWatch.ts b/backend-node/src/lib/fileWatch.ts deleted file mode 100644 index 4988e954f92..00000000000 --- a/backend-node/src/lib/fileWatch.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { FSWatcher } from 'node:fs' -import { watch } from 'node:fs' -import { logger } from './logger' - -type WatchEntry = { - watcher: FSWatcher | undefined - callbacks: Set<() => void> - timeout: NodeJS.Timeout | undefined - persist: boolean -} - -const watchers = new Map() -const DEBOUNCE_MS = 1000 - -/** - * Watches the file at filePath and invokes onChange when it changes (debounced). - * Watch is only started on the first call for a given path. - * When persist is false (default), the watch is removed after the first change so the next access will re-register. - * When persist is true, the watch remains active. - * If the file does not exist, watching is skipped (no error). - */ -export function watchFile(filePath: string, onChange: () => void, persist = false): void { - let entry = watchers.get(filePath) - if (!entry) { - const callbacks = new Set<() => void>() - let watcher: FSWatcher | undefined - try { - watcher = watch(filePath, (_eventType, _filename) => { - const current = watchers.get(filePath) - if (!current) return - if (current.timeout) clearTimeout(current.timeout) - current.timeout = setTimeout(() => { - current.timeout = undefined - for (const cb of current.callbacks) { - try { - cb() - } catch (err: unknown) { - logger.error({ msg: 'file watch callback error', filePath, err }) - } - } - if (!current.persist) { - const toRemove = watchers.get(filePath) - if (toRemove) { - if (toRemove.watcher) toRemove.watcher.close() - watchers.delete(filePath) - } - } - }, DEBOUNCE_MS) - }) - logger.debug({ msg: 'watching file', filePath }) - } catch (err: unknown) { - logger.debug({ msg: 'skipping watch for missing or inaccessible file', filePath, err }) - } - entry = { watcher, callbacks, timeout: undefined, persist } - watchers.set(filePath, entry) - } - entry.callbacks.add(onChange) -} - -/** - * Stops all file watches and clears registered callbacks. - * Call this on application shutdown. - */ -export function stopFileWatches(): void { - for (const entry of watchers.values()) { - if (entry.watcher) { - entry.watcher.close() - } - if (entry.timeout) { - clearTimeout(entry.timeout) - } - } - watchers.clear() - logger.debug({ msg: 'stopped file watches' }) -} diff --git a/backend-node/src/lib/getServiceToken.ts b/backend-node/src/lib/getServiceToken.ts deleted file mode 100644 index fa530df79d0..00000000000 --- a/backend-node/src/lib/getServiceToken.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { HeadersInit } from 'node-fetch' -import { constants } from 'node:http2' -const { HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_ACCEPT } = constants -import { fetchRetry } from '../lib/fetch-retry' - -type AccessToken = { - access_token: string -} - -function base64DecodeValue(value: string): string { - return value ? Buffer.from(value, 'base64').toString('ascii') : undefined -} - -export async function getOcmServiceToken(client_id: string, client_secret: string): Promise { - const ssoPath = 'https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token' - - const id = base64DecodeValue(client_id) - const secret = base64DecodeValue(client_secret) - - const formData = new URLSearchParams({ - grant_type: 'client_credentials', - client_id: id, - client_secret: secret, - }) - - const headers: HeadersInit = { - [HTTP2_HEADER_CONTENT_TYPE]: 'application/x-www-form-urlencoded', - [HTTP2_HEADER_ACCEPT]: 'application/json', - } - - const ssoResponse = await fetchRetry(ssoPath, { - method: 'POST', - headers, - body: formData.toString(), - }) - - if (!ssoResponse.ok) { - const errorText = await ssoResponse.text() - throw new Error(`Token exchange failed (${ssoResponse.status}): ${errorText}`) - } - - const bodyRes = (await ssoResponse.json()) as AccessToken - - const accessTokenSSO = bodyRes.access_token - - return accessTokenSSO -} diff --git a/backend-node/src/lib/gigantic.ts b/backend-node/src/lib/gigantic.ts deleted file mode 100644 index 9ca0b9b5007..00000000000 --- a/backend-node/src/lib/gigantic.ts +++ /dev/null @@ -1,1991 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { ITransformedResource } from '../routes/aggregators/applications' -import type { ServerSideEvent } from './server-side-events' - -export function getGiganticEvents(): ServerSideEvent[] { - const MOCK_CLUSTERS = Number(process.env.MOCK_CLUSTERS) - return [ - ...getMockClusters(MOCK_CLUSTERS), - ...getMockClusterInfo(MOCK_CLUSTERS), - ...getMockClusterAddsons(MOCK_CLUSTERS), - ...getMockPolicies(MOCK_CLUSTERS), - ] -} - -export function getGiganticApps(): ITransformedResource[] { - const MOCK_CLUSTERS = Number(process.env.MOCK_CLUSTERS) - const apps: ITransformedResource[] = [] - const template = templateMaker(applicationsTemplate) - Array.from(new Array(MOCK_CLUSTERS).keys()).forEach((inx) => { - apps.push(...(template({ name: `cluster${inx + 1}` }) as ITransformedResource[])) - }) - return apps -} - -const templateMaker = function (obj: unknown) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function (context: { [x: string]: any }) { - const replacer = function (_key: string, val: () => string | number) { - if (typeof val === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return context[val()] - } - return val - } - return JSON.parse(JSON.stringify(obj, replacer)) as unknown - } -} - -function getMockClusters(n: number): ServerSideEvent[] { - const template = templateMaker(clusterTemplate) - return [template({ name: 'local-cluster' })].concat( - Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }) - ) -} - -function getMockClusterInfo(n: number): ServerSideEvent[] { - const template = templateMaker(managedClusterInfoTemplate) - return [template({ name: 'local-cluster' })].concat( - Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }) - ) -} - -function getMockClusterAddsons(n: number): ServerSideEvent[] { - let addons - let template = templateMaker(appmanager) - addons = [template({ name: 'local-cluster' })].concat( - Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }) - ) - template = templateMaker(certpolicy) - addons = [ - ...addons, - template({ name: 'local-cluster' }), - ...Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }), - ] - template = templateMaker(clusterProxy) - addons = [ - ...addons, - template({ name: 'local-cluster' }), - ...Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }), - ] - template = templateMaker(configPolicy) - addons = [ - ...addons, - template({ name: 'local-cluster' }), - ...Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }), - ] - template = templateMaker(govPolicy) - addons = [ - ...addons, - template({ name: 'local-cluster' }), - ...Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }), - ] - template = templateMaker(hypershiftPolicy) - addons = [ - ...addons, - template({ name: 'local-cluster' }), - ...Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }), - ] - template = templateMaker(workManager) - addons = [ - ...addons, - template({ name: 'local-cluster' }), - ...Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }), - ] - template = templateMaker(managedSrv) - addons = [ - ...addons, - template({ name: 'local-cluster' }), - ...Array.from(new Array(n).keys()).map((inx) => { - return template({ name: `cluster${inx + 1}` }) - }), - ] - return addons -} - -const NONCOMPLIANT = [ - [3, 18, 23, 55, 60, 80, 93], - [1, 33, 43, 68], - [5, 33, 69, 20, 45, 32], -] -type PolicyTemplateType = typeof policyTemplate -function getMockPolicies(n: number): ServerSideEvent[] { - const template = templateMaker(policyTemplate) - return Array.from(new Array(3).keys()).map((pinx) => { - const mockPolicy = template({ name: `policy${pinx + 1}` }) as PolicyTemplateType - Array.from(new Array(n).keys()).forEach((inx) => { - let compliant = NONCOMPLIANT[pinx].indexOf(inx) !== -1 ? 'NonCompliant' : 'Compliant' - switch (pinx) { - case 0: - if (inx == 20 || inx === 71) compliant = 'Pending' - if (inx == 22 || inx === 98) compliant = 'Unknown' - break - case 1: - if (inx == 44) compliant = 'Pending' - if (inx == 85 || inx === 86) compliant = 'Unknown' - break - case 2: - if (inx == 23 || inx === 36) compliant = 'Pending' - // if (inx == 22 || inx === 98) compliant = 'Unknown' - break - } - mockPolicy?.data?.object.status.status.push({ - clustername: `cluster${inx + 1}`, - clusternamespace: `cluster${inx + 1}`, - compliant, - }) - }) - return mockPolicy - }) -} - -const policyTemplate = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'policy.open-cluster-management.io/v1', - kind: 'Policy', - metadata: { - annotations: { - 'policy.open-cluster-management.io/categories': 'CM Configuration Management', - 'policy.open-cluster-management.io/controls': 'CM-4 Baseline Configuration', - 'policy.open-cluster-management.io/standards': 'NIST SP 800-93', - }, - creationTimestamp: '2024-11-04T17:12:36Z', - generation: 1, - name: () => 'name', - namespace: 'open-cluster-management-global-set', - resourceVersion: '3860061', - uid: '437f570c-c73c-4e34-ac3a-deaa3c83dc4b', - }, - spec: { - disabled: false, - 'policy-templates': [ - { - objectDefinition: { - apiVersion: 'policy.open-cluster-management.io/v1', - kind: 'ConfigurationPolicy', - metadata: { - name: 'policy-namespace', - }, - spec: { - 'object-templates': [ - { - complianceType: 'musthave', - objectDefinition: { - apiVersion: 'v1', - kind: 'Namespace', - metadata: { - name: 'jako', - }, - }, - }, - ], - remediationAction: 'inform', - severity: 'low', - }, - }, - }, - ], - }, - status: { - compliant: 'NonCompliant', - placement: [ - { - placement: 'global', - placementBinding: 'test-placement', - }, - ], - status: [ - { - clustername: 'local-cluster', - clusternamespace: 'local-cluster', - compliant: 'NonCompliant', - }, - ], - }, - }, - }, - id: '115', -} - -const clusterTemplate = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'cluster.open-cluster-management.io/v1', - kind: 'ManagedCluster', - metadata: { - annotations: { - 'installer.multicluster.openshift.io/release-version': '2.8.0', - 'open-cluster-management/created-via': 'other', - }, - creationTimestamp: '2024-11-04T04:46:31Z', - labels: { - cloud: 'Amazon', - 'cluster.open-cluster-management.io/clusterset': 'global', - clusterID: '002c4aeb-7a62-46b7-aeba-c5c4e2672aa1', - 'feature.open-cluster-management.io/addon-application-manager': 'available', - 'feature.open-cluster-management.io/addon-cert-policy-controller': 'available', - 'feature.open-cluster-management.io/addon-cluster-proxy': 'available', - 'feature.open-cluster-management.io/addon-config-policy-controller': 'available', - 'feature.open-cluster-management.io/addon-governance-policy-framework': 'available', - 'feature.open-cluster-management.io/addon-hypershift-addon': 'available', - 'feature.open-cluster-management.io/addon-managed-serviceaccount': 'available', - 'feature.open-cluster-management.io/addon-work-manager': 'available', - name: () => 'name', - openshiftVersion: '4.16.17', - 'openshiftVersion-major': '4', - 'openshiftVersion-major-minor': '4.16', - 'velero.io/exclude-from-backup': 'true', - vendor: 'OpenShift', - }, - name: () => 'name', - resourceVersion: '838566', - uid: '57e220b3-6733-4452-b9a5-cee55882be99', - }, - spec: { - hubAcceptsClient: true, - }, - status: { - allocatable: { - cpu: '22500m', - 'ephemeral-storage': '285055434687', - 'hugepages-1Gi': '0', - 'hugepages-2Mi': '0', - memory: '93026772Ki', - pods: '750', - }, - capacity: { - core_worker: '24', - cpu: '24', - 'ephemeral-storage': '312800196Ki', - 'hugepages-1Gi': '0', - 'hugepages-2Mi': '0', - memory: '96479700Ki', - pods: '750', - socket_worker: '3', - }, - conditions: [ - { - lastTransitionTime: '2024-11-04T18:44:30Z', - message: 'Import succeeded', - reason: 'ManagedClusterImported', - status: 'True', - type: 'ManagedClusterImportSucceeded', - }, - { - lastTransitionTime: '2024-11-04T04:46:36Z', - message: 'Accepted by hub cluster admin', - reason: 'HubClusterAdminAccepted', - status: 'True', - type: 'HubAcceptedManagedCluster', - }, - { - lastTransitionTime: '2024-11-04T04:46:47Z', - message: 'Managed cluster joined', - reason: 'ManagedClusterJoined', - status: 'True', - type: 'ManagedClusterJoined', - }, - { - lastTransitionTime: '2024-11-04T04:46:47Z', - message: 'Managed cluster is available', - reason: 'ManagedClusterAvailable', - status: 'True', - type: 'ManagedClusterConditionAvailable', - }, - { - lastTransitionTime: '2024-11-04T04:46:47Z', - message: 'The clock of the managed cluster is synced with the hub.', - reason: 'ManagedClusterClockSynced', - status: 'True', - type: 'ManagedClusterConditionClockSynced', - }, - ], - version: { - kubernetes: 'v1.29.8+632b078', - }, - }, - }, - }, - id: '115', -} - -const managedClusterInfoTemplate = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'internal.open-cluster-management.io/v1beta1', - kind: 'ManagedClusterInfo', - metadata: { - creationTimestamp: '2024-11-04T04:46:31Z', - generation: 2, - labels: { - cloud: 'Amazon', - 'cluster.open-cluster-management.io/clusterset': 'john-set', - clusterID: '002c4aeb-7a62-46b7-aeba-c5c4e2672aa1', - 'feature.open-cluster-management.io/addon-application-manager': 'available', - 'feature.open-cluster-management.io/addon-cert-policy-controller': 'available', - 'feature.open-cluster-management.io/addon-cluster-proxy': 'available', - 'feature.open-cluster-management.io/addon-config-policy-controller': 'available', - 'feature.open-cluster-management.io/addon-governance-policy-framework': 'available', - 'feature.open-cluster-management.io/addon-hypershift-addon': 'available', - 'feature.open-cluster-management.io/addon-managed-serviceaccount': 'available', - 'feature.open-cluster-management.io/addon-work-manager': 'available', - name: () => 'name', - openshiftVersion: '4.16.17', - 'openshiftVersion-major': '4', - 'openshiftVersion-major-minor': '4.16', - 'velero.io/exclude-from-backup': 'true', - vendor: 'OpenShift', - }, - name: () => 'name', - namespace: () => 'name', - resourceVersion: '1333073', - uid: '16c2f789-153b-4976-94cd-ca9b14695499', - }, - spec: { - masterEndpoint: 'https://api.cs-aws-416.com:6443', - }, - status: { - cloudVendor: 'Amazon', - clusterID: '002c4aeb-7a62-46b7-aeba-c5c4e2672aa1', - conditions: [ - { - lastTransitionTime: '2024-11-04T18:44:30Z', - message: 'Import succeeded', - reason: 'ManagedClusterImported', - status: 'True', - type: 'ManagedClusterImportSucceeded', - }, - { - lastTransitionTime: '2024-11-04T04:46:36Z', - message: 'Accepted by hub cluster admin', - reason: 'HubClusterAdminAccepted', - status: 'True', - type: 'HubAcceptedManagedCluster', - }, - { - lastTransitionTime: '2024-11-04T04:46:47Z', - message: 'Managed cluster joined', - reason: 'ManagedClusterJoined', - status: 'True', - type: 'ManagedClusterJoined', - }, - { - lastTransitionTime: '2024-11-04T04:46:47Z', - message: 'Managed cluster is available', - reason: 'ManagedClusterAvailable', - status: 'True', - type: 'ManagedClusterConditionAvailable', - }, - { - lastTransitionTime: '2024-11-04T04:46:47Z', - message: 'The clock of the managed cluster is synced with the hub.', - reason: 'ManagedClusterClockSynced', - status: 'True', - type: 'ManagedClusterConditionClockSynced', - }, - { - lastTransitionTime: '2024-11-04T22:16:58Z', - message: - 'client certificate rotated starting from 2024-11-05 02:41:58 +0000 UTC to 2024-12-04 23:16:37 +0000 UTC', - reason: 'ClientCertificateUpdated', - status: 'True', - type: 'ClusterCertificateRotated', - }, - { - lastTransitionTime: '2024-11-04T04:46:56Z', - message: 'Managed cluster info is synced', - reason: 'ManagedClusterInfoSynced', - status: 'True', - type: 'ManagedClusterInfoSynced', - }, - ], - consoleURL: 'https://console-openshift-console.apps.com', - distributionInfo: { - ocp: { - availableUpdates: ['4.16.18'], - channel: 'stable-4.16', - desired: { - channels: ['candidate-4.16', 'candidate-4.17', 'eus-4.16', 'fast-4.16', 'fast-4.17', 'stable-4.16'], - image: 'quay.io/openshift-release-dev/ocp-release', - url: 'https://access.redhat.com/errata/RHSA-2024:7944', - version: '4.16.17', - }, - desiredVersion: '4.16.17', - lastAppliedAPIServerURL: 'https://api.cs-aws-416.com:6443', - managedClusterClientConfig: { - caBundle: 'LS0tLS1CRUdJTiBDQo=', - url: 'https://api.cs-aws-416.com:6443', - }, - version: '4.16.17', - versionAvailableUpdates: [ - { - channels: ['candidate-4.16', 'candidate-4.17', 'eus-4.16', 'fast-4.16', 'fast-4.17', 'stable-4.16'], - image: 'quay.io/openshift-release-dev/ocp-release', - url: 'https://access.redhat.com/errata/RHSA-2024:8260', - version: '4.16.18', - }, - ], - versionHistory: [ - { - image: 'quay.io/openshift-release-dev/ocp-release', - state: 'Completed', - verified: false, - version: '4.16.17', - }, - ], - }, - type: 'OCP', - }, - kubeVendor: 'OpenShift', - loggingEndpoint: { - ip: '', - }, - loggingPort: { - port: 0, - protocol: 'TCP', - }, - nodeList: [ - { - capacity: { - cpu: '8', - memory: '32159900Ki', - socket: '1', - }, - conditions: [ - { - status: 'True', - type: 'Ready', - }, - ], - labels: { - 'beta.kubernetes.io/instance-type': 'm6a.2xlarge', - 'failure-domain.beta.kubernetes.io/region': 'us-east-1', - 'failure-domain.beta.kubernetes.io/zone': 'us-east-1a', - 'node-role.kubernetes.io/control-plane': '', - 'node-role.kubernetes.io/master': '', - 'node-role.kubernetes.io/worker': '', - 'node.kubernetes.io/instance-type': 'm6a.2xlarge', - 'topology.kubernetes.io/region': 'us-east-1', - 'topology.kubernetes.io/zone': 'us-east-1a', - }, - name: 'ip-10-0-14-175.ec2.internal', - }, - { - capacity: { - cpu: '8', - memory: '32159900Ki', - socket: '1', - }, - conditions: [ - { - status: 'True', - type: 'Ready', - }, - ], - labels: { - 'beta.kubernetes.io/instance-type': 'm6a.2xlarge', - 'failure-domain.beta.kubernetes.io/region': 'us-east-1', - 'failure-domain.beta.kubernetes.io/zone': 'us-east-1b', - 'node-role.kubernetes.io/control-plane': '', - 'node-role.kubernetes.io/master': '', - 'node-role.kubernetes.io/worker': '', - 'node.kubernetes.io/instance-type': 'm6a.2xlarge', - 'topology.kubernetes.io/region': 'us-east-1', - 'topology.kubernetes.io/zone': 'us-east-1b', - }, - name: 'ip-10-0-51-94.ec2.internal', - }, - { - capacity: { - cpu: '8', - memory: '32159900Ki', - socket: '1', - }, - conditions: [ - { - status: 'True', - type: 'Ready', - }, - ], - labels: { - 'beta.kubernetes.io/instance-type': 'm6a.2xlarge', - 'failure-domain.beta.kubernetes.io/region': 'us-east-1', - 'failure-domain.beta.kubernetes.io/zone': 'us-east-1c', - 'node-role.kubernetes.io/control-plane': '', - 'node-role.kubernetes.io/master': '', - 'node-role.kubernetes.io/worker': '', - 'node.kubernetes.io/instance-type': 'm6a.2xlarge', - 'topology.kubernetes.io/region': 'us-east-1', - 'topology.kubernetes.io/zone': 'us-east-1c', - }, - name: 'ip-10-0-93-77.ec2.internal', - }, - ], - version: 'v1.29.8+632b078', - }, - }, - }, - id: '153', -} - -const appmanager: ServerSideEvent = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - kind: 'ManagedClusterAddOn', - metadata: { - creationTimestamp: '2024-11-04T04:47:46Z', - generation: 1, - name: 'application-manager', - namespace: () => 'name', - ownerReferences: [ - { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - blockOwnerDeletion: true, - controller: true, - kind: 'ClusterManagementAddOn', - name: 'application-manager', - uid: '2b92b46b-2c7e-41b8-bba7-d04fe0402a3d', - }, - ], - resourceVersion: '1329359', - uid: 'c3afc10a-6c15-4f2e-8562-3dca6ea8c30e', - }, - spec: { - installNamespace: 'open-cluster-management-agent-addon', - }, - status: { - conditions: [ - { - lastTransitionTime: '2024-11-04T04:48:58Z', - message: 'application-manager add-on is available.', - reason: 'ManagedClusterAddOnLeaseUpdated', - status: 'True', - type: 'Available', - }, - ], - namespace: 'open-cluster-management-agent-addon', - registrations: [ - { - signerName: 'kubernetes.io/kube-apiserver-client', - subject: { - groups: [ - 'system:open-cluster-management:cluster:local-cluster:addon:application-manager', - 'system:open-cluster-management:addon:application-manager', - 'system:authenticated', - ], - user: 'system:open-cluster-management:cluster:local-cluster:addon:application-manager:agent:application-manager', - }, - }, - ], - supportedConfigs: [ - { - group: 'addon.open-cluster-management.io', - resource: 'addondeploymentconfigs', - }, - ], - }, - }, - }, - id: '115', -} -const certpolicy: ServerSideEvent = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - kind: 'ManagedClusterAddOn', - metadata: { - creationTimestamp: '2024-11-04T04:47:46Z', - generation: 1, - name: 'cert-policy-controller', - namespace: () => 'name', - ownerReferences: [ - { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - blockOwnerDeletion: true, - controller: true, - kind: 'ClusterManagementAddOn', - name: 'cert-policy-controller', - uid: '41412e04-f36c-49c8-9c05-dae4a9b776da', - }, - ], - resourceVersion: '1328509', - uid: 'fa2055f6-c57a-4409-bbcd-2d845f88777f', - }, - spec: { - installNamespace: 'open-cluster-management-agent-addon', - }, - status: { - conditions: [ - { - lastTransitionTime: '2024-11-04T04:47:46Z', - message: 'Configurations configured', - reason: 'ConfigurationsConfigured', - status: 'True', - type: 'Configured', - }, - { - lastTransitionTime: '2024-11-04T04:47:50Z', - message: 'completed with no errors.', - reason: 'Completed', - status: 'False', - type: 'Progressing', - }, - { - lastTransitionTime: '2024-11-04T04:47:47Z', - message: 'Registration of the addon agent is configured', - reason: 'SetPermissionApplied', - status: 'True', - type: 'RegistrationApplied', - }, - { - lastTransitionTime: '2024-11-04T04:47:48Z', - message: - 'client certificate rotated starting from 2024-11-05 02:37:48 +0000 UTC to 2024-12-04 23:16:37 +0000 UTC', - reason: 'ClientCertificateUpdated', - status: 'True', - type: 'ClusterCertificateRotated', - }, - { - lastTransitionTime: '2024-11-04T04:47:49Z', - message: 'manifests of addon are applied successfully', - reason: 'AddonManifestApplied', - status: 'True', - type: 'ManifestApplied', - }, - { - lastTransitionTime: '2024-11-04T04:47:58Z', - message: 'cert-policy-controller add-on is available.', - reason: 'ManagedClusterAddOnLeaseUpdated', - status: 'True', - type: 'Available', - }, - ], - namespace: 'open-cluster-management-agent-addon', - registrations: [ - { - signerName: 'kubernetes.io/kube-apiserver-client', - subject: { - groups: [ - 'system:open-cluster-management:cluster:local-cluster:addon:cert-policy-controller', - 'system:open-cluster-management:addon:cert-policy-controller', - 'system:authenticated', - ], - user: 'system:open-cluster-management:cluster:local-cluster:addon:cert-policy-controller:agent:cert-policy-controller', - }, - }, - ], - supportedConfigs: [ - { - group: 'addon.open-cluster-management.io', - resource: 'addondeploymentconfigs', - }, - ], - }, - }, - }, - id: '115', -} -const clusterProxy = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - kind: 'ManagedClusterAddOn', - metadata: { - creationTimestamp: '2024-11-04T04:46:31Z', - generation: 1, - name: 'cluster-proxy', - namespace: () => 'name', - ownerReferences: [ - { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - blockOwnerDeletion: true, - controller: true, - kind: 'ClusterManagementAddOn', - name: 'cluster-proxy', - uid: '116bf016-b633-458a-ac5a-8ef4541b83c9', - }, - ], - resourceVersion: '1333082', - uid: 'f5dca4ba-5ce1-4581-af83-5d9e6bcb50e6', - }, - spec: { - installNamespace: 'open-cluster-management-agent-addon', - }, - status: { - conditions: [ - { - lastTransitionTime: '2024-11-04T04:46:51Z', - message: 'completed with no errors.', - reason: 'Completed', - status: 'False', - type: 'Progressing', - }, - { - lastTransitionTime: '2024-11-04T04:46:31Z', - message: 'Configurations configured', - reason: 'ConfigurationsConfigured', - status: 'True', - type: 'Configured', - }, - { - lastTransitionTime: '2024-11-04T04:46:35Z', - message: 'Registration of the addon agent is configured', - reason: 'SetPermissionApplied', - status: 'True', - type: 'RegistrationApplied', - }, - { - lastTransitionTime: '2024-11-04T04:46:48Z', - message: - 'client certificate rotated starting from 2024-11-05 02:41:58 +0000 UTC to 2024-12-04 23:16:37 +0000 UTC', - reason: 'ClientCertificateUpdated', - status: 'True', - type: 'ClusterCertificateRotated', - }, - { - lastTransitionTime: '2024-11-04T04:46:50Z', - message: 'manifests of addon are applied successfully', - reason: 'AddonManifestApplied', - status: 'True', - type: 'ManifestApplied', - }, - { - lastTransitionTime: '2024-11-04T04:47:28Z', - message: 'cluster-proxy add-on is available.', - reason: 'ManagedClusterAddOnLeaseUpdated', - status: 'True', - type: 'Available', - }, - ], - configReferences: [ - { - desiredConfig: { - name: 'cluster-proxy', - specHash: '832265e7e2ef945b299e2880fc9683de514a4869dc5a6ad1b1f3687e0ee4fd3b', - }, - group: 'proxy.open-cluster-management.io', - lastAppliedConfig: { - name: 'cluster-proxy', - specHash: '832265e7e2ef945b299e2880fc9683de514a4869dc5a6ad1b1f3687e0ee4fd3b', - }, - lastObservedGeneration: 1, - name: 'cluster-proxy', - resource: 'managedproxyconfigurations', - }, - ], - namespace: 'open-cluster-management-agent-addon', - registrations: [ - { - signerName: 'kubernetes.io/kube-apiserver-client', - subject: { - groups: ['open-cluster-management:cluster-proxy'], - user: 'open-cluster-management:cluster-proxy:addon-agent', - }, - }, - { - signerName: 'open-cluster-management.io/proxy-agent-signer', - subject: { - groups: ['open-cluster-management:cluster-proxy'], - organizationUnit: [ - 'signer-316939704966655836456a38794f5a507a4e6b6373627735422f31654335773852616362333664586c31413d', - ], - user: 'open-cluster-management:cluster-proxy:proxy-agent', - }, - }, - ], - supportedConfigs: [ - { - group: 'proxy.open-cluster-management.io', - resource: 'managedproxyconfigurations', - }, - { - group: 'addon.open-cluster-management.io', - resource: 'addondeploymentconfigs', - }, - ], - }, - }, - }, - id: '115', -} -const configPolicy = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - kind: 'ManagedClusterAddOn', - metadata: { - creationTimestamp: '2024-11-04T04:47:46Z', - finalizers: ['addon.open-cluster-management.io/addon-pre-delete'], - generation: 1, - name: 'config-policy-controller', - namespace: () => 'name', - ownerReferences: [ - { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - blockOwnerDeletion: true, - controller: true, - kind: 'ClusterManagementAddOn', - name: 'config-policy-controller', - uid: '3aceaa5f-4134-4938-bdd1-1a4c3a359cd5', - }, - ], - resourceVersion: '1333963', - uid: '0416ece7-4fa0-4059-a5d2-d7f387196a2a', - }, - spec: { - installNamespace: 'open-cluster-management-agent-addon', - }, - status: { - conditions: [ - { - lastTransitionTime: '2024-11-04T04:47:46Z', - message: 'Configurations configured', - reason: 'ConfigurationsConfigured', - status: 'True', - type: 'Configured', - }, - { - lastTransitionTime: '2024-11-04T04:47:54Z', - message: 'completed with no errors.', - reason: 'Completed', - status: 'False', - type: 'Progressing', - }, - { - lastTransitionTime: '2024-11-04T04:47:51Z', - message: 'Registration of the addon agent is configured', - reason: 'SetPermissionApplied', - status: 'True', - type: 'RegistrationApplied', - }, - { - lastTransitionTime: '2024-11-04T04:47:52Z', - message: - 'client certificate rotated starting from 2024-11-05 02:42:51 +0000 UTC to 2024-12-04 23:16:37 +0000 UTC', - reason: 'ClientCertificateUpdated', - status: 'True', - type: 'ClusterCertificateRotated', - }, - { - lastTransitionTime: '2024-11-04T04:47:54Z', - message: 'manifests of addon are applied successfully', - reason: 'AddonManifestApplied', - status: 'True', - type: 'ManifestApplied', - }, - { - lastTransitionTime: '2024-11-04T04:48:28Z', - message: 'config-policy-controller add-on is available.', - reason: 'ManagedClusterAddOnLeaseUpdated', - status: 'True', - type: 'Available', - }, - ], - namespace: 'open-cluster-management-agent-addon', - registrations: [ - { - signerName: 'kubernetes.io/kube-apiserver-client', - subject: { - groups: [ - 'system:open-cluster-management:cluster:local-cluster:addon:config-policy-controller', - 'system:open-cluster-management:addon:config-policy-controller', - 'system:authenticated', - ], - user: 'system:open-cluster-management:cluster:local-cluster:addon:config-policy-controller:agent:config-policy-controller', - }, - }, - ], - supportedConfigs: [ - { - group: 'addon.open-cluster-management.io', - resource: 'addondeploymentconfigs', - }, - ], - }, - }, - }, - id: '115', -} -const govPolicy = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - kind: 'ManagedClusterAddOn', - metadata: { - creationTimestamp: '2024-11-04T04:47:46Z', - generation: 1, - name: 'governance-policy-framework', - namespace: () => 'name', - ownerReferences: [ - { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - blockOwnerDeletion: true, - controller: true, - kind: 'ClusterManagementAddOn', - name: 'governance-policy-framework', - uid: 'dd5f38a9-4048-4e21-a585-78c110d70a4f', - }, - ], - resourceVersion: '1344666', - uid: 'c1dd56f8-010c-4182-bf3b-42001e5041bc', - }, - spec: { - installNamespace: 'open-cluster-management-agent-addon', - }, - status: { - conditions: [ - { - lastTransitionTime: '2024-11-04T04:47:55Z', - message: 'completed with no errors.', - reason: 'Completed', - status: 'False', - type: 'Progressing', - }, - { - lastTransitionTime: '2024-11-04T04:47:46Z', - message: 'Configurations configured', - reason: 'ConfigurationsConfigured', - status: 'True', - type: 'Configured', - }, - { - lastTransitionTime: '2024-11-04T04:47:54Z', - message: 'Registration of the addon agent is configured', - reason: 'SetPermissionApplied', - status: 'True', - type: 'RegistrationApplied', - }, - { - lastTransitionTime: '2024-11-04T04:47:55Z', - message: - 'client certificate rotated starting from 2024-11-05 02:52:55 +0000 UTC to 2024-12-04 23:16:37 +0000 UTC', - reason: 'ClientCertificateUpdated', - status: 'True', - type: 'ClusterCertificateRotated', - }, - { - lastTransitionTime: '2024-11-04T04:47:55Z', - message: 'manifests of addon are applied successfully', - reason: 'AddonManifestApplied', - status: 'True', - type: 'ManifestApplied', - }, - { - lastTransitionTime: '2024-11-04T04:48:28Z', - message: 'governance-policy-framework add-on is available.', - reason: 'ManagedClusterAddOnLeaseUpdated', - status: 'True', - type: 'Available', - }, - ], - namespace: 'open-cluster-management-agent-addon', - registrations: [ - { - signerName: 'kubernetes.io/kube-apiserver-client', - subject: { - groups: [ - 'system:open-cluster-management:cluster:local-cluster:addon:governance-policy-framework', - 'system:open-cluster-management:addon:governance-policy-framework', - 'system:authenticated', - ], - user: 'system:open-cluster-management:cluster:local-cluster:addon:governance-policy-framework:agent:governance-policy-framework', - }, - }, - ], - supportedConfigs: [ - { - group: 'addon.open-cluster-management.io', - resource: 'addondeploymentconfigs', - }, - ], - }, - }, - }, - id: '115', -} -const hypershiftPolicy = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - kind: 'ManagedClusterAddOn', - metadata: { - annotations: { - 'installer.multicluster.openshift.io/release-version': '2.8.0', - }, - creationTimestamp: '2024-11-04T04:46:35Z', - finalizers: ['addon.open-cluster-management.io/addon-pre-delete'], - generation: 1, - labels: { - 'backplaneconfig.name': 'multiclusterengine', - }, - name: 'hypershift-addon', - namespace: () => 'name', - ownerReferences: [ - { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - blockOwnerDeletion: true, - controller: true, - kind: 'ClusterManagementAddOn', - name: 'hypershift-addon', - uid: 'abc241d5-2a88-426a-9337-59aa4920fbce', - }, - ], - resourceVersion: '1333081', - uid: '223be491-f09b-474d-a2c9-8bb587bbb567', - }, - spec: { - installNamespace: 'open-cluster-management-agent-addon', - }, - status: { - addOnConfiguration: {}, - addOnMeta: {}, - conditions: [ - { - lastTransitionTime: '2024-11-04T04:46:35Z', - message: 'completed with no errors.', - reason: 'Completed', - status: 'False', - type: 'Progressing', - }, - { - lastTransitionTime: '2024-11-04T04:46:35Z', - message: 'Configurations configured', - reason: 'ConfigurationsConfigured', - status: 'True', - type: 'Configured', - }, - { - lastTransitionTime: '2024-11-04T04:46:36Z', - message: 'Registration of the addon agent is configured', - reason: 'SetPermissionApplied', - status: 'True', - type: 'RegistrationApplied', - }, - { - lastTransitionTime: '2024-11-04T04:46:48Z', - message: - 'client certificate rotated starting from 2024-11-05 02:41:58 +0000 UTC to 2024-12-04 23:16:37 +0000 UTC', - reason: 'ClientCertificateUpdated', - status: 'True', - type: 'ClusterCertificateRotated', - }, - { - lastTransitionTime: '2024-11-04T04:46:53Z', - message: 'manifests of addon are applied successfully', - reason: 'AddonManifestApplied', - status: 'True', - type: 'ManifestApplied', - }, - { - lastTransitionTime: '2024-11-04T18:46:33Z', - message: 'Hypershift is deployed on managed cluster.', - reason: 'HypershiftDeployed', - status: 'False', - type: 'Degraded', - }, - { - lastTransitionTime: '2024-11-04T18:44:29Z', - message: 'hypershift-addon add-on is available.', - reason: 'ManagedClusterAddOnLeaseUpdated', - status: 'True', - type: 'Available', - }, - ], - configReferences: [ - { - desiredConfig: { - name: 'hypershift-addon-deploy-config', - namespace: 'multicluster-engine', - specHash: '673989f990db2503cf3115ec915ac4d02b1182f6bfaf6350d861ae30d10d0489', - }, - group: 'addon.open-cluster-management.io', - lastAppliedConfig: { - name: 'hypershift-addon-deploy-config', - namespace: 'multicluster-engine', - specHash: '673989f990db2503cf3115ec915ac4d02b1182f6bfaf6350d861ae30d10d0489', - }, - lastObservedGeneration: 1, - name: 'hypershift-addon-deploy-config', - namespace: 'multicluster-engine', - resource: 'addondeploymentconfigs', - }, - ], - healthCheck: { - mode: 'Lease', - }, - namespace: 'open-cluster-management-agent-addon', - registrations: [ - { - signerName: 'kubernetes.io/kube-apiserver-client', - subject: { - groups: [ - 'system:open-cluster-management:cluster:local-cluster:addon:hypershift-addon', - 'system:open-cluster-management:addon:hypershift-addon', - 'system:authenticated', - ], - user: 'system:open-cluster-management:cluster:local-cluster:addon:hypershift-addon:agent:6q4l5', - }, - }, - ], - supportedConfigs: [ - { - group: 'addon.open-cluster-management.io', - resource: 'addondeploymentconfigs', - }, - ], - }, - }, - }, - id: '115', -} -const managedSrv = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - kind: 'ManagedClusterAddOn', - metadata: { - creationTimestamp: '2024-11-04T04:46:31Z', - generation: 1, - name: 'managed-serviceaccount', - namespace: () => 'name', - ownerReferences: [ - { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - blockOwnerDeletion: true, - controller: true, - kind: 'ClusterManagementAddOn', - name: 'managed-serviceaccount', - uid: 'bd94e351-8a31-4403-a92e-7df148cca0f3', - }, - ], - resourceVersion: '2218386', - uid: 'bc360ed0-0c9d-41ff-bafa-af7e5393a06e', - }, - spec: { - installNamespace: 'open-cluster-management-agent-addon', - }, - status: { - conditions: [ - { - lastTransitionTime: '2024-11-04T04:46:31Z', - message: 'Configurations configured', - reason: 'ConfigurationsConfigured', - status: 'True', - type: 'Configured', - }, - { - lastTransitionTime: '2024-11-04T04:46:54Z', - message: 'completed with no errors.', - reason: 'Completed', - status: 'False', - type: 'Progressing', - }, - { - lastTransitionTime: '2024-11-04T04:46:33Z', - message: 'Registration of the addon agent is configured', - reason: 'SetPermissionApplied', - status: 'True', - type: 'RegistrationApplied', - }, - { - lastTransitionTime: '2024-11-04T04:46:48Z', - message: - 'client certificate rotated starting from 2024-11-05 02:41:58 +0000 UTC to 2024-12-04 23:16:37 +0000 UTC', - reason: 'ClientCertificateUpdated', - status: 'True', - type: 'ClusterCertificateRotated', - }, - { - lastTransitionTime: '2024-11-04T04:46:54Z', - message: 'manifests of addon are applied successfully', - reason: 'AddonManifestApplied', - status: 'True', - type: 'ManifestApplied', - }, - { - lastTransitionTime: '2024-11-04T04:47:09Z', - message: 'managed-serviceaccount add-on is available.', - reason: 'ProbeAvailable', - status: 'True', - type: 'Available', - }, - ], - configReferences: [ - { - desiredConfig: { - name: 'managed-serviceaccount-2.8', - specHash: 'a3eb3dcdd45b539fc4fbd21b5cf6ab7383ec74327ed4e6046005183bf72a35e7', - }, - group: 'addon.open-cluster-management.io', - lastAppliedConfig: { - name: 'managed-serviceaccount-2.8', - specHash: 'a3eb3dcdd45b539fc4fbd21b5cf6ab7383ec74327ed4e6046005183bf72a35e7', - }, - lastObservedGeneration: 1, - name: 'managed-serviceaccount-2.8', - resource: 'addontemplates', - }, - ], - healthCheck: { - mode: 'Customized', - }, - namespace: 'open-cluster-management-agent-addon', - registrations: [ - { - signerName: 'kubernetes.io/kube-apiserver-client', - subject: { - groups: [ - 'system:open-cluster-management:cluster:local-cluster:addon:managed-serviceaccount', - 'system:open-cluster-management:addon:managed-serviceaccount', - 'system:authenticated', - ], - user: 'system:open-cluster-management:cluster:local-cluster:addon:managed-serviceaccount:agent:managed-serviceaccount-agent', - }, - }, - ], - supportedConfigs: [ - { - group: 'addon.open-cluster-management.io', - resource: 'addondeploymentconfigs', - }, - { - group: 'addon.open-cluster-management.io', - resource: 'addontemplates', - }, - ], - }, - }, - }, - id: '115', -} -const workManager = { - data: { - type: 'MODIFIED', - object: { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - kind: 'ManagedClusterAddOn', - metadata: { - creationTimestamp: '2024-11-04T04:46:31Z', - generation: 1, - name: 'work-manager', - namespace: () => 'name', - ownerReferences: [ - { - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - blockOwnerDeletion: true, - controller: true, - kind: 'ClusterManagementAddOn', - name: 'work-manager', - uid: '97ba945d-5496-44e1-ae67-4243f14a6c94', - }, - ], - resourceVersion: '1333061', - uid: 'c43e2733-6f9e-44f2-84b8-17231e6752e0', - }, - spec: { - installNamespace: 'open-cluster-management-agent-addon', - }, - status: { - conditions: [ - { - lastTransitionTime: '2024-11-04T04:46:55Z', - message: 'completed with no errors.', - reason: 'Completed', - status: 'False', - type: 'Progressing', - }, - { - lastTransitionTime: '2024-11-04T04:46:31Z', - message: 'Configurations configured', - reason: 'ConfigurationsConfigured', - status: 'True', - type: 'Configured', - }, - { - lastTransitionTime: '2024-11-04T04:46:32Z', - message: 'Registration of the addon agent is configured', - reason: 'SetPermissionApplied', - status: 'True', - type: 'RegistrationApplied', - }, - { - lastTransitionTime: '2024-11-04T04:46:48Z', - message: - 'client certificate rotated starting from 2024-11-05 02:41:58 +0000 UTC to 2024-12-04 23:16:37 +0000 UTC', - reason: 'ClientCertificateUpdated', - status: 'True', - type: 'ClusterCertificateRotated', - }, - { - lastTransitionTime: '2024-11-04T04:46:55Z', - message: 'manifests of addon are applied successfully', - reason: 'AddonManifestApplied', - status: 'True', - type: 'ManifestApplied', - }, - { - lastTransitionTime: '2024-11-04T04:47:28Z', - message: 'work-manager add-on is available.', - reason: 'ManagedClusterAddOnLeaseUpdated', - status: 'True', - type: 'Available', - }, - ], - namespace: 'open-cluster-management-agent-addon', - registrations: [ - { - signerName: 'kubernetes.io/kube-apiserver-client', - subject: { - groups: [ - 'system:open-cluster-management:cluster:local-cluster:addon:work-manager', - 'system:open-cluster-management:addon:work-manager', - 'system:authenticated', - ], - user: 'system:open-cluster-management:cluster:local-cluster:addon:work-manager:agent:work-manager', - }, - }, - ], - supportedConfigs: [ - { - group: 'addon.open-cluster-management.io', - resource: 'addondeploymentconfigs', - }, - ], - }, - }, - }, - id: '115', -} - -const applicationsTemplate = [ - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'app.kubernetes.io/component=controller; app.kubernetes.io/managed-by=cluster-monitoring-operator; app.kubernetes.io/name=prometheus-operator; app.kubernetes.io/part-of=openshift-monitoring; app.kubernetes.io/version=0.73.2', - metadata: { - name: 'openshift-monitoring', - namespace: 'openshift-monitoring', - creationTimestamp: '2024-11-04T04:14:29Z', - }, - status: { - cluster: () => 'name', - resourceName: 'prometheus-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=etcd-operator', - metadata: { - name: 'etcd-operator', - namespace: 'openshift-etcd-operator', - creationTimestamp: '2024-11-04T04:11:21Z', - }, - status: { - cluster: () => 'name', - resourceName: 'etcd-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'app=grc; app.kubernetes.io/instance=grc; app.kubernetes.io/name=grc; chart=grc-chart-2.13.0; component=ocm-policy-addon-ctrl; installer.name=multiclusterhub; installer.namespace=open-cluster-management; release=grc', - metadata: { - name: 'grc', - namespace: 'open-cluster-management', - creationTimestamp: '2024-11-04T04:47:37Z', - }, - status: { - cluster: () => 'name', - resourceName: 'grc-policy-addon-controller', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=service-ca; service-ca=true', - metadata: { - name: 'service-ca', - namespace: 'openshift-service-ca', - creationTimestamp: '2024-11-04T04:13:43Z', - }, - status: { - cluster: () => 'name', - resourceName: 'service-ca', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'app=volsync-addon-controller; app.kubernetes.io/instance=volsync; app.kubernetes.io/name=volsync-addon-controller; chart=volsync-addon-controller-2.13.0; component=volsync-addon-controller; installer.name=multiclusterhub; installer.namespace=open-cluster-management; release=volsync', - metadata: { - name: 'volsync-addon-controller', - namespace: 'open-cluster-management', - creationTimestamp: '2024-11-04T04:47:39Z', - }, - status: { - cluster: () => 'name', - resourceName: 'volsync-addon-controller', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=migrator', - metadata: { - name: 'migrator', - namespace: 'openshift-kube-storage-version-migrator', - creationTimestamp: '2024-11-04T04:13:41Z', - }, - status: { - cluster: () => 'name', - resourceName: 'migrator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'app=console-chart-v2; app.kubernetes.io/instance=console; app.kubernetes.io/name=console-chart; chart=console-chart-2.13.0; component=console; installer.name=multiclusterhub; installer.namespace=open-cluster-management; release=console; subcomponent=acm-cli-downloads', - metadata: { - name: 'console-chart-v2', - namespace: 'open-cluster-management', - creationTimestamp: '2024-11-04T04:47:37Z', - }, - status: { - cluster: () => 'name', - resourceName: 'acm-cli-downloads', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=openshift-apiserver-operator', - metadata: { - name: 'openshift-apiserver-operator', - namespace: 'openshift-apiserver-operator', - creationTimestamp: '2024-11-04T04:11:18Z', - }, - status: { - cluster: () => 'name', - resourceName: 'openshift-apiserver-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=cluster-manager-registration-webhook', - metadata: { - name: 'cluster-manager-registration-webhook', - namespace: 'open-cluster-management-hub', - creationTimestamp: '2024-11-04T04:45:52Z', - }, - status: { - cluster: () => 'name', - resourceName: 'cluster-manager-registration-webhook', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'app.kubernetes.io/component=multi-tenant; app.kubernetes.io/managed-by=olm; app.kubernetes.io/part-of=hyperconverged-cluster; app.kubernetes.io/version=4.16.3; olm.deployment-spec-hash=6EhizNTGkquU961LUQGeXOvJ6kzjzdfVJxD0Z3; olm.managed=true; olm.owner=kubevirt-hyperconverged-operator.v4.16.3; olm.owner.kind=ClusterServiceVersion; olm.owner.namespace=openshift-cnv; operators.coreos.com/kubevirt-hyperconverged.openshift-cnv=', - metadata: { - name: 'hyperconverged-cluster', - namespace: 'openshift-cnv', - creationTimestamp: '2024-11-04T09:37:04Z', - }, - status: { - cluster: () => 'name', - resourceName: 'mtq-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=csi-snapshot-controller-operator', - metadata: { - name: 'csi-snapshot-controller-operator', - namespace: 'openshift-cluster-storage-operator', - creationTimestamp: '2024-11-04T04:11:28Z', - }, - status: { - cluster: () => 'name', - resourceName: 'csi-snapshot-controller-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=openshift-controller-manager-operator', - metadata: { - name: 'openshift-controller-manager-operator', - namespace: 'openshift-controller-manager-operator', - creationTimestamp: '2024-11-04T04:11:18Z', - }, - status: { - cluster: () => 'name', - resourceName: 'openshift-controller-manager-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'app=policyreport; chart=policyreport-2.12.0; component=insights-metrics; heritage=release-service; installer.name=multiclusterhub; installer.namespace=open-cluster-management; release=policyreport', - metadata: { - name: 'policyreport', - namespace: 'open-cluster-management', - creationTimestamp: '2024-11-04T04:47:38Z', - }, - status: { - cluster: () => 'name', - resourceName: 'insights-metrics', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=multus-admission-controller; networkoperator.openshift.io/generates-operator-status=stand-alone', - metadata: { - name: 'multus-admission-controller', - namespace: 'openshift-multus', - creationTimestamp: '2024-11-04T04:12:42Z', - }, - status: { - cluster: () => 'name', - resourceName: 'multus-admission-controller', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=kube-storage-version-migrator-operator', - metadata: { - name: 'kube-storage-version-migrator-operator', - namespace: 'openshift-kube-storage-version-migrator-operator', - creationTimestamp: '2024-11-04T04:11:18Z', - }, - status: { - cluster: () => 'name', - resourceName: 'kube-storage-version-migrator-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=package-server-manager', - metadata: { - name: 'package-server-manager', - namespace: 'openshift-operator-lifecycle-manager', - creationTimestamp: '2024-11-04T04:11:31Z', - }, - status: { - cluster: () => 'name', - resourceName: 'package-server-manager', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'addon.open-cluster-management.io/hosted-manifest-location=hosting; app=config-policy-controller; chart=config-policy-controller-2.2.0; heritage=Helm; release=config-policy-controller', - metadata: { - name: 'config-policy-controller', - namespace: 'open-cluster-management-agent-addon', - creationTimestamp: '2024-11-04T04:47:52Z', - }, - status: { - cluster: () => 'name', - resourceName: 'config-policy-controller', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=cluster-monitoring-operator; app.kubernetes.io/name=cluster-monitoring-operator', - metadata: { - name: 'cluster-monitoring-operator', - namespace: 'openshift-monitoring', - creationTimestamp: '2024-11-04T04:11:30Z', - }, - status: { - cluster: () => 'name', - resourceName: 'cluster-monitoring-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=clustermanager-controller', - metadata: { - name: 'clustermanager-controller', - namespace: 'open-cluster-management-hub', - creationTimestamp: '2024-11-04T04:45:52Z', - }, - status: { - cluster: () => 'name', - resourceName: 'cluster-manager-addon-manager-controller', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'addon.open-cluster-management.io/hosted-manifest-location=hosting; app=cert-policy-controller; chart=cert-policy-controller-2.2.0; heritage=Helm; release=cert-policy-controller', - metadata: { - name: 'cert-policy-controller', - namespace: 'open-cluster-management-agent-addon', - creationTimestamp: '2024-11-04T04:47:48Z', - }, - status: { - cluster: () => 'name', - resourceName: 'cert-policy-controller', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=klusterlet-agent', - metadata: { - name: 'klusterlet-agent', - namespace: 'open-cluster-management-agent', - creationTimestamp: '2024-11-04T04:46:45Z', - }, - status: { - cluster: () => 'name', - resourceName: 'klusterlet-agent', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=kube-apiserver-operator', - metadata: { - name: 'kube-apiserver-operator', - namespace: 'openshift-kube-apiserver-operator', - creationTimestamp: '2024-11-04T04:11:34Z', - }, - status: { - cluster: () => 'name', - resourceName: 'kube-apiserver-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=console; component=ui', - metadata: { - name: 'console', - namespace: 'openshift-console', - creationTimestamp: '2024-11-04T04:28:20Z', - }, - status: { - cluster: () => 'name', - resourceName: 'console', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=hypershift-addon-agent', - metadata: { - name: 'hypershift-addon-agent', - namespace: 'open-cluster-management-agent-addon', - creationTimestamp: '2024-11-04T04:46:51Z', - }, - status: { - cluster: () => 'name', - resourceName: 'hypershift-addon-agent', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=service-ca-operator', - metadata: { - name: 'service-ca-operator', - namespace: 'openshift-service-ca-operator', - creationTimestamp: '2024-11-04T04:11:17Z', - }, - status: { - cluster: () => 'name', - resourceName: 'service-ca-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=kube-controller-manager-operator', - metadata: { - name: 'kube-controller-manager-operator', - namespace: 'openshift-kube-controller-manager-operator', - creationTimestamp: '2024-11-04T04:11:17Z', - }, - status: { - cluster: () => 'name', - resourceName: 'kube-controller-manager-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'addon.open-cluster-management.io/hosted-manifest-location=hosting; app=governance-policy-framework; chart=governance-policy-framework-2.2.0; heritage=Helm; release=governance-policy-framework', - metadata: { - name: 'governance-policy-framework', - namespace: 'open-cluster-management-agent-addon', - creationTimestamp: '2024-11-04T04:47:55Z', - }, - status: { - cluster: () => 'name', - resourceName: 'governance-policy-framework', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'app.kubernetes.io/component=controller; app.kubernetes.io/managed-by=cluster-monitoring-operator; app.kubernetes.io/name=prometheus-operator; app.kubernetes.io/part-of=openshift-monitoring; app.kubernetes.io/version=0.73.2', - metadata: { - name: 'openshift-monitoring', - namespace: 'openshift-user-workload-monitoring', - creationTimestamp: '2024-11-04T18:44:58Z', - }, - status: { - cluster: () => 'name', - resourceName: 'prometheus-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=oauth-openshift', - metadata: { - name: 'oauth-openshift', - namespace: 'openshift-authentication', - creationTimestamp: '2024-11-04T04:28:21Z', - }, - status: { - cluster: () => 'name', - resourceName: 'oauth-openshift', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=machine-approver; machine-approver=true', - metadata: { - name: 'machine-approver', - namespace: 'openshift-cluster-machine-approver', - creationTimestamp: '2024-11-04T04:11:59Z', - }, - status: { - cluster: () => 'name', - resourceName: 'machine-approver', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=openshift-kube-scheduler-operator', - metadata: { - name: 'openshift-kube-scheduler-operator', - namespace: 'openshift-kube-scheduler-operator', - creationTimestamp: '2024-11-04T04:11:17Z', - }, - status: { - cluster: () => 'name', - resourceName: 'openshift-kube-scheduler-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=cluster-manager-work-webhook', - metadata: { - name: 'cluster-manager-work-webhook', - namespace: 'open-cluster-management-hub', - creationTimestamp: '2024-11-04T04:45:52Z', - }, - status: { - cluster: () => 'name', - resourceName: 'cluster-manager-work-webhook', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=olm-operator', - metadata: { - name: 'olm-operator', - namespace: 'openshift-operator-lifecycle-manager', - creationTimestamp: '2024-11-04T04:11:33Z', - }, - status: { - cluster: () => 'name', - resourceName: 'olm-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=catalog-operator', - metadata: { - name: 'catalog-operator', - namespace: 'openshift-operator-lifecycle-manager', - creationTimestamp: '2024-11-04T04:11:33Z', - }, - status: { - cluster: () => 'name', - resourceName: 'catalog-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: - 'app=klusterlet-addon-controller-v2; app.kubernetes.io/name=klusterlet-addon-controller; component=klusterlet-addon-controller; installer.name=multiclusterhub; installer.namespace=open-cluster-management', - metadata: { - name: 'klusterlet-addon-controller-v2', - namespace: 'open-cluster-management', - creationTimestamp: '2024-11-04T04:47:37Z', - }, - status: { - cluster: () => 'name', - resourceName: 'klusterlet-addon-controller-v2', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=route-controller-manager; route-controller-manager=true', - metadata: { - name: 'route-controller-manager', - namespace: 'openshift-route-controller-manager', - creationTimestamp: '2024-11-04T04:13:43Z', - }, - status: { - cluster: () => 'name', - resourceName: 'route-controller-manager', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=openshift-config-operator', - metadata: { - name: 'openshift-config-operator', - namespace: 'openshift-config-operator', - creationTimestamp: '2024-11-04T04:11:34Z', - }, - status: { - cluster: () => 'name', - resourceName: 'openshift-config-operator', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'apiserver=true; app=openshift-apiserver; revision=1', - metadata: { - name: 'openshift-apiserver', - namespace: 'openshift-apiserver', - creationTimestamp: '2024-11-04T04:15:53Z', - }, - status: { - cluster: () => 'name', - resourceName: 'apiserver', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=openshift-controller-manager; controller-manager=true', - metadata: { - name: 'openshift-controller-manager', - namespace: 'openshift-controller-manager', - creationTimestamp: '2024-11-04T04:13:42Z', - }, - status: { - cluster: () => 'name', - resourceName: 'controller-manager', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'apiserver=true; app=openshift-oauth-apiserver; revision=1', - metadata: { - name: 'openshift-oauth-apiserver', - namespace: 'openshift-oauth-apiserver', - creationTimestamp: '2024-11-04T04:15:38Z', - }, - status: { - cluster: () => 'name', - resourceName: 'apiserver', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=klusterlet', - metadata: { - name: 'klusterlet', - namespace: 'open-cluster-management-agent', - creationTimestamp: '2024-11-04T04:46:35Z', - }, - status: { - cluster: () => 'name', - resourceName: 'klusterlet', - }, - }, - { - apiVersion: 'apps/v1', - kind: 'Deployment', - label: 'app=authentication-operator', - metadata: { - name: 'authentication-operator', - namespace: 'openshift-authentication-operator', - creationTimestamp: '2024-11-04T04:11:19Z', - }, - status: { - cluster: () => 'name', - resourceName: 'authentication-operator', - }, - }, -] diff --git a/backend-node/src/lib/json-request.ts b/backend-node/src/lib/json-request.ts deleted file mode 100644 index 009e45adb71..00000000000 --- a/backend-node/src/lib/json-request.ts +++ /dev/null @@ -1,116 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { constants } from 'node:http2' -import type { Agent } from 'node:https' -import type { HeadersInit } from 'node-fetch' -import { fetchRetry } from './fetch-retry' -import type { IResource } from '../resources/resource' -import { join } from 'node:path' -import pluralize from 'pluralize' - -const { HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_AUTHORIZATION, HTTP2_HEADER_ACCEPT, HTTP2_HEADER_USER_AGENT } = - constants - -export function jsonRequest(url: string, token?: string, retry?: number): Promise { - const headers: HeadersInit = { [HTTP2_HEADER_ACCEPT]: 'application/json' } - if (token) headers[HTTP2_HEADER_AUTHORIZATION] = `Bearer ${token}` - return fetchRetry(url, { headers, compress: true }, retry).then( - (response) => response.json() as unknown as Promise - ) -} - -export interface PostResponse { - statusCode: number - body?: T -} - -export interface PutResponse { - statusCode?: number - body?: { - kind?: string - name?: string - message?: string - reason?: string - code?: number - } -} - -export function jsonPost( - url: string, - body: unknown, - token?: string, - userAgent?: string, - agent?: Agent -): Promise> { - const headers: HeadersInit = { - [HTTP2_HEADER_ACCEPT]: 'application/json', - [HTTP2_HEADER_CONTENT_TYPE]: 'application/json', - } - if (token) headers[HTTP2_HEADER_AUTHORIZATION] = `Bearer ${token}` - if (userAgent) headers[HTTP2_HEADER_USER_AGENT] = userAgent - return fetchRetry(url, { - method: 'POST', - headers, - agent, - body: JSON.stringify(body), - compress: true, - }).then(async (response) => { - const result = { - statusCode: response.status, - body: (await response.json()) as unknown as T, - } - return result - }) -} - -export function jsonPut(url: string, body: unknown, token?: string, agent?: Agent): Promise { - const headers: HeadersInit = {} - if (token) headers[HTTP2_HEADER_AUTHORIZATION] = `Bearer ${token}` - return fetchRetry(url, { - method: 'PUT', - headers, - agent, - body: JSON.stringify(body), - compress: true, - }) - .then((response) => ({ - // No response body from cluster-proxy kubevirt requests. - statusCode: response.status, - })) - .catch((err: Error) => { - const errResult = { - body: { - name: err.name, - message: err.message, - }, - } - return errResult - }) -} - -export function resourceUrl(resource: IResource) { - if (!resource.apiVersion) { - throw new Error('resource.apiVersion is required') - } - let path: string = process.env.CLUSTER_API_URL ?? '' - if (resource.apiVersion?.includes('/')) { - path = join(path, '/apis', resource.apiVersion) - } else { - path = join(path, '/api', resource.apiVersion) - } - - const namespace = resource.metadata?.namespace - if (namespace) { - path = join(path, 'namespaces', namespace) - } - - if (resource.kind) { - path = join(path, pluralize(resource.kind.toLowerCase())) - } - - const name = resource.metadata?.name - if (name) { - path = join(path, name) - } - - return path.replaceAll('\\', '/') -} diff --git a/backend-node/src/lib/logger.ts b/backend-node/src/lib/logger.ts deleted file mode 100644 index ea3944145e9..00000000000 --- a/backend-node/src/lib/logger.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import pino from 'pino' -export const logLevel = process.env.LOG_LEVEL ? process.env.LOG_LEVEL : 'debug' - -const options: pino.LoggerOptions = { level: logLevel, base: {} } -export const logger = pino(options) -export function stopLogger(): void { - // do nothing -} diff --git a/backend-node/src/lib/main.ts b/backend-node/src/lib/main.ts deleted file mode 100644 index 5efd9757cd1..00000000000 --- a/backend-node/src/lib/main.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { config } from 'dotenv' -import { cpus, totalmem } from 'node:os' -import { start, stop } from '../app' -import { logger } from './logger' -import { envFilePath } from './paths' - -try { - config({ path: envFilePath() }) -} catch (err) { - // Do Nothing -} - -logger.debug({ - msg: `process start`, - NODE_ENV: process.env.NODE_ENV, - cpus: `${Object.keys(cpus()).length}`, - memory: `${(totalmem() / (1024 * 1024 * 1024)).toPrecision(2).toString()}GB`, - nodeVersion: `${process.versions.node}`, -}) - -process.on('exit', function processExit(code) { - if (code !== 0) { - logger.error({ msg: `process exit`, code: code }) - } else { - logger.debug({ msg: `process exit`, code: code }) - } -}) - -process.on('SIGINT', () => { - if (process.env.NODE_ENV === 'development') console.log() - logger.debug({ msg: 'process SIGINT' }) - void stop() -}) - -process.on('SIGTERM', () => { - logger.debug({ msg: 'process SIGTERM' }) - void stop() -}) - -process.on('uncaughtException', (_err) => { - // console.error(err) - // logger.error({ msg: `process uncaughtException`, error: err.message }) - // console.log(err.stack) -}) - -process.on('multipleResolves', (type: unknown, _promise, reason: unknown) => { - // node-fetch throws multipleResolves on aborted resolved request - if (!reason || (reason as { type?: string }).type === 'aborted') return - logger.error({ - msg: 'process multipleResolves', - type, - reason: reason instanceof Error ? reason.message : reason, - stack: reason instanceof Error ? reason.stack : undefined, - }) -}) - -process.on('unhandledRejection', (reason, _promise) => { - logger.error({ - msg: 'process unhandledRejection', - reason: reason instanceof Error ? reason.message : reason, - stack: reason instanceof Error ? reason.stack : undefined, - }) -}) - -void start() diff --git a/backend-node/src/lib/memory.ts b/backend-node/src/lib/memory.ts deleted file mode 100644 index 6e60b926ac1..00000000000 --- a/backend-node/src/lib/memory.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -/* istanbul ignore file */ - -import { logger } from './logger' - -function logMemory() { - const used = process.memoryUsage() - logger.debug({ - msg: 'memory', - used: `${Math.round(used.rss / 1024 / 1024)} MB`, - }) - setTimeout(logMemory, 60 * 1000).unref() -} - -export function startLoggingMemory(): void { - setTimeout(logMemory, 10 * 1000).unref() -} diff --git a/backend-node/src/lib/multi-cluster-engine.ts b/backend-node/src/lib/multi-cluster-engine.ts deleted file mode 100644 index 5c525e6b06c..00000000000 --- a/backend-node/src/lib/multi-cluster-engine.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { jsonRequest } from './json-request' -import { logger } from './logger' -import { getServiceAccountToken } from './serviceAccountToken' - -// Type returned by /apis/authentication.k8s.io/v1/tokenreviews -export interface MultiClusterEngineComponent { - name: string - enabled: boolean -} - -interface MultiClusterEngine { - spec: { - targetNamespace: string - overrides?: { - components?: MultiClusterEngineComponent[] - } - } -} - -interface MultiClusterEngineList { - items: MultiClusterEngine[] -} - -let MultiClusterEngine: Promise - -/** Clear MultiClusterEngine cache. Used for test isolation. */ -export function resetMultiClusterEngineCache(): void { - MultiClusterEngine = undefined -} - -export async function getMultiClusterEngine(noCache?: boolean): Promise { - const serviceAccountToken = getServiceAccountToken() - if (MultiClusterEngine === undefined || noCache) { - MultiClusterEngine = jsonRequest( - process.env.CLUSTER_API_URL + '/apis/multicluster.openshift.io/v1/multiclusterengines', - serviceAccountToken - ) - .then((response) => { - return response.items && response.items[0] ? response.items[0] : undefined - }) - .catch((err: Error): undefined => { - logger.error({ msg: 'Error getting MultiClusterEngine', error: err.message }) - return undefined - }) - } - return MultiClusterEngine -} - -export async function getMultiClusterEngineComponents( - noCache?: boolean, - throwErrors?: boolean -): Promise { - if (throwErrors) { - // Don't use the cached version if we want to throw errors - const serviceAccountToken = getServiceAccountToken() - const response = await jsonRequest( - process.env.CLUSTER_API_URL + '/apis/multicluster.openshift.io/v1/multiclusterengines', - serviceAccountToken - ) - const multiClusterEngine = response.items && response.items[0] ? response.items[0] : undefined - return multiClusterEngine?.spec?.overrides?.components - } - - const multiClusterEngine = await getMultiClusterEngine(noCache) - return multiClusterEngine?.spec?.overrides?.components -} diff --git a/backend-node/src/lib/multi-cluster-hub.ts b/backend-node/src/lib/multi-cluster-hub.ts deleted file mode 100644 index f7c8c12fc14..00000000000 --- a/backend-node/src/lib/multi-cluster-hub.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { jsonRequest } from './json-request' -import { logger } from './logger' -import { getServiceAccountToken } from './serviceAccountToken' - -// Type returned by /apis/authentication.k8s.io/v1/tokenreviews - -interface MultiClusterHubComponent { - name: string - enabled: boolean -} - -interface MultiClusterHub { - metadata: { - namespace: string - } - status: { - currentVersion: string - } - spec?: { overrides?: { components?: MultiClusterHubComponent[] } } -} - -interface MultiClusterHubList { - items: MultiClusterHub[] -} - -let multiclusterhub: Promise - -/** Clear MultiClusterHub cache. Used for test isolation. */ -export function resetMultiClusterHubCache(): void { - multiclusterhub = undefined -} - -export async function getMultiClusterHub(noCache?: boolean): Promise { - const serviceAccountToken = getServiceAccountToken() - if (multiclusterhub === undefined || noCache) { - multiclusterhub = jsonRequest( - process.env.CLUSTER_API_URL + '/apis/operator.open-cluster-management.io/v1/multiclusterhubs', - serviceAccountToken - ) - .then((response) => { - return response.items && response.items[0] ? response.items[0] : undefined - }) - .catch((err: Error): undefined => { - logger.error({ msg: 'Error getting MultiClusterHub', error: err.message }) - return undefined - }) - } - return multiclusterhub -} - -export async function getMultiClusterHubComponents(noCache?: boolean): Promise { - const multiClusterHub = await getMultiClusterHub(noCache) - return multiClusterHub.spec?.overrides?.components -} diff --git a/backend-node/src/lib/noop.ts b/backend-node/src/lib/noop.ts deleted file mode 100644 index 386c2c8042f..00000000000 --- a/backend-node/src/lib/noop.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -/* istanbul ignore file */ - -export function noop(): void { - // Do nothing -} diff --git a/backend-node/src/lib/pagination.ts b/backend-node/src/lib/pagination.ts deleted file mode 100644 index cea0a5a3dcc..00000000000 --- a/backend-node/src/lib/pagination.ts +++ /dev/null @@ -1,153 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import Fuse from 'fuse.js' -import type { IResource } from '../resources/resource' -import { getAuthorizedResources } from '../routes/events' -import { AppColumns, type ICompressedResource, type ITransformedResource } from '../routes/aggregators/applications' - -export type FilterSelections = { - [filter: string]: string[] -} - -export type FilterCounts = { - [id: string]: { [filter: string]: number } -} - -export interface ISortBy { - index?: number - direction?: 'asc' | 'desc' -} - -export interface IRequestListView { - page: number - perPage: number - sortBy?: ISortBy - search?: string - filters?: FilterSelections -} - -export interface IResultListView { - page: number - items: IResource[] - processedItemCount: number - emptyResult: boolean - isPreProcessed: boolean - request: IRequestListView -} - -export interface PaginatedResults { - next?: { - page: number - limit: number - } - previous?: { - page: number - limit: number - } - results?: IResource[] -} - -export const PREPROCESS_BREAKPOINT = 500 - -export function paginate( - req: Http2ServerRequest, - res: Http2ServerResponse, - token: string, - getItems: () => Promise, - filterItems: (filters: FilterSelections, items: ICompressedResource[]) => ICompressedResource[], - sortItems: (sort: ISortBy, items: ICompressedResource[]) => ICompressedResource[], - addUIData: (items: ITransformedResource[]) => Promise -): void { - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - req.on('end', async () => { - const body = chucks.join('') - - const request = JSON.parse(body) as IRequestListView - const { search, sortBy, filters } = request - let { page, perPage } = request - let items = await getItems() - let itemCount = items.length - if (perPage === -1) { - page = 1 - perPage = itemCount - } - let rpage = page - let emptyResult = false - let isPreProcessed = itemCount === 0 // if false, we pass all data and frontend does the filter/search/sort - const backendLimit = process.env.NODE_ENV === 'test' ? 0 : PREPROCESS_BREAKPOINT - let startIndex = 0 - let endIndex = itemCount - if (itemCount > backendLimit) { - isPreProcessed = true // else we do filter/search/sort/paging here - // filter - if (filters && Object.keys(filters).length > 0) { - items = filterItems(filters, items) - } - - // search - if (search) { - const fuse = new Fuse(items, { - ignoreLocation: true, - threshold: 0.3, - keys: [ - { - name: 'search', - getFn: (item) => { - return [ - item.transform[AppColumns.name][0] as string, - item.transform[AppColumns.namespace][0] as string, - item.transform[AppColumns.clusters][0] as string, - ] - }, - }, - ], - }) - items = fuse.search({ search }).map((result) => result.item) - } - - // sort - if (sortBy && sortBy.index >= 0) { - items = sortItems(sortBy, items) - } - - // adjust page if now past end of items - const start = (page - 1) * perPage - if (start >= items.length) { - rpage = Math.max(1, Math.ceil(items.length / perPage)) - } - - // if item count is 0 it's then search/filter returned no results - // id data,length is 0 there are no resources and we show create resource button - itemCount = items.length - emptyResult = itemCount === 0 - - // slice and dice - startIndex = (rpage - 1) * perPage - endIndex = rpage * perPage - } - - // because rbac is expensive. perform it only on the resources the user wants to see - let authorizedItems = await getAuthorizedResources(token, items, startIndex, endIndex) - - // add data required by ui - authorizedItems = await addUIData(authorizedItems) - - // remove the transform work attribute - authorizedItems = authorizedItems.map(({ transform, remoteClusters, ...keepAttrs }) => keepAttrs) - - const results: IResultListView = { - page: rpage, - items: authorizedItems, - processedItemCount: itemCount, - emptyResult, - isPreProcessed, - request, - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(results)) - }) -} diff --git a/backend-node/src/lib/paths.ts b/backend-node/src/lib/paths.ts deleted file mode 100644 index fc051dca4a2..00000000000 --- a/backend-node/src/lib/paths.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { join } from 'node:path' - -export function envFilePath(): string { - return process.env.ENV_FILE || '.env' -} - -export function configDir(): string { - return process.env.CONFIG_DIR || './config' -} - -export function certsDir(): string { - return process.env.CERTS_DIR || './certs' -} - -export function certFile(name: string): string { - return join(certsDir(), name) -} diff --git a/backend-node/src/lib/placementDebugCAWatch.ts b/backend-node/src/lib/placementDebugCAWatch.ts deleted file mode 100644 index d7277cbd2e7..00000000000 --- a/backend-node/src/lib/placementDebugCAWatch.ts +++ /dev/null @@ -1,218 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import got, { HTTPError, TimeoutError } from 'got' -import { pipeline } from 'node:stream/promises' -import { Transform } from 'node:stream' -import { logger } from './logger' -import { getCACertificate, getServiceAccountToken } from './serviceAccountToken' - -const HUB_NAMESPACE = 'open-cluster-management-hub' -const CONFIGMAP_NAME = 'ca-bundle-configmap' -const CA_BUNDLE_KEY = 'ca-bundle.crt' -const API_PATH = `/api/v1/namespaces/${HUB_NAMESPACE}/configmaps` -const FIELD_SELECTOR = `fieldSelector=metadata.name=${CONFIGMAP_NAME}` - -type ConfigMap = { - metadata?: { name?: string; resourceVersion?: string } - data?: Record -} - -let stopping = false -let currentCA: string | undefined -let activeRequest: { destroy: () => void } | undefined - -export function getPlacementDebugCA(): string | undefined { - return currentCA -} - -export function watchPlacementDebugCA(onCAChange: () => void): () => void { - stopping = false - currentCA = undefined - void listAndWatchPlacementDebugCA(onCAChange) - return () => { - stopping = true - activeRequest?.destroy() - } -} - -async function listAndWatchPlacementDebugCA(onCAChange: () => void): Promise { - while (!stopping) { - try { - const serviceAccountToken = getServiceAccountToken() - const { resourceVersion } = await listPlacementDebugCA(serviceAccountToken, onCAChange) - await watchPlacementDebugCAStream(serviceAccountToken, resourceVersion, onCAChange) - } catch (err: unknown) { - if (stopping) break - await handleWatchError(err) - } - } -} - -async function handleWatchError(err: unknown): Promise { - if (err instanceof SyntaxError) { - // Non-JSON response (e.g. stale resource version) — retry immediately - } else if (err instanceof HTTPError) { - switch (err.response.statusCode) { - case 403: - logger.error({ msg: 'placement debug CA watch', status: 'Forbidden' }) - break - case 404: - logger.trace({ msg: 'placement debug CA watch', status: 'Not found' }) - break - default: - logger.error({ msg: 'placement debug CA watch error', error: err.message }) - } - await new Promise((resolve) => setTimeout(resolve, 60_000 + Math.ceil(Math.random() * 10_000)).unref()) - } else if (err instanceof Error) { - if (err.message === 'Premature close' || err.message.startsWith('too old resource version')) { - // Retry list and watch immediately - } else { - logger.error({ msg: 'placement debug CA watch error', error: err.message }) - await new Promise((resolve) => setTimeout(resolve, 60_000 + Math.ceil(Math.random() * 10_000)).unref()) - } - } else { - logger.error({ msg: 'placement debug CA watch error', error: JSON.stringify(err) }) - await new Promise((resolve) => setTimeout(resolve, 60_000 + Math.ceil(Math.random() * 10_000)).unref()) - } -} - -async function listPlacementDebugCA( - serviceAccountToken: string, - onCAChange: () => void -): Promise<{ resourceVersion: string }> { - const url = `${process.env.CLUSTER_API_URL}${API_PATH}?${FIELD_SELECTOR}` - const response = await got - .get(url, { - headers: { authorization: `Bearer ${serviceAccountToken}` }, - https: { certificateAuthority: getCACertificate() }, - }) - .json<{ - metadata: { resourceVersion: string } - items: ConfigMap[] - }>() - - const ca = response.items?.[0]?.data?.[CA_BUNDLE_KEY] - if (ca) { - applyIfChanged(ca, onCAChange) - } else if (currentCA !== undefined) { - logger.info({ msg: 'placement debug CA bundle removed' }) - currentCA = undefined - onCAChange() - } - - return { resourceVersion: response.metadata.resourceVersion } -} - -async function watchPlacementDebugCAStream( - serviceAccountToken: string, - initialResourceVersion: string, - onCAChange: () => void -): Promise { - const resourceVersionRef = { value: initialResourceVersion } - - while (!stopping) { - const url = - `${process.env.CLUSTER_API_URL}${API_PATH}` + - `?watch&allowWatchBookmarks&${FIELD_SELECTOR}&resourceVersion=${resourceVersionRef.value}` - const request = got.stream(url, { - headers: { authorization: `Bearer ${serviceAccountToken}` }, - https: { certificateAuthority: getCACertificate() }, - timeout: { socket: 5 * 60 * 1000 + Math.ceil(Math.random() * 10 * 1000) }, - }) - activeRequest = request - try { - await pipeline(request, createSplitStream(), createCAWatchProcessor(resourceVersionRef, onCAChange)) - } catch (err: unknown) { - if (err instanceof TimeoutError) { - // Socket timeout — retry the watch - } else if (err instanceof HTTPError) { - throw err - } else if ((err as Error)?.message === 'Premature close') { - // Stream destroyed or connection lost — retry the watch - } else { - throw err - } - } finally { - if (activeRequest === request) activeRequest = undefined - } - } -} - -function createSplitStream(): Transform { - let buffer = '' - return new Transform({ - objectMode: true, - transform(chunk: Buffer, _encoding, callback) { - buffer += chunk.toString() - const lines = buffer.split('\n') - buffer = lines.pop() || '' - for (const line of lines) { - if (line.trim()) { - this.push(line) - } - } - callback() - }, - flush(callback) { - if (buffer.trim()) { - this.push(buffer) - } - callback() - }, - }) -} - -function createCAWatchProcessor(resourceVersionRef: { value: string }, onCAChange: () => void): Transform { - return new Transform({ - objectMode: true, - transform(data: string, _encoding, callback): void { - try { - const event = JSON.parse(data) as { - type: string - object: ConfigMap & { message?: string } - } - - const resourceVersion = event.object?.metadata?.resourceVersion - if (resourceVersion) { - resourceVersionRef.value = resourceVersion - } - - switch (event.type) { - case 'ADDED': - case 'MODIFIED': - applyIfChanged(event.object?.data?.[CA_BUNDLE_KEY], onCAChange) - break - case 'DELETED': - if (currentCA !== undefined) { - logger.info({ msg: 'placement debug CA bundle removed' }) - currentCA = undefined - onCAChange() - } - break - case 'BOOKMARK': - break - case 'ERROR': - logger.warn({ msg: 'placement debug CA watch error event', message: event.object?.message }) - callback(new Error(event.object?.message ?? 'Unknown watch error')) - return - } - - callback() - } catch (err: unknown) { - const error = err instanceof Error ? err : new Error(JSON.stringify(err)) - callback(error) - } - }, - }) -} - -function applyIfChanged(ca: string | undefined, onCAChange: () => void): void { - if (!ca) return - if (currentCA === ca) { - logger.debug({ msg: 'placement debug CA bundle unchanged' }) - return - } - logger.info({ msg: 'placement debug CA bundle updated' }) - currentCA = ca - onCAChange() -} diff --git a/backend-node/src/lib/random-string.ts b/backend-node/src/lib/random-string.ts deleted file mode 100644 index 7bdc712fd04..00000000000 --- a/backend-node/src/lib/random-string.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -const randomCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - -export function randomString(length: number, base = randomCharacters.length): string { - if (base > randomCharacters.length || base <= 0) base = randomCharacters.length - let text = '' - for (let i = 0; i < length; i++) { - const index = Math.floor(Math.random() * base) % base - text += randomCharacters.charAt(index) - } - return text -} diff --git a/backend-node/src/lib/request-retry.ts b/backend-node/src/lib/request-retry.ts deleted file mode 100644 index 1debadc7165..00000000000 --- a/backend-node/src/lib/request-retry.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { IncomingMessage, OutgoingHttpHeaders } from 'node:http' -import { constants } from 'node:http2' -import type { RequestOptions } from 'node:https' -import { request } from 'node:https' -import type { AbortSignal } from 'node-fetch/externals' -import { logger } from './logger' -import { getDefaultAgent } from './agent' - -const { HTTP2_HEADER_CONTENT_LENGTH, HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_AUTHORIZATION, HTTP2_HEADER_ACCEPT } = - constants - -// TODO HTTP2_HEADER_ACCEPT_ENCODING - -export function requestRetry(options: { - url: string - method?: 'GET' | 'PUT' | 'POST' | 'PATCH' | 'DELETE' - headers?: OutgoingHttpHeaders - token?: string - timeout?: number // Milliseconds before a request times out. - body?: unknown - onResponse: (response: IncomingMessage) => void - onClose: (statusCode?: number) => void - onError: (err: Error) => void - signal?: AbortSignal -}): void { - const body = options.body ? JSON.stringify(options.body) : undefined - options.headers = options.headers ?? {} - options.headers[HTTP2_HEADER_ACCEPT] = 'application/json' - if (body) { - options.headers[HTTP2_HEADER_CONTENT_TYPE] = 'application/json' - options.headers[HTTP2_HEADER_CONTENT_LENGTH] = body.length.toString() - } - if (options.token) { - options.headers[HTTP2_HEADER_AUTHORIZATION] = `Bearer ${options.token}` - } - const requestOptions = options as RequestOptions - requestOptions.agent = getDefaultAgent() - - let delay = 10000 - let retries = 0 - - function requestAttempt(requestOptions?: RequestOptions): void { - function handleError(err: Error) { - let retry = false - if (err instanceof Error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access - switch ((err as any)?.code) { - case 'ETIMEDOUT': - case 'ECONNRESET': - case 'ENOTFOUND': - retry = true - break - default: - switch (err.message) { - case 'Network Error': - retry = true - break - } - break - } - } - - if (retry) { - retries-- - setTimeout(requestAttempt, delay) - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment - logger.warn({ msg: 'retrying request', code: (err as any)?.code, url: options.url }) - } else { - options.onError(err) - options.onClose() - } - } - - try { - const clientRequest = request(options.url, requestOptions) - .on('response', (response: IncomingMessage) => { - const retryAfter = Number(response.headers['retry-after']) - if (!Number.isInteger(retryAfter)) delay = retryAfter - switch (response.statusCode) { - case 429: // Too Many Requests - setTimeout(requestAttempt, delay) - logger.warn({ msg: 'retrying request', status: response.statusCode, url: options.url }) - break - - case 408: // Request Timeout - case 500: // Internal Server Error - case 502: // Bad Gateway - case 503: // Service Unavailable - case 504: // Gateway Timeout - case 522: // Connection timed out - case 524: // A Timeout Occurred - if (retries > 0) { - retries-- - setTimeout(requestAttempt, delay) - logger.warn({ msg: 'retrying request', status: response.statusCode, url: options.url }) - } else { - options.onError(new Error(`response error statusCode:${response.statusCode}`)) - options.onClose(response.statusCode) - } - break - - default: - clientRequest.removeListener('error', handleError) - response.on('error', options.onError) - response.on('close', () => options.onClose(response.statusCode)) - options.onResponse(response) - } - }) - .on('error', (err) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - if ((err as any).code !== 'ABORT_ERR') { - throw err - } - }) - .on('timeout', () => { - // Emitted when the underlying socket times out from inactivity. - // This only notifies that the socket has been idle. - // The request must be aborted manually. - which should be destroy() - clientRequest.destroy() - }) - clientRequest.addListener('error', handleError) - - if (options.signal) { - options.signal.addEventListener('abort', () => { - clientRequest.destroy() - }) - } - - clientRequest.end(body) - } catch (err) { - if (err instanceof Error) handleError(err) - } finally { - if (delay === 0) delay = 100 - else delay *= 2 - } - } - - try { - requestAttempt(requestOptions) - } catch (err) { - if (err instanceof Error) options.onError(err) - throw err - } -} diff --git a/backend-node/src/lib/respond.ts b/backend-node/src/lib/respond.ts deleted file mode 100644 index 050d5f1485b..00000000000 --- a/backend-node/src/lib/respond.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import { logger } from './logger' - -const { - HTTP_STATUS_OK, - HTTP_STATUS_CREATED, - HTTP_STATUS_BAD_REQUEST, - HTTP_STATUS_UNAUTHORIZED, - HTTP_STATUS_NOT_FOUND, - HTTP_STATUS_CONFLICT, - HTTP_STATUS_INTERNAL_SERVER_ERROR, -} = constants - -export function respond(res: Http2ServerResponse, data: unknown, status = 200): void { - let jsonString: string - if (typeof data === 'string') { - jsonString = data - } else if (data instanceof Buffer) { - jsonString = data.toString() - } else { - jsonString = JSON.stringify(data) - } - res.writeHead(status, { 'content-type': 'application/json' }).end(jsonString) -} - -export function respondOK(_req: Http2ServerRequest, res: Http2ServerResponse): void { - res.writeHead(HTTP_STATUS_OK).end() -} - -export function respondCreated(res: Http2ServerResponse, data: Record): void { - if (data) { - res.writeHead(HTTP_STATUS_CREATED, { 'content-type': 'application/json' }).end(JSON.stringify(data)) - } else { - res.writeHead(HTTP_STATUS_CREATED).end() - } -} - -export function redirect(res: Http2ServerResponse, location: string): void { - res.writeHead(302, { location }).end() -} - -export function respondBadRequest(_req: Http2ServerRequest, res: Http2ServerResponse): void { - res.writeHead(HTTP_STATUS_BAD_REQUEST).end() -} - -export function unauthorized(_req: Http2ServerRequest, res: Http2ServerResponse): void { - res.writeHead(HTTP_STATUS_UNAUTHORIZED).end() -} - -export function notFound(_req: Http2ServerRequest, res: Http2ServerResponse): void { - res.writeHead(HTTP_STATUS_NOT_FOUND).end() -} - -export function respondConflict(_req: Http2ServerRequest, res: Http2ServerResponse): void { - res.writeHead(HTTP_STATUS_CONFLICT).end() -} - -export function respondInternalServerError(_req: Http2ServerRequest, res: Http2ServerResponse): void { - res.writeHead(HTTP_STATUS_INTERNAL_SERVER_ERROR).end() -} - -export function catchInternalServerError(res: Http2ServerResponse): (err: unknown) => void { - return (err) => { - logger.error(err) - if (!res.headersSent) { - respondInternalServerError(undefined, res) - } - } -} diff --git a/backend-node/src/lib/search.ts b/backend-node/src/lib/search.ts deleted file mode 100644 index c4b0ef01d08..00000000000 --- a/backend-node/src/lib/search.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { OutgoingHttpHeaders } from 'node:http2' -import type { RequestOptions } from 'node:https' -import { request } from 'node:https' -import { URL } from 'node:url' -import { getMultiClusterHub } from '../lib/multi-cluster-hub' -import { getNamespace, getServiceAccountToken } from '../lib/serviceAccountToken' -import { logger } from './logger' -import type { IQuery } from '../routes/aggregators/applications' -import { getServiceAgent } from './agent' - -export type ISearchResult = { - data: { - searchResult: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items?: any - count?: number - related?: { - count?: number - kind: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items?: any - }[] - }[] - } - message?: string -} - -export async function getServiceAccountSearchRequestOptions() { - const serviceAccountToken = getServiceAccountToken() - const headers: OutgoingHttpHeaders = { - authorization: `Bearer ${serviceAccountToken}`, - accept: 'application/json', - 'content-type': 'application/json', - } - const options = await getSearchRequestOptions(headers) - return options -} - -export async function getSearchRequestOptions(headers: OutgoingHttpHeaders): Promise { - const mch = await getMultiClusterHub() - const namespace = getNamespace() - const machineNs = process.env.NODE_ENV === 'test' ? 'undefined' : `${mch?.metadata?.namespace || namespace}` - const searchService = `https://search-search-api.${machineNs}.svc.cluster.local:4010` - const searchUrl = process.env.SEARCH_API_URL || searchService - const endpoint = process.env.globalSearchFeatureFlag === 'enabled' ? '/federated' : '/searchapi/graphql' - const url = new URL(searchUrl + endpoint) - headers.host = url.hostname - const options: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port, - path: url.pathname, - method: 'POST', - headers, - agent: getServiceAgent(), - } - return options -} - -export async function getSearchResults(query: IQuery) { - const options = await getServiceAccountSearchRequestOptions() - const requestTimeout = 2 * 60 * 1000 - return new Promise((resolve, reject) => { - let body = '' - const id = setTimeout(() => { - logger.error(`getSearchResults request timeout`) - reject(new Error('request timeout')) - }, requestTimeout) - const req = request(options, (res) => { - res.on('data', (data) => { - body += data - }) - res.on('end', () => { - try { - const result = JSON.parse(body) as ISearchResult - const message = typeof result === 'string' ? result : result.message - if (message) { - logger.error(`getSearchResults return error ${message}`) - reject(new Error(result.message)) - } - resolve(result) - } catch (e) { - // search might be overwhelmed - // pause before next request - logger.error(`getSearchResults parse error ${e} ${body}`) - setTimeout(() => { - reject(new Error(body)) - }, requestTimeout) - } - clearTimeout(id) - }) - }) - req.on('error', (e) => { - logger.error(`getSearchResults request error ${e.message}`) - reject(e) - }) - req.write(JSON.stringify(query)) - req.end() - }) -} - -const ping = { - operationName: 'searchResult', - variables: { - input: [ - { - filters: [ - { - property: 'kind', - values: ['Pod'], - }, - { - property: 'name', - values: ['search-api*'], - }, - ], - limit: 1, - }, - ], - }, - query: 'query searchResult($input: [SearchInput]) {\n searchResult: search(input: $input) {\n items\n }\n}', -} - -export async function pingSearchAPI() { - const options = await getServiceAccountSearchRequestOptions() - return new Promise((resolve, reject) => { - let body = '' - const id = setTimeout( - () => { - logger.error(`ping searchAPI timeout`) - reject(new Error('request timeout')) - }, - 4 * 60 * 1000 - ) - const req = request(options, (res) => { - res.on('data', (data) => { - body += data - }) - res.on('end', () => { - try { - const result = JSON.parse(body) as { data: unknown } - if (result.data) { - resolve(true) - } else { - reject(new Error('no data')) - } - } catch (e) { - logger.error(`pingSearchAPI parse error ${e} ${body}`) - reject(new Error(String(e).valueOf())) - } - clearTimeout(id) - }) - }) - req.on('error', (e) => { - reject(e) - }) - req.write(JSON.stringify(ping)) - req.end() - }) -} diff --git a/backend-node/src/lib/server-side-events.ts b/backend-node/src/lib/server-side-events.ts deleted file mode 100644 index f56757a39d1..00000000000 --- a/backend-node/src/lib/server-side-events.ts +++ /dev/null @@ -1,462 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import type { Transform } from 'node:stream' -import { clearInterval } from 'node:timers' -import type { Zlib } from 'node:zlib' -import { batchPromiseAll } from './batch-promise-all' -import { getEncodeStream, inflateEvent } from './compression' -import { setCookie } from './cookies' -import { logger } from './logger' -import { randomString } from './random-string' -import { getGiganticEvents } from './gigantic' -import { sizeOf } from '../routes/aggregators/utils' - -// TODO - RESET EVENT -// TODO BOOKMARK EVENT - -// If a client hasn't finished receiving a broadcast in PURGE_CLIENT_TIMEOUT -// assume the browser has been refreshed or closed -const PURGE_CLIENT_TIMEOUT = 30 * 60 * 1000 - -const instanceID = randomString(8) - -const { - HTTP2_HEADER_CONTENT_TYPE, - HTTP_STATUS_OK, - HTTP2_HEADER_CACHE_CONTROL, - HTTP2_HEADER_CONTENT_ENCODING, - HTTP2_HEADER_ACCEPT_ENCODING, -} = constants - -export interface ServerSideEvent { - id?: string - name?: string - namespace?: string - data?: DataT -} -export interface WatchEvent { - type: 'ADDED' | 'DELETED' | 'MODIFIED' | 'EOP' - object: { - kind: string - apiVersion: string - metadata: { - name: string - namespace: string - resourceVersion: string - } - } -} - -export interface ServerSideEventClient { - token: string - events?: Record - namespaces?: Record - writableStream: NodeJS.WritableStream - compressionStream: Transform & Zlib - eventQueue: Promise[] - processing?: NodeJS.Timeout -} - -export class ServerSideEvents { - private static eventID = 2 - private static lastLoadedID = 2 - private static events: Record = { - 1: { id: '1', data: { type: 'START' } }, - 2: { id: '2', data: { type: 'LOADED' } }, - } - private static clients: Record = {} - - public static eventFilter: (clientID: string, event: Readonly) => Promise - - public static async dispose(): Promise { - if (ServerSideEvents.intervalTimer !== undefined) { - clearInterval(ServerSideEvents.intervalTimer) - ServerSideEvents.intervalTimer = undefined - } - - for (const clientID in this.clients) { - const compressionStream = this.clients[clientID].compressionStream - if (compressionStream) compressionStream.end() - await new Promise((resolve) => this.clients[clientID]?.writableStream.end(resolve)) - } - - this.clients = {} - - return Promise.resolve() - } - - /** Reset all static state to initial values. Used for test isolation. */ - public static reset(): void { - this.eventID = 2 - this.lastLoadedID = 2 - this.events = { - 1: { id: '1', data: { type: 'START' } }, - 2: { id: '2', data: { type: 'LOADED' } }, - } - this.clients = {} - // Re-initialize interval timer if it was disposed - this.intervalTimer ??= setInterval(() => { - ServerSideEvents.keepAlivePing() - }, 10 * 1000) - } - - public static async pushEvent(event: ServerSideEvent): Promise { - const eventID = ++this.eventID - event.id = eventID.toString() - this.events[eventID] = event - await this.broadcastEvent(event) - - this.removeEvent(this.lastLoadedID) - this.lastLoadedID = ++this.eventID - const loadedEvent = { - id: this.lastLoadedID.toString(), - data: { type: 'LOADED' }, - } - this.events[this.lastLoadedID] = loadedEvent - await this.broadcastEvent(loadedEvent) - - return eventID - } - - private static async broadcastEvent(event: ServerSideEvent): Promise { - for (const clientID in this.clients) { - await this.sendEvent(clientID, event) - } - } - - private static async sendEvent(clientID: string, event: ServerSideEvent): Promise { - const client = this.clients[clientID] - if (!client) return - if (client.events && !client.events[event.name]) return - if (client.namespaces && !client.namespaces[event.namespace]) return - event = await inflateEvent(event) - if (this.eventFilter) { - client.eventQueue.push( - this.eventFilter(client.token, event) - .then((shouldSendEvent) => (shouldSendEvent ? event : undefined)) - .catch((): undefined => undefined) - ) - } else { - client.eventQueue.push(Promise.resolve(event)) - } - void this.processClient(clientID) - } - - private static async processClient(clientID: string): Promise { - const client = this.clients[clientID] - if (!client) return - if (client.processing) return - // we will deactivate this browser's updates - // if it hasn't accepted new stream data for - // PURGE_CLIENT_TIMEOUT - client.processing = setTimeout(() => { - delete this.clients[clientID] - }, PURGE_CLIENT_TIMEOUT) - while (client.eventQueue.length) { - try { - const event = await client.eventQueue.shift() - if (event) { - const eventString = this.createEventString(event) - - if (client?.compressionStream) { - const writeResult = client.compressionStream.write(eventString, 'utf8') - if (!writeResult) await new Promise((resolve) => client.compressionStream.once('drain', resolve)) - } else if (client?.writableStream) { - const writeResult = client.writableStream.write(eventString, 'utf8') - if (!writeResult) await new Promise((resolve) => client.writableStream.once('drain', resolve)) - } - - const watchEvent = event.data as { - type: string - object: { - apiVersion: string - kind: string - metadata: { name: string; namespace: string } - } - } - - if (watchEvent?.object) { - const { kind, metadata } = watchEvent.object - const name = metadata?.name - const namespace = metadata?.namespace - if (process.env.LOG_EVENTS === 'true') { - logger.debug({ msg: 'event', type: watchEvent.type, kind, name, namespace }) - } - } - } - } catch (err) { - logger.error(err) - } - } - try { - if (client.compressionStream) client.compressionStream.flush() - } catch (err) { - logger.error(err) - } - clearTimeout(client.processing) - delete client.processing - } - - private static createEventString(event: ServerSideEvent): string { - let eventString = `id:${event.id}\n` - if (event.name) eventString += `event:${event.name}\n` - switch (typeof event.data) { - case 'string': - case 'number': - case 'bigint': - eventString += `data:${event.data}\n` - break - case 'boolean': - eventString += `data:${event.data ? 'true' : 'false'}\n` - break - case 'object': - try { - eventString += `data:${JSON.stringify(event.data)}\n` - } catch (err) { - logger.error(err) - return '' - } - break - default: - return '' - } - eventString += '\n' - return eventString - } - - public static removeEvent(eventID: number): void { - delete this.events[eventID] - } - - public static getClients() { - return this.clients - } - - public static getEvents() { - return this.events - } - - public static async handleRequest( - token: string, - req: Http2ServerRequest, - res: Http2ServerResponse - ): Promise { - const [writableStream, compressionStream, encoding] = getEncodeStream( - res, - req.headers[HTTP2_HEADER_ACCEPT_ENCODING], - process.env.DISABLE_STREAM_COMPRESSION === 'true' - ) - - let events: Record - let namespaces: Record - const queryStringIndex = req.url.indexOf('?') - if (queryStringIndex !== -1) { - const queryString = req.url.substr(queryStringIndex + 1) - const parts = queryString.split('&') - for (const part of parts) { - if (part.startsWith('events=')) { - events = part - .substr(7) - .split(',') - .reduce( - (events, event) => { - events[event] = true - return events - }, - {} as Record - ) - } else if (part.startsWith('namespaces=')) { - namespaces = part - .substr(11) - .split(',') - .reduce( - (namespaces, namespace) => { - namespaces[namespace] = true - return namespaces - }, - {} as Record - ) - } - } - } - const eventClient: ServerSideEventClient = { - token, - events, - namespaces, - writableStream, - compressionStream, - eventQueue: [], - } - const clientID = randomString(8) - this.clients[clientID] = eventClient - - res.setTimeout(2147483647) - req.setTimeout(2147483647) - - setCookie(res, 'watch', instanceID) - - res.writeHead(HTTP_STATUS_OK, { - [HTTP2_HEADER_CONTENT_TYPE]: 'text/event-stream', - [HTTP2_HEADER_CACHE_CONTROL]: 'no-store, no-transform', - [HTTP2_HEADER_CONTENT_ENCODING]: encoding, - }) - res.on('close', () => { - if (this.clients[clientID]?.writableStream === writableStream) { - delete this.clients[clientID] - } - - logger.info({ msg: 'event stream close' }) - }) - - // SORT EVENTS INTO SMALLER PACKETS - // SO THAT BROWSER PAGE LOADS QUICKER - // uncompress and split events into packets - const values = Object.values(this.events) - const compressed = sizeOf(values) - let parts = await batchPromiseAll(values, (event) => inflateEvent(event)) - - // mock a large environment - if (process.env.MOCK_CLUSTERS) { - const loaded = parts.pop() - parts = [...parts, ...getGiganticEvents()] - parts.push(loaded) - } - - // remove START, SETTINGS and LOADED from events - const start = parts.shift() - const end = parts.pop() - const inx = parts.findIndex(({ data }) => { - return (data as { type?: 'SETTINGS' }).type === 'SETTINGS' - }) - const settings = parts.splice(inx, 1)[0] - - // separate resource by kind - // we want to send the resources that populate the main console pages first - // then send the details 2nd - const clusters: ServerSideEvent[] = [] - const policies: ServerSideEvent[] = [] - const agents: ServerSideEvent[] = [] - const infos: ServerSideEvent[] = [] - const addons: ServerSideEvent[] = [] - const rbac: ServerSideEvent[] = [] - const other: ServerSideEvent[] = [] - const remainder: ServerSideEvent[] = [] - parts.forEach((event) => { - const data = event.data as WatchEvent - // see frontend/src/components/LoadPluginData.tsx for what pages are fast loaded - switch (data.object.kind) { - case 'ManagedCluster': - case 'HostedCluster': - case 'ClusterDeployment': - case 'ManagedClusterSet': - clusters.push(event) - break - case 'Policy': - case 'PolicySet': - policies.push(event) - break - case 'AgentClusterInstall': - agents.push(event) - break - case 'ManagedClusterInfo': - infos.push(event) - break - case 'ManagedClusterAddOn': - addons.push(event) - break - case 'MulticlusterRoleAssignment': - case 'User': - case 'Group': - rbac.push(event) - break - case 'Search': - case 'Secret': - other.push(event) - break - default: - remainder.push(event) - break - } - }) - - // sort events alphabetically so that browser list fills from top to bottom - const compareFn = - (propName: 'name' | 'namespace') => (a: ServerSideEvent, b: ServerSideEvent) => { - const adata = a.data as WatchEvent - const bdata = b.data as WatchEvent - return adata.object.metadata[propName].localeCompare(bdata.object.metadata[propName]) - } - clusters.sort(compareFn('name')) - infos.sort(compareFn('namespace')) - policies.sort(compareFn('name')) - addons.sort(compareFn('namespace')) - rbac.sort(compareFn('name')) - - // send packets of resources - // with resources that fill main console pages first - let sentCount = 0 - const sending = [start, settings] - do { - sending.push(...clusters.splice(0, 200)) - sending.push(...agents.splice(0, 200)) - sending.push(...infos.splice(0, 200)) - sending.push(...policies.splice(0, 200)) - sending.push(...addons.splice(0, 400)) - sending.push(...rbac.splice(0, 200)) - sending.push(...other.splice(0, 100)) - - // EOP tells browser (LoadData) to process and recoil resources that have been sent so far - sending.push({ id: '999999', data: { type: 'EOP' } }) // END OF PACKET - } while ( - clusters.length || - policies.length || - addons.length || - infos.length || - agents.length || - rbac.length || - other.length - ) - - // send the remaining resources - do { - sending.push(...remainder.splice(0, 1978)) - } while (remainder.length) - sending.push(end) - await Promise.all( - sending.map((event) => { - const promise = this.sendEvent(clientID, event) - sentCount++ - return promise - }) - ) - const uncompressed = sizeOf(sending) - - logger.info({ msg: 'event stream start', events: sentCount, compression: 100 - (compressed / uncompressed) * 100 }) - - return eventClient - } - - private static keepAlivePing(): void { - for (const clientID in this.clients) { - const client = this.clients[clientID] - if (client?.compressionStream) { - try { - client.compressionStream.write(':\n\n') - client.compressionStream.flush() - } catch (err) { - logger.error(err) - } - } else if (client?.writableStream) { - try { - client.writableStream.write(':\n\n') - } catch (err) { - logger.error(err) - } - } - } - } - private static intervalTimer: NodeJS.Timeout | undefined = setInterval(() => { - ServerSideEvents.keepAlivePing() - }, 10 * 1000) -} diff --git a/backend-node/src/lib/server.ts b/backend-node/src/lib/server.ts deleted file mode 100644 index e3b95d361fd..00000000000 --- a/backend-node/src/lib/server.ts +++ /dev/null @@ -1,197 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -/* istanbul ignore file */ -import type { Http2Server, Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants, createSecureServer, createServer } from 'node:http2' - -import type { Socket } from 'node:net' -import type { TLSSocket } from 'node:tls' -import { logger } from './logger' -import { readFileSync } from 'node:fs' -import { certFile } from './paths' - -let server: Http2Server | undefined - -interface ISocketRequests { - socketID: number - activeRequests: number -} - -let nextSocketID = 0 -const sockets: { [id: string]: Socket | TLSSocket | undefined } = {} - -export type ServerOptions = { - requestHandler: - | ((req: Http2ServerRequest, res: Http2ServerResponse) => void) - | ((req: Http2ServerRequest, res: Http2ServerResponse) => Promise) - logRequest?: (req: Http2ServerRequest, res: Http2ServerResponse) => void -} - -export function startServer(options: ServerOptions): Promise { - isStopping = false - let cert: Buffer | undefined - let key: Buffer | undefined - try { - cert = readFileSync(certFile('tls.crt')) - key = readFileSync(certFile('tls.key')) - } catch (err) { - logger.error({ msg: 'no certs' }) - } - - try { - if (cert && key) { - logger.info({ msg: `server start`, secure: true, options }) - server = createSecureServer({ cert, key, allowHTTP1: true, ...options }, options.requestHandler) - } else { - logger.info({ msg: `server start`, secure: false }) - server = createServer(options.requestHandler) - } - return new Promise((resolve, reject) => { - server - ?.listen(process.env.PORT, () => { - const address = server?.address() - if (address == null) { - logger.debug({ msg: `server listening` }) - } else if (typeof address === 'string') { - logger.debug({ msg: `server listening`, address }) - } else { - logger.debug({ msg: `server listening`, port: address.port }) - } - resolve(server) - }) - .on('connection', (socket: Socket) => { - let socketID = nextSocketID++ - while (sockets[socketID] !== undefined) { - socketID = nextSocketID++ - } - sockets[socketID] = socket - ;(socket as unknown as ISocketRequests).socketID = socketID - ;(socket as unknown as ISocketRequests).activeRequests = 0 - socket.on('close', () => { - const socketID = (socket as unknown as ISocketRequests).socketID - if (socketID < nextSocketID) nextSocketID = socketID - sockets[socketID] = undefined - }) - }) - .on('request', (req: Http2ServerRequest, res: Http2ServerResponse) => { - if (isStopping) { - res.setHeader(constants.HTTP2_HEADER_CONNECTION, 'close') - } - - const start = process.hrtime() - const socket = req.socket as unknown as ISocketRequests - socket.activeRequests++ - req.on('close', () => { - socket.activeRequests-- - if (isStopping) { - req.socket.destroy() - } - - if (options.logRequest) { - options.logRequest(req, res) - } else { - if (req.url === '/readinessProbe') return - if (req.url === '/livenessProbe') return - - let logTrace = false - if ( - req.url === '/authenticated' || - req.url === '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews' - ) { - logTrace = true - } - - let msg: Record - if (res.getHeader('content-type') !== 'text/event-stream') - msg = { - msg: req.method.toLowerCase(), - path: req.url, - status: res.statusCode, - ms: 0, - } - - const diff = process.hrtime(start) - const time = Math.round((diff[0] * 1e9 + diff[1]) / 10000) / 100 - msg.ms = time - - if (logTrace) { - logger.trace(msg) - } else if (res.statusCode < 500) { - logger.debug(msg) - } else { - logger.error(msg) - } - } - }) - }) - .on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - logger.error({ - msg: `server error`, - error: 'address already in use', - port: Number(process.env.PORT), - }) - reject(new Error('address already in use')) - } else { - logger.error({ msg: `server error`, error: err.message }) - } - if (server?.listening) server.close() - }) - }) - } catch (err) { - if (err instanceof Error) { - logger.error({ msg: `server start error`, error: err.message, stack: err.stack }) - } else { - logger.error({ msg: `server start error` }) - } - void stopServer() - return Promise.resolve(undefined) - } -} - -let isStopping = false - -export async function stopServer(): Promise { - if (isStopping) return - isStopping = true - - for (const socketID of Object.keys(sockets)) { - const socket = sockets[socketID] - if (socket !== undefined) { - if ((socket as unknown as ISocketRequests).activeRequests === 0) { - socket.destroy() - } - } - } - - if (server?.listening) { - logger.info({ msg: 'closing server' }) - if (process.env.NODE_ENV === 'production') { - logger.info({ msg: 'waiting 25 seconds before closing the server' }) - await new Promise((resolve) => - setTimeout( - () => - server?.close((err: Error | undefined) => { - if (err) { - logger.error({ msg: 'server close error', name: err.name, error: err.message }) - } else { - logger.debug({ msg: 'server closed' }) - } - resolve() - }), - 25 * 1000 - ) - ) - } else { - await new Promise((resolve) => - server?.close((err: Error | undefined) => { - if (err) { - logger.error({ msg: 'server close error', name: err.name, error: err.message }) - } else { - logger.debug({ msg: 'server closed' }) - } - resolve() - }) - ) - } - } -} diff --git a/backend-node/src/lib/serviceAccountToken.ts b/backend-node/src/lib/serviceAccountToken.ts deleted file mode 100644 index a8312a867c2..00000000000 --- a/backend-node/src/lib/serviceAccountToken.ts +++ /dev/null @@ -1,119 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { rootCertificates } from 'node:tls' -import { logger } from './logger' -import { watchFile } from './fileWatch' - -const SERVICE_ACCOUNT_BASE_PATH = '/var/run/secrets/kubernetes.io/serviceaccount' - -function getServiceAccountFilePath(name: string): string { - return join(SERVICE_ACCOUNT_BASE_PATH, name) -} - -/** - * Reads a service account file and sets up a watch; when the file changes, - * onChange is called. By default the watch expires after the first change (persist: false). - */ -export function watchServiceAccountFile( - name: string, - defaultValue: string, - exitOnError?: boolean, - onChange?: () => void -): string { - const filePath = getServiceAccountFilePath(name) - if (onChange && process.env.NODE_ENV !== 'development') { - watchFile(filePath, onChange) - } - return readFileOrUseDefault(filePath, defaultValue, exitOnError) -} - -function readFileOrUseDefault(filePath: string, defaultValue: string, exitOnError?: boolean): string { - let value: string - try { - value = readFileSync(filePath, 'utf-8') - } catch (err: unknown) { - value = defaultValue - /* istanbul ignore if */ - if (!value) { - const msg = `Error reading file ${filePath}` - if (err instanceof Error) { - logger.error(msg, err?.message) - } else { - logger.error({ msg, err }) - } - if (exitOnError) { - process.exit(1) - } - } - } - return value -} - -let serviceAccountToken: string -export function getServiceAccountToken(): string { - if (serviceAccountToken === undefined) { - serviceAccountToken = watchServiceAccountFile('token', process.env.TOKEN, true, () => { - serviceAccountToken = undefined - }) - } - return serviceAccountToken -} - -let namespace: string -export function getNamespace(): string { - if (namespace === undefined) { - const potentialNamespace = watchServiceAccountFile('namespace', process.env.SEARCH_API_URL, undefined, () => { - namespace = undefined - }) - if (potentialNamespace !== process.env.SEARCH_API_URL) { - namespace = potentialNamespace - } - } - return namespace -} - -function base64DecodeValue(value: string): string { - return value ? Buffer.from(value, 'base64').toString('ascii') : undefined -} - -type Certificates = string | string[] - -function getCertificate( - name: string, - base64DefaultValue: string, - includeRoot?: boolean, - onChange?: () => void -): Certificates { - const internal_cert = watchServiceAccountFile(name, base64DecodeValue(base64DefaultValue), undefined, onChange) - return [internal_cert, ...(includeRoot ? rootCertificates : [])] // include root certificates in addition to internal cluster certificates -} - -let ca_cert: Certificates -export function getCACertificate(onChange?: () => void): Certificates { - if (ca_cert === undefined) { - ca_cert = getCertificate('ca.crt', process.env.CA_CERT, true, () => { - ca_cert = undefined - onChange?.() - }) - } - return ca_cert -} - -let service_ca_cert: Certificates -export function getServiceCACertificate(onChange?: () => void): Certificates { - if (service_ca_cert === undefined) { - // in dev mode, connections to Services need to be proxied via Routes, so they need root certificates - service_ca_cert = getCertificate( - 'service-ca.crt', - process.env.SERVICE_CA_CERT, - process.env.NODE_ENV !== 'production', - () => { - service_ca_cert = undefined - onChange?.() - } - ) - } - return service_ca_cert -} diff --git a/backend-node/src/lib/tlsProfileWatch.ts b/backend-node/src/lib/tlsProfileWatch.ts deleted file mode 100644 index 83f4c1638d8..00000000000 --- a/backend-node/src/lib/tlsProfileWatch.ts +++ /dev/null @@ -1,356 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import got, { HTTPError, TimeoutError } from 'got' -import { getCiphers, type SecureContextOptions, type SecureVersion } from 'node:tls' -import { pipeline } from 'node:stream/promises' -import { Transform } from 'node:stream' -import { logger } from './logger' -import { getCACertificate, getServiceAccountToken } from './serviceAccountToken' -import { getFips } from 'node:crypto' - -type TLSProfileType = 'Old' | 'Intermediate' | 'Modern' | 'Custom' -type TLSVersionValue = 'VersionTLS10' | 'VersionTLS11' | 'VersionTLS12' | 'VersionTLS13' - -const TLS_VERSION_MAP: Record = { - VersionTLS10: 'TLSv1', - VersionTLS11: 'TLSv1.1', - VersionTLS12: 'TLSv1.2', - VersionTLS13: 'TLSv1.3', -} - -type TLSProfileSpec = { - minTLSVersion?: TLSVersionValue - ciphers?: string[] - groups?: string[] -} - -type TLSSecurityProfile = { - type?: TLSProfileType - custom?: TLSProfileSpec - old?: Record - intermediate?: Record - modern?: Record -} - -type APIServerSpec = { - tlsSecurityProfile?: TLSSecurityProfile -} - -type APIServer = { - apiVersion: 'config.openshift.io/v1' - kind: 'APIServer' - metadata?: { - name: string - resourceVersion?: string - } - spec: APIServerSpec -} - -// In FIPS mode, X25519MLKEM768 and X25519 are not approved and must be excluded. -const ALL_ECDH_CURVES = [ - 'X25519', - 'secp256r1', - 'secp384r1', - 'secp521r1', - 'X25519MLKEM768', - 'SecP256r1MLKEM768', - 'SecP384r1MLKEM1024', -] -const FIPS_ECDH_CURVES = ['secp256r1', 'secp384r1', 'secp521r1', 'SecP256r1MLKEM768', 'SecP384r1MLKEM1024'] - -/** - * Built-in TLS security profiles for OpenShift API servers. - * List of ciphers and minimum TLS version for each built-inprofile can be obtained from the following command: - * ``` - * oc explain apiserver.spec.tlsSecurityProfile. - * ``` - */ -const BUILTIN_SPECS: Record = { - Old: { - minTLSVersion: 'VersionTLS10', - ciphers: [ - 'TLS_AES_128_GCM_SHA256', - 'TLS_AES_256_GCM_SHA384', - 'TLS_CHACHA20_POLY1305_SHA256', - 'ECDHE-ECDSA-AES128-GCM-SHA256', - 'ECDHE-RSA-AES128-GCM-SHA256', - 'ECDHE-ECDSA-AES256-GCM-SHA384', - 'ECDHE-RSA-AES256-GCM-SHA384', - 'ECDHE-ECDSA-CHACHA20-POLY1305', - 'ECDHE-RSA-CHACHA20-POLY1305', - 'DHE-RSA-AES128-GCM-SHA256', - 'DHE-RSA-AES256-GCM-SHA384', - 'DHE-RSA-CHACHA20-POLY1305', - 'ECDHE-ECDSA-AES128-SHA256', - 'ECDHE-RSA-AES128-SHA256', - 'ECDHE-ECDSA-AES128-SHA', - 'ECDHE-RSA-AES128-SHA', - 'ECDHE-ECDSA-AES256-SHA384', - 'ECDHE-RSA-AES256-SHA384', - 'ECDHE-ECDSA-AES256-SHA', - 'ECDHE-RSA-AES256-SHA', - 'DHE-RSA-AES128-SHA256', - 'DHE-RSA-AES256-SHA256', - 'AES128-GCM-SHA256', - 'AES256-GCM-SHA384', - 'AES128-SHA256', - 'AES256-SHA256', - 'AES128-SHA', - 'AES256-SHA', - 'DES-CBC3-SHA', - ], - groups: ['X25519MLKEM768', 'X25519', 'secp256r1', 'secp384r1'], - }, - Intermediate: { - minTLSVersion: 'VersionTLS12', - ciphers: [ - 'TLS_AES_128_GCM_SHA256', - 'TLS_AES_256_GCM_SHA384', - 'TLS_CHACHA20_POLY1305_SHA256', - 'ECDHE-ECDSA-AES128-GCM-SHA256', - 'ECDHE-RSA-AES128-GCM-SHA256', - 'ECDHE-ECDSA-AES256-GCM-SHA384', - 'ECDHE-RSA-AES256-GCM-SHA384', - 'ECDHE-ECDSA-CHACHA20-POLY1305', - 'ECDHE-RSA-CHACHA20-POLY1305', - 'DHE-RSA-AES128-GCM-SHA256', - 'DHE-RSA-AES256-GCM-SHA384', - ], - groups: ['X25519MLKEM768', 'X25519', 'secp256r1', 'secp384r1'], - }, - Modern: { - minTLSVersion: 'VersionTLS13', - ciphers: ['TLS_AES_128_GCM_SHA256', 'TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256'], - groups: ['X25519MLKEM768', 'X25519', 'secp256r1', 'secp384r1'], - }, -} - -function toNodeTLSOptions(spec?: TLSSecurityProfile): SecureContextOptions { - const securityProfileSpec = - spec?.type === 'Custom' && spec.custom - ? spec.custom - : (BUILTIN_SPECS[spec?.type ?? 'Intermediate'] ?? BUILTIN_SPECS['Intermediate']) - const minVersion = TLS_VERSION_MAP[securityProfileSpec.minTLSVersion ?? 'VersionTLS12'] - const supportedCiphers = getCiphers() - // Per OpenShift API: if the profile is custom and the minimum TLS version is TLSv1.3, custom ciphers are not allowed - // Use the modern ciphers if none are set. - const defaultCiphers = spec?.type === 'Custom' && minVersion === 'TLSv1.3' ? BUILTIN_SPECS['Modern'].ciphers : [] - const potentialCiphers = securityProfileSpec.ciphers?.length > 0 ? securityProfileSpec.ciphers : defaultCiphers - const ciphers = potentialCiphers?.filter((c) => supportedCiphers.includes(c.toLowerCase())).join(':') - // If no groups are set, explicitly set ECDH curves to enable PQC (X25519MLKEM768). - // The default image crypto policy (/etc/crypto-policies/config) does not include them - const potentialGroups = - securityProfileSpec.groups?.length > 0 ? securityProfileSpec.groups : BUILTIN_SPECS['Intermediate'].groups - const ecdhCurve = potentialGroups - ?.filter((g) => (getFips() !== 0 ? FIPS_ECDH_CURVES : ALL_ECDH_CURVES).includes(g)) - .join(':') - return { minVersion, ciphers, ecdhCurve } -} - -const API_PATH = '/apis/config.openshift.io/v1/apiservers' -const FIELD_SELECTOR = 'fieldSelector=metadata.name=cluster' - -let stoppingTLSProfileWatch = false -let currentTLSOptions: SecureContextOptions | undefined -let activeRequest: { destroy: () => void } | undefined - -export function watchTLSSecurityProfile(onProfileChange: (opts: SecureContextOptions) => Promise): () => void { - stoppingTLSProfileWatch = false - currentTLSOptions = undefined - void listAndWatchTLSProfile(onProfileChange) - return () => { - stoppingTLSProfileWatch = true - activeRequest?.destroy() - } -} - -async function listAndWatchTLSProfile(onProfileChange: (opts: SecureContextOptions) => Promise): Promise { - while (!stoppingTLSProfileWatch) { - try { - const serviceAccountToken = getServiceAccountToken() - const { resourceVersion } = await listTLSProfile(serviceAccountToken, onProfileChange) - await watchTLSProfileStream(serviceAccountToken, resourceVersion, onProfileChange) - } catch (err: unknown) { - if (stoppingTLSProfileWatch) break - await handleWatchError(err) - } - } -} - -async function handleWatchError(err: unknown): Promise { - if (err instanceof SyntaxError) { - // Non-JSON response (e.g. stale resource version) — retry immediately - } else if (err instanceof HTTPError) { - switch (err.response.statusCode) { - case 403: - logger.error({ msg: 'TLS profile watch', status: 'Forbidden' }) - break - case 404: - logger.trace({ msg: 'TLS profile watch', status: 'Not found' }) - break - default: - logger.error({ msg: 'TLS profile watch error', error: err.message }) - } - await new Promise((resolve) => setTimeout(resolve, 60_000 + Math.ceil(Math.random() * 10_000)).unref()) - } else if (err instanceof Error) { - if (err.message === 'Premature close' || err.message.startsWith('too old resource version')) { - // Retry list and watch immediately - } else { - logger.error({ msg: 'TLS profile watch error', error: err.message }) - await new Promise((resolve) => setTimeout(resolve, 60_000 + Math.ceil(Math.random() * 10_000)).unref()) - } - } else { - logger.error({ msg: 'TLS profile watch error', error: JSON.stringify(err) }) - await new Promise((resolve) => setTimeout(resolve, 60_000 + Math.ceil(Math.random() * 10_000)).unref()) - } -} - -async function listTLSProfile( - serviceAccountToken: string, - onProfileChange: (opts: SecureContextOptions) => Promise -): Promise<{ resourceVersion: string }> { - const url = `${process.env.CLUSTER_API_URL}${API_PATH}?${FIELD_SELECTOR}` - const response = await got - .get(url, { - headers: { authorization: `Bearer ${serviceAccountToken}` }, - https: { certificateAuthority: getCACertificate() }, - }) - .json<{ - metadata: { resourceVersion: string } - items: APIServer[] - }>() - - if (response.items?.length > 0) { - const opts = toNodeTLSOptions(response.items[0].spec?.tlsSecurityProfile) - await applyIfChanged(opts, onProfileChange) - } - - return { resourceVersion: response.metadata.resourceVersion } -} - -async function watchTLSProfileStream( - serviceAccountToken: string, - initialResourceVersion: string, - onProfileChange: (opts: SecureContextOptions) => Promise -): Promise { - const resourceVersionRef = { value: initialResourceVersion } - - while (!stoppingTLSProfileWatch) { - const url = - `${process.env.CLUSTER_API_URL}${API_PATH}` + - `?watch&allowWatchBookmarks&${FIELD_SELECTOR}&resourceVersion=${resourceVersionRef.value}` - const request = got.stream(url, { - headers: { authorization: `Bearer ${serviceAccountToken}` }, - https: { certificateAuthority: getCACertificate() }, - timeout: { socket: 5 * 60 * 1000 + Math.ceil(Math.random() * 10 * 1000) }, - }) - activeRequest = request - try { - await pipeline(request, createSplitStream(), createTLSWatchProcessor(resourceVersionRef, onProfileChange)) - } catch (err: unknown) { - if (err instanceof TimeoutError) { - // Socket timeout — retry the watch - } else if (err instanceof HTTPError) { - throw err - } else if ((err as Error)?.message === 'Premature close') { - // Stream destroyed or connection lost — retry the watch - } else { - throw err - } - } finally { - if (activeRequest === request) activeRequest = undefined - } - } -} - -function createSplitStream(): Transform { - let buffer = '' - return new Transform({ - objectMode: true, - transform(chunk: Buffer, _encoding, callback) { - buffer += chunk.toString() - const lines = buffer.split('\n') - buffer = lines.pop() || '' - for (const line of lines) { - if (line.trim()) { - this.push(line) - } - } - callback() - }, - flush(callback) { - if (buffer.trim()) { - this.push(buffer) - } - callback() - }, - }) -} - -function createTLSWatchProcessor( - resourceVersionRef: { value: string }, - onProfileChange: (opts: SecureContextOptions) => Promise -): Transform { - return new Transform({ - objectMode: true, - async transform(data: string, _encoding, callback): Promise { - try { - const event = JSON.parse(data) as { - type: string - object: APIServer & { message?: string } - } - - const resourceVersion = event.object?.metadata?.resourceVersion - if (resourceVersion) { - resourceVersionRef.value = resourceVersion - } - - switch (event.type) { - case 'ADDED': - case 'MODIFIED': - if (event.object?.spec) { - await applyIfChanged(toNodeTLSOptions(event.object.spec.tlsSecurityProfile), onProfileChange) - } - break - case 'BOOKMARK': - break - case 'ERROR': - logger.warn({ msg: 'TLS profile watch error event', message: event.object?.message }) - throw new Error(event.object?.message ?? 'Unknown watch error') - } - - callback() - } catch (err: unknown) { - const error = err instanceof Error ? err : new Error(JSON.stringify(err)) - callback(error) - } - }, - }) -} - -async function applyIfChanged( - opts: SecureContextOptions, - onProfileChange: (opts: SecureContextOptions) => Promise -): Promise { - if ( - currentTLSOptions?.minVersion === opts.minVersion && - currentTLSOptions?.ciphers === opts.ciphers && - currentTLSOptions?.ecdhCurve === opts.ecdhCurve - ) { - logger.debug({ - msg: 'TLS security profile unchanged', - minVersion: opts.minVersion, - ciphers: opts.ciphers, - ecdhCurve: opts.ecdhCurve, - }) - return - } - logger.info({ - msg: 'TLS security profile changed', - minVersion: opts.minVersion, - ciphers: opts.ciphers, - ecdhCurve: opts.ecdhCurve, - }) - currentTLSOptions = opts - await onProfileChange(opts) -} diff --git a/backend-node/src/lib/token.ts b/backend-node/src/lib/token.ts deleted file mode 100644 index 198159984ab..00000000000 --- a/backend-node/src/lib/token.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import { parseCookies } from '../lib/cookies' -import { fetchRetry } from '../lib/fetch-retry' -import { unauthorized } from './respond' -import { LocalStorage } from 'node-localstorage' -import type { TLSSocket } from 'node:tls' -import { certsDir } from './paths' - -const { HTTP2_HEADER_AUTHORIZATION } = constants - -const ADMIN_TOKEN = 'admin-token' - -export function getToken(req: Http2ServerRequest): string | undefined { - let token = parseCookies(req)['acm-access-token-cookie'] - if (!token) { - const authorizationHeader = req.headers[HTTP2_HEADER_AUTHORIZATION] - if (typeof authorizationHeader === 'string' && authorizationHeader.startsWith('Bearer ')) { - token = authorizationHeader.slice(7) - } - } - /* istanbul ignore if */ - if (!token && process.env.NODE_ENV === 'development') { - const localStorage = new LocalStorage(certsDir()) - token = localStorage.getItem(ADMIN_TOKEN) - } - return token -} - -// GET /api returns the core API group (~200 bytes) — unlike /apis which grows -// with every installed CRD. The response body is drained so the socket returns -// to the keepAlive pool immediately and native memory does not accumulate. -export async function isAuthenticated(token: string): Promise { - const response = await fetchRetry(process.env.CLUSTER_API_URL + '/api', { - headers: { [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${token}` }, - }) - response.body?.on('error', () => undefined).resume() - return response.status -} - -export const isHttp2ServerResponse = ( - resOrSocket: Http2ServerResponse | TLSSocket -): resOrSocket is Http2ServerResponse => 'socket' in resOrSocket - -export async function getAuthenticatedToken(req: Http2ServerRequest, res: Http2ServerResponse): Promise -export async function getAuthenticatedToken(req: Http2ServerRequest, socket: TLSSocket): Promise -export async function getAuthenticatedToken( - req: Http2ServerRequest, - resOrSocket: Http2ServerResponse | TLSSocket -): Promise -export async function getAuthenticatedToken( - req: Http2ServerRequest, - resOrSocket: Http2ServerResponse | TLSSocket -): Promise { - const token = getToken(req) - - if (token) { - const status = await isAuthenticated(token) - /* istanbul ignore if */ - if (status === constants.HTTP_STATUS_OK) { - if (process.env.NODE_ENV === 'development') { - const localStorage = new LocalStorage(certsDir()) - localStorage.setItem(ADMIN_TOKEN, token) - } - return token - } - if (isHttp2ServerResponse(resOrSocket)) { - resOrSocket.writeHead(status).end() - } else { - resOrSocket.destroy() - } - } else if (isHttp2ServerResponse(resOrSocket)) { - unauthorized(req, resOrSocket) - } else { - resOrSocket.destroy() - } - throw new Error('Unauthenticated request') -} diff --git a/backend-node/src/resources/resource-list.ts b/backend-node/src/resources/resource-list.ts deleted file mode 100644 index 14e29f96a4e..00000000000 --- a/backend-node/src/resources/resource-list.ts +++ /dev/null @@ -1,16 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -export interface ResourceList { - apiversion: string - kind: string - metadata?: { - name: string - namespace?: string - resourceVersion?: string - managedFields?: unknown - selfLink?: string - uid?: string - labels?: Record - } - items: T[] -} diff --git a/backend-node/src/resources/resource.ts b/backend-node/src/resources/resource.ts deleted file mode 100644 index bb5f5e0c3ce..00000000000 --- a/backend-node/src/resources/resource.ts +++ /dev/null @@ -1,237 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -export interface ISearchResource { - _uid?: string - _hostingResource?: string - _relatedUids?: string[] - _hostingSubscription?: boolean - apigroup: string - apiversion: string - kind: string - name: string - namespace: string - cluster: string - label?: string - created: string - applicationSet?: string - type?: string - status?: string - current?: string - desired?: string - available?: string - ready?: string - healthStatus?: string - syncStatus?: string - deployments?: ISearchResource[] -} - -export type SearchResult = { - items: ISearchResource[] - related: { - kind: string - items: ISearchResource[] - }[] -} - -interface OwnerReference { - apiVersion: string - blockOwnerDeletion?: boolean - controller?: boolean - kind: string - name: string - uid?: string -} -export interface IResource { - kind: string - apiVersion: string - metadata?: { - name: string - namespace?: string - resourceVersion?: string - managedFields?: unknown - selfLink?: string - uid?: string - labels?: Record - annotations?: Record - ownerReferences?: OwnerReference[] - creationTimestamp?: string | number | Date - } -} - -export type Cluster = { - name: string - kubeApiServer?: string - consoleUrl?: string -} -export interface ClusterDeployment extends IResource { - spec?: { - // from ClusterDeployment - baseDomain?: string - clusterName?: string - clusterInstallRef?: { - group: string - kind: string - version: string - name: string - } - } - status: { - webConsoleURL?: string - // from ClusterDeployment - apiURL?: string - cluster?: string - } -} -export interface ManagedCluster extends IResource { - status: { - clusterClaims: { - name: string - value: string - }[] - } -} -export interface ManagedClusterInfo extends IResource { - spec?: { - masterEndpoint: string - } - status: { - consoleURL?: string - } -} -export interface HostedClusterK8sResource extends IResource { - spec?: { - masterEndpoint: string - dns: { - baseDomain: string - } - } -} - -export interface IResourceDefinition { - apiVersion: string - kind: string -} -export interface IPlacementDecision extends IResource { - status?: { - decisions?: [{ clusterName: string }] - } -} -export interface ISubscription extends IResource { - spec?: { - placement?: { - placementRef?: { - name: string - } - } - } - status?: { - decisions?: [{ clusterName: string }] - } -} - -export const ApplicationApiVersion = 'app.k8s.io/v1beta1' -export type ApplicationApiVersionType = 'app.k8s.io/v1beta1' - -export const ApplicationKind = 'Application' -export type ApplicationKindType = 'Application' - -export const ApplicationDefinition: IResourceDefinition = { - apiVersion: ApplicationApiVersion, - kind: ApplicationKind, -} - -export const ArgoApplicationApiVersion = 'argoproj.io/v1alpha1' -export type ArgoApplicationApiVersionType = 'argoproj.io/v1alpha1' - -export const ArgoApplicationKind = 'Application' -export type ArgoApplicationKindType = 'Application' - -export const ArgoApplicationDefinition: IResourceDefinition = { - apiVersion: ArgoApplicationApiVersion, - kind: ArgoApplicationKind, -} -export interface IArgoApplication extends IResource { - cluster?: string - spec: { - source?: { - path?: string - repoURL: string - targetRevision?: string - chart?: string - } - destination: { - name?: string - namespace: string - server?: string - } - } - status?: { - cluster?: string - decisions?: [{ clusterName: string }] - } -} - -export const ApplicationSetApiVersion = 'argoproj.io/v1alpha1' -export type ApplicationSetApiVersionType = 'argoproj.io/v1alpha1' - -export const ApplicationSetKind = 'ApplicationSet' -export type ApplicationSetKindType = 'ApplicationSet' - -export const ApplicationSetDefinition: IResourceDefinition = { - apiVersion: ApplicationSetApiVersion, - kind: ApplicationSetKind, -} - -export interface Selector { - matchLabels?: Record -} -export interface IApplicationSet extends IResource { - apiVersion: ApplicationSetApiVersionType - kind: ApplicationSetKindType - spec: { - template?: { - spec?: { - destination?: { - namespace: string - server: string - } - project: string - source?: { - path?: string - repoURL: string - targetRevision?: string - chart?: string - } - sources?: { - path?: string - repoURL: string - targetRevision?: string - chart?: string - repositoryType?: string - }[] - } - } - generators?: { - clusterDecisionResource?: { - labelSelector?: Selector - configMapRef?: string - requeueAfterSeconds?: number - } - }[] - } - transformed?: { - clusterCount?: string - } -} -export interface IOCPApplication extends IResource { - label?: string - status?: { - cluster?: string - } -} -export interface IService extends IResource { - spec?: { - ports?: { - port: number - }[] - } -} diff --git a/backend-node/src/resources/route.ts b/backend-node/src/resources/route.ts deleted file mode 100644 index 9211b1cee13..00000000000 --- a/backend-node/src/resources/route.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { IResource } from './resource' - -export interface Route extends IResource { - spec: { - host?: string - path?: string - to?: { - kind?: 'Service' - name?: string - weight?: number - } - port?: { - targetPort?: string - } - tls?: { - termination?: 'edge' | 'passthrough' | 'reencrypt' - insecureEdgeTerminationPolicy?: 'Allow' | 'Disable' | 'Redirect' - } - wildcardPolicy?: 'Subdomain' | 'None' - } -} diff --git a/backend-node/src/resources/secret.ts b/backend-node/src/resources/secret.ts deleted file mode 100644 index 1e8cc68f3a5..00000000000 --- a/backend-node/src/resources/secret.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import type { IResource } from './resource' - -export interface Secret extends IResource { - data?: { - [key: string]: string - } -} diff --git a/backend-node/src/resources/status.ts b/backend-node/src/resources/status.ts deleted file mode 100644 index 994bb928dee..00000000000 --- a/backend-node/src/resources/status.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -export interface Status { - kind: 'Status' - apiVersion: 'v1' - metadata: unknown - status: string - message: string - reason: string - code: number -} diff --git a/backend-node/src/resources/watch-options.ts b/backend-node/src/resources/watch-options.ts deleted file mode 100644 index 45ac79f88f3..00000000000 --- a/backend-node/src/resources/watch-options.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -export interface IWatchOptions { - apiVersion: string - kind: string - labelSelector?: Record - fieldSelector?: Record - // poll the resource list instead of watching it - // process the items in its own cache so not to overload event cache - isPolled?: boolean - /** - * Whether watch events should be forwarded to browser clients via SSE. - * When false, resources are still cached in the backend (available via - * `getKubeResources`) but no `ServerSideEvents.pushEvent` calls are made, - * avoiding per-client RBAC checks and SSE bandwidth for resources the - * frontend does not consume through the event stream. - * Defaults to true when omitted. - */ - forwardEventsToClients?: boolean -} diff --git a/backend-node/src/routes/aggregator.ts b/backend-node/src/routes/aggregator.ts deleted file mode 100644 index 3f38026391d..00000000000 --- a/backend-node/src/routes/aggregator.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -// POST /aggregate/* is served by the Go listener (ACM-42600). -// These helpers remain for Search/compression types and Jest unit tests of the TS cache. -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { notFound, unauthorized } from '../lib/respond' -import { getAuthenticatedToken } from '../lib/token' -import { paginate } from '../lib/pagination' -import { - startAggregatingApplications, - stopAggregatingApplications, - polledApplicationAggregation, - getApplications, - filterApplications, - sortApplications, - addUIData, -} from './aggregators/applications' -import { requestAggregatedStatuses } from './aggregators/statuses' -import { requestAggregatedAppSetData } from './aggregators/appSetData' -import type { IResource } from '../resources/resource' -import type { IWatchOptions } from '../resources/watch-options' - -export function startAggregating(): void { - void startAggregatingApplications() -} - -export function stopAggregating(): void { - stopAggregatingApplications() -} - -export async function polledAggregation( - options: IWatchOptions, - items: IResource[], - shouldPostProcess: boolean -): Promise { - await polledApplicationAggregation(options, items, shouldPostProcess) -} - -export async function aggregate(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (!token) return unauthorized(req, res) - const type = req.url.split('?')[0].split('/') - if (type.length < 3) return notFound(req, res) - switch (type[2]) { - case 'applications': - return paginate(req, res, token, getApplications, filterApplications, sortApplications, addUIData) - case 'statuses': - return requestAggregatedStatuses(req, res, token, getApplications) - case 'appSetData': - return requestAggregatedAppSetData(req, res) - } - - return notFound(req, res) -} diff --git a/backend-node/src/routes/aggregators/appSetData.ts b/backend-node/src/routes/aggregators/appSetData.ts deleted file mode 100644 index 619bcd340ed..00000000000 --- a/backend-node/src/routes/aggregators/appSetData.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import type { Cluster, IApplicationSet, IPlacementDecision, IResource } from '../../resources/resource' -import { getAppSetAppsMap, getAppStatusByNameMap } from './applicationsArgo' -import { getToken } from '../../lib/token' -import { unauthorized } from '../../lib/respond' -import { jsonRequest, resourceUrl } from '../../lib/json-request' -import get from 'get-value' -import { getHubClusterName, getKubeResources } from '../events' -import { getApplicationClusters, getClusters } from './utils' -interface IAppSetData { - // appset resource - appset: IResource - // list of clusters this app is on - clusterList: string[] - // placement decision for this appset - placementDecision?: IPlacementDecision - // placement for this appset - placement?: IResource - // all apps that belong to this appset - appSetApps: IResource[] - // used in topology--for appsets -- shows status for each app in this appset - appStatusByNameMap: Record - // is this appset a pull model appset? - isAppSetPullModel: boolean -} - -export function requestAggregatedAppSetData(req: Http2ServerRequest, res: Http2ServerResponse): void { - const chunks: string[] = [] - req.on('data', (chuck: string) => { - chunks.push(chuck) - }) - req.on('end', async () => { - const token = getToken(req) - if (!token) return unauthorized(req, res) - const body = chunks.join('') - let appset: IApplicationSet - let isAppSetPullModel = false - try { - appset = JSON.parse(body) as IApplicationSet - } catch (error) { - console.error('Invalid appSetData request body', error) - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Invalid request body' })) - return - } - - try { - const resourcePath = resourceUrl(appset) - appset = await jsonRequest(resourcePath, token) - } catch (error) { - console.error(error) - res.statusCode = 400 - res.end(JSON.stringify({ error: 'Failed to fetch resource' })) - return - } - // get appset data - const appSetApps = getAppSetAppsMap()[appset.metadata.name] || [] - const appStatusByNameMap = getAppStatusByNameMap()[`${appset.metadata.namespace}/${appset.metadata.name}`] || {} - - // get appset placment onto clusters - const hubClusterName = getHubClusterName() - const clusters: Cluster[] = await getClusters() - const localCluster = clusters.find((cls) => cls.name === hubClusterName) - const clusterList: string[] = await getApplicationClusters(appset, 'appset', [], [], localCluster, clusters) - const isClusterListEmpty = clusterList.length === 0 - let placement: IResource | undefined - let placementDecision: IPlacementDecision | undefined - const placementName = getPlacementNameFromAppSetSpec(appset.spec) - if (placementName) { - const placements = await getKubeResources('Placement', 'cluster.open-cluster-management.io/v1beta1') - const placementDecisions = await getKubeResources( - 'PlacementDecision', - 'cluster.open-cluster-management.io/v1beta1' - ) - placementDecision = placementDecisions?.find((p: IPlacementDecision) => { - const labels = p.metadata.labels - return ( - p.metadata.namespace === appset.metadata.namespace && - labels?.['cluster.open-cluster-management.io/placement'] === placementName - ) - }) - if (isClusterListEmpty && placementDecision?.status?.decisions) { - for (const decision of placementDecision.status.decisions) { - const clusterName = decision.clusterName - if (clusterName && !clusterList.includes(clusterName)) { - clusterList.push(clusterName) - } - } - } - - const decisionOwnerReference = get(placementDecision, ['metadata', 'ownerReferences'], undefined) as - Array<{ kind?: string; name?: string; namespace?: string }> | undefined - - if (decisionOwnerReference && decisionOwnerReference[0]) { - const owner0 = decisionOwnerReference[0] - placement = placements.find( - (resource: IResource) => - resource.kind === owner0.kind && - resource.metadata.name === owner0.name && - resource.metadata.namespace === appset.metadata.namespace - ) - } - } - isAppSetPullModel = !!get( - appset, - ['spec', 'template', 'metadata', 'annotations', 'apps.open-cluster-management.io/ocm-managed-cluster'], - { default: false } - ) - const result: IAppSetData = { - appset, - clusterList, - placement, - placementDecision, - appSetApps, - appStatusByNameMap, - isAppSetPullModel, - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(result)) - }) -} - -const appSetPlacementStr = [ - 'clusterDecisionResource', - 'labelSelector', - 'matchLabels', - 'cluster.open-cluster-management.io/placement', -] -export function getPlacementNameFromAppSetSpec(spec: Record | undefined): string { - if (!spec || typeof spec !== 'object') return '' - const generatorWithCDR = findObjectWithKey(spec, 'clusterDecisionResource') - if (!generatorWithCDR) return '' - return (get(generatorWithCDR, appSetPlacementStr, { default: '' }) as string) || '' -} - -/** - * Recursively search an object for a property with the given key. - * Returns the first matching object that contains the key, or undefined. - */ -function findObjectWithKey(obj: unknown, key: string): Record | undefined { - if (!obj || typeof obj !== 'object') return undefined - const record = obj as Record - if (key in record) return record - for (const value of Object.values(record)) { - const found = findObjectWithKey(value, key) - if (found) return found - } - return undefined -} diff --git a/backend-node/src/routes/aggregators/applications.ts b/backend-node/src/routes/aggregators/applications.ts deleted file mode 100644 index 11fd1860723..00000000000 --- a/backend-node/src/routes/aggregators/applications.ts +++ /dev/null @@ -1,445 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { getKubeResources } from '../events' -import { addOCPQueryInputs, addSystemQueryInputs, cacheOCPApplications } from './applicationsOCP' -import { ApplicationSetKind, type IApplicationSet, type IResource, type SearchResult } from '../../resources/resource' -import type { FilterSelections, ISortBy } from '../../lib/pagination' -import { logger } from '../../lib/logger' -import { - discoverSystemAppNamespacePrefixes, - getApplicationsHelper, - logApplicationCountChanges, - transform, -} from './utils' -import { getSearchResults, type ISearchResult, pingSearchAPI } from '../../lib/search' -import { - addArgoQueryInputs, - cacheArgoApplications, - polledArgoApplicationAggregation, - getAppSetAppsMap, - getAppSetPlacementData, -} from './applicationsArgo' -import { addPushModelPodQueryInputs, type PushModelResourceMap } from './applicationsPushModel' -import { getGiganticApps } from '../../lib/gigantic' -import { createDictionary, inflateApps } from '../../lib/compression' -import type { IWatchOptions } from '../../resources/watch-options' - -export enum AppColumns { - name = 0, - type, - namespace, - clusters, - health, - synced, - deployed, - created, -} - -export enum TransformColumns { - name = 0, - type, - namespace, - clusters, - statuses, - scores, - created, -} -export interface IArgoApplication extends IResource { - cluster?: string - spec: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [x: string]: any - destination: { - name?: string - namespace: string - server?: string - } - } - status?: { - cluster?: string - decisions?: [{ clusterName: string }] - } -} -export interface IOCPApplication extends IResource { - label?: string - status?: { - cluster?: string - } -} -export interface IDecision extends IResource { - status?: { - decisions?: [{ clusterName: string }] - } -} -export interface ISubscription extends IResource { - spec?: { - placement?: { - placementRef?: { - name: string - } - } - } - status?: { - decisions?: [{ clusterName: string }] - } -} - -export enum StatusColumn { - counts = 0, - messages = 1, -} - -export enum ScoreColumn { - healthy = 0, - progress = 1, - warning = 2, - danger = 3, - unknown = 4, -} -export const ScoreColumnSize = Object.keys(ScoreColumn).length / 2 - -export type ApplicationStatusEntry = [number[], Record[]] - -export type ApplicationStatuses = { - health: ApplicationStatusEntry - synced: ApplicationStatusEntry - deployed: ApplicationStatusEntry -} - -// each app has distinct statuses for each cluster it's on -// string is appid (type/ns/name) -export type ApplicationClusterStatusMap = Record -// string is cluster name -export type ApplicationStatusMap = Record -// string is AppColumns -export type ApplicationScoresMap = Record - -// transform is either a string (for app name) or a map of the statuses of that app on each cluster -export type Transform = (string | ApplicationScoresMap | ApplicationStatusMap)[][] -export interface ITransformedResource extends IResource { - transform?: Transform - remoteClusters?: string[] -} -export interface ICompressedResource { - compressed: Buffer - transform?: Transform - remoteClusters?: string[] -} - -export type ApplicationCache = { - resources?: ICompressedResource[] - resourceMap?: { [key: string]: ICompressedResource[] } - resourceUidMap?: { [key: string]: ICompressedResource } -} - -const appDict = createDictionary() -export function getAppDict() { - return appDict -} - -export type ApplicationCacheType = { - [type: string]: ApplicationCache -} -export const applicationCache: ApplicationCacheType = {} -const appKeys = [ - 'subscription', - 'appset', - 'localArgoApps', - 'remoteArgoApps', - 'localOCPApps', - 'remoteOCPApps', - 'localSysApps', - 'remoteSysApps', -] -export const resetApplicationCache = () => { - appKeys.forEach((key) => { - applicationCache[key] = { resources: [] } - }) -} -resetApplicationCache() - -export const SEARCH_TIMEOUT = 5 * 60 * 1000 - -// will divide queries into application prefixes (a*, b*) not to execeed this value: process.env.APP_SEARCH_LIMIT -// however if a single letter prefix (ex: a*) returns more then this amount, we need to have a higher max -export const SEARCH_QUERY_LIMIT = 20000 - -export interface IQuery { - operationName: string - variables: { input: { filters: { property: string; values: string[] }[]; relatedKinds: string[]; limit: number }[] } - query: string -} -const queryTemplate: IQuery = { - operationName: 'searchResult', - variables: { - input: [], - }, - query: - 'query searchResult($input: [SearchInput]) {\n searchResult: search(input: $input) {\n items\n related {\n kind\n items\n }}\n}', -} - -export const promiseTimeout = (promise: Promise, delay: number) => { - let timeoutID: string | number | NodeJS.Timeout - const promises = [ - new Promise((_resolve, reject) => { - timeoutID = setTimeout(() => reject(new Error(`timeout of ${delay} exceeded`)), delay) - }), - promise.then((data) => { - clearTimeout(timeoutID) - return data - }), - ] - return Promise.race(promises) -} - -// ////////////////////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////////////////////// -// ////////////////////////////////////////////////////////////////////////////////// -export async function startAggregatingApplications() { - await discoverSystemAppNamespacePrefixes() - void searchLoop() -} - -let stopping = false -export function stopAggregatingApplications(): void { - stopping = true -} - -/** Reset aggregation stopping flag. Used for test isolation. */ -export function resetAggregatingApplications(): void { - stopping = false -} - -export async function polledApplicationAggregation( - options: IWatchOptions, - items: IResource[], - shouldPostProcess: boolean -): Promise { - return polledArgoApplicationAggregation(options, items, shouldPostProcess) -} - -export async function getApplications() { - await aggregateLocalApplications() - let items = getApplicationsHelper(applicationCache, Object.keys(applicationCache)) - // mock a large environment - if (process.env.MOCK_CLUSTERS) { - items = items.concat((await transform(getGiganticApps(), {})).resources) - } - return items -} - -// not going to be many, will grab on each query from client -export async function aggregateLocalApplications() { - // ACM Apps - try { - applicationCache['subscription'] = await transform( - structuredClone(await getKubeResources('Application', 'app.k8s.io/v1beta1')), - {} - ) - } catch (e) { - logger.error(`aggregateLocalApplications subscription exception ${e}`) - } -} - -export function filterApplications(filters: FilterSelections, items: ICompressedResource[]) { - const filterCategories = Object.keys(filters) - items = items.filter((item) => { - let isFilterMatch = true - // Item must match 1 filter of each category - filterCategories.forEach((filter: string) => { - let isMatch = true - switch (filter) { - case 'type': - isMatch = filters['type'].some((value: string) => value === item.transform[AppColumns.type][0]) - break - case 'cluster': - isMatch = filters['cluster'].some( - (value: string) => item.transform[AppColumns.clusters].indexOf(value) !== -1 - ) - break - case 'podStatuses': - isMatch = filters['podStatuses'].some( - (value: string) => value === getStatusFilterKey(item, AppColumns.deployed) - ) - break - case 'healthStatus': - isMatch = filters['healthStatus'].some( - (value: string) => value === getStatusFilterKey(item, AppColumns.health) - ) - break - case 'syncStatus': - isMatch = filters['syncStatus'].some((value: string) => value === getStatusFilterKey(item, AppColumns.synced)) - break - default: - isMatch = false - break - } - if (!isMatch) { - isFilterMatch = false - } - }) - return isFilterMatch - }) - return items -} - -export function getStatusFilterKey(item: ICompressedResource, index: AppColumns) { - const score = (item.transform[TransformColumns.scores] as ApplicationScoresMap[])[0][index] - switch (index) { - case AppColumns.health: - return score < 1000 ? 'Healthy' : 'Unhealthy' - case AppColumns.synced: - return score < 1000 ? 'Synced' : 'OutOfSync' - case AppColumns.deployed: - return score < 1000 ? 'Deployed' : 'Not Deployed' - default: - return '' - } -} - -const stringCompareColumns = new Set([ - AppColumns.name, - AppColumns.namespace, - AppColumns.clusters, - AppColumns.created, -]) - -const scoreCompareColumns = new Set([AppColumns.health, AppColumns.synced, AppColumns.deployed]) - -export function sortApplications(sortBy: ISortBy, items: ICompressedResource[]) { - const index = sortBy.index - items = items.sort((a, b) => { - if (stringCompareColumns.has(index)) { - const aValue = a.transform[index] - const bValue = b.transform[index] - if (!aValue || !bValue) return 0 - return (aValue[0] as string).localeCompare(bValue[0] as string) - } - if (scoreCompareColumns.has(index)) { - const aScore = (a.transform[TransformColumns.scores] as ApplicationScoresMap[])[0][index] - const bScore = (b.transform[TransformColumns.scores] as ApplicationScoresMap[])[0][index] - return bScore - aScore - } - return 0 - }) - if (sortBy.direction === 'desc') { - items = items.reverse() - } - return items -} - -// add data to the apps that can be used by the ui but -// w/o downloading all the appsets, apps, etc -export async function addUIData(items: ITransformedResource[]) { - const argoAppSets = await inflateApps(getApplicationsHelper(applicationCache, ['appset'])) - const appSetAppsMap = getAppSetAppsMap() - items = items.map((item) => { - return { - ...item, - uidata: { - clusterList: item?.transform?.[AppColumns.clusters] || [], - appClusterStatuses: item?.transform?.[TransformColumns.statuses] || [], - appSetPlacementData: - item.kind === ApplicationSetKind ? getAppSetPlacementData(item, argoAppSets as IApplicationSet[]) : ['', []], - appSetApps: - item.kind === ApplicationSetKind - ? appSetAppsMap[item.metadata.name]?.map((app) => app.metadata.name) || [] - : [], - }, - } - }) - return items -} - -export async function searchLoop() { - let pass = 1 - let searchAPIMissing = false - while (!stopping) { - // make sure there's an active search api - // otherwise there's no point - let exists - do { - // see if search api is running - try { - exists = await pingSearchAPI() - } catch (e) { - logger.error(`pingSearchAPI ${e}`) - exists = false - } - /* istanbul ignore if */ if (!exists) { - if (!searchAPIMissing) { - logger.error('search API missing') - searchAPIMissing = true - } - await new Promise((r) => setTimeout(r, 5 * 60 * 1000)) - } - } while (!exists) - /* istanbul ignore if */ - if (searchAPIMissing) { - logger.info('search API found') - searchAPIMissing = false - } - - // query and save the remote applications - try { - await promiseTimeout(aggregateRemoteApplications(pass), SEARCH_TIMEOUT * 2).catch((e) => - logger.error(`aggregateRemoteApplications exception ${e}`) - ) - } catch (e) { - logger.error(`aggregateRemoteApplications exception ${e}`) - } - pass++ - logApplicationCountChanges(applicationCache, pass) - - // process every APP_SEARCH_INTERVAL seconds - /* istanbul ignore if */ - if (process.env.NODE_ENV !== 'test') { - await new Promise((r) => setTimeout(r, pass <= 3 ? 15000 : Number(process.env.APP_SEARCH_INTERVAL) || 60000)) - } else { - stopping = true - } - } -} - -export async function aggregateRemoteApplications(pass: number) { - //////////// BUILD QUERY INPUTS ////////////////// - const querySystemApps = pass < 60 || pass % 5 === 0 - const query = structuredClone(queryTemplate) - addArgoQueryInputs(applicationCache, query) - addOCPQueryInputs(applicationCache, query) - if (querySystemApps) { - await addSystemQueryInputs(applicationCache, query) - } - // Push model AppSet apps live on the hub but deploy to remote clusters. - // Search relatedKinds won't cross that cluster boundary, so we add a - // dedicated query for the workloads listed in each hub Application's - // status.resources and then merge the resulting pods back in. - let pushModelResourceMap: PushModelResourceMap | undefined - const pushModelQueryIndex = query.variables.input.length - try { - pushModelResourceMap = await addPushModelPodQueryInputs(query) - } catch (e) { - logger.error(`addPushModelPodQueryInputs exception ${e}`) - } - const hasPushModelQuery = (pushModelResourceMap?.size ?? 0) > 0 - - //////////// MAKE QUERY ////////////////////////// - let results: ISearchResult - try { - results = await getSearchResults(query) - } catch (e) { - logger.error(`getSearchResults ${e}`) - return - } - const searchResult = results.data?.searchResult - // //////////// SAVE RESULTS /////////////////// - const ocpArgoAppFilter = await cacheArgoApplications( - applicationCache, - searchResult?.[0] as SearchResult, - hasPushModelQuery ? (searchResult?.[pushModelQueryIndex] as SearchResult) : undefined, - hasPushModelQuery ? pushModelResourceMap : undefined - ) - await cacheOCPApplications(applicationCache, searchResult?.[1] as SearchResult, ocpArgoAppFilter) - if (querySystemApps) { - await cacheOCPApplications(applicationCache, searchResult?.[2] as SearchResult, ocpArgoAppFilter, true) - } -} diff --git a/backend-node/src/routes/aggregators/applicationsArgo.ts b/backend-node/src/routes/aggregators/applicationsArgo.ts deleted file mode 100644 index 77a39cdd496..00000000000 --- a/backend-node/src/routes/aggregators/applicationsArgo.ts +++ /dev/null @@ -1,639 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import get from 'get-value' -import { deflateResource } from '../../lib/compression' -import { logger } from '../../lib/logger' -import { - ApplicationKind, - ArgoApplicationApiVersion, - ArgoApplicationKind, - type Cluster, - type IApplicationSet, - type IResource, - type ISearchResource, - type SearchResult, -} from '../../resources/resource' -import type { IWatchOptions } from '../../resources/watch-options' -import { getHubClusterName, getKubeResources } from '../events' -import { - applicationCache, - getAppDict, - ScoreColumn, - ScoreColumnSize, - SEARCH_QUERY_LIMIT, - StatusColumn, - type ApplicationCacheType, - type ApplicationClusterStatusMap, - type ApplicationStatuses, - type IArgoApplication, - type IQuery, - type ITransformedResource, -} from './applications' -import type { PushModelResourceEntry, PushModelResourceMap } from './applicationsPushModel' -import { - cacheRemoteApps, - computeAppHealthStatus, - computeAppSyncStatus, - computeDeployedPodStatuses, - computePodStatus, - getApplicationClusters, - getApplicationType, - getClusters, - getNextApplicationPageChunk, - getTransform, - transform, - type ApplicationPageChunk, -} from './utils' - -interface IArgoAppStatusResource { - group?: string - kind: string - name: string - namespace: string - version?: string - status?: string - health?: { status: string } -} - -interface IArgoAppLocalResource extends IResource { - spec: { - destination: { - name?: string - namespace: string - server?: string - } - } - status?: { - resources: IArgoAppStatusResource[] - } -} - -export interface IArgoAppRemoteResource { - _uid: string - _hostingResource: string - name: string - namespace: string - created: string - destinationNamespace: string - destinationName: string - destinationCluster: string - destinationServer: string - path: string - repoURL: string - targetRevision: string - chart: string - cluster: string - healthStatus: string - syncStatus: string -} - -let hubClusterName: string -let clusters: Cluster[] -let localCluster: Cluster -let placementDecisions: IResource[] - -// APPSETS ARE ALL ON HUB as KUBERNETES RESOURCES -// for PUSH APPSETS, APPS ARE ON HUB (kube) but pushed to anywhere (hub/cluster) -// for PULL APPSETS, APPS ARE ONLY REMOTE (SEARCH api), but can be pulled into local - -// MAINTAINING A MAP OF APPSETS AND THEIR APPS (from kube) -// we create this map by looping through all local argo apps, getting its owner reference appset name, -// and adding it to the map with the appset name as the key and the app as a value array -let appSetAppsMap: Record = {} -let tempAppSetAppsMap: Record = {} -export function getAppSetAppsMap() { - return appSetAppsMap || {} -} - -// MAINTAINING A MAP OF PULLED APPSETS AND THEIR APPS (from search) -// we create this map by looping through all searched argo apps -// there is no owner reference, but a search record has a _hostingResource -// which constains the same information as the owner reference -// as 'ApplicationSet/openshift-gitops/fernando-2' -let pulledAppSetMap: Record = {} -let tempPulledAppSetMap: Record = {} -export function getPulledAppSetMap() { - return Object.keys(pulledAppSetMap).length === 0 ? tempPulledAppSetMap : pulledAppSetMap || {} -} - -const appStatusByNameMap: Record> = {} -export function getAppStatusByNameMap() { - return appStatusByNameMap || {} -} - -/** Reset all Argo application module-level state. Used for test isolation. */ -export function resetArgoApplicationState() { - appSetAppsMap = {} - tempAppSetAppsMap = {} - pulledAppSetMap = {} - tempPulledAppSetMap = {} - for (const key in appStatusByNameMap) { - delete appStatusByNameMap[key] - } - hubClusterName = undefined - clusters = undefined - localCluster = undefined - placementDecisions = undefined - ocpArgoAppFilter.clear() - argoPageChunks.length = 0 - for (const key in oldResourceUidSets) { - delete oldResourceUidSets[key] - } -} - -// filter out ocp apps that are argo apps -// each entry is a string of the form: -// -- -const ocpArgoAppFilter: Set = new Set() - -// in case there are lots of argo apps, instead of searching all at once, -// we search in chunks of 1000 apps at a time -let argoPageChunk: ApplicationPageChunk -const argoPageChunks: ApplicationPageChunk[] = [] - -const oldResourceUidSets: Record> = {} - -export function addArgoQueryInputs(applicationCache: ApplicationCacheType, query: IQuery) { - argoPageChunk = getNextApplicationPageChunk(applicationCache, argoPageChunks, 'remoteArgoApps') - const filters = [ - { - property: 'kind', - values: ['Application'], - }, - { - property: 'apigroup', - values: ['argoproj.io'], - }, - ] - /* istanbul ignore if */ - if (argoPageChunk?.keys) { - filters.push({ - property: 'name', - values: argoPageChunk.keys, - }) - } - query.variables.input.push({ - filters, - relatedKinds: ['Pod', 'ReplicaSet', 'Deployment', 'StatefulSet'], - limit: SEARCH_QUERY_LIMIT, - }) -} - -export async function cacheArgoApplications( - applicationCache: ApplicationCacheType, - searchResult: SearchResult, - pushModelSearchResult?: SearchResult, - pushModelResourceMap?: PushModelResourceMap -) { - const hubClusterName = getHubClusterName() - const clusters: Cluster[] = await getClusters() - const localCluster = clusters.find((cls) => cls.name === hubClusterName) - const remoteArgoApps = searchResult.items.filter((app) => app.cluster !== hubClusterName) - const argoStatusMap = createArgoStatusMap(searchResult, clusters) - - if (pushModelSearchResult && pushModelResourceMap?.size > 0) { - mergePushModelPodStatuses(pushModelSearchResult, pushModelResourceMap, argoStatusMap) - } - // should be rarely used, argo apps are usually created by appsets - if (applicationCache['localArgoApps']?.resourceUidMap) { - try { - const localArgoAppsMap = applicationCache['localArgoApps'].resourceUidMap - await transform(Object.values(localArgoAppsMap), argoStatusMap, false, localCluster, clusters, localArgoAppsMap) - } catch (e) { - logger.error(`getLocalArgoApps exception ${e}`) - } - } - try { - // cache remote argo apps - await cacheRemoteApps( - applicationCache, - argoStatusMap, - getRemoteArgoApps(ocpArgoAppFilter, remoteArgoApps), - argoPageChunk, - 'remoteArgoApps' - ) - } catch (e) { - logger.error(`cacheRemoteApps exception ${e}`) - } - - if (applicationCache['appset']?.resourceUidMap) { - try { - const appsetMap = applicationCache['appset'].resourceUidMap - await transform(Object.values(appsetMap), argoStatusMap, false, localCluster, clusters, appsetMap) - } catch (e) { - logger.error(`aggregateLocalApplications appset exception ${e}`) - } - } - - return ocpArgoAppFilter -} - -export async function polledArgoApplicationAggregation( - options: IWatchOptions, - items: ITransformedResource[], - shouldPostProcess: boolean -): Promise { - const { kind } = options - - // get resourceUidMap - const appKey = kind === ApplicationKind ? 'localArgoApps' : 'appset' - let resourceUidMap = applicationCache[appKey]?.resourceUidMap - if (!resourceUidMap) { - delete applicationCache[appKey].resources - resourceUidMap = applicationCache[appKey].resourceUidMap = {} - } - - // initialize data for this pass (pass continues until shouldPostProcess) - if (!oldResourceUidSets[appKey]) { - oldResourceUidSets[appKey] = new Set(Object.keys(resourceUidMap)) - hubClusterName = getHubClusterName() - clusters = await getClusters() - localCluster = clusters.find((cls) => cls.name === hubClusterName) - placementDecisions = await getKubeResources('PlacementDecision', 'cluster.open-cluster-management.io/v1beta1') - } - - // filter out apps that belong to an appset - if (kind === ApplicationKind) { - items = filterArgoApps(items, clusters, ocpArgoAppFilter, tempAppSetAppsMap) - } - - // add uidata transforms - await Promise.all( - items.map(async (item) => { - const uid = get(item, 'metadata.uid') as string - let transform = resourceUidMap[uid]?.transform - if (!transform) { - const type = getApplicationType(item) - const _clusters = await getApplicationClusters(item, type, [], placementDecisions, localCluster, clusters) - transform = getTransform(item, type, {}, _clusters) - } - resourceUidMap[uid] = { compressed: await deflateResource(item, getAppDict()), transform } - oldResourceUidSets[appKey].delete(uid) - }) - ) - - if (shouldPostProcess) { - // cleanup resourceUidMap - for (const uid of oldResourceUidSets[appKey]) { - delete resourceUidMap[uid] - } - delete oldResourceUidSets[appKey] - - // we have built up a map of appsets -> a list of its argo apps - // if argo apps have finished polling, set that temp appset map into the real one - // the real one will be used while a new temp map is being created - // this fixes the problem where the argo app moves to a new appset of the same name in a new cluster - if (kind === ApplicationKind) { - appSetAppsMap = tempAppSetAppsMap - tempAppSetAppsMap = {} - } - } -} - -function filterArgoApps( - items: IResource[], - clusters: Cluster[], - ocpAppSetFilter: Set, - appSetAppsMap: Record -) { - return items.filter((app) => { - const argoApp = app as IArgoAppLocalResource - const resources = argoApp.status ? argoApp.status.resources : undefined - const definedNamespace = resources?.[0].namespace - - // cache Argo app signature for filtering OCP apps later - ocpAppSetFilter.add( - `${argoApp.metadata.name}-${ - definedNamespace ?? argoApp.spec.destination.namespace - }-${getArgoDestinationCluster(argoApp.spec.destination, clusters, getHubClusterName())}` - ) - const isChildOfAppset = - argoApp.metadata.ownerReferences && argoApp.metadata?.ownerReferences[0].kind === 'ApplicationSet' - if (!argoApp.metadata.ownerReferences || !isChildOfAppset) { - return true - } - const appSetName = get(argoApp, ['metadata', 'ownerReferences', '0', 'name']) as string - let apps = appSetAppsMap[appSetName] - if (!apps) { - apps = appSetAppsMap[appSetName] = [] - } - const inx = apps.findIndex((itm) => itm.metadata.uid === app.metadata.uid) - if (inx !== -1) { - apps[inx] = app - } else { - apps.push(app) - } - return false - }) -} - -function getRemoteArgoApps(ocpAppSetFilter: Set, remoteArgoApps: ISearchResource[]) { - const argoApps = remoteArgoApps as unknown as IArgoAppRemoteResource[] - const apps: IResource[] = [] - - // since searched argo apps can be spread out into multiple searches - // we build up a temp map, and when the next search is done, we copy it to the real map - // this can happen because the search is done in chunks of 1000 apps at a time - if (argoPageChunks.length === 0) { - pulledAppSetMap = tempPulledAppSetMap - tempPulledAppSetMap = {} - } - - argoApps.forEach((argoApp: IArgoAppRemoteResource) => { - // cache Argo app signature for filtering OCP apps later - ocpAppSetFilter.add(`${argoApp.name}-${argoApp.destinationNamespace}-${argoApp.cluster}`) - if (argoApp._hostingResource) { - const [kind, , appSetName] = argoApp._hostingResource.split('/') - if (kind === 'ApplicationSet') { - let apps = tempPulledAppSetMap[appSetName] - if (!apps) { - apps = tempPulledAppSetMap[appSetName] = [] - } - const inx = apps.findIndex((itm) => itm._uid === argoApp._uid) - if (inx !== -1) { - apps[inx] = argoApp - } else { - apps.push(argoApp) - } - } - } else { - // Skip apps created by Argo pull model - apps.push({ - apiVersion: ArgoApplicationApiVersion, - kind: ArgoApplicationKind, - metadata: { - name: argoApp.name, - namespace: argoApp.namespace, - creationTimestamp: argoApp.created, - }, - spec: { - destination: { - namespace: argoApp.destinationNamespace, - name: argoApp.destinationName, - server: argoApp.destinationCluster || argoApp.destinationServer, - }, - source: { - path: argoApp.path, - repoURL: argoApp.repoURL, - targetRevision: argoApp.targetRevision, - chart: argoApp.chart, - }, - }, - status: { - cluster: argoApp.cluster, - health: { - status: argoApp.healthStatus, - }, - sync: { - status: argoApp.syncStatus, - }, - }, - } as IResource) - } - }) - - return apps -} - -function getArgoDestinationCluster( - destination: { name?: string; namespace: string; server?: string }, - clusters: Cluster[], - cluster?: string -) { - // cluster is the name of the managed cluster where the Argo app is defined - let clusterName = '' - const serverApi = destination?.server - if (serverApi) { - /* istanbul ignore if */ - if (serverApi === 'https://kubernetes.default.svc') { - clusterName = cluster ?? getHubClusterName() - } else { - const server = clusters.find((cls) => cls.kubeApiServer === serverApi) - /* istanbul ignore next */ clusterName = server ? server.name : 'unknown' - } - } else { - // target destination was set using the name property - /* istanbul ignore next */ clusterName = destination?.name || 'unknown' - /* istanbul ignore next */ if (cluster && (clusterName === 'in-cluster' || clusterName === getHubClusterName())) { - clusterName = cluster - } - - /* istanbul ignore next */ if (clusterName === 'in-cluster') { - clusterName = getHubClusterName() - } - } - return clusterName -} - -const appSetPlacementStr = [ - 'clusterDecisionResource', - 'labelSelector', - 'matchLabels', - 'cluster.open-cluster-management.io/placement', -] -export function getAppSetPlacementData(appSet: IResource, applicationSets: IApplicationSet[]) { - const appSetsSharingPlacement: string[] = [] - const currentAppSetGenerators = (appSet as IApplicationSet).spec?.generators - /* istanbul ignore next */ - const currentAppSetPlacement = currentAppSetGenerators - ? (get(currentAppSetGenerators[0], appSetPlacementStr, { default: '' }) as string) - : undefined - - /* istanbul ignore if */ - if (!currentAppSetPlacement) { - return ['', []] - } - - applicationSets.forEach((item) => { - const appSetGenerators = item.spec.generators - /* istanbul ignore next */ - const appSetPlacement = appSetGenerators - ? (get(appSetGenerators[0], appSetPlacementStr, { default: '' }) as string) - : '' - /* istanbul ignore if */ - if ( - item.metadata.name !== appSet.metadata?.name || - (item.metadata.name === appSet.metadata?.name && item.metadata.namespace !== appSet.metadata?.namespace) - ) { - if (appSetPlacement && appSetPlacement === currentAppSetPlacement && item.metadata.name) { - appSetsSharingPlacement.push(item.metadata.name) - } - } - }) - - return [currentAppSetPlacement, appSetsSharingPlacement] -} - -function buildWorkloadUidMap( - items: ISearchResource[], - pushModelResourceMap: PushModelResourceMap -): Map { - const workloadUidMap = new Map() - for (const item of items) { - const key = `${item.cluster}/${item.namespace}/${item.name}` - const entry = pushModelResourceMap.get(key) - if (entry) { - workloadUidMap.set(item._uid, entry) - } - } - return workloadUidMap -} - -function findOwningEntry( - pod: ISearchResource, - workloadUidMap: Map -): PushModelResourceEntry | undefined { - if (!pod._relatedUids) return undefined - for (const uid of pod._relatedUids) { - const entry = workloadUidMap.get(uid) - if (entry) return entry - } - return undefined -} - -function hasDeployedPods(appStatuses: ApplicationStatuses): boolean { - const counts = appStatuses.deployed[StatusColumn.counts] - return ( - counts[ScoreColumn.healthy] + - counts[ScoreColumn.progress] + - counts[ScoreColumn.warning] + - counts[ScoreColumn.danger] > - 0 - ) -} - -function collectAlreadyPopulated( - pushModelResourceMap: PushModelResourceMap, - argoClusterStatusMap: ApplicationClusterStatusMap -): Set { - const populated = new Set() - for (const entry of pushModelResourceMap.values()) { - const appStatuses = argoClusterStatusMap[entry.appSetKey]?.[entry.targetCluster] - if (appStatuses && hasDeployedPods(appStatuses)) { - populated.add(`${entry.appSetKey}/${entry.targetCluster}`) - } - } - return populated -} - -function bucketPodsByEntry( - pods: ISearchResource[], - workloadUidMap: Map, - alreadyPopulated: Set, - argoClusterStatusMap: ApplicationClusterStatusMap -): Map { - const podsByEntry = new Map() - for (const pod of pods) { - const matchedEntry = findOwningEntry(pod, workloadUidMap) - if (!matchedEntry) continue - - const entryKey = `${matchedEntry.appSetKey}/${matchedEntry.targetCluster}` - if (alreadyPopulated.has(entryKey)) continue - - const appStatuses = argoClusterStatusMap[matchedEntry.appSetKey]?.[matchedEntry.targetCluster] - if (!appStatuses) continue - - let bucket = podsByEntry.get(entryKey) - if (!bucket) { - bucket = { statuses: appStatuses, pods: [] } - podsByEntry.set(entryKey, bucket) - } - bucket.pods.push(pod) - } - return podsByEntry -} - -export function mergePushModelPodStatuses( - searchResult: SearchResult, - pushModelResourceMap: PushModelResourceMap, - argoClusterStatusMap: ApplicationClusterStatusMap -) { - if (!searchResult?.items?.length) return - - const workloadUidMap = buildWorkloadUidMap(searchResult.items, pushModelResourceMap) - - const podRelated = searchResult.related?.find((r) => r.kind === 'Pod') - if (!podRelated) return - - // Identify entries already populated by computeDeployedPodStatuses so we - // don't double-count pods that the main Argo search already found. - const alreadyPopulated = collectAlreadyPopulated(pushModelResourceMap, argoClusterStatusMap) - const podsByEntry = bucketPodsByEntry(podRelated.items, workloadUidMap, alreadyPopulated, argoClusterStatusMap) - - for (const { statuses, pods } of podsByEntry.values()) { - computePodStatus(statuses.deployed, pods) - } -} - -export function createArgoStatusMap(searchResult: SearchResult, clusters: Cluster[]) { - const argoClusterStatusMap: ApplicationClusterStatusMap = {} - const statuses2IDMap = new WeakMap() - const sortedClusterNames = clusters.map((c) => c.name).sort((a, b) => b.length - a.length) - - // create an app map with syncs and health - searchResult.items.forEach((app: ISearchResource) => { - let appKey - let appName - let appCluster = app.cluster - let appSetName = '' - let appNamespace = app.namespace - if (app._hostingResource) { - ;[, appNamespace, appSetName] = app._hostingResource.split('/') - appName = `${appNamespace}/${appSetName}` - appKey = `appset/${appName}` - } else if (app.applicationSet) { - // don't count the placeholder app on the hub for this pulled appset - if (!app.label?.includes('apps.open-cluster-management.io/pull-to-ocm-managed-cluster=true')) { - appName = `${app.namespace}/${app.applicationSet}` - appKey = `appset/${appName}` - const namePart = app.name.startsWith(app.applicationSet) - ? app.name.substring(app.applicationSet.length + 1) - : app.applicationSet - appCluster = sortedClusterNames.find( - (cluster: string) => - namePart === cluster || namePart.includes(`-${cluster}`) || namePart.includes(`${cluster}-`) - ) - appSetName = app.applicationSet - } - } else { - appName = `${app.namespace}/${app.name}` - appKey = `argo/${appName}` - } - if (appKey) { - let appStatusMap = argoClusterStatusMap[appKey] - if (!appStatusMap) { - appStatusMap = argoClusterStatusMap[appKey] = {} - } - let appStatuses = appStatusMap[appCluster] - if (!appStatuses) { - appStatuses = appStatusMap[appCluster] = { - health: [new Array(ScoreColumnSize).fill(0) as number[], []], - synced: [new Array(ScoreColumnSize).fill(0) as number[], []], - deployed: [new Array(ScoreColumnSize).fill(0) as number[], []], - } - } - computeAppHealthStatus(appStatuses.health, app) - computeAppSyncStatus(appStatuses.synced, app) - // kube status might not be updated to latest search status - if (appSetName) { - if (!appStatusByNameMap[`${appNamespace}/${appSetName}`]) { - appStatusByNameMap[`${appNamespace}/${appSetName}`] = {} - } - appStatusByNameMap[`${appNamespace}/${appSetName}`][app.name] = { - health: { status: app.healthStatus }, - sync: { status: app.syncStatus }, - } - } - let appIDMap = statuses2IDMap.get(appStatuses) - if (!appIDMap) { - appIDMap = { appName, uids: [] } - statuses2IDMap.set(appStatuses, appIDMap) - } - appIDMap.uids.push(app._uid) - } - }) - - // compute pod statuses - computeDeployedPodStatuses(searchResult.related, argoClusterStatusMap, statuses2IDMap) - - return argoClusterStatusMap -} diff --git a/backend-node/src/routes/aggregators/applicationsOCP.ts b/backend-node/src/routes/aggregators/applicationsOCP.ts deleted file mode 100644 index 19409d6c3f5..00000000000 --- a/backend-node/src/routes/aggregators/applicationsOCP.ts +++ /dev/null @@ -1,329 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { logger } from '../../lib/logger' -import type { IResource, ISearchResource, SearchResult } from '../../resources/resource' -import { getHubClusterName, getKubeResources } from '../events' -import { - AppColumns, - ScoreColumnSize, - SEARCH_QUERY_LIMIT, - type ApplicationCacheType, - type ApplicationClusterStatusMap, - type ApplicationStatuses, - type IQuery, -} from './applications' -import { - appOwnerLabels, - cacheRemoteApps, - computeDeployedPodStatuses, - getApplicationType, - getAppNameFromLabel, - getClusterMap, - getNextApplicationPageChunk, - transform, - type ApplicationPageChunk, -} from './utils' - -// getting system apps by its cluster name in cluster chunks -const REMOTE_CLUSTER_CHUNKS = 10 -const clusterNameChunks: string[][] = [] - -let ocpPageChunk: ApplicationPageChunk -const ocpPageChunks: ApplicationPageChunk[] = [] - -let clusterNameChunk: string[] - -// Openshift/Flux -export function addOCPQueryInputs(applicationCache: ApplicationCacheType, query: IQuery) { - ocpPageChunk = getNextApplicationPageChunk(applicationCache, ocpPageChunks, 'remoteOCPApps') - const filters = [ - { - property: 'kind', - values: ['Deployment'], - }, - { - property: 'label', - values: [...appOwnerLabels.map((label) => `${label}*`)], - }, - { - property: 'namespace', - values: ['!openshift*'], - }, - { - property: 'namespace', - values: ['!open-cluster-management*'], - }, - ] - if (ocpPageChunk?.keys) { - filters.push({ - property: 'name', - values: ocpPageChunk.keys, - }) - } - query.variables.input.push({ - filters, - relatedKinds: ['Pod', 'ReplicaSet', 'StatefulSet'], - limit: SEARCH_QUERY_LIMIT, - }) -} - -export async function addSystemQueryInputs(applicationCache: ApplicationCacheType, query: IQuery) { - clusterNameChunk = await getNextClusterNameChunk(applicationCache) - query.variables.input.push({ - filters: [ - { - property: 'kind', - values: ['Deployment'], - }, - { - property: 'label', - values: [...appOwnerLabels.map((label) => `${label}*`)], - }, - { - property: 'namespace', - values: ['openshift*', 'open-cluster-management*'], - }, - { - property: 'cluster', - values: clusterNameChunk, - }, - ], - relatedKinds: ['Pod', 'ReplicaSet', 'StatefulSet'], - limit: SEARCH_QUERY_LIMIT, - }) -} - -export async function cacheOCPApplications( - applicationCache: ApplicationCacheType, - searchResult: SearchResult, - ocpArgoAppFilter: Set, - isSystemMode?: boolean -) { - const helmReleases = await getKubeResources('HelmRelease', 'apps.open-cluster-management.io/v1') - - // filter ocp apps from this search - const localOCPApps: IResource[] = [] - const remoteOCPApps: IResource[] = [] - const ocpApps: ISearchResource[] = [] - try { - const openShiftAppResourceMaps: Record = {} - searchResult.items.forEach((ocpApp: ISearchResource) => { - if (ocpApp._hostingSubscription) { - // don't list subscription apps as ocp - return - } - - const labels = (ocpApp.label || '') - .replaceAll(/\s/g, '') - .split(';') - .map((label: string) => { - const [annotation, value] = label.split('=') - return { annotation, value } - }) - - const { itemLabel, isManagedByHelm, argoInstanceLabelValue } = getValues(labels) - - if (itemLabel && isManagedByHelm) { - const helmRelease = helmReleases.find( - (hr) => hr.metadata.name === itemLabel && hr.metadata.namespace === ocpApp.namespace - ) - if (helmRelease?.metadata.annotations?.['apps.open-cluster-management.io/hosting-subscription']) { - // don't list helm subscription apps as ocp - return - } - } - if (itemLabel) { - const key = `${itemLabel}-${ocpApp.namespace}-${ocpApp.cluster}` - const argoKey = `${argoInstanceLabelValue}-${ocpApp.namespace}-${ocpApp.cluster}` - // filter out ocp apps that are argo apps - if (!ocpArgoAppFilter.has(argoKey)) { - const existing = openShiftAppResourceMaps[key] - // check if this ocp app is using multiple deployments :) - // and if so, remember them all when creating pod status - if (existing) { - existing.push(ocpApp) - } else { - openShiftAppResourceMaps[key] = [ocpApp] - } - } - } - }) - - Object.entries(openShiftAppResourceMaps).forEach(([, values]) => { - const value = values[0] - const appLabel = getAppNameFromLabel(value.label, value.name) - const resourceName = value.name - let apps - if (value.cluster === getHubClusterName()) { - apps = localOCPApps - } else { - apps = remoteOCPApps - } - const app = { - apiVersion: value.apigroup ? `${value.apigroup}/${value.apiversion}` : value.apiversion, - kind: value.kind, - label: value.label, - metadata: { - name: appLabel, - namespace: value.namespace, - creationTimestamp: value.created, - }, - status: { - cluster: value.cluster, - resourceName, - }, - } - apps.push(app) - value.type = getApplicationType(app) - value.deployments = values - ocpApps.push(value) - }) - } catch (e) { - logger.error(`processing ${isSystemMode ? 'system' : 'ocp/flex'} exception ${e}`) - } - const ocpStatusMap = createOCPStatusMap(ocpApps, searchResult.related) - - if (!isSystemMode) { - try { - applicationCache['localOCPApps'] = await transform(localOCPApps, ocpStatusMap) - } catch (e) { - logger.error(`getLocalOCPApps exception ${e}`) - } - try { - await cacheRemoteApps(applicationCache, ocpStatusMap, remoteOCPApps, ocpPageChunk, 'remoteOCPApps') - } catch (e) { - logger.error(`getRemoteOCPApps exception ${e}`) - } - } else { - // if we just got remote clusters this time, don't touch localSysApps - if (localOCPApps.length) { - try { - applicationCache['localSysApps'] = await transform(localOCPApps, ocpStatusMap) - } catch (e) { - logger.error(`getLocalSystemApps exception ${e}`) - } - } - try { - // cache remote system apps - await cacheRemoteSystemApps(applicationCache, ocpStatusMap, remoteOCPApps, clusterNameChunk) - } catch (e) { - logger.error(`cacheRemoteSystemApps exception ${e}`) - } - } -} - -async function cacheRemoteSystemApps( - applicationCache: ApplicationCacheType, - ocpStatusMap: ApplicationClusterStatusMap, - remoteSysApps: IResource[], - clusterNameChunk: string[] -) { - // initialize map - clusterNameChunk.forEach((clustername) => { - applicationCache['remoteSysApps'].resourceMap[clustername] = [] - }) - const resources = (await transform(remoteSysApps, ocpStatusMap, true)).resources - resources.forEach((resource) => { - const clustername = (resource.transform[AppColumns.clusters] as string[]).join() - const clusterResources = applicationCache['remoteSysApps'].resourceMap[clustername] - clusterResources.push(resource) - }) -} - -async function getNextClusterNameChunk(applicationCache: ApplicationCacheType): Promise { - // if no cluster name chucks left, create a new array of chunks - if (clusterNameChunks.length === 0) { - const clusterMap = await getClusterMap() - const clusterNames = Object.keys(clusterMap) - if (clusterNames.length > 0) { - const chunks = clusterNames.reduce((chunks: string[][], clusterName, index) => { - const cindex = Math.floor(index / REMOTE_CLUSTER_CHUNKS) - chunks[cindex] = (chunks[cindex] ?? []).concat(clusterName) - return chunks - }, []) - clusterNameChunks.push(...chunks) - } else { - clusterNameChunks.push([getHubClusterName()]) - } - - // update remoteSysApps map - const remoteSysMap = applicationCache['remoteSysApps'].resourceMap - if (applicationCache['remoteSysApps'].resources) { - delete applicationCache['remoteSysApps'].resources - applicationCache['remoteSysApps'].resourceMap = {} - } else if (Object.keys(remoteSysMap).length) { - // purge resource map of clusters that no longer exist - Object.keys(remoteSysMap).forEach((name) => { - if (!clusterMap[name]) { - delete remoteSysMap[name] - } - }) - } - } - return clusterNameChunks.shift() -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getValues(labels: { annotation: any; value: any }[]) { - let itemLabel = '' - let argoInstanceLabelValue = '' - let isManagedByHelm - - labels?.forEach(({ annotation, value }) => { - value = value as string - if (annotation === 'app') { - itemLabel = value as string - } else if (annotation === 'app.kubernetes.io/part-of') { - if (!itemLabel) { - itemLabel = value as string - } - } - if (annotation === 'app.kubernetes.io/instance') { - argoInstanceLabelValue = value as string - } - if (annotation === 'app.kubernetes.io/managed-by' && value === 'Helm') { - isManagedByHelm = true - } - }) - return { - itemLabel, - isManagedByHelm, - argoInstanceLabelValue, - } -} - -export function createOCPStatusMap(ocpApps: ISearchResource[], relatedResources: SearchResult['related']) { - const ocpClusterStatusMap: ApplicationClusterStatusMap = {} - const statuses2IDMap = new WeakMap< - ApplicationStatuses, - { appName: string; deployments: ISearchResource[]; uids: string[] } - >() - - // create an app map with syncs and health - ocpApps.forEach((app: ISearchResource) => { - const appName = `${app.namespace}/${getAppNameFromLabel(app.label, app.name)}` - const appKey = `${app.type}/${appName}` - let appStatusMap = ocpClusterStatusMap[appKey] - if (!appStatusMap) { - appStatusMap = ocpClusterStatusMap[appKey] = {} - } - let appStatuses = appStatusMap[app.cluster] - if (!appStatuses) { - appStatuses = appStatusMap[app.cluster] = { - health: [new Array(ScoreColumnSize).fill(0) as number[], []], - synced: [new Array(ScoreColumnSize).fill(0) as number[], []], - deployed: [new Array(ScoreColumnSize).fill(0) as number[], []], - } - } - let appIDMap = statuses2IDMap.get(appStatuses) - if (!appIDMap) { - appIDMap = { appName, deployments: app.deployments, uids: [] } - statuses2IDMap.set(appStatuses, appIDMap) - } - appIDMap.uids.push(app._uid) - }) - - // compute pod statuses - computeDeployedPodStatuses(relatedResources, ocpClusterStatusMap, statuses2IDMap, true) - - return ocpClusterStatusMap -} diff --git a/backend-node/src/routes/aggregators/applicationsPushModel.ts b/backend-node/src/routes/aggregators/applicationsPushModel.ts deleted file mode 100644 index cce7b7f35ce..00000000000 --- a/backend-node/src/routes/aggregators/applicationsPushModel.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Cluster, IResource } from '../../resources/resource' -import { getHubClusterName } from '../events' -import { SEARCH_QUERY_LIMIT, type IArgoApplication, type IQuery } from './applications' -import { getAppSetAppsMap } from './applicationsArgo' -import { getArgoDestinationCluster, getClusters } from './utils' - -export interface PushModelResourceEntry { - appSetKey: string - targetCluster: string -} - -export type PushModelResourceMap = Map - -interface IArgoAppPushModelResource extends IResource { - spec: { - destination: { - name?: string - namespace: string - server?: string - } - } - status?: { - resources: Array<{ - kind: string - name: string - namespace: string - }> - } -} - -const workloadKinds = new Set(['Deployment', 'StatefulSet']) - -async function collectPushModelWorkloads( - apps: IArgoApplication[], - appSetName: string, - allClusters: Cluster[], - hubName: string, - resourceMap: PushModelResourceMap, - deploymentNames: Set, - clusterFilters: Set -) { - for (const app of apps) { - const argoApp = app as IArgoAppPushModelResource - const targetCluster = await getArgoDestinationCluster(argoApp.spec.destination, allClusters, undefined, hubName) - if (!targetCluster || targetCluster === hubName) continue - - const resources = argoApp.status?.resources - if (!resources) continue - - const appSetKey = `appset/${argoApp.metadata.namespace}/${appSetName}` - clusterFilters.add(targetCluster) - - for (const res of resources) { - if (workloadKinds.has(res.kind)) { - const ns = res.namespace || argoApp.spec.destination.namespace - deploymentNames.add(res.name) - resourceMap.set(`${targetCluster}/${ns}/${res.name}`, { appSetKey, targetCluster }) - } - } - } -} - -export async function addPushModelPodQueryInputs(query: IQuery): Promise { - const resourceMap: PushModelResourceMap = new Map() - const currentAppSetAppsMap = getAppSetAppsMap() - const hubName = getHubClusterName() - const allClusters = await getClusters() - - const deploymentNames = new Set() - const clusterFilters = new Set() - - for (const [appSetName, apps] of Object.entries(currentAppSetAppsMap)) { - await collectPushModelWorkloads( - apps, - appSetName, - allClusters, - hubName, - resourceMap, - deploymentNames, - clusterFilters - ) - } - - if (deploymentNames.size > 0) { - query.variables.input.push({ - filters: [ - { property: 'kind', values: ['Deployment', 'StatefulSet'] }, - { property: 'name', values: Array.from(deploymentNames) }, - { property: 'cluster', values: Array.from(clusterFilters) }, - ], - relatedKinds: ['Pod', 'ReplicaSet'], - limit: SEARCH_QUERY_LIMIT, - }) - } - - return resourceMap -} diff --git a/backend-node/src/routes/aggregators/statuses.ts b/backend-node/src/routes/aggregators/statuses.ts deleted file mode 100644 index d114a5472f8..00000000000 --- a/backend-node/src/routes/aggregators/statuses.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import type { FilterCounts } from '../../lib/pagination' -import { getAuthorizedResources } from '../events' -import { - AppColumns, - type ApplicationStatusMap, - getStatusFilterKey, - type ICompressedResource, - TransformColumns, -} from './applications' -import { systemAppNamespacePrefixes } from './utils' - -export interface IRequestStatuses { - clusters?: string[] -} - -export interface IResultStatuses { - itemCount: string - filterCounts: FilterCounts - systemAppNSPrefixes: string[] - loading: boolean -} - -export function requestAggregatedStatuses( - req: Http2ServerRequest, - res: Http2ServerResponse, - token: string, - getItems: () => Promise -): void { - const chucks: string[] = [] - req.on('data', (chuck: string) => { - chucks.push(chuck) - }) - req.on('end', async () => { - const body = chucks.join('') - const { clusters = [] } = JSON.parse(body) as IRequestStatuses - let items = await getItems() - - // should we filter count by provided cluster names - if (clusters.length) { - items = items.filter((item) => { - return clusters.some((value: string) => item.transform[AppColumns.clusters].indexOf(value) !== -1) - }) - } - // filter by rbac - const authorizedItems = await getAuthorizedResources(token, items, 0, items.length) - - // count filter entries - const filterCounts: FilterCounts = { type: {}, cluster: {}, podStatuses: {}, healthStatus: {}, syncStatus: {} } - authorizedItems.forEach((item) => { - if (item.transform) { - incFilterCounts(filterCounts, 'type', item.transform[TransformColumns.type] as string[]) - incFilterCounts(filterCounts, 'cluster', item.transform[TransformColumns.clusters] as string[]) - incStatusCounts(filterCounts, 'healthStatus', item as unknown as ICompressedResource, AppColumns.health) - incStatusCounts(filterCounts, 'syncStatus', item as unknown as ICompressedResource, AppColumns.synced) - incStatusCounts(filterCounts, 'podStatuses', item as unknown as ICompressedResource, AppColumns.deployed) - } - }) - - const results: IResultStatuses = { - itemCount: authorizedItems.length.toString(), - filterCounts, - systemAppNSPrefixes: systemAppNamespacePrefixes, - loading: false, - } - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(results)) - }) -} - -// add to filters count that appears in filter dropdown -function incFilterCounts(mapmap: FilterCounts, id: string, keys: string[]) { - let map = mapmap[id] - if (!map) map = mapmap[id] = {} - keys.forEach((key) => { - if (key in map) { - map[key]++ - } else { - map[key] = 1 - } - }) -} - -// add to filters count that appears in filter dropdown -function incStatusCounts(mapmap: FilterCounts, id: string, item: ICompressedResource, index: AppColumns) { - let map = mapmap[id] - if (!map) map = mapmap[id] = {} - const type = (item.transform[TransformColumns.type] as string[])[0] - // don't count health or sync for non-argo apps - if ((index === AppColumns.health || index === AppColumns.synced) && (type === 'appset' || type === 'argo')) { - const statuses = (item.transform[TransformColumns.statuses] as ApplicationStatusMap[])[0] - if (Object.keys(statuses).length) { - const key = getStatusFilterKey(item, index) - if (key in map) { - map[key]++ - } else { - map[key] = 1 - } - } - } -} diff --git a/backend-node/src/routes/aggregators/utils.ts b/backend-node/src/routes/aggregators/utils.ts deleted file mode 100644 index 366ac5fe5b5..00000000000 --- a/backend-node/src/routes/aggregators/utils.ts +++ /dev/null @@ -1,1135 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import get from 'get-value' -import { getKubeResources, getHubClusterName, getEventCache, getEventDict } from '../events' -import type { - Cluster, - IResource, - ManagedClusterInfo, - IArgoApplication, - IPlacementDecision, - ISubscription, - IOCPApplication, - IApplicationSet, - ManagedCluster, - ClusterDeployment, - HostedClusterK8sResource, - ISearchResource, - SearchResult, - IService, -} from '../../resources/resource' -import { - AppColumns, - type ApplicationCache, - type ApplicationCacheType, - type ApplicationClusterStatusMap, - ScoreColumn, - type ApplicationStatuses, - type ApplicationStatusMap, - getAppDict, - type ICompressedResource, - type ITransformedResource, - type Transform, - StatusColumn, - type ApplicationStatusEntry, - ScoreColumnSize, -} from './applications' -import { logger } from '../../lib/logger' -import { getMultiClusterHub } from '../../lib/multi-cluster-hub' -import { getMultiClusterEngine } from '../../lib/multi-cluster-engine' -import { ServerSideEvents } from '../../lib/server-side-events' -import { getPulledAppSetMap, getAppSetAppsMap, type IArgoAppRemoteResource } from './applicationsArgo' -import { deflateResource, inflateApp } from '../../lib/compression' - -const CLUSTER_PROXY_SERVICE_NAME = 'cluster-proxy-addon-user' -const CLUSTER_PROXY_SERVICE_NAMESPACE = 'multicluster-engine' -const CLUSTER_PROXY_SERVICE_PORT = 9092 - -////////////////////////////////////////////////////////////////// -////////////// TRANSFORM ///////////////////////////////////////// -////////////////////////////////////////////////////////////////// - -export async function transform( - items: ITransformedResource[] | ICompressedResource[], - argoClusterStatusMap: ApplicationClusterStatusMap, - isRemote?: boolean, - localCluster?: Cluster, - clusters?: Cluster[], - itemMap?: Record -): Promise { - const subscriptions = await getKubeResources('Subscription', 'apps.open-cluster-management.io/v1') - const placementDecisions = await getKubeResources('PlacementDecision', 'cluster.open-cluster-management.io/v1beta1') - const localClusterName = getHubClusterName() - await Promise.all( - items.map(async (app, inx) => { - app = await inflateApp(app) - const type = getApplicationType(app) - - if (type === 'subscription') { - const subAnnotation = app.metadata?.annotations?.['apps.open-cluster-management.io/subscriptions'] - if (subAnnotation) { - const subRefs = subAnnotation.split(',').map((ref) => ref.trim()) - const allLabels: Record = {} - - subRefs.forEach((subRef) => { - const [subNs, subName] = subRef.split('/') - const subscription = subscriptions.find( - (s) => s.metadata?.namespace === subNs && s.metadata?.name === subName - ) - if (subscription?.metadata?.labels) { - Object.assign(allLabels, subscription.metadata.labels) - } - }) - - if (Object.keys(allLabels).length > 0) { - app.metadata.labels = { ...app.metadata.labels, ...allLabels } - } - } - } - - const _clusters = await getApplicationClusters( - app, - type, - subscriptions, - placementDecisions, - localCluster, - clusters - ) - items[inx] = { - transform: getTransform(app, type, argoClusterStatusMap, _clusters), - remoteClusters: - (isRemote || (type === 'subscription' && _clusters.filter((n) => n !== localClusterName)).length > 0) && - _clusters, - compressed: await deflateResource(app, getAppDict()), - } - if (itemMap) { - itemMap[app.metadata.uid] = items[inx] - } - }) - ) - return { resources: items as unknown as ICompressedResource[] } -} - -export function getTransform( - app: IResource, - type: string, - clusterStatusMap: ApplicationClusterStatusMap, - clusters: string[] -): Transform { - const statusKey = `${type}/${app.metadata.namespace}/${app.metadata.name}` - const appStatuses = getAppStatues(type, statusKey, clusterStatusMap, clusters) - const appScores = getAppStatusScores(clusters, appStatuses) - return [ - [app.metadata.name], - [type], - [getAppNamespace(app)], - clusters, - [appStatuses], - [appScores], - [app.metadata.creationTimestamp as string], - ] -} - -function getAppStatues( - type: string, - statusKey: string, - clusterStatusMap: ApplicationClusterStatusMap, - clusters: string[] -) { - const appStatuses = clusterStatusMap[statusKey] - if (!appStatuses) { - if (type === 'appset') { - if (clusters.length === 0) { - clusters.push('-') - } - // Build a single ApplicationStatusMap object rather than an array of objects - const appStatusMap: ApplicationStatusMap = {} - clusters.forEach((cluster) => { - appStatusMap[cluster] = { - health: [[0, 0, 0, 0, 1], [{ key: 'Status', value: 'Missing' }]], - synced: [[0, 0, 0, 0, 1], [{ key: 'Status', value: 'Missing' }]], - deployed: [[0, 0, 0, 0, 0], []], - } - }) - return appStatusMap - } else { - return {} - } - } - return appStatuses -} - -export function getAppNamespace(resource: IResource): string { - let namespace = resource.metadata?.namespace - if (resource.apiVersion === 'argoproj.io/v1alpha1' && resource.kind === 'Application') { - const argoApp = resource as IArgoApplication - namespace = argoApp.spec.destination.namespace - } - return namespace -} -export function getApplicationType(resource: IResource | IOCPApplication) { - if (resource.apiVersion === 'app.k8s.io/v1beta1') { - if (resource.kind === 'Application') { - return 'subscription' - } - } else if (resource.apiVersion === 'argoproj.io/v1alpha1') { - if (resource.kind === 'Application') { - return 'argo' - } else if (resource.kind === 'ApplicationSet') { - return 'appset' - } - } else if ('label' in resource) { - const isFlux = isFluxApplication(resource.label) - if (isFlux) { - return 'flux' - } else if (isSystemApp(resource.metadata?.namespace)) { - return 'openshift-default' - } - return 'openshift' - } - return '-' -} - -const fluxAnnotations = { - helm: ['helm.toolkit.fluxcd.io/name', 'helm.toolkit.fluxcd.io/namespace'], - git: ['kustomize.toolkit.fluxcd.io/name', 'kustomize.toolkit.fluxcd.io/namespace'], -} - -function isFluxApplication(label: string) { - let isFlux = false - Object.entries(fluxAnnotations).forEach(([, values]) => { - const [nameAnnotation, namespaceAnnotation] = values - if (label.includes(nameAnnotation) && label.includes(namespaceAnnotation)) { - isFlux = true - } - }) - return isFlux -} - -////////////////////////////////////////////////////////////////// -////////////// COMPUTE STATUSES ///////////////////////////////////////// -////////////////////////////////////////////////////////////////// -const resErrorStates = new Set([ - 'err', - 'off', - 'invalid', - 'kill', - 'propagationfailed', - 'imagepullbackoff', - 'crashloopbackoff', - 'lost', -]) -const resWarningStates = new Set(['pending', 'creating', 'terminating']) - -export function computeAppHealthStatus(health: ApplicationStatusEntry, app: ISearchResource) { - switch (app.healthStatus) { - case 'Healthy': - health[StatusColumn.counts][ScoreColumn.healthy]++ - break - case 'Degraded': - health[StatusColumn.counts][ScoreColumn.danger]++ - extractMessages(health, app, app.healthStatus) - break - case 'Progressing': - health[StatusColumn.counts][ScoreColumn.progress]++ - extractMessages(health, app, app.healthStatus) - break - case 'Unknown': - health[StatusColumn.counts][ScoreColumn.unknown]++ - extractMessages(health, app, app.healthStatus) - break - default: - health[StatusColumn.counts][ScoreColumn.warning]++ - extractMessages(health, app, app.healthStatus) - break - } -} - -export function computeAppSyncStatus(synced: ApplicationStatusEntry, app: ISearchResource) { - switch (app.syncStatus) { - case 'Synced': - synced[StatusColumn.counts][ScoreColumn.healthy]++ - break - case 'OutOfSync': - synced[StatusColumn.counts][ScoreColumn.warning]++ - break - case 'Unknown': - synced[StatusColumn.counts][ScoreColumn.unknown]++ - extractMessages(synced, app, app.syncStatus) - break - default: - synced[StatusColumn.counts][ScoreColumn.danger]++ - extractMessages(synced, app, app.syncStatus) - break - } -} - -export function computeDeployedPodStatuses( - related: SearchResult['related'], - appStatusesMap: ApplicationClusterStatusMap, - statuses2IDMap: WeakMap, - ignoreHealthCheck?: boolean -) { - // create maps for deployment and replica set - const deploymentMap = createResourceMap(related, 'Deployment') - const replicaSetMap = createResourceMap(related, 'ReplicaSet') - const podMap = createResourceMap(related, 'Pod') - Object.keys(appStatusesMap).forEach((appMapKey) => { - Object.keys(appStatusesMap[appMapKey]).forEach((clusterKey) => { - const appStatuses = appStatusesMap[appMapKey][clusterKey] - if (appStatuses) { - if ( - (appStatuses.health[StatusColumn.counts][ScoreColumn.healthy] > 0 && - appStatuses.synced[StatusColumn.counts][ScoreColumn.healthy] > 0) || - ignoreHealthCheck - ) { - // use these ids to find matching resources to add to app statuses - const ids = statuses2IDMap.get(appStatuses) - const podItems = collectRelatedResources(clusterKey, podMap, ids) - const replicaItems = collectRelatedResources(clusterKey, replicaSetMap, ids) - const deploymentItems = collectRelatedResources(clusterKey, deploymentMap, ids) - // compute pod statuses - computePodStatus(appStatuses.deployed, podItems) - - // calculate current pod count from deployed status - const currentPodCount = - appStatuses.deployed[StatusColumn.counts][ScoreColumn.danger] + - appStatuses.deployed[StatusColumn.counts][ScoreColumn.warning] + - appStatuses.deployed[StatusColumn.counts][ScoreColumn.healthy] + - appStatuses.deployed[StatusColumn.counts][ScoreColumn.progress] - - // compute desired pod count - let desiredPodCount = 0 - if (replicaItems && replicaItems.length > 0) { - desiredPodCount = replicaItems.reduce((acc, item) => { - const desired = Number(item.desired ?? 0) - return acc + desired - }, 0) - } - if (deploymentItems && deploymentItems.length > 0) { - desiredPodCount *= deploymentItems.reduce((acc, item) => { - const desired = Number(item.desired ?? 1) - return acc * desired - }, 1) - } - - // handle missing pods - const deployed = appStatuses.deployed - if (currentPodCount < desiredPodCount) { - let missingCount = desiredPodCount - currentPodCount - - // helper function to process items - const processItems = (items: ISearchResource[]) => { - for (const item of items) { - if (missingCount <= 0) break - - const available = Number(item.available ?? item.current ?? 0) - const desired = Number(item.desired ?? 0) - - if (available === desired) { - continue - } else if (available < desired || desired <= 0) { - deployed[StatusColumn.counts][ScoreColumn.progress]++ - extractMessages(deployed, item) - missingCount-- - } else if (item.desired === undefined || available === 0) { - deployed[StatusColumn.counts][ScoreColumn.danger]++ - extractMessages(deployed, item) - missingCount-- - } - } - } - - // process replicaItems and deploymentItems - if (replicaItems && replicaItems.length > 0) { - processItems(replicaItems) - } - if (deploymentItems && deploymentItems.length > 0) { - processItems(deploymentItems) - } - // if there are still missing pods, add them to the danger count - if (missingCount > 0) { - deployed[StatusColumn.counts][ScoreColumn.warning] += missingCount - deployed[StatusColumn.messages] = [] //[{ key: 'Status', value: `Missing ${missingCount} pods` }] - } - } else if (currentPodCount === 0 && desiredPodCount === 0) { - appStatuses.deployed[StatusColumn.counts] = new Array(ScoreColumnSize).fill(0) as number[] - } - } - } - }) - }) -} - -export function computePodStatus(deployed: ApplicationStatusEntry, pods: ISearchResource[] = []) { - pods.forEach((pod) => { - const status = pod.status.toLocaleLowerCase() - if (status !== 'terminating') { - if (resErrorStates.has(status)) { - deployed[StatusColumn.counts][ScoreColumn.danger]++ - extractMessages(deployed, pod, status) - } else if (resWarningStates.has(status)) { - deployed[StatusColumn.counts][ScoreColumn.warning]++ - extractMessages(deployed, pod, status) - } else { - deployed[StatusColumn.counts][ScoreColumn.healthy]++ - } - } - }) -} - -function createResourceMap(related: SearchResult['related'], kind: string) { - const byName = new Map() - const byUid = new Map() - const relatedItems = related?.find((r) => r.kind === kind) - relatedItems?.items.forEach((item: ISearchResource) => { - let name = getAppNameFromLabel(item.label) - if (name) { - name = `${item.cluster}/${item.namespace}/${name}` - byName.set(name, [...(byName.get(name) || []), item]) - } - if (item._relatedUids) { - item._relatedUids.forEach((uid) => { - if (byUid.has(uid)) { - byUid.get(uid).push(item) - } else { - byUid.set(uid, [item]) - } - }) - } - }) - return { byName, byUid } -} - -function collectRelatedResources( - cluster: string, - map: { byName: Map; byUid: Map }, - ids: { appName: string; deployments?: ISearchResource[]; uids: string[] } -) { - let items: ISearchResource[] = [] - // if this ocp app is made up of multiple deployments, find resources for each deployment - if (ids.deployments && ids.deployments.length > 1) { - ids.deployments.forEach((deployment: ISearchResource) => { - const item = - map.byName.get(`${cluster}/${deployment.namespace}/${deployment.name}`) || map.byUid.get(deployment._uid) || [] - if (item) { - items.push(...item) - } - }) - } else { - // otherwise, just find resources using the app label - items = map.byName.get(`${cluster}/${ids.appName}`) || [] - } - // if no resources found, find resources using the app ownerId - if (items.length === 0) { - ids.uids.forEach((uid) => { - const related = map.byUid.get(uid) - if (related) { - items.push(...(related || [])) - } - }) - } - // Remove duplicates based on _uid - const uniqueItems = new Map() - items.forEach((item) => { - if (item._uid) { - uniqueItems.set(item._uid, item) - } - }) - return Array.from(uniqueItems.values()) -} - -function getAppStatusScores(clusters: string[], appStatuses: ApplicationStatusMap) { - return { - [AppColumns.health]: getAppStatusScore(clusters, appStatuses, AppColumns.health), - [AppColumns.synced]: getAppStatusScore(clusters, appStatuses, AppColumns.synced), - [AppColumns.deployed]: getAppStatusScore(clusters, appStatuses, AppColumns.deployed), - } -} - -function getAppStatusScore(clusters: string[], statuses: ApplicationStatusMap, index: AppColumns) { - let score = 0 - clusters.forEach((cluster) => { - const stats = statuses?.[cluster] - if (stats) { - let column: number[] - switch (index) { - case AppColumns.health: - column = stats.health[StatusColumn.counts] - break - case AppColumns.synced: - column = stats.synced[StatusColumn.counts] - break - case AppColumns.deployed: - column = stats.deployed[StatusColumn.counts] - break - } - if (column) { - score = - column[ScoreColumn.danger] * 1000000 + - column[ScoreColumn.warning] * 100000 + - column[ScoreColumn.progress] * 10000 + - column[ScoreColumn.unknown] * 1000 + - column[ScoreColumn.healthy] - } - } - }) - return score -} - -export function extractMessages(ase: ApplicationStatusEntry, app: ISearchResource, status?: string) { - if (status) { - ase[StatusColumn.messages].push({ key: 'Status', value: status }) - } - Object.entries(app).forEach((entry: [string, string]) => { - if (entry[0].startsWith('_') && (entry[0].includes('condition') || entry[0].includes('missing'))) { - // Don't add message if it already exists - if (!ase[StatusColumn.messages].some((msg) => msg.key === entry[0])) { - ase[StatusColumn.messages].push({ key: entry[0], value: entry[1] }) - } - } - }) -} - -// when these labels are found on a resource they denote what application created them -export const appOwnerLabels: string[] = [ - 'kustomize.toolkit.fluxcd.io/name=', // Flux - 'helm.toolkit.fluxcd.io/name=', // Flux - 'app=', // OpenShift - 'app.kubernetes.io/part-of=', // OpenShift - // 'app.kubernetes.io/name=', // OpenShift -] - -export function getAppNameFromLabel(label: string, defaultName?: string) { - const matchingLabel = appOwnerLabels.find((labelPattern) => label?.includes(labelPattern)) - if (!matchingLabel) return defaultName - - const startIdx = label.indexOf(matchingLabel) + matchingLabel.length - const endIdx = label.indexOf(';', startIdx) - return label.substring(startIdx, endIdx > -1 ? endIdx : undefined) -} - -////////////////////////////////////////////////////////////////// -////////////// OTHER ///////////////////////////////////////// -////////////////////////////////////////////////////////////////// - -export const systemAppNamespacePrefixes: string[] = [] - -/** Clear system app namespace prefixes. Used for test isolation. */ -export function resetSystemAppNamespacePrefixes() { - systemAppNamespacePrefixes.length = 0 -} - -export async function discoverSystemAppNamespacePrefixes() { - if (!systemAppNamespacePrefixes.length) { - systemAppNamespacePrefixes.push('openshift') - systemAppNamespacePrefixes.push('hive') - systemAppNamespacePrefixes.push('open-cluster-management') - const mch = await getMultiClusterHub() - if (mch?.metadata?.namespace && mch.metadata.namespace !== 'open-cluster-management') { - systemAppNamespacePrefixes.push(mch.metadata.namespace) - } - const mce = await getMultiClusterEngine() - systemAppNamespacePrefixes.push(mce?.spec?.targetNamespace || 'multicluster-engine') - } - return systemAppNamespacePrefixes -} - -export function isSystemApp(namespace?: string) { - return namespace && systemAppNamespacePrefixes.some((prefix) => namespace.startsWith(prefix)) -} - -export async function getApplicationClusters( - resource: IResource | IOCPApplication | IArgoApplication, - type: string, - subscriptions: IResource[], - placementDecisions: IResource[], - localCluster: Cluster, - clusters: Cluster[] = [] -) { - switch (type) { - case 'flux': - case 'openshift': - case 'openshift-default': - if ('status' in resource) { - return [resource?.status?.cluster] - } - break - case 'argo': - if ('spec' in resource) { - return [await getArgoCluster(resource, clusters)] - } - break - case 'appset': - if ('spec' in resource) { - if (isArgoPullModel(resource as IApplicationSet)) { - const apps = getPulledAppSetMap()[resource.metadata?.name] || [] - return getArgoPullModelClusterList(apps) - } else { - const apps = getAppSetAppsMap()[resource.metadata?.name] || [] - return await getArgoPushModelClusterList(apps, localCluster, clusters) - } - } - break - case 'subscription': - return getSubscriptionCluster(resource, subscriptions, placementDecisions) - } - return [getHubClusterName()] -} - -const isArgoPullModel = (resource: IApplicationSet) => { - if ( - get(resource, [ - 'spec', - 'template', - 'metadata', - 'annotations', - 'apps.open-cluster-management.io/ocm-managed-cluster', - ]) - ) { - return true - } - return false -} - -function getArgoPullModelClusterList(apps: IArgoAppRemoteResource[]) { - const clusterSet = new Set() - apps.forEach((app) => { - clusterSet.add(app.cluster) - }) - return Array.from(clusterSet) -} - -export const getArgoPushModelClusterList = async ( - resources: IArgoApplication[], - localCluster: Cluster | undefined, - managedClusters: Cluster[] -) => { - const clusterSet = new Set() - - for (const resource of resources) { - const isRemoteArgoApp = !!resource.status?.cluster - - if ( - (resource.spec.destination?.name === 'in-cluster' || - resource.spec.destination?.name === localCluster?.name || - isLocalClusterURL(resource.spec.destination?.server ?? '', localCluster)) && - !isRemoteArgoApp - ) { - clusterSet.add(localCluster?.name ?? '') - } else if (isRemoteArgoApp) { - clusterSet.add( - await getArgoDestinationCluster( - resource.spec.destination, - managedClusters, - resource.status.cluster, - localCluster?.name - ) - ) - } else { - clusterSet.add( - await getArgoDestinationCluster(resource.spec.destination, managedClusters, undefined, localCluster?.name) - ) - } - } - - return Array.from(clusterSet) -} - -function isLocalClusterURL(url: string, localCluster: Cluster | undefined) { - if (url === 'https://kubernetes.default.svc') { - return true - } - - let argoServerURL - const localClusterURL = new URL( - (get(localCluster || {}, 'consoleURL', { default: 'https://localhost' }) as string) || 'https://localhost' - ) - - try { - argoServerURL = new URL(url) - } catch { - return false - } - - const hostnameWithOutAPI = argoServerURL.hostname.substring(argoServerURL.hostname.indexOf('api.') + 4) - - if (localClusterURL.host.includes(hostnameWithOutAPI)) { - return true - } - return false -} - -function getSubscriptionCluster( - resource: IResource, - subscriptions: ISubscription[], - placementDecisions: IPlacementDecision[] -) { - const clusterSet = new Set() - const subAnnotationArray = getSubscriptionAnnotations(resource) - for (const sa of subAnnotationArray) { - if (isLocalSubscription(sa, subAnnotationArray)) { - // skip local sub - continue - } - - const subDetails = sa.split('/') - subscriptions.forEach((sub) => { - if (sub.metadata.name === subDetails[1] && sub.metadata.namespace === subDetails[0]) { - const placementRef = sub.spec.placement?.placementRef - const placement = placementDecisions.find( - (placementDecision) => - placementDecision.metadata.labels?.['cluster.open-cluster-management.io/placement'] === placementRef?.name - ) - - const decisions = placement?.status?.decisions - - if (decisions) { - decisions.forEach((cluster: { clusterName: string }) => { - clusterSet.add(cluster.clusterName) - }) - } - } - }) - } - return Array.from(clusterSet) -} - -const localSubSuffixStr = '-local' -const subAnnotationStr = 'apps.open-cluster-management.io/subscriptions' - -function getSubscriptionAnnotations(resource: IResource) { - const subAnnotation = resource.metadata?.annotations ? resource.metadata?.annotations[subAnnotationStr] : undefined - return subAnnotation ? subAnnotation.split(',') : [] -} - -const isLocalSubscription = (subName: string, subList: string[]) => { - return subName.endsWith(localSubSuffixStr) && subList.includes(subName.slice(0, -localSubSuffixStr.length)) -} - -async function getArgoCluster(resource: IArgoApplication, clusters: Cluster[]) { - if (resource.status?.cluster) { - return resource.status?.cluster - } else if ( - resource.spec.destination?.name === 'in-cluster' || - resource.spec.destination?.name === getHubClusterName() || - resource.spec.destination?.server === 'https://kubernetes.default.svc' - ) { - return getHubClusterName() - } else { - return await getArgoDestinationCluster(resource.spec.destination, clusters, resource?.status?.cluster) - } -} - -export async function getArgoDestinationCluster( - destination: { name?: string; namespace: string; server?: string }, - clusters: Cluster[], - cluster?: string, - hubClusterName?: string -) { - // cluster is the name of the managed cluster where the Argo app is defined - let clusterName - const serverApi = destination?.server - if (serverApi) { - if (serverApi === 'https://kubernetes.default.svc') { - clusterName = cluster || hubClusterName - } else { - const clusterProxyService = await getClusterProxyService() - let server - if (clusterProxyService) { - // if cluster proxy is enabled, use the cluster proxy url - server = clusters.find((cls) => { - const url = getClusterProxyServiceURL(clusterProxyService, cls.name) - return url === serverApi - }) - } else { - server = clusters.find((cls) => cls.kubeApiServer === serverApi) - } - clusterName = server ? server.name : 'unknown' - } - } else { - // target destination was set using the name property - clusterName = destination?.name || 'unknown' - if (cluster && (clusterName === 'in-cluster' || clusterName === hubClusterName)) { - clusterName = cluster - } - - if (clusterName === 'in-cluster') { - clusterName = hubClusterName - } - } - - return clusterName -} - -//////////////////////////////////////////////////////////////////////////////////////////////// -// /////////////////// map created from cluster kube resources collected by events.ts ///////////////// -/////////////////////////////////////////////////////////////////////////////////////////////// -export type ClusterMapType = { - [key: string]: IResource -} -export async function getClusterMap(): Promise { - const managedClusters = await getKubeResources('ManagedCluster', 'cluster.open-cluster-management.io/v1') - return managedClusters.reduce((clusterMap, cluster) => { - if (cluster.metadata.name) { - clusterMap[cluster.metadata.name] = cluster - } - return clusterMap - }, {} as ClusterMapType) -} - -///////////////////////////////////////////////////////////////////////////////// -// ///////// DISTRIBUTE APP QUERIES OVER MULTIPLE SEARCHES ///////////////////// -//////////////////////////////////////////////////////////////////////////////// -export type ApplicationPageChunk = { - keys?: string[] - limit: number -} - -export function getNextApplicationPageChunk( - applicationCache: ApplicationCacheType, - applicationPageChunks: ApplicationPageChunk[], - remoteCacheKey: string -): ApplicationPageChunk { - // if no cluster name chucks left, create a new array of chunks - if (applicationPageChunks.length === 0) { - // get all apps - let applications: ICompressedResource[] = [] - if (applicationCache[remoteCacheKey]?.resources) { - applications = applicationCache[remoteCacheKey].resources - } else if (applicationCache[remoteCacheKey]?.resourceMap) { - applications = Object.values(applicationCache[remoteCacheKey].resourceMap).flat() - } - if (applications.length) { - // create array of frequency of name prefixes - const a = 'a'.charCodeAt(0) - const z = '0'.charCodeAt(0) - const sz = 26 + 10 - const prefixFrequency = new Array(sz).fill(0) as number[] - applications.forEach((app) => { - const name = app.transform[AppColumns.name][0] as string - const ltr = name.charCodeAt(0) - const index = ltr < a ? ltr - z + 26 : ltr - a - prefixFrequency[index]++ - }) - - // create applicationPageChunks - let currentPageChunk: ApplicationPageChunk = { - limit: 0, - keys: [], - } - prefixFrequency.forEach((n, inx) => { - currentPageChunk.keys.push(`${String.fromCharCode(inx + (inx < 26 ? a : z - 26))}*`) - currentPageChunk.limit += n - // start a new page if limit exceeds page maximum - // but consolidate letters that have no occurance with this one - if ( - currentPageChunk.limit + (inx < sz ? prefixFrequency[inx + 1] : 0) > - (Number(process.env.APP_SEARCH_LIMIT) || 5000) - ) { - applicationPageChunks.push(currentPageChunk) - currentPageChunk = { - limit: 0, - keys: [], - } - } - }) - - if (currentPageChunk.limit === 0 && applicationPageChunks.length && applicationPageChunks.length === 1) { - applicationPageChunks.length = 0 - } - // unless there are multiple pages, ignore paging - if (applicationPageChunks.length) { - applicationPageChunks.push(currentPageChunk) - } - } - - // REDISTRIBUTE apps - if (applicationPageChunks.length) { - delete applicationCache[remoteCacheKey].resources - - // if there were no keys before, or the keys changed, redistribute apps - if ( - !applicationCache[remoteCacheKey].resourceMap || - !applicationPageChunks.every(({ keys }) => !!applicationCache[remoteCacheKey].resourceMap[keys.join()]) - ) { - applicationCache[remoteCacheKey].resourceMap = {} - applicationPageChunks.forEach(({ keys }) => { - applicationCache[remoteCacheKey].resourceMap[keys.join()] = [] - }) - - // create a key to values map - const reverse: Record = {} - Object.entries(applicationCache[remoteCacheKey].resourceMap).forEach(([key, value]) => { - key.split(',').forEach((k) => { - reverse[k[0]] = value - }) - }) - // for each app name, stuff it into the array that belongs to that key - applications.forEach((app) => { - const name = app.transform[AppColumns.name][0] as string - reverse[name[0]].push(app) - }) - } - } else if (applicationCache[remoteCacheKey]?.resources) { - // if no keys but there were keys before, delete old resourceMap - applicationCache[remoteCacheKey].resources = applications - delete applicationCache[remoteCacheKey].resourceMap - return - } - } - return applicationPageChunks.shift() -} - -export async function cacheRemoteApps( - applicationCache: ApplicationCacheType, - argoClusterStatusMap: ApplicationClusterStatusMap, - remoteApps: IResource[], - applicationPageChunk: ApplicationPageChunk, - remoteCacheKey: string -) { - const resources = (await transform(remoteApps, argoClusterStatusMap, true)).resources - if (!applicationPageChunk) { - applicationCache[remoteCacheKey].resources = resources - } else { - applicationCache[remoteCacheKey].resourceMap[applicationPageChunk.keys.join()] = resources - } -} - -export function getApplicationsHelper(applicationCache: ApplicationCacheType, keys: string[]) { - const items: ICompressedResource[] = [] - keys.forEach((key) => { - if (applicationCache[key]?.resources) { - items.push(...applicationCache[key].resources) - } else if (applicationCache[key]?.resourceUidMap) { - const allResources = Object.values(applicationCache[key].resourceUidMap) - items.push(...allResources) - } else if (applicationCache[key]?.resourceMap) { - const allResources = Object.values(applicationCache[key].resourceMap) - items.push(...allResources.flat()) - } - }) - return items -} - -////////////////////////////////////////////////////////////////// -// /////////////////// MINI useAllClusters from frontend ///////////////// -////////////////////////////////////////////////////////////////// - -// stream lined version of map clusters in frontend -export async function getClusters(): Promise { - const managedClusters = await getKubeResources('ManagedCluster', 'cluster.open-cluster-management.io/v1') - const clusterDeployments = await getKubeResources('ClusterDeployment', 'hive.openshift.io/v1') - const managedClusterInfos = await getKubeResources( - 'ManagedClusterInfo', - 'internal.open-cluster-management.io/v1beta1' - ) - const hostedClusters = await getKubeResources('HostedCluster', 'hypershift.openshift.io/v1beta1') - const mcs = managedClusters.filter((mc) => mc.metadata?.name) ?? [] - const cds = clusterDeployments.filter( - // CDs with AgentCluster as owner are just meta objects for AI. We can ignore them. - (cd) => (cd.metadata.ownerReferences ? !cd.metadata.ownerReferences.some((o) => o.kind === 'AgentCluster') : true) - ) - const uniqueClusterNames = Array.from( - new Set([ - ...cds.map((cd) => cd.metadata.name), - ...managedClusterInfos.map((mc) => mc.metadata.name), - ...mcs.map((mc) => mc.metadata.name), - ...hostedClusters.map((hc) => hc.metadata?.name), - ]) - ) - const managedClusterMap = keyBy(managedClusters, 'metadata.name') - const hostedClusterMap = keyBy(hostedClusters, 'metadata.name') - const clusterDeploymentsMap = keyBy(cds, 'metadata.name') - const managedClusterInfosMap = keyBy(managedClusterInfos, 'metadata.name') - return uniqueClusterNames.map((cluster) => { - const managedCluster = managedClusterMap[cluster] as ManagedCluster - const hostedCluster = hostedClusterMap[cluster] - const clusterDeployment = clusterDeploymentsMap[cluster] as ClusterDeployment - const managedClusterInfo = managedClusterInfosMap[cluster] as ManagedClusterInfo - const consoleURL = getConsoleUrl(clusterDeployment, managedClusterInfo, managedCluster, hostedCluster) - return { - name: - clusterDeployment?.metadata.name ?? - managedCluster?.metadata.name ?? - managedClusterInfo?.metadata.name ?? - hostedCluster?.metadata?.name ?? - '', - kubeApiServer: getKubeApiServer(clusterDeployment, managedClusterInfo), - consoleURL, - } - }) -} - -function getKubeApiServer(clusterDeployment?: ClusterDeployment, managedClusterInfo?: ManagedClusterInfo) { - return ( - clusterDeployment?.status?.apiURL ?? - managedClusterInfo?.spec?.masterEndpoint ?? - `https://api.${clusterDeployment?.spec?.clusterName || ''}.${clusterDeployment?.spec?.baseDomain || ''}` - ) -} -export function getConsoleUrl( - clusterDeployment?: ClusterDeployment, - managedClusterInfo?: ManagedClusterInfo, - managedCluster?: ManagedCluster, - hostedCluster?: HostedClusterK8sResource -) { - const consoleUrlClaim = managedCluster?.status?.clusterClaims?.find( - (cc) => cc.name === 'consoleurl.cluster.open-cluster-management.io' - ) - if (consoleUrlClaim) return consoleUrlClaim.value - return ( - clusterDeployment?.status?.webConsoleURL ?? - managedClusterInfo?.status?.consoleURL ?? - getHypershiftConsoleURL(hostedCluster) - ) -} - -const getHypershiftConsoleURL = (hostedCluster?: HostedClusterK8sResource) => { - if (!hostedCluster) { - return undefined - } - return `https://console-openshift-console.apps.${hostedCluster.metadata?.name}.${hostedCluster.spec?.dns.baseDomain}` -} - -////////////////////////////////////////////////////////////////// -///////////////////////////// LOGGING //////////////////////////// -////////////////////////////////////////////////////////////////// - -type AppCountType = { - [type: string]: number -} -const appCount: AppCountType = {} -const appCountKeys = [ - 'localArgoApps', - 'remoteArgoApps', - 'localOCPApps', - 'remoteOCPApps', - 'localSysApps', - 'remoteSysApps', -] -appCountKeys.forEach((key) => { - appCount[key] = 0 -}) - -export function logApplicationCountChanges(applicationCache: ApplicationCacheType, pass: number) { - let change = false - appCountKeys.forEach((key) => { - let count - if (applicationCache[key]?.resourceMap) { - count = Object.values(applicationCache[key].resourceMap).flat().length - } else if (applicationCache[key]?.resourceUidMap) { - count = Object.values(applicationCache[key].resourceUidMap).length - } else if (applicationCache[key]?.resources) { - count = applicationCache[key].resources.length - } - if (count !== appCount[key]) { - change = true - appCount[key] = count - } - }) - if (change) { - logger.info({ - msg: 'search change', - appCount, - }) - } else if (pass % 50 === 0) { - logger.info({ - msg: 'search', - appCount, - }) - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const memUsed = (cache: any) => { - return `${Math.round(sizeOf(cache) / 1024) - .toString() - .replaceAll(/\B(?=(\d{3})+(?!\d))/g, ',')} KB` - } - - const appDictObj = getAppDict() - const eventDictObj = getEventDict() - - logger.info({ - msg: 'memory', - caches: { - clients: Object.keys(ServerSideEvents.getClients()).length, - appCache: memUsed(applicationCache), - appDict: memUsed(appDictObj), - eventCache: memUsed(getEventCache()), - eventDict: memUsed(eventDictObj), - }, - }) - if (logger.isLevelEnabled('debug')) { - const recentAppDict = appDictObj.drainRecentlyAdded() - const recentEventDict = eventDictObj.drainRecentlyAdded() - if (recentAppDict.length > 0) { - logger.debug({ - msg: 'appDict growth', - appDictEntries: appDictObj.snapshotSize(), - newEntries: recentAppDict.length, - sample: recentAppDict.slice(0, 50), - }) - } - if (recentEventDict.length > 0) { - logger.debug({ - msg: 'eventDict growth', - eventDictEntries: eventDictObj.snapshotSize(), - newEntries: recentEventDict.length, - sample: recentEventDict.slice(0, 50), - }) - } - } -} - -export function sizeOf(data: unknown) { - let arraySize = 0 - const serializedObj = JSON.stringify(data, (key, value) => { - if (key === 'data' && Array.isArray(value)) { - arraySize += value.length - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return value - } - }) - return Buffer.byteLength(serializedObj ?? '', 'utf8') + arraySize -} - -////////////////////////////////////////////////////////////////// -///////////////// A LITTLE BIT OF LODASH //////////////// -////////////////////////////////////////////////////////////////// -interface ResultType { - [key: string]: IResource -} -type SelectorType = string | ((item: IResource) => string) -export function keyBy(array: IResource[], selector: SelectorType) { - const result: ResultType = {} - for (const item of array) { - const key = typeof selector === 'string' ? (get(item, selector) as string) : selector(item) - result[key] = item - } - return result -} - -////////////////////////////////////////////////////////////////// -///////////////// CLUSTER PROXY SUPPORT //////////////// -////////////////////////////////////////////////////////////////// -export async function getClusterProxyService() { - const services = await getKubeResources('Service', 'v1') - return services.find( - (s) => s.metadata?.name === 'cluster-proxy-addon-user' && s.metadata?.namespace === 'multicluster-engine' - ) -} - -export function getClusterProxyServiceURL(service: IService, cluster: string) { - if (!service) { - return undefined - } - if (!cluster) { - return undefined - } - let port = CLUSTER_PROXY_SERVICE_PORT - if (service.spec?.ports) { - port = service.spec.ports[0].port - } - - return `https://${CLUSTER_PROXY_SERVICE_NAME}.${CLUSTER_PROXY_SERVICE_NAMESPACE}.svc.cluster.local:${port}/${cluster}` -} diff --git a/backend-node/src/routes/events.ts b/backend-node/src/routes/events.ts deleted file mode 100644 index ced6f14a5ed..00000000000 --- a/backend-node/src/routes/events.ts +++ /dev/null @@ -1,1011 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import get from 'get-value' -import got, { CancelError, HTTPError, TimeoutError } from 'got' -import { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import pluralize from 'pluralize' -import { pipeline } from 'node:stream/promises' -import { Transform } from 'node:stream' -import { batchPromiseAll } from '../lib/batch-promise-all' -import { createDictionary, deflateResource, inflateResource } from '../lib/compression' -import { jsonPost } from '../lib/json-request' -import { logger } from '../lib/logger' -import { type ServerSideEvent, ServerSideEvents } from '../lib/server-side-events' -import { getCACertificate, getServiceAccountToken } from '../lib/serviceAccountToken' -import { getAuthenticatedToken } from '../lib/token' -import type { IResource } from '../resources/resource' -import type { IWatchOptions } from '../resources/watch-options' -import { getAppDict, type ICompressedResource, type ITransformedResource } from './aggregators/applications' - -export async function events(req: Http2ServerRequest, res: Http2ServerResponse): Promise { - const token = await getAuthenticatedToken(req, res) - if (token) { - await ServerSideEvents.handleRequest(token, req, res) - } -} - -interface WatchEvent { - type: 'ADDED' | 'DELETED' | 'MODIFIED' | 'BOOKMARK' | 'ERROR' | 'EOP' - object: IResource -} - -export interface SettingsEvent { - type: 'SETTINGS' - settings: Record -} - -type ServerSideEventData = WatchEvent | SettingsEvent | { type: 'START' | 'LOADED' } - -let requests: { cancel: () => void }[] = [] - -export async function getKubeResources(kind: string, apiVersion: string) { - const option = { apiVersion, kind } - const apiVersionPlural = apiVersionPluralFn(option) - const entries = Object.values(resourceCache[apiVersionPlural] || {}) - return batchPromiseAll(entries, (event) => - event.compressed.then((compressed) => inflateResource(compressed, eventDict)) - ) -} - -let hubClusterName = 'local-cluster' -export function getHubClusterName() { - return hubClusterName -} - -/** Reset hub cluster name to default. Used for test isolation. */ -export function resetHubClusterName() { - hubClusterName = 'local-cluster' -} - -let isHubSelfManaged: boolean = false -export function getIsHubSelfManaged() { - return isHubSelfManaged -} - -let isObservabilityInstalled: boolean = false -export function getIsObservabilityInstalled() { - return isObservabilityInstalled -} -export function resetIsObservabilityInstalled() { - isObservabilityInstalled = false -} - -// because rbac checks are expensive, -// run them only on the resources requested by the UI -export async function getAuthorizedResources( - token: string, - resources: ICompressedResource[], - startInx: number, - stopInx: number -): Promise { - const authorized: ITransformedResource[] = [] - - // check every resource until we have reached just the requested number of items - // anything more is a waste of response time - let inx = 0 - const chunkSize = stopInx > 100 ? 100 : 50 - while (resources.length > inx && authorized.length < stopInx) { - // perform it in item chunks - const _resources = (await Promise.all( - resources.slice(inx, inx + chunkSize).map(async (compressedResource) => { - const { compressed, transform, remoteClusters } = compressedResource - const resource = await inflateResource(compressed, getAppDict()) - return { ...resource, transform, remoteClusters } - }) - )) as ITransformedResource[] - const queue = _resources.map((resource) => { - return ( - resource.remoteClusters - ? canAccessRemoteResource(token, resource.remoteClusters) - : canListResources(token, resource) - ) - .then((allowResource) => (allowResource ? resource : undefined)) - .catch(() => {}) as Promise - }) - while (queue.length) { - const resource = await queue.shift() - if (resource) { - authorized.push(resource) - } - } - inx += chunkSize - } - return authorized.slice(startInx, stopInx) -} - -function canListResources(token: string, resource: IResource): Promise { - return canListClusterScopedKind(resource, token).then((allowed) => { - if (allowed) return true - return canListNamespacedScopedKind(resource, token) - }) -} - -// can this user access at least one of these remote clusters -function canAccessRemoteResource(token: string, clusterNames: string[]): Promise { - const promises = clusterNames.map((namespace) => { - return canAccess( - { - kind: 'ManagedClusterView', - apiVersion: 'view.open-cluster-management.io/v1beta1', - metadata: { namespace }, - }, - 'create', - token - ) - }) - return Promise.allSettled(promises).then((results) => { - return results.some((result) => result.status == 'fulfilled' && result.value) - }) -} - -export interface ResourceCache { - [apiVersionKind: string]: { - [uid: string]: { - compressed: Promise - eventID: Promise - } - } -} - -const resourceCache: ResourceCache = {} -export function getEventCache() { - return resourceCache -} - -/** Clear all cached resources. Used for test isolation. */ -export function resetResourceCache() { - for (const key in resourceCache) { - delete resourceCache[key] - } -} - -const eventDict = createDictionary() -export function getEventDict() { - return eventDict -} - -const accessCache: Record }>> = {} - -/** Clear all cached RBAC access checks. Used for test isolation. */ -export function resetAccessCache() { - for (const key in accessCache) { - delete accessCache[key] - } -} - -export function getAccessCache() { - return accessCache -} - -export const ACCESS_CACHE_TTL = 60 * 1000 // 60 seconds -export const ACCESS_CACHE_CLEANUP_INTERVAL = 90 * 1000 // 90 seconds -export const ACCESS_CACHE_MAX_TOKENS = 1000 // Maximum number of token entries to keep - -let accessCacheCleanupTimer: NodeJS.Timeout | undefined - -export function cleanupAccessCache() { - const now = Date.now() - const cutoffTime = now - ACCESS_CACHE_TTL - const tokenStats: Array<{ token: string; newestTime: number }> = [] - - for (const token in accessCache) { - const tokenCache = accessCache[token] - let newestTime = 0 - - for (const key in tokenCache) { - if (tokenCache[key].time < cutoffTime) { - delete tokenCache[key] - } else if (tokenCache[key].time > newestTime) { - newestTime = tokenCache[key].time - } - } - - if (Object.keys(tokenCache).length === 0) { - delete accessCache[token] - } else { - tokenStats.push({ token, newestTime }) - } - } - - if (tokenStats.length > ACCESS_CACHE_MAX_TOKENS) { - tokenStats.sort((a, b) => a.newestTime - b.newestTime) - const tokensToRemove = tokenStats.length - ACCESS_CACHE_MAX_TOKENS - - for (let i = 0; i < tokensToRemove; i++) { - delete accessCache[tokenStats[i].token] - } - } -} - -function startAccessCacheCleanup() { - if (accessCacheCleanupTimer) return - - accessCacheCleanupTimer = setInterval(() => { - try { - cleanupAccessCache() - } catch (err: unknown) { - logger.error({ msg: 'accessCache cleanup failed', error: err }) - } - }, ACCESS_CACHE_CLEANUP_INTERVAL) - - accessCacheCleanupTimer.unref() - logger.info({ msg: 'accessCache cleanup started', interval: ACCESS_CACHE_CLEANUP_INTERVAL }) -} - -function stopAccessCacheCleanup() { - if (accessCacheCleanupTimer) { - clearInterval(accessCacheCleanupTimer) - accessCacheCleanupTimer = undefined - logger.info({ msg: 'accessCache cleanup stopped' }) - } -} - -const definitions: IWatchOptions[] = [ - { kind: 'ClusterManagementAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1' }, - { kind: 'ManagedClusterAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1' }, - { kind: 'Agent', apiVersion: 'agent-install.openshift.io/v1beta1' }, - { kind: 'AgentServiceConfig', apiVersion: 'agent-install.openshift.io/v1beta1' }, - { kind: 'InfraEnv', apiVersion: 'agent-install.openshift.io/v1beta1' }, - { kind: 'NMStateConfig', apiVersion: 'agent-install.openshift.io/v1beta1' }, - { kind: 'Application', apiVersion: 'app.k8s.io/v1beta1' }, - { kind: 'Channel', apiVersion: 'apps.open-cluster-management.io/v1' }, - { kind: 'GitOpsCluster', apiVersion: 'apps.open-cluster-management.io/v1beta1' }, - { kind: 'HelmRelease', apiVersion: 'apps.open-cluster-management.io/v1' }, - { kind: 'Subscription', apiVersion: 'apps.open-cluster-management.io/v1' }, - { kind: 'SubscriptionReport', apiVersion: 'apps.open-cluster-management.io/v1alpha1' }, - { kind: 'Application', apiVersion: 'argoproj.io/v1alpha1', isPolled: true }, - { kind: 'ApplicationSet', apiVersion: 'argoproj.io/v1alpha1', isPolled: true }, - { kind: 'ArgoCD', apiVersion: 'argoproj.io/v1alpha1' }, - { kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false }, - { kind: 'Infrastructure', apiVersion: 'config.openshift.io/v1' }, - { - kind: 'CertificateSigningRequest', - apiVersion: 'certificates.k8s.io/v1', - labelSelector: { 'open-cluster-management.io/cluster-name': '' }, - }, - { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1' }, - { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, - { kind: 'PlacementDecision', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, - { kind: 'ManagedClusterSetBinding', apiVersion: 'cluster.open-cluster-management.io/v1beta2' }, - { kind: 'ManagedClusterSet', apiVersion: 'cluster.open-cluster-management.io/v1beta2' }, - { kind: 'ClusterCurator', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, - { kind: 'Subscription', apiVersion: 'operators.coreos.com/v1alpha1' }, - { kind: 'ClusterExtension', apiVersion: 'olm.operatorframework.io/v1' }, - { kind: 'DiscoveredCluster', apiVersion: 'discovery.open-cluster-management.io/v1' }, - { kind: 'DiscoveryConfig', apiVersion: 'discovery.open-cluster-management.io/v1' }, - { kind: 'AgentClusterInstall', apiVersion: 'extensions.hive.openshift.io/v1beta1' }, - { kind: 'ClusterClaim', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ClusterDeployment', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ClusterImageSet', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ClusterPool', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ClusterProvision', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'MachinePool', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ManagedClusterInfo', apiVersion: 'internal.open-cluster-management.io/v1beta1' }, - { kind: 'BareMetalHost', apiVersion: 'metal3.io/v1alpha1' }, - { kind: 'MultiClusterEngine', apiVersion: 'multicluster.openshift.io/v1' }, - { kind: 'ClusterVersion', apiVersion: 'config.openshift.io/v1' }, - { kind: 'StorageClass', apiVersion: 'storage.k8s.io/v1' }, - { kind: 'PlacementBinding', apiVersion: 'policy.open-cluster-management.io/v1' }, - { kind: 'Policy', apiVersion: 'policy.open-cluster-management.io/v1' }, - { kind: 'PolicyAutomation', apiVersion: 'policy.open-cluster-management.io/v1beta1' }, - { kind: 'PolicySet', apiVersion: 'policy.open-cluster-management.io/v1beta1' }, - { kind: 'SubmarinerConfig', apiVersion: 'submarineraddon.open-cluster-management.io/v1alpha1' }, - { kind: 'AnsibleJob', apiVersion: 'tower.ansible.com/v1alpha1' }, - { kind: 'AnsibleWorkflow', apiVersion: 'tower.ansible.com/v1alpha1' }, - { - kind: 'ConfigMap', - apiVersion: 'v1', - fieldSelector: { 'metadata.name': 'assisted-service' }, - }, - { - kind: 'ConfigMap', - apiVersion: 'v1', - fieldSelector: { 'metadata.namespace': 'openshift-config-managed', 'metadata.name': 'console-public' }, - }, - { kind: 'ConfigMap', apiVersion: 'v1', fieldSelector: { 'metadata.name': 'console-search-config' } }, - { kind: 'Namespace', apiVersion: 'v1' }, - { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'cluster.open-cluster-management.io/credentials': '' } }, - // **Need to look for creds with: 'cluster.open-cluster-management.io/type': 'ans', for edit scenarios - { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'cluster.open-cluster-management.io/type': 'ans' } }, - { kind: 'Secret', apiVersion: 'v1', fieldSelector: { 'metadata.name': 'auto-import-secret' } }, - { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'argocd.argoproj.io/secret-type': 'repository' } }, - { kind: 'PolicyReport', apiVersion: 'wgpolicyk8s.io/v1alpha2' }, - { kind: 'HostedCluster', apiVersion: 'hypershift.openshift.io/v1beta1' }, - { kind: 'NodePool', apiVersion: 'hypershift.openshift.io/v1beta1' }, - { kind: 'AgentMachine', apiVersion: 'capi-provider.agent-install.openshift.io/v1alpha1' }, - { kind: 'ConfigMap', apiVersion: 'v1', labelSelector: { 'hypershift.openshift.io/supported-versions': 'true' } }, - { kind: 'Search', apiVersion: 'search.open-cluster-management.io/v1alpha1' }, - // Configmaps that contain Grafana dashboard IDs - { - kind: 'ConfigMap', - apiVersion: 'v1', - fieldSelector: { 'metadata.name': 'grafana-dashboard-acm-openshift-virtualization-clusters-overview' }, - }, - { - kind: 'ConfigMap', - apiVersion: 'v1', - fieldSelector: { 'metadata.name': 'grafana-dashboard-acm-openshift-virtualization-single-vm-view' }, - }, - { kind: 'MulticlusterRoleAssignment', apiVersion: 'rbac.open-cluster-management.io/v1beta1' }, - { kind: 'User', apiVersion: 'user.openshift.io/v1' }, - { kind: 'Group', apiVersion: 'user.openshift.io/v1' }, - { - kind: 'Service', - apiVersion: 'v1', - fieldSelector: { 'metadata.name': 'cluster-proxy-addon-user', 'metadata.namespace': 'multicluster-engine' }, - }, -] - -export function startWatching(): void { - ServerSideEvents.eventFilter = eventFilter - startAccessCacheCleanup() - - for (const definition of definitions) { - void listAndWatch(definition) - } -} -// https://kubernetes.io/docs/reference/using-api/api-concepts/ -export async function listAndWatch(options: IWatchOptions) { - const serviceAccountToken = getServiceAccountToken() - while (!stopping) { - try { - const { resourceVersion } = await listKubernetesObjects(serviceAccountToken, options) - if (options.isPolled) { - await pollKubernetesObjects(serviceAccountToken, options) - } else { - await watchKubernetesObjects(serviceAccountToken, options, resourceVersion) - } - } catch (err: unknown) { - if (err instanceof SyntaxError) { - // Happens when the response body is not JSON - // Such as the case when the resource version if too old - // fall through to rerun the list function - } else if (err instanceof HTTPError) { - switch (err.response.statusCode) { - case 403: - logger.error({ msg: 'watch', ...options, status: 'Forbidden' }) - await new Promise((resolve) => - setTimeout(resolve, 1 * 60 * 1000 + Math.ceil(Math.random() * 10 * 1000)).unref() - ) - break - case 404: - logger.trace({ msg: 'watch', ...options, status: 'Not found' }) - await new Promise((resolve) => - setTimeout(resolve, 1 * 60 * 1000 + Math.ceil(Math.random() * 10 * 1000)).unref() - ) - break - } - } else if (err instanceof Error) { - if (err.message === 'Premature close' || err.message.startsWith('too old resource version')) { - // Retry list and watch/poll immediately - } else { - await new Promise((resolve) => setTimeout(resolve, 60 * 1000 + Math.ceil(Math.random() * 10 * 1000)).unref()) - } - } else { - await new Promise((resolve) => setTimeout(resolve, 60 * 1000 + Math.ceil(Math.random() * 10 * 1000)).unref()) - } - } - } -} - -async function listKubernetesObjects(serviceAccountToken: string, options: IWatchOptions) { - let resourceVersion = '' - let _continue: string | undefined - let itemCount = 0 - let items: IResource[] = [] - const { isPolled } = options - while (!stopping) { - const url = resourceUrl(options, { limit: '100', continue: _continue }) - const request = got - .get(url, { - headers: { authorization: `Bearer ${serviceAccountToken}` }, - https: { certificateAuthority: getCACertificate() }, - }) - .json<{ - metadata: { _continue?: string; continue?: string; resourceVersion: string } - items: IResource[] - }>() - try { - requests.push(request) - const body = await request - _continue = body.metadata._continue ?? body.metadata.continue - const pruned = pruneResources(options, body.items) - if (isPolled) { - itemCount += pruned.length - } else { - items = items.concat(pruned) - resourceVersion = body.metadata.resourceVersion - } - } finally { - requests = requests.filter((r) => r !== request) - } - if (!_continue) break - } - - if (!isPolled || itemCount > 1000) { - logger.info({ - msg: isPolled ? 'polled' : 'list', - kind: options.kind, - labels: options.labelSelector, - fields: options.fieldSelector, - apiVersion: options.apiVersion, - count: itemCount || items.length, - }) - } - if (isPolled) { - return { size: itemCount } - } - - const forward = options.forwardEventsToClients !== false - await batchPromiseAll(items, (item) => cacheResource(item, forward)) - - // Remove items that are no longer in kubernetes - const apiVersionPlural = apiVersionPluralFn(options) - const cache = resourceCache[apiVersionPlural] - const removeResources: IResource[] = [] - for (const uid in cache) { - const existing = cache[uid] - const resource = await existing.compressed.then((compressed) => inflateResource(compressed, eventDict)) - if (options.fieldSelector && !matchesSelector(resource, options.fieldSelector)) { - // skip as this object would not be in the items result for this list operation - continue - } - if (options.labelSelector && !matchesSelector(resource.metadata?.labels, options.labelSelector)) { - // skip as this object would not be in the items result for this list operation - continue - } - if (!items.find((resource) => resource.metadata.uid === uid)) { - removeResources.push(resource) - } - } - await batchPromiseAll(removeResources, (resource) => deleteResource(resource, forward)) - - return { resourceVersion, size: items.length } -} - -async function pollKubernetesObjects(serviceAccountToken: string, options: IWatchOptions) { - while (!stopping) { - logger.debug({ - msg: 'poll', - kind: options.kind, - labels: options.labelSelector, - fields: options.fieldSelector, - apiVersion: options.apiVersion, - }) - - let size = 2000 - try { - ;({ size } = await listKubernetesObjects(serviceAccountToken, options)) - } catch (e) { - logger.error(`poll kubernetes exception ${e}`) - } - - /* istanbul ignore if */ - if (process.env.NODE_ENV !== 'test') { - // polling interval starting at minTimeout and increasing up to maxTimeout seconds - // where anything above maxApp will get the maximum maxTimeout - // for larger kube resource lists - const maxApps = 5000 - const minTimeout = 15000 - const maxTimeout = 45000 - const timeout = Math.round( - size > maxApps ? maxTimeout : (size * (maxTimeout - minTimeout)) / maxApps + minTimeout - ) - await new Promise((r) => setTimeout(r, timeout)) - } else { - stopping = true - } - } -} - -/** - * Creates a Transform stream that splits incoming data by newline characters - */ -export function createSplitStream() { - let buffer = '' - return new Transform({ - objectMode: true, - transform(chunk: Buffer, _encoding, callback) { - buffer += chunk.toString() - const lines = buffer.split('\n') - // Keep the last incomplete line in the buffer - buffer = lines.pop() || '' - // Push all complete lines - for (const line of lines) { - if (line.trim()) { - this.push(line) - } - } - callback() - }, - flush(callback) { - // Push any remaining data in buffer - if (buffer.trim()) { - this.push(buffer) - } - callback() - }, - }) -} - -/** - * Helper to convert unknown error to string - */ -export function errorToString(err: unknown): string { - if (err instanceof Error) { - return err.message - } - if (typeof err === 'string') { - return err - } - return JSON.stringify(err) -} - -/** - * Creates a Transform stream that processes watch events with async operations - */ -export function createWatchEventProcessor(options: IWatchOptions, url: string, resourceVersionRef: { value: string }) { - const forward = options.forwardEventsToClients !== false - return new Transform({ - objectMode: true, - async transform(data: string, _encoding, callback): Promise { - try { - let watchEvent: WatchEvent - try { - watchEvent = JSON.parse(data) as WatchEvent - } catch (err: unknown) { - logger.error({ - msg: 'JSON.parse failed', - error: errorToString(err), - data, - url, - }) - throw err - } - pruneResources(options, [watchEvent.object]) - switch (watchEvent.type) { - case 'ADDED': - case 'MODIFIED': - try { - await cacheResource(watchEvent.object, forward) - } catch (err: unknown) { - logger.error({ - msg: 'cacheResource failed', - error: errorToString(err), - }) - throw err - } - break - case 'DELETED': - try { - await deleteResource(watchEvent.object, forward) - } catch (err: unknown) { - logger.error({ - msg: 'deleteResource failed', - error: errorToString(err), - }) - throw err - } - break - } - - switch (watchEvent.type) { - case 'ADDED': - logger.debug({ - msg: 'added', - kind: watchEvent.object.kind, - name: watchEvent.object.metadata.name, - namespace: watchEvent.object.metadata.namespace, - apiVersion: watchEvent.object.apiVersion, - }) - resourceVersionRef.value = watchEvent.object.metadata.resourceVersion - break - case 'MODIFIED': - logger.debug({ - msg: 'modify', - kind: watchEvent.object.kind, - name: watchEvent.object.metadata.name, - namespace: watchEvent.object.metadata.namespace, - apiVersion: watchEvent.object.apiVersion, - }) - resourceVersionRef.value = watchEvent.object.metadata.resourceVersion - break - case 'DELETED': - logger.debug({ - msg: 'delete', - kind: watchEvent.object.kind, - name: watchEvent.object.metadata.name, - namespace: watchEvent.object.metadata.namespace, - apiVersion: watchEvent.object.apiVersion, - }) - resourceVersionRef.value = watchEvent.object.metadata.resourceVersion - break - case 'BOOKMARK': - logger.trace({ - msg: watchEvent.type.toLowerCase(), - kind: options.kind, - apiVersion: options.apiVersion, - message: (watchEvent.object as unknown as { message: string }).message, - reason: (watchEvent.object as unknown as { reason: string }).reason, - }) - resourceVersionRef.value = watchEvent.object.metadata.resourceVersion - break - case 'ERROR': - if ((watchEvent.object as unknown as { message?: string }).message.startsWith('too old resource version')) { - logger.warn({ - msg: 'watch', - warning: (watchEvent.object as unknown as { message?: string }).message, - action: 'retrying watch', - kind: options.kind, - apiVersion: options.apiVersion, - }) - } else { - logger.warn({ - msg: 'watch', - action: 'retrying watch', - kind: options.kind, - apiVersion: options.apiVersion, - event: watchEvent, - }) - } - throw new Error((watchEvent.object as unknown as { message?: string }).message) - } - - // Don't push anything downstream - we're just processing events - callback() - } catch (err: unknown) { - // Catch any unexpected errors and pass them to the callback - callback(err instanceof Error ? err : new Error(errorToString(err))) - } - }, - }) -} - -async function watchKubernetesObjects( - serviceAccountToken: string, - options: IWatchOptions, - initialResourceVersion: string -) { - const resourceVersionRef = { value: initialResourceVersion } - while (!stopping) { - logger.debug({ - msg: 'watch', - kind: options.kind, - labels: options.labelSelector, - fields: options.fieldSelector, - apiVersion: options.apiVersion, - }) - - try { - const url = resourceUrl(options, { - watch: undefined, - allowWatchBookmarks: undefined, - resourceVersion: resourceVersionRef.value, - }) - const request = got.stream(url, { - headers: { authorization: `Bearer ${serviceAccountToken}` }, - https: { certificateAuthority: getCACertificate() }, - timeout: { socket: 5 * 60 * 1000 + Math.ceil(Math.random() * 10 * 1000) }, - }) - // TODO use abort signal when on node 16 - const cancelObj = { cancel: () => request.destroy() } - requests.push(cancelObj) - try { - await pipeline(request, createSplitStream(), createWatchEventProcessor(options, url, resourceVersionRef)) - } finally { - requests = requests.filter((r) => r !== cancelObj) - } - } catch (err: unknown) { - if (err instanceof TimeoutError) { - // Timeout when we have not recieved an event in 5 min - // Do nothing - retry the watch - } else if (err instanceof CancelError) { - // Aborting the list/watch causes a CancelError - // Do nothing - fall through to allow exit - } else if (err instanceof SyntaxError) { - // Happens when the response body is not JSON - // Such as the case when the resource version if too old - // Need to throw error to cause a list function to rerun - logger.trace({ msg: 'SyntaxError', ...options }) - throw err - } else if (err instanceof HTTPError) { - switch (err.response.statusCode) { - case 410: - // https://kubernetes.io/docs/reference/using-api/api-concepts/ - // A given Kubernetes server will only preserve a historical record of changes for a limited time. - // Clusters using etcd 3 preserve changes in the last 5 minutes by default. - // When the requested watch operations fail because the historical version of that resource is not available, - // clients must handle the case by recognizing the status code 410 Gone, clearing their local cache, - // performing a new get or list operation, and starting the watch from the resourceVersion that was returned. - // - // Throw error fall through to perform a list and reconcile - throw err - default: - logger.warn({ - msg: 'watch', - warning: (err as Error)?.message, - ...options, - errorName: (err as Error)?.name, - }) - throw err - } - } else { - if ((err as Error)?.message === 'Premature close') { - // Do nothing - } else { - logger.warn({ - msg: 'watch', - warning: (err as Error)?.message, - ...options, - errorName: (err as Error)?.name, - }) - throw err - } - } - } - } -} - -function apiVersionPluralFn(options: { apiVersion: string; kind: string }) { - return `/${options.apiVersion}/${pluralize(options.kind.toLowerCase())}` -} - -function resourceUrl(options: IWatchOptions, query: Record) { - let url = process.env.CLUSTER_API_URL ?? '' - url += options.apiVersion.includes('/') ? '/apis' : '/api' - url += apiVersionPluralFn(options) - - const queryStrings: string[] = [] - for (const key in query) { - const value = query[key] - if (value === undefined) { - queryStrings.push(`${key}`) - } else { - queryStrings.push(`${key}=${value}`) - } - } - - if (options?.labelSelector) { - let labelSelector = 'labelSelector=' - labelSelector += Object.keys(options.labelSelector) - .map((key) => `${key}=${options.labelSelector[key] ?? ''}`) - .join(',') - queryStrings.push(labelSelector) - } - - if (options?.fieldSelector) { - let fieldSelector = 'fieldSelector=' - fieldSelector += Object.keys(options.fieldSelector) - .map((key) => `${key}=${options.fieldSelector[key] ?? ''}`) - .join(',') - queryStrings.push(fieldSelector) - } - - if (queryStrings.length) { - url += '?' + queryStrings.join('&') - } - - return url -} - -const NO_BROADCAST_EVENT_ID = Promise.resolve(-1) - -export async function cacheResource(resource: IResource, forwardEventsToClients = true) { - const apiVersionPlural = apiVersionPluralFn(resource) - let cache = resourceCache[apiVersionPlural] - if (!cache) { - cache = {} - resourceCache[apiVersionPlural] = cache - } - - const uid = resource.metadata.uid - - let existing = cache[uid] - while (existing) { - if ( - (await inflateResource(await existing.compressed, eventDict)).metadata.resourceVersion === - resource.metadata.resourceVersion - ) { - return resource.metadata.resourceVersion - } - const eventID = await existing.eventID - const latestExisting = cache[uid] - if (latestExisting === existing) { - // if no other cacheResource call updated the cache while we were awaiting, we can replace the cache entry and event - if (eventID > 0) ServerSideEvents.removeEvent(eventID) - break - } - // if a deleteResource ran while we were awaiting, we will exit the loop because the resource is no longer existing - // if another cacheResource call updated the cache while we were awaiting, we will check again if the resourceVersion is the same - existing = latestExisting - } - const compressed = deflateResource(resource, eventDict) - const eventID = forwardEventsToClients - ? compressed.then((compressed) => ServerSideEvents.pushEvent({ data: { type: 'MODIFIED', object: compressed } })) - : NO_BROADCAST_EVENT_ID - cache[uid] = { compressed, eventID } - - if (resource.kind === 'ManagedCluster') { - if (resource?.metadata?.labels?.['local-cluster'] === 'true') { - hubClusterName = resource?.metadata?.name - isHubSelfManaged = true - } - } - - if ( - resource.kind === 'ManagedClusterAddOn' && - resource.apiVersion.startsWith('addon.open-cluster-management.io/') && - (resource.metadata?.name === 'observability-controller' || - resource.metadata?.name == 'multicluster-observability-addon') - ) { - isObservabilityInstalled = true - } -} - -async function deleteResource(resource: IResource, forwardEventsToClients = true) { - const apiVersionPlural = apiVersionPluralFn(resource) - const cache = resourceCache[apiVersionPlural] - if (!cache) return - - const uid = resource.metadata.uid - - const existing = cache[uid] - if (existing) { - const eventID = await existing.eventID - if (eventID > 0) ServerSideEvents.removeEvent(eventID) - } - - if (forwardEventsToClients) { - const deletedID = await ServerSideEvents.pushEvent({ - data: { - type: 'DELETED', - object: { - kind: resource.kind, - apiVersion: resource.apiVersion, - metadata: { name: resource.metadata.name, namespace: resource.metadata.namespace }, - }, - }, - }) - // after deletion has been broadcast to current clients, no need to retain - ServerSideEvents.removeEvent(deletedID) - } - delete cache[uid] -} - -function matchesSelector(target: object | undefined, selector: Record) { - if (target === undefined) return false - for (const key in selector) { - const value = selector[key] - const targetValue = get(target, key) as unknown - if (targetValue !== value) return false - } - return true -} - -function eventFilter(token: string, serverSideEvent: ServerSideEvent): Promise { - switch (serverSideEvent.data?.type) { - case 'START': - case 'EOP': - case 'LOADED': - case 'SETTINGS': - return Promise.resolve(true) - - case 'DELETED': - // TODO - Security issue: Only send delete events to clients who can access that item - // - Problem is if the namespace goes away, access check will fail - // - Need to track what is sent to client and only send if they previously accessed this event - return Promise.resolve(true) - case 'ADDED': - case 'MODIFIED': { - const watchEvent = serverSideEvent.data - const resource = watchEvent.object - return canListClusterScopedKind(resource, token).then((allowed) => { - if (allowed) return true - return canListNamespacedScopedKind(resource, token).then((allowed) => { - if (allowed) return true - return canGetResource(resource, token) - }) - }) - } - default: - logger.warn({ msg: 'unhandled server side event data type', serverSideEvent }) - return Promise.resolve(false) - } -} - -function canListClusterScopedKind(resource: IResource, token: string): Promise { - return canAccess({ kind: resource.kind, apiVersion: resource.apiVersion }, 'list', token) -} - -function canListNamespacedScopedKind(resource: IResource, token: string): Promise { - if (!resource.metadata?.namespace) return Promise.resolve(false) - return canAccess( - { - kind: resource.kind, - apiVersion: resource.apiVersion, - metadata: { namespace: resource.metadata.namespace }, - }, - 'list', - token - ) -} - -function canGetResource(resource: IResource, token: string): Promise { - return canAccess(resource, 'get', token) -} - -export function canAccess( - resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, - verb: 'get' | 'list' | 'create', - token: string -): Promise { - // Cache is cleaned up periodically by cleanupAccessCache() to prevent unbounded memory growth - - const key = `${resource.kind}:${resource.metadata?.namespace}:${resource.metadata?.name}` - if (!accessCache[token]) accessCache[token] = {} - const existing = accessCache[token][key] - if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { - return existing.promise - } - - const promise = jsonPost<{ status: { allowed: boolean } }>( - process.env.CLUSTER_API_URL + '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - { - apiVersion: 'authorization.k8s.io/v1', - kind: 'SelfSubjectAccessReview', - metadata: {}, - spec: { - resourceAttributes: { - group: resource.apiVersion.includes('/') ? resource.apiVersion.split('/')[0] : '', - name: resource.metadata?.name, - namespace: - resource.metadata?.namespace ?? (resource.kind === 'Namespace' ? resource.metadata?.name : undefined), - resource: pluralize(resource.kind.toLowerCase()), - verb, - }, - }, - }, - token - ).then((result) => { - if (process.env.LOG_ACCESS === 'true') { - logger.debug({ - msg: 'access', - allowed: result.body.status.allowed, - verb, - resource: pluralize(resource.kind.toLowerCase()), - name: resource.metadata?.name, - namespace: resource.metadata?.namespace, - }) - } - return result.body.status.allowed - }) - - accessCache[token][key] = { - time: Date.now(), - promise, - } - return promise -} - -let stopping = false -export function stopWatching(): void { - stopping = true - stopAccessCacheCleanup() - for (const request of requests) { - request.cancel() - } -} - -function pruneResources(option: IWatchOptions, items: IResource[]) { - return items.map((resource) => { - resource.kind = option.kind - resource.apiVersion = option.apiVersion - switch (resource.kind) { - case 'Policy': - break - default: - delete resource.metadata.managedFields - } - return resource - }) -} diff --git a/backend-node/src/routes/liveness.ts b/backend-node/src/routes/liveness.ts deleted file mode 100644 index b84e8670e11..00000000000 --- a/backend-node/src/routes/liveness.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { constants } from 'node:http2' -import { FetchError } from 'node-fetch' -import { fetchRetry } from '../lib/fetch-retry' -import { logger } from '../lib/logger' -import { respondInternalServerError, respondOK } from '../lib/respond' -import { getServiceAccountToken } from '../lib/serviceAccountToken' -const { HTTP2_HEADER_AUTHORIZATION } = constants - -// The kubelet uses liveness probes to know when to restart a container. -export function liveness(req: Http2ServerRequest, res: Http2ServerResponse): void { - if (!isLive) { - respondInternalServerError(req, res) - } else { - respondOK(req, res) - } -} - -let isLive = true - -export function setDead(): void { - if (isLive) { - logger.warn('liveness set to false') - isLive = false - } -} - -export async function apiServerPing(): Promise { - const msg = 'kube api server ping failed' - try { - const response = await fetchRetry(process.env.CLUSTER_API_URL + '/apis', { - headers: { [HTTP2_HEADER_AUTHORIZATION]: `Bearer ${getServiceAccountToken()}` }, - }) - if (response.status !== 200) { - const { status } = response - logger.error({ msg, response: { status } }) - setDead() - } - void response.blob() - } catch (err) { - if (err instanceof FetchError) { - logger.error({ msg, error: err.message }) - if (err.errno === 'ENOTFOUND' || err.code === 'ENOTFOUND') { - setDead() - } - } else if (err instanceof Error) { - logger.error({ msg, error: err.message }) - } else { - logger.error({ msg, err: err as unknown }) - } - } -} - -if (process.env.NODE_ENV === 'production') { - setInterval(apiServerPing, 30 * 1000).unref() -} diff --git a/backend-node/src/routes/readiness.ts b/backend-node/src/routes/readiness.ts deleted file mode 100644 index 049569b480e..00000000000 --- a/backend-node/src/routes/readiness.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { Http2ServerRequest, Http2ServerResponse } from 'node:http2' -import { liveness } from './liveness' - -// The kubelet uses readiness probes to know when a container is ready to start accepting traffic -export function readiness(req: Http2ServerRequest, res: Http2ServerResponse): void { - liveness(req, res) -} diff --git a/backend-node/test/app.test.ts b/backend-node/test/app.test.ts deleted file mode 100644 index c80b2b7787d..00000000000 --- a/backend-node/test/app.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { stopFileWatches } from '../src/lib/fileWatch' -import { stop } from '../src/app' - -jest.mock('../src/lib/fileWatch', () => ({ - ...jest.requireActual('../src/lib/fileWatch'), - stopFileWatches: jest.fn(), -})) - -const mockStopFileWatches = stopFileWatches as jest.MockedFunction - -describe('app stop', () => { - it('calls stopFileWatches on shutdown', async () => { - await stop() - expect(mockStopFileWatches).toHaveBeenCalled() - }) -}) diff --git a/backend-node/test/jest-setup.ts b/backend-node/test/jest-setup.ts deleted file mode 100644 index 0e77ac85c63..00000000000 --- a/backend-node/test/jest-setup.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import iconvLite from 'iconv-lite' - -process.env.NODE_ENV = 'test' -process.env.LOG_LEVEL = 'silent' -process.env.CLUSTER_API_URL = 'https://example.com' -process.env.TOKEN = 'sa-token' -process.env.ENV_FILE = '../backend/.env' -process.env.CONFIG_DIR = '../backend/config' -process.env.CERTS_DIR = '../backend/certs' - -iconvLite.encodingExists('foo') diff --git a/backend-node/test/lib/agent.test.ts b/backend-node/test/lib/agent.test.ts deleted file mode 100644 index b33cc5fc79f..00000000000 --- a/backend-node/test/lib/agent.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { execFileSync } from 'node:child_process' -import { createServer, type Server } from 'node:https' -import { connect, type PeerCertificate, type TLSSocket } from 'node:tls' -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import type { Agent } from 'node:https' -import { getDefaultAgent, getInsightsAgent } from '../../src/lib/agent' -import * as serviceAccountTokenModule from '../../src/lib/serviceAccountToken' - -// getCACertificate()/getServiceCACertificate() read real files from disk (e.g. the SA-mounted -// ca.crt / service-ca.crt) before ever consulting the CA_CERT/SERVICE_CA_CERT env var fallbacks, and -// some CI environments (e.g. tests running inside a real Kubernetes pod) genuinely have those files -// present, which would otherwise silently override whatever this test tries to configure via env -// vars. Mocking the module directly keeps this test's CA trust deterministic regardless of the -// environment's filesystem. -jest.mock('../../src/lib/serviceAccountToken') - -const mockedGetCACertificate = serviceAccountTokenModule.getCACertificate as jest.MockedFunction< - typeof serviceAccountTokenModule.getCACertificate -> -const mockedGetServiceCACertificate = serviceAccountTokenModule.getServiceCACertificate as jest.MockedFunction< - typeof serviceAccountTokenModule.getServiceCACertificate -> - -// Simulates an in-cluster service (e.g. the Insights Operator proxy) whose TLS certificate is -// signed by a private CA (standing in for OpenShift's service-ca), to verify that getInsightsAgent() -// trusts it while the unrelated getDefaultAgent() correctly does not. -// -// This connects with `tls.connect` directly (rather than `fetch`/`https.request`) because other -// test files' use of `nock` monkey-patches the shared, process-wide `http`/`https` modules for the -// lifetime of the Jest worker; going through that layer here would make the outcome depend on -// which test file happened to run first in this worker. `tls.connect` isn't touched by `nock`. -describe('agent', () => { - let dir: string - let server: Server - let port: number - - beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'agent-test-')) - const caKey = join(dir, 'ca.key') - const caCrt = join(dir, 'ca.crt') - const otherCaKey = join(dir, 'other-ca.key') - const otherCaCrt = join(dir, 'other-ca.crt') - const leafKey = join(dir, 'leaf.key') - const leafCsr = join(dir, 'leaf.csr') - const leafCrt = join(dir, 'leaf.crt') - const extFile = join(dir, 'leaf.ext') - - execFileSync('openssl', [ - 'req', - '-x509', - '-newkey', - 'rsa:2048', - '-keyout', - caKey, - '-out', - caCrt, - '-days', - '1', - '-nodes', - '-subj', - '/CN=Test Service CA', - ]) - // Unrelated CA used to stand in for the kube-apiserver ca.crt that getDefaultAgent() trusts, - // to show that it doesn't happen to trust the service-ca above. - execFileSync('openssl', [ - 'req', - '-x509', - '-newkey', - 'rsa:2048', - '-keyout', - otherCaKey, - '-out', - otherCaCrt, - '-days', - '1', - '-nodes', - '-subj', - '/CN=Test Kube API CA', - ]) - execFileSync('openssl', [ - 'req', - '-newkey', - 'rsa:2048', - '-keyout', - leafKey, - '-out', - leafCsr, - '-nodes', - '-subj', - '/CN=127.0.0.1', - ]) - writeFileSync(extFile, 'subjectAltName=IP:127.0.0.1\n') - execFileSync('openssl', [ - 'x509', - '-req', - '-in', - leafCsr, - '-CA', - caCrt, - '-CAkey', - caKey, - '-CAcreateserial', - '-out', - leafCrt, - '-days', - '1', - '-extfile', - extFile, - ]) - - mockedGetServiceCACertificate.mockReturnValue([readFileSync(caCrt, 'utf-8')]) - mockedGetCACertificate.mockReturnValue([readFileSync(otherCaCrt, 'utf-8')]) - - server = createServer({ cert: readFileSync(leafCrt), key: readFileSync(leafKey) }, (_req, res) => { - res.writeHead(200) - res.end('ok') - }) - - return new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => { - port = (server.address() as { port: number }).port - resolve() - }) - }) - }) - - afterAll(() => { - rmSync(dir, { recursive: true, force: true }) - return new Promise((resolve) => server.close(() => resolve())) - }) - - // rejectUnauthorized is set to false so the handshake always completes and we can inspect the - // verification outcome via `authorized`/`authorizationError`, instead of racing an 'error' event - // (which is what would fire on an untrusted cert with the default rejectUnauthorized: true). - function handshake(agent: Agent): Promise<{ authorized: boolean; authorizationError: Error; cert: PeerCertificate }> { - return new Promise((resolve, reject) => { - const socket: TLSSocket = connect( - { - host: '127.0.0.1', - port, - ca: agent.options.ca, - rejectUnauthorized: false, - }, - () => { - resolve({ - authorized: socket.authorized, - authorizationError: socket.authorizationError, - cert: socket.getPeerCertificate(), - }) - socket.end() - } - ) - socket.on('error', reject) - }) - } - - it('getInsightsAgent trusts a certificate signed by the cluster service-ca', async () => { - const { authorized, authorizationError, cert } = await handshake(getInsightsAgent()) - expect(authorizationError).toBeNull() - expect(authorized).toBe(true) - expect(cert?.subject.CN).toEqual('127.0.0.1') - }) - - it('getDefaultAgent does not trust a certificate signed by the cluster service-ca', async () => { - const { authorized, authorizationError } = await handshake(getDefaultAgent()) - expect(authorized).toBe(false) - expect(authorizationError).toBeTruthy() - }) -}) diff --git a/backend-node/test/lib/batch-promise-all.test.ts b/backend-node/test/lib/batch-promise-all.test.ts deleted file mode 100644 index cb73a05f8a2..00000000000 --- a/backend-node/test/lib/batch-promise-all.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { batchPromiseAll, BATCH_SIZE } from '../../src/lib/batch-promise-all' - -describe('batchPromiseAll', () => { - it('should process all items and return results in order', async () => { - const items = [1, 2, 3, 4, 5] - const results = await batchPromiseAll(items, (n) => Promise.resolve(n * 2)) - expect(results).toEqual([2, 4, 6, 8, 10]) - }) - - it('should handle empty arrays', async () => { - const results = await batchPromiseAll([], (n: number) => Promise.resolve(n)) - expect(results).toEqual([]) - }) - - it('should process items in batches', async () => { - const items = Array.from({ length: BATCH_SIZE * 2 + 3 }, (_, i) => i) - const batchesProcessed: number[][] = [] - - await batchPromiseAll(items, (n) => { - const last = batchesProcessed[batchesProcessed.length - 1] - if (!last || last.length >= BATCH_SIZE) { - batchesProcessed.push([]) - } - batchesProcessed[batchesProcessed.length - 1].push(n) - return Promise.resolve(n) - }) - - expect(batchesProcessed.length).toBe(3) - expect(batchesProcessed[0].length).toBe(BATCH_SIZE) - expect(batchesProcessed[1].length).toBe(BATCH_SIZE) - expect(batchesProcessed[2].length).toBe(3) - }) - - it('should yield the event loop between batches', async () => { - let yielded = false - const items = Array.from({ length: BATCH_SIZE + 1 }, (_, i) => i) - - const originalSetImmediate = global.setImmediate - global.setImmediate = ((fn: () => void) => { - yielded = true - return originalSetImmediate(fn) - }) as typeof setImmediate - - try { - await batchPromiseAll(items, (n) => Promise.resolve(n)) - } finally { - global.setImmediate = originalSetImmediate - } - expect(yielded).toBe(true) - }) - - it('should not yield after the last batch', async () => { - let yieldCount = 0 - const items = Array.from({ length: BATCH_SIZE }, (_, i) => i) - - const originalSetImmediate = global.setImmediate - global.setImmediate = ((fn: () => void) => { - yieldCount++ - return originalSetImmediate(fn) - }) as typeof setImmediate - - try { - await batchPromiseAll(items, (n) => Promise.resolve(n)) - } finally { - global.setImmediate = originalSetImmediate - } - expect(yieldCount).toBe(0) - }) - - it('should propagate errors from the mapper', async () => { - const items = [1, 2, 3] - await expect( - batchPromiseAll(items, (n) => { - if (n === 2) return Promise.reject(new Error('test error')) - return Promise.resolve(n) - }) - ).rejects.toThrow('test error') - }) -}) diff --git a/backend-node/test/lib/compression.test.ts b/backend-node/test/lib/compression.test.ts deleted file mode 100644 index e8e1ff210b2..00000000000 --- a/backend-node/test/lib/compression.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { createDictionary, deflateResource, inflateResource, isTimestamp } from '../../src/lib/compression' -import type { IResource } from '../../src/resources/resource' -import { logger } from '../../src/lib/logger' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const asResource = (obj: Record) => obj as unknown as IResource - -describe('isTimestamp', () => { - it('detects UTC timestamps with Z suffix', () => { - expect(isTimestamp('2026-05-27T20:18:12Z')).toBe(true) - }) - - it('detects timestamps with milliseconds', () => { - expect(isTimestamp('2026-05-27T20:18:12.000Z')).toBe(true) - }) - - it('detects timestamps with timezone offset', () => { - expect(isTimestamp('2026-05-27T20:18:12+05:30')).toBe(true) - expect(isTimestamp('2026-05-27T20:18:12-04:00')).toBe(true) - }) - - it('rejects non-timestamp strings of similar length', () => { - expect(isTimestamp('this-is-not-a-timest')).toBe(false) - expect(isTimestamp('abcdefghijklmnopqrst')).toBe(false) - expect(isTimestamp('192.168.1.1:8080/api')).toBe(false) - }) - - it('rejects near-valid timestamp shapes', () => { - expect(isTimestamp('2026/05/27T12:34:56Z')).toBe(false) // wrong date separator - expect(isTimestamp('2026-05-27 12:34:56Z')).toBe(false) // space instead of T - expect(isTimestamp('2026-05-27T12:34:56UTC')).toBe(false) // wrong length, "UTC" suffix - expect(isTimestamp('2026-05-27T12.34.56Z')).toBe(false) // dots instead of colons in time - expect(isTimestamp('026-05-27T12:34:56Z')).toBe(false) // wrong length, 3-digit year - expect(isTimestamp('2026-05-27T12:34:56+0530')).toBe(true) // colonless offset is still a valid timestamp - }) - - it('rejects strings of wrong length', () => { - expect(isTimestamp('short')).toBe(false) - expect(isTimestamp('2026-05-27')).toBe(false) - expect(isTimestamp('a-very-long-string-that-is-definitely-not-a-timestamp')).toBe(false) - }) - - it('rejects empty string', () => { - expect(isTimestamp('')).toBe(false) - }) -}) - -describe('dictionary tracking', () => { - let isLevelEnabledSpy: jest.SpyInstance - - beforeEach(() => { - isLevelEnabledSpy = jest.spyOn(logger, 'isLevelEnabled').mockReturnValue(true) - }) - - afterEach(() => { - isLevelEnabledSpy.mockRestore() - }) - - it('tracks recently added entries and exposes snapshot size', () => { - const dict = createDictionary() - dict.add('alpha') - dict.add('beta') - dict.add('alpha') - - expect(dict.snapshotSize()).toBe(2) - expect(dict.recentlyAdded).toContain('alpha') - expect(dict.recentlyAdded).toContain('beta') - }) - - it('drains recently added entries', () => { - const dict = createDictionary() - dict.add('one') - dict.add('two') - - const drained = dict.drainRecentlyAdded() - expect(drained).toEqual(['one', 'two']) - expect(dict.recentlyAdded).toHaveLength(0) - expect(dict.snapshotSize()).toBe(2) - }) -}) - -describe('compressResource timestamp handling', () => { - it('does not add timestamp values to the dictionary', async () => { - const dict = createDictionary() - const resource = asResource({ - apiVersion: 'argoproj.io/v1alpha1', - kind: 'Application', - metadata: { - name: 'test-app', - uid: 'test-uid', - }, - status: { - reconciledAt: '2026-05-27T20:18:12Z', - operationState: { - startedAt: '2026-05-27T20:18:10Z', - finishedAt: '2026-05-27T20:18:12Z', - }, - }, - }) - - await deflateResource(resource, dict) - const dictEntries = dict.arr - - expect(dictEntries).not.toContain('2026-05-27T20:18:12Z') - expect(dictEntries).not.toContain('2026-05-27T20:18:10Z') - }) - - it('still adds non-timestamp short strings to the dictionary', async () => { - const dict = createDictionary() - const resource = asResource({ - apiVersion: 'argoproj.io/v1alpha1', - kind: 'Application', - metadata: { - name: 'test-app', - uid: 'test-uid', - }, - status: { - health: { - status: 'Healthy', - }, - sync: { - status: 'Synced', - }, - }, - }) - - await deflateResource(resource, dict) - const dictEntries = dict.arr - - expect(dictEntries).toContain('Healthy') - expect(dictEntries).toContain('Synced') - }) - - it('round-trips resources with timestamp values correctly', async () => { - const dict = createDictionary() - const resource = asResource({ - apiVersion: 'argoproj.io/v1alpha1', - kind: 'Application', - metadata: { - name: 'test-app', - uid: 'test-uid', - }, - status: { - reconciledAt: '2026-05-27T20:18:12Z', - health: { - status: 'Healthy', - }, - }, - }) - - const compressed = await deflateResource(resource, dict) - const inflated = structuredClone(await inflateResource(compressed, dict)) as unknown as { - apiVersion: string - metadata: { name: string } - status: { reconciledAt: string; health: { status: string } } - } - - expect(inflated.status.reconciledAt).toBe('2026-05-27T20:18:12Z') - expect(inflated.status.health.status).toBe('Healthy') - expect(inflated.apiVersion).toBe('argoproj.io/v1alpha1') - expect(inflated.metadata.name).toBe('test-app') - }) - - it('does not grow the dictionary on repeated compression with changing timestamps', async () => { - const dict = createDictionary() - const makeResource = (ts: string) => - asResource({ - apiVersion: 'argoproj.io/v1alpha1', - kind: 'Application', - metadata: { name: 'test-app', uid: 'uid-1' }, - status: { reconciledAt: ts }, - }) - - await deflateResource(makeResource('2026-05-27T10:00:00Z'), dict) - const sizeAfterFirst = dict.arr.length - - await deflateResource(makeResource('2026-05-27T10:03:00Z'), dict) - await deflateResource(makeResource('2026-05-27T10:06:00Z'), dict) - await deflateResource(makeResource('2026-05-27T10:09:00Z'), dict) - const sizeAfterFourth = dict.arr.length - - expect(sizeAfterFourth).toBe(sizeAfterFirst) - }) -}) diff --git a/backend-node/test/lib/fileWatch.test.ts b/backend-node/test/lib/fileWatch.test.ts deleted file mode 100644 index aaf2a4d9088..00000000000 --- a/backend-node/test/lib/fileWatch.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { watch } from 'node:fs' -import { stopFileWatches, watchFile } from '../../src/lib/fileWatch' - -jest.mock('node:fs', () => ({ - ...jest.requireActual('node:fs'), - watch: jest.fn(), -})) - -const mockWatch = watch as jest.MockedFunction - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -describe('fileWatch', () => { - let mockClose: jest.Mock - let watchListener: (eventType: string, filename: string) => void - - beforeEach(() => { - jest.useFakeTimers() - mockClose = jest.fn() - mockWatch.mockImplementation((_path: string, listener: (event: string, name: string) => void) => { - watchListener = listener - return { close: mockClose } as unknown as ReturnType - }) - }) - - afterEach(() => { - stopFileWatches() - jest.useRealTimers() - mockWatch.mockReset() - }) - - it('starts watching on first watchFile call and invokes onChange after debounce when file change is emitted', () => { - const onChange = jest.fn() - watchFile('/var/run/secrets/kubernetes.io/serviceaccount/token', onChange) - - expect(mockWatch).toHaveBeenCalledTimes(1) - expect(mockWatch).toHaveBeenCalledWith('/var/run/secrets/kubernetes.io/serviceaccount/token', expect.any(Function)) - - watchListener('change', 'token') - expect(onChange).not.toHaveBeenCalled() - - jest.advanceTimersByTime(1000) - expect(onChange).toHaveBeenCalledTimes(1) - }) - - it('reuses same watcher for multiple callbacks on the same path', () => { - const onChange1 = jest.fn() - const onChange2 = jest.fn() - watchFile('/some/path/ca.crt', onChange1) - watchFile('/some/path/ca.crt', onChange2) - - expect(mockWatch).toHaveBeenCalledTimes(1) - - watchListener('change', 'ca.crt') - jest.advanceTimersByTime(1000) - expect(onChange1).toHaveBeenCalledTimes(1) - expect(onChange2).toHaveBeenCalledTimes(1) - }) - - it('removes watch when persist is false (default) so next watchFile creates a new watcher', () => { - const onChange = jest.fn() - watchFile('/path/token', onChange) - expect(mockWatch).toHaveBeenCalledTimes(1) - - watchListener('change', 'token') - jest.advanceTimersByTime(1000) - expect(onChange).toHaveBeenCalledTimes(1) - expect(mockClose).toHaveBeenCalledTimes(1) - - watchFile('/path/token', jest.fn()) - expect(mockWatch).toHaveBeenCalledTimes(2) - }) - - it('does not remove watch when persist is true', () => { - const onChange = jest.fn() - watchFile('/path/token', onChange, true) - watchListener('change', 'token') - jest.advanceTimersByTime(1000) - expect(onChange).toHaveBeenCalledTimes(1) - expect(mockClose).not.toHaveBeenCalled() - }) - - it('creates separate watchers for different paths', () => { - watchFile('/path/a', jest.fn()) - watchFile('/path/b', jest.fn()) - expect(mockWatch).toHaveBeenCalledTimes(2) - expect(mockWatch).toHaveBeenCalledWith('/path/a', expect.any(Function)) - expect(mockWatch).toHaveBeenCalledWith('/path/b', expect.any(Function)) - }) - - it('debounces rapid change events', () => { - const onChange = jest.fn() - watchFile('/path/token', onChange) - - watchListener('change', 'token') - jest.advanceTimersByTime(500) - watchListener('change', 'token') - jest.advanceTimersByTime(500) - expect(onChange).not.toHaveBeenCalled() - jest.advanceTimersByTime(500) - expect(onChange).toHaveBeenCalledTimes(1) - }) - - it('stopFileWatches closes all watchers and clears state', () => { - const onChange = jest.fn() - watchFile('/path/token', onChange) - expect(mockClose).not.toHaveBeenCalled() - - stopFileWatches() - expect(mockClose).toHaveBeenCalledTimes(1) - - stopFileWatches() - expect(mockClose).toHaveBeenCalledTimes(1) - }) - - it('does not throw when watch throws (e.g. file missing)', () => { - mockWatch.mockImplementation(() => { - throw new Error('ENOENT') - }) - const onChange = jest.fn() - - expect(() => watchFile('/nonexistent/file', onChange)).not.toThrow() - stopFileWatches() - }) -}) - -describe('fileWatch with real timers', () => { - let mockClose: jest.Mock - let watchListener: (eventType: string, filename: string) => void - - beforeEach(() => { - mockClose = jest.fn() - mockWatch.mockImplementation((_path: string, listener: (event: string, name: string) => void) => { - watchListener = listener - return { close: mockClose } as unknown as ReturnType - }) - }) - - afterEach(() => { - stopFileWatches() - mockWatch.mockReset() - }) - - it('invokes onChange after debounce with real timers', async () => { - jest.useRealTimers() - const onChange = jest.fn() - watchFile('/path/token', onChange) - watchListener('change', 'token') - await delay(1100) - expect(onChange).toHaveBeenCalledTimes(1) - jest.useFakeTimers() - }) -}) - -describe('watchServiceAccountFile path', () => { - it('getServiceAccountToken triggers watchFile with path containing serviceaccount and token', async () => { - const watchModule = await import('../../src/lib/fileWatch') - const watchFileSpy = jest.spyOn(watchModule, 'watchFile') - const tokenModule = await import('../../src/lib/serviceAccountToken') - tokenModule.getServiceAccountToken() - expect(watchFileSpy).toHaveBeenCalledWith(expect.stringContaining('serviceaccount'), expect.any(Function)) - expect(watchFileSpy.mock.calls[0][0]).toContain('token') - watchFileSpy.mockRestore() - }) -}) diff --git a/backend-node/test/lib/getServiceToken.test.ts b/backend-node/test/lib/getServiceToken.test.ts deleted file mode 100644 index a8550dad95b..00000000000 --- a/backend-node/test/lib/getServiceToken.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import nock from 'nock' -import { getOcmServiceToken } from '../../src/lib/getServiceToken' - -const SSO_HOST = 'https://sso.redhat.com' -const SSO_PATH = '/auth/realms/redhat-external/protocol/openid-connect/token' - -describe('getOcmServiceToken', () => { - beforeEach(() => { - nock.cleanAll() - }) - - test('should exchange client credentials for an access token', async () => { - const clientId = Buffer.from('my-client-id').toString('base64') - const clientSecret = Buffer.from('my-client-secret').toString('base64') - - nock(SSO_HOST) - .post(SSO_PATH, (body: string) => { - const params = new URLSearchParams(body) - return ( - params.get('grant_type') === 'client_credentials' && - params.get('client_id') === 'my-client-id' && - params.get('client_secret') === 'my-client-secret' - ) - }) - .reply(200, { access_token: 'mock-access-token' }) - - const token = await getOcmServiceToken(clientId, clientSecret) - expect(token).toBe('mock-access-token') - }) - - test('should base64-decode the client_id and client_secret', async () => { - const rawId = 'decoded-id' - const rawSecret = 'decoded-secret' - const clientId = Buffer.from(rawId).toString('base64') - const clientSecret = Buffer.from(rawSecret).toString('base64') - - nock(SSO_HOST) - .post(SSO_PATH, (body: string) => { - const params = new URLSearchParams(body) - return params.get('client_id') === rawId && params.get('client_secret') === rawSecret - }) - .reply(200, { access_token: 'token-123' }) - - const token = await getOcmServiceToken(clientId, clientSecret) - expect(token).toBe('token-123') - }) - - test('should throw an error when the SSO request fails', async () => { - const clientId = Buffer.from('id').toString('base64') - const clientSecret = Buffer.from('secret').toString('base64') - - nock(SSO_HOST).post(SSO_PATH).reply(401, 'Invalid credentials') - - await expect(getOcmServiceToken(clientId, clientSecret)).rejects.toThrow( - 'Token exchange failed (401): Invalid credentials' - ) - }) - - test('should throw an error on server error response', async () => { - const clientId = Buffer.from('id').toString('base64') - const clientSecret = Buffer.from('secret').toString('base64') - - nock(SSO_HOST).post(SSO_PATH).reply(500, 'Internal Server Error') - - await expect(getOcmServiceToken(clientId, clientSecret)).rejects.toThrow('Token exchange failed (500)') - }) -}) diff --git a/backend-node/test/lib/paths.test.ts b/backend-node/test/lib/paths.test.ts deleted file mode 100644 index 02e36ce00c9..00000000000 --- a/backend-node/test/lib/paths.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { certFile, certsDir, configDir, envFilePath } from '../../src/lib/paths' - -describe('paths', () => { - const originalEnv = process.env - - beforeEach(() => { - process.env = { ...originalEnv } - delete process.env.ENV_FILE - delete process.env.CONFIG_DIR - delete process.env.CERTS_DIR - }) - - afterAll(() => { - process.env = originalEnv - }) - - it('uses defaults when env vars are unset', () => { - expect(envFilePath()).toBe('.env') - expect(configDir()).toBe('./config') - expect(certsDir()).toBe('./certs') - expect(certFile('tls.crt')).toBe('./certs/tls.crt') - }) - - it('uses env overrides when set', () => { - process.env.ENV_FILE = '../backend/.env' - process.env.CONFIG_DIR = '../backend/config' - process.env.CERTS_DIR = '../backend/certs' - - expect(envFilePath()).toBe('../backend/.env') - expect(configDir()).toBe('../backend/config') - expect(certsDir()).toBe('../backend/certs') - expect(certFile('tls.key')).toBe('../backend/certs/tls.key') - }) -}) diff --git a/backend-node/test/lib/placementDebugCAWatch.test.ts b/backend-node/test/lib/placementDebugCAWatch.test.ts deleted file mode 100644 index 8bbf1e2ba19..00000000000 --- a/backend-node/test/lib/placementDebugCAWatch.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import nock from 'nock' -import { watchPlacementDebugCA, getPlacementDebugCA } from '../../src/lib/placementDebugCAWatch' -import * as serviceAccountTokenModule from '../../src/lib/serviceAccountToken' - -jest.mock('../../src/lib/serviceAccountToken') - -const mockedGetServiceAccountToken = serviceAccountTokenModule.getServiceAccountToken as jest.MockedFunction< - typeof serviceAccountTokenModule.getServiceAccountToken -> -const mockedGetCACertificate = serviceAccountTokenModule.getCACertificate as jest.MockedFunction< - typeof serviceAccountTokenModule.getCACertificate -> - -const API_PATH = '/api/v1/namespaces/open-cluster-management-hub/configmaps' -const TEST_CA = '-----BEGIN CERTIFICATE-----\nTEST_CA_DATA\n-----END CERTIFICATE-----' -const UPDATED_CA = '-----BEGIN CERTIFICATE-----\nUPDATED_CA_DATA\n-----END CERTIFICATE-----' - -function makeConfigMapList(resourceVersion: string, caData?: string) { - const items = - caData !== undefined - ? [ - { - metadata: { name: 'ca-bundle-configmap', resourceVersion }, - data: { 'ca-bundle.crt': caData }, - }, - ] - : [] - return { metadata: { resourceVersion }, items } -} - -function makeConfigMapListNoKey(resourceVersion: string) { - return { - metadata: { resourceVersion }, - items: [ - { - metadata: { name: 'ca-bundle-configmap', resourceVersion }, - data: { 'some-other-key': 'value' }, - }, - ], - } -} - -function makeWatchEvent(type: string, resourceVersion: string, caData?: string, extra?: Record) { - const data: Record = {} - if (caData !== undefined) data['ca-bundle.crt'] = caData - return JSON.stringify({ - type, - object: { - metadata: { name: 'ca-bundle-configmap', resourceVersion }, - data: caData !== undefined ? data : undefined, - ...extra, - }, - }) -} - -function waitForCalls(fn: { mock: { calls: unknown[][] } }, count: number, timeoutMs = 5000): Promise { - return new Promise((resolve, reject) => { - const deadline = Date.now() + timeoutMs - const check = setInterval(() => { - if (fn.mock.calls.length >= count) { - clearInterval(check) - resolve() - } else if (Date.now() > deadline) { - clearInterval(check) - reject(new Error(`Timed out waiting for ${count} calls, got ${fn.mock.calls.length}`)) - } - }, 20) - }) -} - -describe('placementDebugCAWatch', () => { - let stopWatch: (() => void) | undefined - const originalClusterApiUrl = process.env.CLUSTER_API_URL - - beforeEach(() => { - jest.clearAllMocks() - stopWatch = undefined - process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' - mockedGetServiceAccountToken.mockReturnValue('mock-token') - mockedGetCACertificate.mockReturnValue(undefined) - }) - - afterEach(async () => { - stopWatch?.() - stopWatch = undefined - nock.abortPendingRequests() - nock.cleanAll() - await new Promise((resolve) => setTimeout(resolve, 50)) - }) - - afterAll(() => { - if (originalClusterApiUrl === undefined) { - delete process.env.CLUSTER_API_URL - } else { - process.env.CLUSTER_API_URL = originalClusterApiUrl - } - }) - - it('should call onCAChange after the initial list with CA bundle present', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000', TEST_CA)) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 1) - - expect(onCAChange).toHaveBeenCalledTimes(1) - expect(getPlacementDebugCA()).toBe(TEST_CA) - }) - - it('should not call onCAChange when list returns empty items', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await new Promise((resolve) => setTimeout(resolve, 200)) - - expect(onCAChange).not.toHaveBeenCalled() - expect(getPlacementDebugCA()).toBeUndefined() - }) - - it('should not call onCAChange when ConfigMap exists but ca-bundle.crt key is missing', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapListNoKey('1000')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await new Promise((resolve) => setTimeout(resolve, 200)) - - expect(onCAChange).not.toHaveBeenCalled() - expect(getPlacementDebugCA()).toBeUndefined() - }) - - it('should clear CA and call onCAChange when relist finds ConfigMap removed', async () => { - const onCAChange = jest.fn() - - // First list returns CA - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000', TEST_CA)) - - // Watch returns ERROR to trigger relist - const errorEvent = makeWatchEvent('ERROR', '1001', undefined, { - message: 'too old resource version: 1000 (5000)', - }) - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, errorEvent + '\n') - - // Relist returns empty — ConfigMap was deleted - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch === undefined) - .reply(200, makeConfigMapList('5000')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 2) - - expect(onCAChange).toHaveBeenCalledTimes(2) - expect(getPlacementDebugCA()).toBeUndefined() - }) - - it('should call onCAChange when watch delivers ADDED event', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000')) - - const addedEvent = makeWatchEvent('ADDED', '1001', TEST_CA) - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, addedEvent + '\n') - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 1) - - expect(onCAChange).toHaveBeenCalledTimes(1) - expect(getPlacementDebugCA()).toBe(TEST_CA) - }) - - it('should call onCAChange when watch delivers MODIFIED event with new CA', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000', TEST_CA)) - - const modifiedEvent = makeWatchEvent('MODIFIED', '1001', UPDATED_CA) - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, modifiedEvent + '\n') - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 2) - - expect(onCAChange).toHaveBeenCalledTimes(2) - expect(getPlacementDebugCA()).toBe(UPDATED_CA) - }) - - it('should not call onCAChange a second time when CA has not changed', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000', TEST_CA)) - - const watchBody = - makeWatchEvent('MODIFIED', '1001', TEST_CA) + '\n' + makeWatchEvent('MODIFIED', '1002', TEST_CA) + '\n' - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, watchBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 1) - await new Promise((resolve) => setTimeout(resolve, 300)) - - expect(onCAChange).toHaveBeenCalledTimes(1) - }) - - it('should clear CA on DELETED event', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000', TEST_CA)) - - const deletedEvent = makeWatchEvent('DELETED', '1001') - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, deletedEvent + '\n') - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 2) - - expect(onCAChange).toHaveBeenCalledTimes(2) - expect(getPlacementDebugCA()).toBeUndefined() - }) - - it('should handle BOOKMARK events without calling onCAChange', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000', TEST_CA)) - - const bookmarkEvent = JSON.stringify({ - type: 'BOOKMARK', - object: { metadata: { name: 'ca-bundle-configmap', resourceVersion: '2000' } }, - }) - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, bookmarkEvent + '\n') - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 1) - await new Promise((resolve) => setTimeout(resolve, 300)) - - expect(onCAChange).toHaveBeenCalledTimes(1) - }) - - it('should re-list after a watch ERROR event with "too old resource version"', async () => { - const onCAChange = jest.fn() - let listCount = 0 - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch === undefined) - .reply(200, () => { - listCount++ - return makeConfigMapList('1000', TEST_CA) - }) - - const errorEvent = makeWatchEvent('ERROR', '1001', undefined, { - message: 'too old resource version: 1000 (5000)', - }) - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, errorEvent + '\n') - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch === undefined) - .reply(200, () => { - listCount++ - return makeConfigMapList('5000', TEST_CA) - }) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await new Promise((resolve, reject) => { - const deadline = Date.now() + 5000 - const interval = setInterval(() => { - if (listCount >= 2) { - clearInterval(interval) - resolve() - } else if (Date.now() > deadline) { - clearInterval(interval) - reject(new Error(`Timed out waiting for relist; listCount=${listCount}`)) - } - }, 20) - }) - - expect(listCount).toBeGreaterThanOrEqual(2) - }) - - it('should stop the loop when the stop function is called', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000', TEST_CA)) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 1) - - stopWatch() - - await new Promise((resolve) => setTimeout(resolve, 200)) - - const callCount = onCAChange.mock.calls.length - await new Promise((resolve) => setTimeout(resolve, 200)) - expect(onCAChange).toHaveBeenCalledTimes(callCount) - }) - - it('should retry watch (not full relist) on premature close', async () => { - const onCAChange = jest.fn() - let listCount = 0 - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch === undefined) - .reply(200, () => { - listCount++ - return makeConfigMapList('1000', TEST_CA) - }) - - // Watch responds with empty body — causes "Premature close" - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, '') - - // Second watch also ends - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, '') - - // Third watch stays open - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 1) - await new Promise((resolve) => setTimeout(resolve, 500)) - - expect(listCount).toBe(1) - expect(onCAChange).toHaveBeenCalledTimes(1) - }) - - it('should use the service account token from getServiceAccountToken', async () => { - mockedGetServiceAccountToken.mockReturnValue('custom-sa-token') - const onCAChange = jest.fn() - - const scope = nock('https://api.test-cluster.com:6443', { - reqheaders: { authorization: 'Bearer custom-sa-token' }, - }) - .get(API_PATH) - .query(true) - .reply(200, makeConfigMapList('1000', TEST_CA)) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 1) - - expect(scope.isDone()).toBe(true) - expect(mockedGetServiceAccountToken).toHaveBeenCalled() - }) - - it('should handle multiple CA changes in a single watch stream', async () => { - const onCAChange = jest.fn() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeConfigMapList('1000', TEST_CA)) - - const watchBody = - makeWatchEvent('MODIFIED', '1001', UPDATED_CA) + '\n' + makeWatchEvent('MODIFIED', '1002', TEST_CA) + '\n' - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, watchBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchPlacementDebugCA(onCAChange) - - await waitForCalls(onCAChange, 3) - - expect(onCAChange).toHaveBeenCalledTimes(3) - expect(getPlacementDebugCA()).toBe(TEST_CA) - }) -}) diff --git a/backend-node/test/lib/tlsProfileWatch.test.ts b/backend-node/test/lib/tlsProfileWatch.test.ts deleted file mode 100644 index f152bf4f4bd..00000000000 --- a/backend-node/test/lib/tlsProfileWatch.test.ts +++ /dev/null @@ -1,1016 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import nock from 'nock' -import type { SecureContextOptions } from 'node:tls' -import { watchTLSSecurityProfile } from '../../src/lib/tlsProfileWatch' -import * as serviceAccountTokenModule from '../../src/lib/serviceAccountToken' -import * as crypto from 'node:crypto' - -jest.mock('../../src/lib/serviceAccountToken') -jest.mock('node:crypto', () => { - const actual = jest.requireActual('node:crypto') - return { ...actual, getFips: jest.fn(() => 0) } -}) - -type ProfileChangeHandler = (opts: SecureContextOptions) => Promise - -const mockedGetServiceAccountToken = serviceAccountTokenModule.getServiceAccountToken as jest.MockedFunction< - typeof serviceAccountTokenModule.getServiceAccountToken -> -const mockedGetCACertificate = serviceAccountTokenModule.getCACertificate as jest.MockedFunction< - typeof serviceAccountTokenModule.getCACertificate -> -const mockedGetFips = crypto.getFips as jest.MockedFunction - -function createProfileChangeMock(): jest.MockedFunction { - return jest.fn, [SecureContextOptions]>().mockResolvedValue(undefined) -} - -const API_PATH = '/apis/config.openshift.io/v1/apiservers' - -function makeAPIServerList( - resourceVersion: string, - profileType?: string, - minTLSVersion?: string, - ciphers?: string[], - groups?: string[] -) { - const spec: Record = {} - if (profileType) { - const profile: Record = { type: profileType } - if (profileType === 'Custom') { - profile.custom = { minTLSVersion, ciphers, groups } - } - spec.tlsSecurityProfile = profile - } - return { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServerList', - metadata: { resourceVersion }, - items: [ - { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServer', - metadata: { name: 'cluster', resourceVersion }, - spec, - }, - ], - } -} - -function makeWatchEvent( - type: string, - resourceVersion: string, - profileType?: string, - minTLSVersion?: string, - extra?: Record, - groups?: string[], - ciphers?: string[] -) { - const spec: Record = {} - if (profileType) { - spec.tlsSecurityProfile = { type: profileType } - if (profileType === 'Custom') { - ;(spec.tlsSecurityProfile as Record).custom = { minTLSVersion, ciphers, groups } - } else if (minTLSVersion) { - ;(spec.tlsSecurityProfile as Record).custom = { minTLSVersion } - } - } - return JSON.stringify({ - type, - object: { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServer', - metadata: { name: 'cluster', resourceVersion }, - spec, - ...extra, - }, - }) -} - -function waitForCalls(fn: { mock: { calls: unknown[][] } }, count: number, timeoutMs = 5000): Promise { - return new Promise((resolve, reject) => { - const deadline = Date.now() + timeoutMs - const check = setInterval(() => { - if (fn.mock.calls.length >= count) { - clearInterval(check) - resolve() - } else if (Date.now() > deadline) { - clearInterval(check) - reject(new Error(`Timed out waiting for ${count} calls, got ${fn.mock.calls.length}`)) - } - }, 20) - }) -} - -describe('tlsProfileWatch', () => { - let stopWatch: (() => void) | undefined - const originalClusterApiUrl = process.env.CLUSTER_API_URL - - beforeEach(() => { - jest.clearAllMocks() - stopWatch = undefined - process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' - mockedGetServiceAccountToken.mockReturnValue('mock-token') - mockedGetCACertificate.mockReturnValue(undefined) - mockedGetFips.mockReturnValue(0) - }) - - afterEach(async () => { - stopWatch?.() - stopWatch = undefined - nock.abortPendingRequests() - nock.cleanAll() - // Allow the async list+watch loop to observe the stop flag and exit - // before the next test resets it via watchTLSSecurityProfile - await new Promise((resolve) => setTimeout(resolve, 50)) - }) - - afterAll(() => { - if (originalClusterApiUrl === undefined) { - delete process.env.CLUSTER_API_URL - } else { - process.env.CLUSTER_API_URL = originalClusterApiUrl - } - }) - - it('should call onProfileChange after the initial list with Intermediate profile', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange).toHaveBeenCalledTimes(1) - expect(onProfileChange.mock.calls[0][0].minVersion).toBe('TLSv1.2') - }) - - it('should call onProfileChange with Old profile TLSv1 minVersion', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeAPIServerList('1000', 'Old')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].minVersion).toBe('TLSv1') - }) - - it('should call onProfileChange with Modern profile TLSv1.3 minVersion', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeAPIServerList('1000', 'Modern')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].minVersion).toBe('TLSv1.3') - }) - - it('should default to Intermediate when no TLS profile is set on the APIServer', async () => { - const onProfileChange = createProfileChangeMock() - - const listBody = { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServerList', - metadata: { resourceVersion: '500' }, - items: [ - { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServer', - metadata: { name: 'cluster', resourceVersion: '500' }, - spec: {}, - }, - ], - } - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, listBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].minVersion).toBe('TLSv1.2') - }) - - it('should not call onProfileChange when list returns empty items', async () => { - const onProfileChange = createProfileChangeMock() - - const emptyList = { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServerList', - metadata: { resourceVersion: '100' }, - items: [] as unknown[], - } - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, emptyList) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await new Promise((resolve) => setTimeout(resolve, 200)) - - expect(onProfileChange).not.toHaveBeenCalled() - }) - - it('should not call onProfileChange a second time when the profile has not changed', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - const watchBody = - makeWatchEvent('MODIFIED', '1001', 'Intermediate') + - '\n' + - makeWatchEvent('MODIFIED', '1002', 'Intermediate') + - '\n' - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, watchBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - await new Promise((resolve) => setTimeout(resolve, 300)) - - expect(onProfileChange).toHaveBeenCalledTimes(1) - }) - - it('should call onProfileChange when the watch delivers a profile change', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - const watchBody = makeWatchEvent('MODIFIED', '1001', 'Modern') + '\n' - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, watchBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 2) - - expect(onProfileChange).toHaveBeenCalledTimes(2) - expect(onProfileChange.mock.calls[0][0].minVersion).toBe('TLSv1.2') - expect(onProfileChange.mock.calls[1][0].minVersion).toBe('TLSv1.3') - }) - - it('should handle BOOKMARK events without calling onProfileChange', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - const bookmarkEvent = JSON.stringify({ - type: 'BOOKMARK', - object: { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServer', - metadata: { name: 'cluster', resourceVersion: '2000' }, - spec: {}, - }, - }) - const watchBody = bookmarkEvent + '\n' - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, watchBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - await new Promise((resolve) => setTimeout(resolve, 300)) - - expect(onProfileChange).toHaveBeenCalledTimes(1) - }) - - it('should re-list after a watch ERROR event with "too old resource version"', async () => { - const onProfileChange = createProfileChangeMock() - let listCount = 0 - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch === undefined) - .reply(200, () => { - listCount++ - return makeAPIServerList('1000', 'Intermediate') - }) - - const errorEvent = JSON.stringify({ - type: 'ERROR', - object: { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServer', - metadata: { name: 'cluster' }, - spec: {}, - message: 'too old resource version: 1000 (5000)', - }, - }) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, errorEvent + '\n') - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch === undefined) - .reply(200, () => { - listCount++ - return makeAPIServerList('5000', 'Intermediate') - }) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await new Promise((resolve, reject) => { - const deadline = Date.now() + 5000 - const interval = setInterval(() => { - if (listCount >= 2) { - clearInterval(interval) - resolve() - } else if (Date.now() > deadline) { - clearInterval(interval) - reject(new Error(`Timed out waiting for relist; listCount=${listCount}`)) - } - }, 20) - }) - - expect(listCount).toBeGreaterThanOrEqual(2) - }) - - it('should stop the loop when the stop function is called', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - stopWatch() - - await new Promise((resolve) => setTimeout(resolve, 200)) - - const callCount = onProfileChange.mock.calls.length - await new Promise((resolve) => setTimeout(resolve, 200)) - expect(onProfileChange).toHaveBeenCalledTimes(callCount) - }) - - it('should call onProfileChange on ADDED event during watch', async () => { - const onProfileChange = createProfileChangeMock() - - const emptyList = { - apiVersion: 'config.openshift.io/v1', - kind: 'APIServerList', - metadata: { resourceVersion: '100' }, - items: [] as unknown[], - } - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, emptyList) - - const addedEvent = makeWatchEvent('ADDED', '200', 'Modern') - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, addedEvent + '\n') - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange).toHaveBeenCalledTimes(1) - expect(onProfileChange.mock.calls[0][0].minVersion).toBe('TLSv1.3') - }) - - it('should retry immediately when watch stream ends prematurely', async () => { - const onProfileChange = createProfileChangeMock() - let listCount = 0 - - // First list - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch === undefined) - .reply(200, () => { - listCount++ - return makeAPIServerList('1000', 'Intermediate') - }) - - // Watch responds with empty body — causes "Premature close", which retries the watch - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, '') - - // Second watch also ends — causes another "Premature close", which retries the watch again - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, '') - - // Third watch stays open - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - await new Promise((resolve) => setTimeout(resolve, 500)) - - // Only one list call — premature close retries the watch, not the full list+watch cycle - expect(listCount).toBe(1) - expect(onProfileChange).toHaveBeenCalledTimes(1) - }) - - it('should use the service account token from getServiceAccountToken', async () => { - mockedGetServiceAccountToken.mockReturnValue('custom-sa-token') - const onProfileChange = createProfileChangeMock() - - const scope = nock('https://api.test-cluster.com:6443', { - reqheaders: { authorization: 'Bearer custom-sa-token' }, - }) - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(scope.isDone()).toBe(true) - expect(mockedGetServiceAccountToken).toHaveBeenCalled() - }) - - it('should handle multiple profile changes in a single watch stream', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - const watchBody = - makeWatchEvent('MODIFIED', '1001', 'Modern') + '\n' + makeWatchEvent('MODIFIED', '1002', 'Old') + '\n' - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, watchBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 3) - - expect(onProfileChange).toHaveBeenCalledTimes(3) - expect(onProfileChange.mock.calls[0][0].minVersion).toBe('TLSv1.2') - expect(onProfileChange.mock.calls[1][0].minVersion).toBe('TLSv1.3') - expect(onProfileChange.mock.calls[2][0].minVersion).toBe('TLSv1') - }) - - describe('groups/ecdhCurve', () => { - it('should include default groups for Intermediate profile', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519MLKEM768:X25519:secp256r1:secp384r1') - }) - - it('should include default groups for Old profile', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443').get(API_PATH).query(true).reply(200, makeAPIServerList('1000', 'Old')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519MLKEM768:X25519:secp256r1:secp384r1') - }) - - it('should include default groups for Modern profile', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Modern')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519MLKEM768:X25519:secp256r1:secp384r1') - }) - - it('should use explicit groups from Custom profile', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList('1000', 'Custom', 'VersionTLS12', ['ECDHE-RSA-AES128-GCM-SHA256'], ['X25519', 'secp256r1']) - ) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519:secp256r1') - }) - - it('should fall back to Intermediate groups when Custom profile has no groups', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Custom', 'VersionTLS12', ['ECDHE-RSA-AES128-GCM-SHA256'])) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519MLKEM768:X25519:secp256r1:secp384r1') - }) - - it('should include PQC hybrid groups in Custom profile when specified', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList('1000', 'Custom', 'VersionTLS13', undefined, [ - 'X25519MLKEM768', - 'SecP256r1MLKEM768', - 'X25519', - 'secp256r1', - ]) - ) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519MLKEM768:SecP256r1MLKEM768:X25519:secp256r1') - }) - - it('should include secp521r1 when specified in Custom profile', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList( - '1000', - 'Custom', - 'VersionTLS12', - ['ECDHE-RSA-AES128-GCM-SHA256'], - ['X25519', 'secp256r1', 'secp384r1', 'secp521r1'] - ) - ) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519:secp256r1:secp384r1:secp521r1') - }) - - it('should trigger onProfileChange when only groups change via watch', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList('1000', 'Custom', 'VersionTLS12', ['ECDHE-RSA-AES128-GCM-SHA256'], ['X25519', 'secp256r1']) - ) - - const watchBody = - makeWatchEvent( - 'MODIFIED', - '1001', - 'Custom', - 'VersionTLS12', - undefined, - ['X25519', 'secp256r1', 'secp384r1'], - ['ECDHE-RSA-AES128-GCM-SHA256'] - ) + '\n' - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, watchBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 2) - - expect(onProfileChange).toHaveBeenCalledTimes(2) - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519:secp256r1') - expect(onProfileChange.mock.calls[1][0].ecdhCurve).toBe('X25519:secp256r1:secp384r1') - }) - - it('should not trigger onProfileChange when groups are unchanged', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList('1000', 'Custom', 'VersionTLS12', ['ECDHE-RSA-AES128-GCM-SHA256'], ['X25519', 'secp256r1']) - ) - - const watchBody = - makeWatchEvent( - 'MODIFIED', - '1001', - 'Custom', - 'VersionTLS12', - undefined, - ['X25519', 'secp256r1'], - ['ECDHE-RSA-AES128-GCM-SHA256'] - ) + '\n' - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .reply(200, watchBody) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - await new Promise((resolve) => setTimeout(resolve, 300)) - - expect(onProfileChange).toHaveBeenCalledTimes(1) - }) - }) - - describe('FIPS mode', () => { - beforeEach(() => { - mockedGetFips.mockReturnValue(1) - }) - - it('should exclude X25519 and X25519MLKEM768 from built-in profiles in FIPS mode', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Intermediate')) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('secp256r1:secp384r1') - }) - - it('should exclude X25519 and X25519MLKEM768 from Custom profile groups in FIPS mode', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList( - '1000', - 'Custom', - 'VersionTLS12', - ['ECDHE-RSA-AES128-GCM-SHA256'], - ['X25519MLKEM768', 'X25519', 'secp256r1', 'secp384r1'] - ) - ) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('secp256r1:secp384r1') - }) - - it('should allow FIPS-approved PQC hybrids in FIPS mode', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList( - '1000', - 'Custom', - 'VersionTLS12', - ['ECDHE-RSA-AES128-GCM-SHA256'], - ['SecP256r1MLKEM768', 'SecP384r1MLKEM1024', 'secp256r1', 'secp384r1'] - ) - ) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe( - 'SecP256r1MLKEM768:SecP384r1MLKEM1024:secp256r1:secp384r1' - ) - }) - - it('should allow secp521r1 in FIPS mode', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList( - '1000', - 'Custom', - 'VersionTLS12', - ['ECDHE-RSA-AES128-GCM-SHA256'], - ['secp256r1', 'secp384r1', 'secp521r1'] - ) - ) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('secp256r1:secp384r1:secp521r1') - }) - - it('should result in empty ecdhCurve when only non-FIPS groups are specified', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply( - 200, - makeAPIServerList( - '1000', - 'Custom', - 'VersionTLS12', - ['ECDHE-RSA-AES128-GCM-SHA256'], - ['X25519MLKEM768', 'X25519'] - ) - ) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('') - }) - }) - - describe('Custom profile with TLS 1.3', () => { - it('should use Modern ciphers when Custom profile has TLS 1.3 and no ciphers', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Custom', 'VersionTLS13', undefined, ['X25519MLKEM768', 'X25519'])) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - const opts = onProfileChange.mock.calls[0][0] - expect(opts.minVersion).toBe('TLSv1.3') - expect(opts.ecdhCurve).toBe('X25519MLKEM768:X25519') - // Should contain TLS 1.3 ciphers from Modern profile - expect(opts.ciphers).toContain('TLS_AES_128_GCM_SHA256') - }) - - it('should use explicit groups with TLS 1.3 Custom profile', async () => { - const onProfileChange = createProfileChangeMock() - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query(true) - .reply(200, makeAPIServerList('1000', 'Custom', 'VersionTLS13', undefined, ['X25519MLKEM768'])) - - nock('https://api.test-cluster.com:6443') - .get(API_PATH) - .query((q) => q.watch !== undefined) - .delay(60000) - .reply(200, '') - - stopWatch = watchTLSSecurityProfile(onProfileChange) - - await waitForCalls(onProfileChange, 1) - - expect(onProfileChange.mock.calls[0][0].ecdhCurve).toBe('X25519MLKEM768') - }) - }) -}) diff --git a/backend-node/test/mock-request.ts b/backend-node/test/mock-request.ts deleted file mode 100644 index 874403dee99..00000000000 --- a/backend-node/test/mock-request.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { - constants, - Http2ServerRequest, - Http2ServerResponse, - type IncomingHttpHeaders, - type OutgoingHttpHeaders, - type ServerHttp2Stream, - type ServerStreamResponseOptions, -} from 'node:http2' -import nock from 'nock' -import { Duplex } from 'node:stream' -import { requestHandler, stop } from '../src/app' - -export async function request( - method: 'GET' | 'PUT' | 'POST' | 'DELETE', - path: string, - body?: Record, - extraHeaders?: IncomingHttpHeaders -): Promise { - const stream = createReadWriteStream() - const headers: IncomingHttpHeaders = { - ...extraHeaders, - [constants.HTTP2_HEADER_METHOD]: method, - [constants.HTTP2_HEADER_PATH]: path, - [constants.HTTP2_HEADER_AUTHORIZATION]: 'Bearer ', - } - if (body) { - headers[constants.HTTP2_HEADER_CONTENT_TYPE] = 'application/json' - } - - const result = new Promise((resolve) => { - const req = new Http2ServerRequest(stream as ServerHttp2Stream, headers, {}, []) - const res = mockResponse(resolve) - void requestHandler(req, res) - }) - - if (body) { - stream.write(Buffer.from(JSON.stringify(body))) - } - stream.end() - - return result -} - -export function mockResponse(resolve: (value: Http2ServerResponse) => void): Http2ServerResponse { - const stream = createReadWriteStream() as ServerHttp2Stream - const res = new Http2ServerResponse(stream) - stream.respond = (headers?: OutgoingHttpHeaders, _options?: ServerStreamResponseOptions) => { - if (headers) { - res.statusCode = Number(headers[constants.HTTP2_HEADER_STATUS]) - } - resolve(res) - } - setTimeout(() => resolve(res), 2000) // time out after 2 seconds - return res -} - -beforeAll(nock.disableNetConnect) -afterAll(stop) - -export function createReadWriteStream() { - const chunks: unknown[] = [] - let destroy = false - let write = false - const stream = new Duplex({ - autoDestroy: false, - write: (chunk: unknown, _encoding: BufferEncoding, next: (error?: Error | null) => void) => { - if (write) { - write = false - stream.push(chunk) - } else { - chunks.push(chunk) - } - next() - }, - final: (done: (error?: Error | null) => void) => { - if (write) { - stream.push(null) // No more data - } else { - destroy = true - } - done() - }, - read: (_size: number) => { - if (chunks.length > 0) { - stream.push(chunks.shift()) - } else if (destroy) { - stream.push(null) - } else { - write = true - } - }, - }) - return stream -} - -export async function requestMultiChunk( - method: 'GET' | 'PUT' | 'POST' | 'DELETE', - path: string, - body: Record, - extraHeaders?: IncomingHttpHeaders -): Promise { - const stream = createReadWriteStream() - const headers: IncomingHttpHeaders = { - ...extraHeaders, - [constants.HTTP2_HEADER_METHOD]: method, - [constants.HTTP2_HEADER_PATH]: path, - [constants.HTTP2_HEADER_AUTHORIZATION]: 'Bearer ', - } - headers[constants.HTTP2_HEADER_CONTENT_TYPE] = 'application/json' - - const result = new Promise((resolve) => { - const req = new Http2ServerRequest(stream as ServerHttp2Stream, headers, {}, []) - const res = mockResponse(resolve) - void requestHandler(req, res) - }) - - const bodyBuffer = Buffer.from(JSON.stringify(body)) - const mid = Math.floor(bodyBuffer.length / 2) - stream.write(bodyBuffer.subarray(0, mid)) - stream.write(bodyBuffer.subarray(mid)) - stream.end() - - return result -} - -export async function waitUntil(callback: () => Promise | boolean): Promise { - return new Promise((resolve) => { - function attempt() { - const result = callback() - if (result instanceof Promise) { - result - .then((success) => { - if (success) resolve() - else setTimeout(attempt, 1) - }) - .catch(() => { - setTimeout(attempt, 1) - }) - } else { - if (result) resolve() - setTimeout(attempt, 1) - } - } - attempt() - }) -} diff --git a/backend-node/test/routes/aggregators/applications.test.ts b/backend-node/test/routes/aggregators/applications.test.ts deleted file mode 100644 index 151dceef3ec..00000000000 --- a/backend-node/test/routes/aggregators/applications.test.ts +++ /dev/null @@ -1,649 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import nock from 'nock' - -// Mock the search module BEFORE importing anything that uses it -jest.mock('../../../src/lib/search', () => { - const actual = jest.requireActual('../../../src/lib/search') - return { - ...actual, - getSearchResults: jest.fn(), - } -}) - -import { - aggregateRemoteApplications, - resetApplicationCache, - applicationCache, - filterApplications, - sortApplications, - getStatusFilterKey, - addUIData, - AppColumns, - type ICompressedResource, - type ApplicationScoresMap, - stopAggregatingApplications, -} from '../../../src/routes/aggregators/applications' -import { cacheResource } from '../../../src/routes/events' -import type { IResource } from '../../../src/resources/resource' -import { getSearchResults } from '../../../src/lib/search' - -// Get the mocked function -const mockGetSearchResults = getSearchResults as jest.MockedFunction - -// Set a reasonable test timeout -jest.setTimeout(10000) - -describe('applications aggregateRemoteApplications', () => { - beforeEach(() => { - resetApplicationCache() - nock.cleanAll() - // Mock for getMultiClusterHub - nock(process.env.CLUSTER_API_URL || 'https://example.com') - .get('/apis/operator.open-cluster-management.io/v1/multiclusterhubs') - .times(20) - .reply(200, { items: [] }) - }) - - afterEach(async () => { - stopAggregatingApplications() - nock.cleanAll() - // Give time for any pending promises to settle - await new Promise((resolve) => setImmediate(resolve)) - }) - - afterAll(async () => { - nock.restore() - // Clean up the ServerSideEvents interval to prevent orphan handles - const { ServerSideEvents } = await import('../../../src/lib/server-side-events') - await ServerSideEvents.dispose() - }) - - describe('aggregateRemoteApplications', () => { - beforeEach(() => { - // Set up a default mock return value for all tests in this describe block - mockGetSearchResults.mockResolvedValue({ - data: { - searchResult: [ - { items: [], related: [] }, // Argo - { items: [], related: [] }, // OCP - ], - }, - }) - }) - - it('should cache system applications when pass is appropriate', async () => { - // Setup - const managedCluster: IResource = { - apiVersion: 'cluster.open-cluster-management.io/v1', - kind: 'ManagedCluster', - metadata: { - name: 'local-cluster', - labels: { - 'local-cluster': 'true', - name: 'local-cluster', - }, - }, - } - await cacheResource(managedCluster) - - // Mock API calls - nock(process.env.CLUSTER_API_URL || 'https://example.com') - .post(/.*/) - .times(20) - .reply(200, { status: { allowed: true } }) - - nock(process.env.CLUSTER_API_URL || 'https://example.com') - .get(/.*/) - .times(20) - .reply(200, { items: [] }) - - // Mock getSearchResults to return controlled data - mockGetSearchResults.mockResolvedValue({ - data: { - searchResult: [ - { - items: [], - related: [], - }, - { - items: [], - related: [], - }, - { - // System apps - items: [ - { - _uid: 'sys-app-uid-1', - apigroup: 'apps', - apiversion: 'v1', - kind: 'Deployment', - name: 'test-system-app', - namespace: 'openshift-console', - cluster: 'local-cluster', - created: '2024-01-01T00:00:00Z', - label: 'app=test-system-app', - }, - ], - related: [], - }, - ], - }, - }) - - // Execute - pass 1 will query system apps (pass < 60 || pass % 5 === 0) - await aggregateRemoteApplications(1) - - // Verify: system apps should be processed - expect(applicationCache['remoteSysApps']).toBeDefined() - }) - - it('should handle search API errors gracefully', async () => { - // Mock getSearchResults to throw an error - mockGetSearchResults.mockRejectedValue(new Error('Search API unavailable')) - - // Execute - should not throw - await aggregateRemoteApplications(1) - - // Verify: cache should still be defined but possibly empty - expect(applicationCache).toBeDefined() - }) - - it('should cache system applications when pass is appropriate', async () => { - // Setup - const managedCluster: IResource = { - apiVersion: 'cluster.open-cluster-management.io/v1', - kind: 'ManagedCluster', - metadata: { - name: 'local-cluster', - labels: { - 'local-cluster': 'true', - name: 'local-cluster', - }, - }, - } - await cacheResource(managedCluster) - - // Mock API calls - nock(process.env.CLUSTER_API_URL || 'https://example.com') - .post(/.*/) - .times(20) - .reply(200, { status: { allowed: true } }) - - nock(process.env.CLUSTER_API_URL || 'https://example.com') - .get(/.*/) - .times(20) - .reply(200, { items: [] }) - - // Mock getSearchResults - only 2 search results should be queried - mockGetSearchResults.mockResolvedValue({ - data: { - searchResult: [ - { - items: [], - related: [], - }, - { - items: [], - related: [], - }, - ], - }, - }) - - // Execute - pass > 60 and not divisible by 5, should NOT query system apps - await aggregateRemoteApplications(61) - - // Verify: function completed without error - expect(applicationCache).toBeDefined() - }) - - it('should handle search API errors gracefully', async () => { - // Mock getSearchResults to throw an error - mockGetSearchResults.mockRejectedValue(new Error('Search API unavailable')) - - // Execute - should not throw - await aggregateRemoteApplications(1) - - // Verify: cache should still be defined but possibly empty - expect(applicationCache).toBeDefined() - }) - }) - - describe('filterApplications', () => { - it('should filter applications by type', () => { - const items: ICompressedResource[] = [ - { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - ['2024-01-01'], // created - ], - }, - { - compressed: Buffer.from('{}'), - transform: [ - ['app2'], // name - ['argo'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - ['2024-01-01'], // created - ], - }, - ] - - const filters = { - type: ['subscription'], - } - - const filtered = filterApplications(filters, items) - expect(filtered.length).toBe(1) - expect(filtered[0].transform[AppColumns.type][0]).toBe('subscription') - }) - - it('should filter applications by cluster', () => { - const items: ICompressedResource[] = [ - { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1', 'cluster2'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - ['2024-01-01'], // created - ], - }, - { - compressed: Buffer.from('{}'), - transform: [ - ['app2'], // name - ['argo'], // type - ['default'], // namespace - ['cluster3'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - ['2024-01-01'], // created - ], - }, - ] - - const filters = { - cluster: ['cluster2'], - } - - const filtered = filterApplications(filters, items) - expect(filtered.length).toBe(1) - expect(filtered[0].transform[AppColumns.clusters]).toContain('cluster2') - }) - }) - - describe('sortApplications', () => { - it('should sort applications by name ascending', () => { - const items: ICompressedResource[] = [ - { - compressed: Buffer.from('{}'), - transform: [ - ['zebra'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - ['2024-01-01'], // created - ], - }, - { - compressed: Buffer.from('{}'), - transform: [ - ['apple'], // name - ['argo'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - ['2024-01-01'], // created - ], - }, - ] - - const sorted = sortApplications({ index: AppColumns.name, direction: 'asc' }, items) - expect(sorted[0].transform[AppColumns.name][0]).toBe('apple') - expect(sorted[1].transform[AppColumns.name][0]).toBe('zebra') - }) - - it('should sort applications by name descending', () => { - const items: ICompressedResource[] = [ - { - compressed: Buffer.from('{}'), - transform: [ - ['apple'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - ['2024-01-01'], // created - ], - }, - { - compressed: Buffer.from('{}'), - transform: [ - ['zebra'], // name - ['argo'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - ['2024-01-01'], // created - ], - }, - ] - - const sorted = sortApplications({ index: AppColumns.name, direction: 'desc' }, items) - expect(sorted[0].transform[AppColumns.name][0]).toBe('zebra') - expect(sorted[1].transform[AppColumns.name][0]).toBe('apple') - }) - - it('should sort applications by health status', () => { - const items: ICompressedResource[] = [ - { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - healthy (low score) - ['2024-01-01'], // created - ], - }, - { - compressed: Buffer.from('{}'), - transform: [ - ['app2'], // name - ['argo'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 1000, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - }, - ], // scores - unhealthy (high score) - ['2024-01-01'], // created - ], - }, - ] - - const sorted = sortApplications({ index: AppColumns.health, direction: 'asc' }, items) - // With 'asc' direction and bScore - aScore sort, higher scores come first - // So app2 (score 1000) should be first, app1 (score 0) should be second - expect((sorted[0].transform[5] as ApplicationScoresMap[])[0][AppColumns.health]).toBeGreaterThan( - (sorted[1].transform[5] as ApplicationScoresMap[])[0][AppColumns.health] - ) - }) - }) - - describe('getStatusFilterKey', () => { - it('should return Healthy for health status < 1000', () => { - const item: ICompressedResource = { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [{ [AppColumns.health]: 500, [AppColumns.synced]: 0, [AppColumns.deployed]: 0 }], // scores - ['2024-01-01'], // created - ], - } - - const key = getStatusFilterKey(item, AppColumns.health) - expect(key).toBe('Healthy') - }) - - it('should return Unhealthy for health status >= 1000', () => { - const item: ICompressedResource = { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [{ [AppColumns.health]: 1500, [AppColumns.synced]: 0, [AppColumns.deployed]: 0 }], // scores - ['2024-01-01'], // created - ], - } - - const key = getStatusFilterKey(item, AppColumns.health) - expect(key).toBe('Unhealthy') - }) - - it('should return Synced for sync status < 1000', () => { - const item: ICompressedResource = { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [{ [AppColumns.health]: 0, [AppColumns.synced]: 500, [AppColumns.deployed]: 0 }], // scores - ['2024-01-01'], // created - ], - } - - const key = getStatusFilterKey(item, AppColumns.synced) - expect(key).toBe('Synced') - }) - - it('should return OutOfSync for sync status >= 1000', () => { - const item: ICompressedResource = { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [{ [AppColumns.health]: 0, [AppColumns.synced]: 1500, [AppColumns.deployed]: 0 }], // scores - ['2024-01-01'], // created - ], - } - - const key = getStatusFilterKey(item, AppColumns.synced) - expect(key).toBe('OutOfSync') - }) - - it('should return Deployed for deployed status < 1000', () => { - const item: ICompressedResource = { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [{ [AppColumns.health]: 0, [AppColumns.synced]: 0, [AppColumns.deployed]: 500 }], // scores - ['2024-01-01'], // created - ], - } - - const key = getStatusFilterKey(item, AppColumns.deployed) - expect(key).toBe('Deployed') - }) - - it('should return Not Deployed for deployed status >= 1000', () => { - const item: ICompressedResource = { - compressed: Buffer.from('{}'), - transform: [ - ['app1'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [{ [AppColumns.health]: 0, [AppColumns.synced]: 0, [AppColumns.deployed]: 1500 }], // scores - ['2024-01-01'], // created - ], - } - - const key = getStatusFilterKey(item, AppColumns.deployed) - expect(key).toBe('Not Deployed') - }) - }) - - describe('addUIData', () => { - it('should add UI data to application items', async () => { - const items = [ - { - apiVersion: 'app.k8s.io/v1beta1', - kind: 'Application', - metadata: { - name: 'test-app', - namespace: 'default', - }, - transform: [ - ['test-app'], // name - ['subscription'], // type - ['default'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - } as ApplicationScoresMap, - ], // scores - ['2024-01-01'], // created - ], - }, - ] - - const result = await addUIData(items) - type ResultWithUiData = (typeof result)[0] & { - uidata?: { - clusterList?: string[] - appClusterStatuses?: unknown[] - appSetPlacementData?: unknown[] - appSetApps?: unknown[] - } - } - const firstResult = result[0] as ResultWithUiData - expect(firstResult.uidata).toBeDefined() - expect(firstResult.uidata?.clusterList).toEqual(['cluster1']) - expect(firstResult.uidata?.appClusterStatuses).toEqual([{}]) - expect(firstResult.uidata?.appSetPlacementData).toEqual(['', []]) - expect(firstResult.uidata?.appSetApps).toEqual([]) - }) - - it('should add ApplicationSet specific UI data', async () => { - const items = [ - { - apiVersion: 'argoproj.io/v1alpha1', - kind: 'ApplicationSet', - metadata: { - name: 'test-appset', - namespace: 'argocd', - }, - - spec: { - generators: [] as unknown[], - }, - transform: [ - ['test-appset'], // name - ['appset'], // type - ['argocd'], // namespace - ['cluster1'], // clusters - [{}], // statuses - [ - { - [AppColumns.health]: 0, - [AppColumns.synced]: 0, - [AppColumns.deployed]: 0, - } as ApplicationScoresMap, - ], // scores - ['2024-01-01'], // created - ], - }, - ] - - const result = await addUIData(items) - type ResultWithUiData = (typeof result)[0] & { - uidata?: { - appSetPlacementData?: unknown[] - } - } - const firstResult = result[0] as ResultWithUiData - expect(firstResult.uidata).toBeDefined() - expect(firstResult.uidata?.appSetPlacementData).toBeDefined() - }) - }) -}) diff --git a/backend-node/test/routes/aggregators/applicationsArgoMergePush.test.ts b/backend-node/test/routes/aggregators/applicationsArgoMergePush.test.ts deleted file mode 100644 index 379894bc2f9..00000000000 --- a/backend-node/test/routes/aggregators/applicationsArgoMergePush.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -import { mergePushModelPodStatuses } from '../../../src/routes/aggregators/applicationsArgo' -import { type PushModelResourceMap } from '../../../src/routes/aggregators/applicationsPushModel' -import { - type ApplicationClusterStatusMap, - type ApplicationStatuses, - ScoreColumn, - StatusColumn, -} from '../../../src/routes/aggregators/applications' -import type { ISearchResource, SearchResult } from '../../../src/resources/resource' - -function makeEmptyStatuses(): ApplicationStatuses { - return { - health: [[0, 0, 0, 0, 0], []], - synced: [[0, 0, 0, 0, 0], []], - deployed: [[0, 0, 0, 0, 0], []], - } -} - -function makeSearchItem( - uid: string, - cluster: string, - namespace: string, - name: string, - kind = 'Deployment' -): ISearchResource { - return { - _uid: uid, - apigroup: 'apps', - apiversion: 'v1', - kind, - name, - namespace, - cluster, - created: '2024-01-01T00:00:00Z', - } -} - -function makePod( - uid: string, - cluster: string, - namespace: string, - name: string, - status: string, - relatedUids: string[] -): ISearchResource { - return { - _uid: uid, - _relatedUids: relatedUids, - apigroup: '', - apiversion: 'v1', - kind: 'Pod', - name, - namespace, - cluster, - created: '2024-01-01T00:00:00Z', - status, - } -} - -function makeSearchResult(items: ISearchResource[], pods: ISearchResource[]): SearchResult { - return { - items, - related: pods.length > 0 ? [{ kind: 'Pod', items: pods }] : [], - } -} - -describe('mergePushModelPodStatuses', () => { - const appSetKey = 'appset/openshift-gitops/my-appset' - - it('should count healthy running pods', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { - 'remote-1': makeEmptyStatuses(), - }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web-server', { appSetKey, targetCluster: 'remote-1' }], - ]) - - const deployUid = 'deploy-uid-1' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web-server')], - [makePod('pod-1', 'remote-1', 'default', 'web-server-abc', 'Running', [deployUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - const deployed = statusMap[appSetKey]['remote-1'].deployed - expect(deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(1) - expect(deployed[StatusColumn.counts][ScoreColumn.danger]).toBe(0) - expect(deployed[StatusColumn.counts][ScoreColumn.warning]).toBe(0) - }) - - it('should count multiple pods with different statuses', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { - 'remote-1': makeEmptyStatuses(), - }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web-server', { appSetKey, targetCluster: 'remote-1' }], - ]) - - const deployUid = 'deploy-uid-1' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web-server')], - [ - makePod('pod-1', 'remote-1', 'default', 'web-server-aaa', 'Running', [deployUid]), - makePod('pod-2', 'remote-1', 'default', 'web-server-bbb', 'CrashLoopBackOff', [deployUid]), - makePod('pod-3', 'remote-1', 'default', 'web-server-ccc', 'Pending', [deployUid]), - ] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - const deployed = statusMap[appSetKey]['remote-1'].deployed - expect(deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(1) - expect(deployed[StatusColumn.counts][ScoreColumn.danger]).toBe(1) - expect(deployed[StatusColumn.counts][ScoreColumn.warning]).toBe(1) - }) - - it('should classify error pod statuses correctly', () => { - const errorStatuses = [ - 'err', - 'off', - 'invalid', - 'kill', - 'propagationfailed', - 'imagepullbackoff', - 'crashloopbackoff', - 'lost', - ] - for (const errorStatus of errorStatuses) { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [makePod('pod-1', 'remote-1', 'default', 'web-aaa', errorStatus, [deployUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - const deployed = statusMap[appSetKey]['remote-1'].deployed - expect(deployed[StatusColumn.counts][ScoreColumn.danger]).toBe(1) - } - }) - - it('should classify warning pod statuses correctly', () => { - const warningStatuses = ['pending', 'creating'] - for (const warnStatus of warningStatuses) { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [makePod('pod-1', 'remote-1', 'default', 'web-aaa', warnStatus, [deployUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - const deployed = statusMap[appSetKey]['remote-1'].deployed - expect(deployed[StatusColumn.counts][ScoreColumn.warning]).toBe(1) - } - }) - - it('should skip terminating pods', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [makePod('pod-1', 'remote-1', 'default', 'web-aaa', 'Terminating', [deployUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - const counts = statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts] - const total = - counts[ScoreColumn.healthy] + - counts[ScoreColumn.danger] + - counts[ScoreColumn.warning] + - counts[ScoreColumn.progress] - expect(total).toBe(0) - }) - - it('should not double-count pods when deployed counts already populated', () => { - const statuses = makeEmptyStatuses() - statuses.deployed[StatusColumn.counts][ScoreColumn.healthy] = 2 - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': statuses }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [makePod('pod-1', 'remote-1', 'default', 'web-aaa', 'Running', [deployUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - // Should NOT have been incremented — entry was already populated - expect(statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(2) - }) - - it('should skip already-populated entries even when danger/warning counts exist', () => { - const statuses = makeEmptyStatuses() - statuses.deployed[StatusColumn.counts][ScoreColumn.danger] = 1 - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': statuses }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [makePod('pod-1', 'remote-1', 'default', 'web-aaa', 'Running', [deployUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - expect(statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(0) - expect(statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.danger]).toBe(1) - }) - - it('should still add pods for entries that are NOT already populated', () => { - const populatedStatuses = makeEmptyStatuses() - populatedStatuses.deployed[StatusColumn.counts][ScoreColumn.healthy] = 1 - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { - 'local-cluster': populatedStatuses, - 'remote-1': makeEmptyStatuses(), - }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [makePod('pod-1', 'remote-1', 'default', 'web-aaa', 'Running', [deployUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - // remote-1 was not pre-populated, so the pod should be counted - expect(statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(1) - // local-cluster was pre-populated, should stay unchanged - expect(statusMap[appSetKey]['local-cluster'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(1) - }) - - it('should skip pods that are not related to any known workload', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [makePod('pod-1', 'remote-1', 'default', 'other-pod', 'Running', ['unrelated-uid'])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - const counts = statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts] - expect(counts[ScoreColumn.healthy]).toBe(0) - }) - - it('should skip pods without _relatedUids', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [ - { - _uid: 'pod-1', - apigroup: '', - apiversion: 'v1', - kind: 'Pod', - name: 'web-aaa', - namespace: 'default', - cluster: 'remote-1', - created: '2024-01-01T00:00:00Z', - status: 'Running', - }, - ] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - expect(statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(0) - }) - - it('should handle empty search result items gracefully', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - - mergePushModelPodStatuses({ items: [], related: [] }, resourceMap, statusMap) - - const counts = statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts] - const total = - counts[ScoreColumn.healthy] + - counts[ScoreColumn.danger] + - counts[ScoreColumn.warning] + - counts[ScoreColumn.progress] - expect(total).toBe(0) - }) - - it('should handle search result with no Pod related kind', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - - mergePushModelPodStatuses( - { - items: [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - related: [{ kind: 'ReplicaSet', items: [] }], - }, - resourceMap, - statusMap - ) - - const counts = statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts] - const total = - counts[ScoreColumn.healthy] + - counts[ScoreColumn.danger] + - counts[ScoreColumn.warning] + - counts[ScoreColumn.progress] - expect(total).toBe(0) - }) - - it('should skip pods whose appSetKey has no entry in the status map', () => { - const statusMap: ApplicationClusterStatusMap = {} - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - [makePod('pod-1', 'remote-1', 'default', 'web-aaa', 'Running', [deployUid])] - ) - - // Should not throw - expect(() => mergePushModelPodStatuses(searchResult, resourceMap, statusMap)).not.toThrow() - }) - - it('should handle workloads from multiple appsets independently', () => { - const appSetKeyA = 'appset/ns-a/appset-a' - const appSetKeyB = 'appset/ns-b/appset-b' - const statusMap: ApplicationClusterStatusMap = { - [appSetKeyA]: { 'remote-1': makeEmptyStatuses() }, - [appSetKeyB]: { 'remote-2': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web-a', { appSetKey: appSetKeyA, targetCluster: 'remote-1' }], - ['remote-2/default/web-b', { appSetKey: appSetKeyB, targetCluster: 'remote-2' }], - ]) - const deployUidA = 'deploy-uid-a' - const deployUidB = 'deploy-uid-b' - const searchResult = makeSearchResult( - [ - makeSearchItem(deployUidA, 'remote-1', 'default', 'web-a'), - makeSearchItem(deployUidB, 'remote-2', 'default', 'web-b'), - ], - [ - makePod('pod-a', 'remote-1', 'default', 'web-a-aaa', 'Running', [deployUidA]), - makePod('pod-b', 'remote-2', 'default', 'web-b-bbb', 'CrashLoopBackOff', [deployUidB]), - ] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - expect(statusMap[appSetKeyA]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(1) - expect(statusMap[appSetKeyA]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.danger]).toBe(0) - expect(statusMap[appSetKeyB]['remote-2'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(0) - expect(statusMap[appSetKeyB]['remote-2'].deployed[StatusColumn.counts][ScoreColumn.danger]).toBe(1) - }) - - it('should match pods via ReplicaSet UID in the related chain', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - const deployUid = 'deploy-uid' - const rsUid = 'replicaset-uid' - const searchResult = makeSearchResult( - [makeSearchItem(deployUid, 'remote-1', 'default', 'web')], - // Pod is related to BOTH the ReplicaSet and the Deployment - [makePod('pod-1', 'remote-1', 'default', 'web-aaa', 'Running', [rsUid, deployUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - expect(statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(1) - }) - - it('should handle search result items that do not match any resource map entry', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/default/web', { appSetKey, targetCluster: 'remote-1' }], - ]) - // Search returns a different deployment that is not in the resource map - const unknownUid = 'unknown-deploy-uid' - const searchResult = makeSearchResult( - [makeSearchItem(unknownUid, 'remote-1', 'other-ns', 'other-deploy')], - [makePod('pod-1', 'remote-1', 'other-ns', 'other-pod', 'Running', [unknownUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - expect(statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(0) - }) - - it('should handle StatefulSet items the same as Deployments', () => { - const statusMap: ApplicationClusterStatusMap = { - [appSetKey]: { 'remote-1': makeEmptyStatuses() }, - } - const resourceMap: PushModelResourceMap = new Map([ - ['remote-1/data-ns/db', { appSetKey, targetCluster: 'remote-1' }], - ]) - const stsUid = 'sts-uid' - const searchResult = makeSearchResult( - [makeSearchItem(stsUid, 'remote-1', 'data-ns', 'db', 'StatefulSet')], - [makePod('pod-1', 'remote-1', 'data-ns', 'db-0', 'Running', [stsUid])] - ) - - mergePushModelPodStatuses(searchResult, resourceMap, statusMap) - - expect(statusMap[appSetKey]['remote-1'].deployed[StatusColumn.counts][ScoreColumn.healthy]).toBe(1) - }) -}) diff --git a/backend-node/test/routes/aggregators/applicationsPushModel.test.ts b/backend-node/test/routes/aggregators/applicationsPushModel.test.ts deleted file mode 100644 index 9ebd347067e..00000000000 --- a/backend-node/test/routes/aggregators/applicationsPushModel.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ - -jest.mock('../../../src/routes/events', () => ({ - getHubClusterName: jest.fn(() => 'local-cluster'), - getKubeResources: jest.fn((): unknown[] => []), -})) - -jest.mock('../../../src/routes/aggregators/applicationsArgo', () => ({ - getAppSetAppsMap: jest.fn(() => ({})), -})) - -jest.mock('../../../src/routes/aggregators/utils', () => ({ - getClusters: jest.fn(() => Promise.resolve([])), - getArgoDestinationCluster: jest.fn(() => Promise.resolve('unknown')), -})) - -import { SEARCH_QUERY_LIMIT, type IArgoApplication, type IQuery } from '../../../src/routes/aggregators/applications' -import { getAppSetAppsMap } from '../../../src/routes/aggregators/applicationsArgo' -import { addPushModelPodQueryInputs } from '../../../src/routes/aggregators/applicationsPushModel' -import { getArgoDestinationCluster, getClusters } from '../../../src/routes/aggregators/utils' -import { getHubClusterName } from '../../../src/routes/events' - -const mockGetAppSetAppsMap = getAppSetAppsMap as jest.MockedFunction -const mockGetHubClusterName = getHubClusterName as jest.MockedFunction -const mockGetClusters = getClusters as jest.MockedFunction -const mockGetArgoDestinationCluster = getArgoDestinationCluster as jest.MockedFunction - -function makeQuery(): IQuery { - return { operationName: 'searchResult', variables: { input: [] }, query: '' } -} - -function makeArgoApp( - name: string, - namespace: string, - destination: { name?: string; namespace: string; server?: string }, - resources?: Array<{ kind: string; name: string; namespace: string }> -): IArgoApplication { - return { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name, - namespace, - uid: `${name}-uid`, - resourceVersion: '1', - ownerReferences: [{ kind: 'ApplicationSet', name: 'my-appset', apiVersion: 'argoproj.io/v1alpha1' }], - }, - spec: { destination }, - ...(resources ? { status: { resources } } : {}), - } as unknown as IArgoApplication -} - -describe('applicationsPushModel', () => { - beforeEach(() => { - jest.clearAllMocks() - mockGetHubClusterName.mockReturnValue('local-cluster') - mockGetClusters.mockResolvedValue([ - { name: 'local-cluster', kubeApiServer: 'https://api.local:6443' }, - { name: 'remote-1', kubeApiServer: 'https://api.remote-1:6443' }, - { name: 'remote-2', kubeApiServer: 'https://api.remote-2:6443' }, - ]) - }) - - describe('addPushModelPodQueryInputs', () => { - it('should return an empty map when no appsets exist', async () => { - mockGetAppSetAppsMap.mockReturnValue({}) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result).toBeInstanceOf(Map) - expect(result.size).toBe(0) - expect(query.variables.input).toHaveLength(0) - }) - - it('should skip apps targeting the hub cluster', async () => { - mockGetArgoDestinationCluster.mockResolvedValue('local-cluster') - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp('my-appset-local-cluster', 'openshift-gitops', { name: 'in-cluster', namespace: 'default' }, [ - { kind: 'Deployment', name: 'my-deploy', namespace: 'default' }, - ]), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(0) - expect(query.variables.input).toHaveLength(0) - }) - - it('should skip apps without status.resources', async () => { - mockGetArgoDestinationCluster.mockResolvedValue('remote-1') - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp('my-appset-remote-1', 'openshift-gitops', { name: 'remote-1', namespace: 'default' }), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(0) - expect(query.variables.input).toHaveLength(0) - }) - - it('should collect Deployment workloads from remote push model apps', async () => { - mockGetArgoDestinationCluster.mockResolvedValue('remote-1') - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp('my-appset-remote-1', 'openshift-gitops', { name: 'remote-1', namespace: 'default' }, [ - { kind: 'Deployment', name: 'web-server', namespace: 'app-ns' }, - { kind: 'Service', name: 'web-svc', namespace: 'app-ns' }, - ]), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(1) - expect(result.has('remote-1/app-ns/web-server')).toBe(true) - const entry = result.get('remote-1/app-ns/web-server') - expect(entry.appSetKey).toBe('appset/openshift-gitops/my-appset') - expect(entry.targetCluster).toBe('remote-1') - - expect(query.variables.input).toHaveLength(1) - const input = query.variables.input[0] - expect(input.filters).toEqual([ - { property: 'kind', values: ['Deployment', 'StatefulSet'] }, - { property: 'name', values: ['web-server'] }, - { property: 'cluster', values: ['remote-1'] }, - ]) - expect(input.relatedKinds).toEqual(['Pod', 'ReplicaSet']) - expect(input.limit).toBe(SEARCH_QUERY_LIMIT) - }) - - it('should collect StatefulSet workloads', async () => { - mockGetArgoDestinationCluster.mockResolvedValue('remote-1') - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp('my-appset-remote-1', 'openshift-gitops', { name: 'remote-1', namespace: 'default' }, [ - { kind: 'StatefulSet', name: 'db', namespace: 'data-ns' }, - ]), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(1) - expect(result.has('remote-1/data-ns/db')).toBe(true) - }) - - it('should ignore non-workload kinds like Service and ConfigMap', async () => { - mockGetArgoDestinationCluster.mockResolvedValue('remote-1') - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp('my-appset-remote-1', 'openshift-gitops', { name: 'remote-1', namespace: 'default' }, [ - { kind: 'Service', name: 'svc', namespace: 'app-ns' }, - { kind: 'ConfigMap', name: 'cfg', namespace: 'app-ns' }, - ]), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(0) - expect(query.variables.input).toHaveLength(0) - }) - - it('should use destination namespace when resource namespace is empty', async () => { - mockGetArgoDestinationCluster.mockResolvedValue('remote-1') - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp('my-appset-remote-1', 'openshift-gitops', { name: 'remote-1', namespace: 'target-ns' }, [ - { kind: 'Deployment', name: 'web', namespace: '' }, - ]), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(1) - expect(result.has('remote-1/target-ns/web')).toBe(true) - }) - - it('should handle multiple appsets with apps on different remote clusters', async () => { - mockGetArgoDestinationCluster.mockResolvedValueOnce('remote-1').mockResolvedValueOnce('remote-2') - mockGetAppSetAppsMap.mockReturnValue({ - 'appset-a': [ - makeArgoApp('appset-a-remote-1', 'ns-a', { name: 'remote-1', namespace: 'default' }, [ - { kind: 'Deployment', name: 'web-a', namespace: 'default' }, - ]), - ], - 'appset-b': [ - makeArgoApp('appset-b-remote-2', 'ns-b', { name: 'remote-2', namespace: 'default' }, [ - { kind: 'Deployment', name: 'web-b', namespace: 'default' }, - ]), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(2) - expect(result.get('remote-1/default/web-a')?.appSetKey).toBe('appset/ns-a/appset-a') - expect(result.get('remote-2/default/web-b')?.appSetKey).toBe('appset/ns-b/appset-b') - - const input = query.variables.input[0] as { - filters: Array<{ property: string; values: string[] }> - } - expect(input.filters[1].values).toEqual(expect.arrayContaining(['web-a', 'web-b'])) - expect(input.filters[2].values).toEqual(expect.arrayContaining(['remote-1', 'remote-2'])) - }) - - it('should handle mixed hub and remote apps in a single appset', async () => { - mockGetArgoDestinationCluster.mockResolvedValueOnce('local-cluster').mockResolvedValueOnce('remote-1') - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp('my-appset-local', 'openshift-gitops', { name: 'in-cluster', namespace: 'default' }, [ - { kind: 'Deployment', name: 'web', namespace: 'default' }, - ]), - makeArgoApp('my-appset-remote', 'openshift-gitops', { name: 'remote-1', namespace: 'default' }, [ - { kind: 'Deployment', name: 'web', namespace: 'default' }, - ]), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - // Only the remote app's Deployment should be collected - expect(result.size).toBe(1) - expect(result.has('remote-1/default/web')).toBe(true) - expect(result.has('local-cluster/default/web')).toBe(false) - }) - - it('should deduplicate deployment names across apps', async () => { - mockGetArgoDestinationCluster.mockResolvedValueOnce('remote-1').mockResolvedValueOnce('remote-2') - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp('my-appset-remote-1', 'openshift-gitops', { name: 'remote-1', namespace: 'default' }, [ - { kind: 'Deployment', name: 'shared-web', namespace: 'default' }, - ]), - makeArgoApp('my-appset-remote-2', 'openshift-gitops', { name: 'remote-2', namespace: 'default' }, [ - { kind: 'Deployment', name: 'shared-web', namespace: 'default' }, - ]), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(2) - const input = query.variables.input[0] as { - filters: Array<{ property: string; values: string[] }> - } - // The deployment name should only appear once in the filter - expect(input.filters[1].values).toEqual(['shared-web']) - }) - - it('should skip apps whose destination resolves to undefined', async () => { - mockGetArgoDestinationCluster.mockResolvedValue(undefined) - mockGetAppSetAppsMap.mockReturnValue({ - 'my-appset': [ - makeArgoApp( - 'my-appset-unknown', - 'openshift-gitops', - { server: 'https://api.unknown:6443', namespace: 'default' }, - [{ kind: 'Deployment', name: 'web', namespace: 'default' }] - ), - ], - }) - const query = makeQuery() - - const result = await addPushModelPodQueryInputs(query) - - expect(result.size).toBe(0) - expect(query.variables.input).toHaveLength(0) - }) - }) -}) diff --git a/backend-node/test/routes/aggregators/utils.test.ts b/backend-node/test/routes/aggregators/utils.test.ts deleted file mode 100644 index 58d776bb4b6..00000000000 --- a/backend-node/test/routes/aggregators/utils.test.ts +++ /dev/null @@ -1,1401 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { - transform, - getClusterMap, - cacheRemoteApps, - getClusters, - getApplicationType, - getAppNamespace, - getApplicationClusters, - computeAppHealthStatus, - computeAppSyncStatus, - extractMessages, - getAppNameFromLabel, - isSystemApp, - discoverSystemAppNamespacePrefixes, - getArgoPushModelClusterList, - getArgoDestinationCluster, - getClusterProxyService, - getClusterProxyServiceURL, - keyBy, - sizeOf, - logApplicationCountChanges, -} from '../../../src/routes/aggregators/utils' -import { cacheResource, getEventCache, getEventDict } from '../../../src/routes/events' -import type { - IResource, - IArgoApplication, - ManagedCluster, - ManagedClusterInfo, - ClusterDeployment, - ISearchResource, - Cluster, - IService, -} from '../../../src/resources/resource' -import type { ApplicationClusterStatusMap, ITransformedResource } from '../../../src/routes/aggregators/applications' -import { getAppDict } from '../../../src/routes/aggregators/applications' -import { ServerSideEvents } from '../../../src/lib/server-side-events' -import { logger } from '../../../src/lib/logger' - -describe('aggregators utils', () => { - beforeEach(() => { - // Clear the cache before each test - const cache = getEventCache() - for (const key in cache) { - delete cache[key] - } - - // Clear ServerSideEvents to prevent async issues - const events = ServerSideEvents.getEvents() - for (const key in events) { - if (key !== '1' && key !== '2') { - // Keep START and LOADED events - delete events[key] - } - } - }) - - afterEach(async () => { - // Clean up any remaining async operations - const cache = getEventCache() - for (const key in cache) { - delete cache[key] - } - - // Clear all events except base events - const events = ServerSideEvents.getEvents() - for (const key in events) { - if (key !== '1' && key !== '2') { - delete events[key] - } - } - - // Wait for any pending promises to resolve - await new Promise((resolve) => setImmediate(resolve)) - }) - - afterAll(async () => { - // Stop the ServerSideEvents interval timer to allow Jest to exit cleanly - await ServerSideEvents.dispose() - }) - - describe('transform', () => { - it('should transform items with cluster status map', async () => { - const items: ITransformedResource[] = [ - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'test-app', - namespace: 'argocd', - uid: 'test-uid-1', - resourceVersion: '1', - creationTimestamp: '2024-01-01T00:00:00Z', - }, - spec: { - destination: { - namespace: 'default', - server: 'https://kubernetes.default.svc', - }, - source: { - repoURL: 'https://github.com/test/repo', - path: 'manifests', - }, - }, - } as IArgoApplication, - ] - - const argoClusterStatusMap: ApplicationClusterStatusMap = {} - - const result = await transform(items, argoClusterStatusMap) - - expect(result).toBeDefined() - expect(result.resources).toBeDefined() - expect(result.resources).toHaveLength(1) - }) - - it('should handle empty items array', async () => { - const result = await transform([], {}) - - expect(result).toBeDefined() - expect(result.resources).toEqual([]) - }) - - it('should transform subscription app type', async () => { - const items: ITransformedResource[] = [ - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'subscription-app', - namespace: 'default', - uid: 'sub-uid-1', - resourceVersion: '1', - creationTimestamp: '2024-01-01T00:00:00Z', - }, - spec: { - destination: { - namespace: 'default', - server: 'https://kubernetes.default.svc', - }, - }, - } as IArgoApplication, - ] - - const result = await transform(items, {}) - - expect(result.resources).toHaveLength(1) - }) - - it('should include itemMap when provided', async () => { - const items: ITransformedResource[] = [ - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'test-app', - namespace: 'argocd', - uid: 'map-uid-1', - resourceVersion: '1', - creationTimestamp: '2024-01-01T00:00:00Z', - }, - spec: { - destination: { - namespace: 'default', - server: 'https://kubernetes.default.svc', - }, - }, - } as IArgoApplication, - ] - - const itemMap = {} as Record - await transform(items, {}, false, undefined, undefined, itemMap as never) - - expect(Object.keys(itemMap)).toHaveLength(1) - expect(itemMap['map-uid-1' as keyof typeof itemMap]).toBeDefined() - }) - }) - - describe('getClusterMap', () => { - it('should return empty map when no clusters are cached', async () => { - const clusterMap = await getClusterMap() - expect(clusterMap).toEqual({}) - }) - - it('should return map of managed clusters by name', async () => { - const cluster1: ManagedCluster = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'cluster1', - uid: 'cluster-uid-1', - resourceVersion: '1', - }, - status: { - clusterClaims: [], - }, - } - - const cluster2: ManagedCluster = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'cluster2', - uid: 'cluster-uid-2', - resourceVersion: '1', - }, - status: { - clusterClaims: [], - }, - } - - await cacheResource(cluster1) - await cacheResource(cluster2) - - const clusterMap = await getClusterMap() - - expect(Object.keys(clusterMap)).toHaveLength(2) - expect(clusterMap['cluster1']).toBeDefined() - expect(clusterMap['cluster2']).toBeDefined() - expect(clusterMap['cluster1'].metadata.name).toBe('cluster1') - expect(clusterMap['cluster2'].metadata.name).toBe('cluster2') - }) - - it('should handle clusters without names', async () => { - const clusterWithoutName: IResource = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: '', - uid: 'cluster-uid-3', - resourceVersion: '1', - }, - } - - await cacheResource(clusterWithoutName) - - const clusterMap = await getClusterMap() - - // Should not include clusters without names - expect(Object.keys(clusterMap)).toHaveLength(0) - }) - - it('should update map when clusters change', async () => { - const managedCluster: ManagedCluster = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'test-cluster', - uid: 'cluster-uid-4', - resourceVersion: '1', - }, - status: { - clusterClaims: [], - }, - } - - await cacheResource(managedCluster) - - const clusterMap1 = await getClusterMap() - expect(Object.keys(clusterMap1)).toHaveLength(1) - - const cluster2: ManagedCluster = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'another-cluster', - uid: 'cluster-uid-5', - resourceVersion: '1', - }, - status: { - clusterClaims: [], - }, - } - - await cacheResource(cluster2) - - const clusterMap2 = await getClusterMap() - expect(Object.keys(clusterMap2)).toHaveLength(2) - }) - }) - - describe('cacheRemoteApps', () => { - it('should cache remote apps without page chunk', async () => { - const applicationCache = { - remoteKey: {}, - } as Record }> - - const remoteApps: ITransformedResource[] = [ - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'remote-app', - namespace: 'argocd', - uid: 'remote-uid-1', - resourceVersion: '1', - creationTimestamp: '2024-01-01T00:00:00Z', - }, - spec: { - destination: { - namespace: 'default', - server: 'https://kubernetes.default.svc', - }, - source: { - repoURL: 'https://github.com/test/repo', - path: 'manifests', - }, - }, - } as IArgoApplication, - ] - - await cacheRemoteApps(applicationCache as never, {}, remoteApps, undefined, 'remoteKey') - - expect(applicationCache.remoteKey.resources).toBeDefined() - expect(applicationCache.remoteKey.resources).toHaveLength(1) - }) - - it('should cache remote apps with page chunk', async () => { - const applicationCache = { - remoteKey: { - resourceMap: {}, - }, - } as Record }> - - const remoteApps: ITransformedResource[] = [ - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'app-with-chunk', - namespace: 'argocd', - uid: 'chunk-uid-1', - resourceVersion: '1', - creationTimestamp: '2024-01-01T00:00:00Z', - }, - spec: { - destination: { - namespace: 'default', - server: 'https://kubernetes.default.svc', - }, - }, - } as IArgoApplication, - ] - - const pageChunk = { - keys: ['a*', 'b*'], - limit: 100, - } - - await cacheRemoteApps(applicationCache as never, {}, remoteApps, pageChunk, 'remoteKey') - - expect(applicationCache.remoteKey.resourceMap?.['a*,b*']).toBeDefined() - expect(applicationCache.remoteKey.resourceMap?.['a*,b*']).toHaveLength(1) - }) - - it('should handle empty remote apps array', async () => { - const applicationCache = { - emptyKey: {}, - } as Record - - await cacheRemoteApps(applicationCache as never, {}, [], undefined, 'emptyKey') - - expect(applicationCache.emptyKey.resources).toBeDefined() - expect(applicationCache.emptyKey.resources).toHaveLength(0) - }) - }) - - describe('getClusters', () => { - it('should return empty array when no clusters are cached', async () => { - const clusters = await getClusters() - expect(clusters).toEqual([]) - }) - - it('should return clusters from ManagedCluster resources', async () => { - const managedCluster: ManagedCluster = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'managed-1', - uid: 'managed-uid-1', - resourceVersion: '1', - }, - status: { - clusterClaims: [], - }, - } - - await cacheResource(managedCluster) - - const clusters = await getClusters() - - expect(clusters).toHaveLength(1) - expect(clusters[0].name).toBe('managed-1') - }) - - it('should return clusters from multiple sources', async () => { - const managedCluster: ManagedCluster = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'managed-cluster', - uid: 'managed-uid-2', - resourceVersion: '1', - }, - status: { - clusterClaims: [], - }, - } - - const clusterDeployment: ClusterDeployment = { - kind: 'ClusterDeployment', - apiVersion: 'hive.openshift.io/v1', - metadata: { - name: 'hive-cluster', - uid: 'hive-uid-1', - resourceVersion: '1', - }, - spec: { - clusterName: 'hive-cluster', - baseDomain: 'example.com', - }, - status: {}, - } - - const managedClusterInfo: ManagedClusterInfo = { - kind: 'ManagedClusterInfo', - apiVersion: 'internal.open-cluster-management.io/v1beta1', - metadata: { - name: 'info-cluster', - uid: 'info-uid-1', - resourceVersion: '1', - }, - spec: { - masterEndpoint: 'https://api.example.com:6443', - }, - status: {}, - } - - await cacheResource(managedCluster) - await cacheResource(clusterDeployment) - await cacheResource(managedClusterInfo) - - const clusters = await getClusters() - - expect(clusters.length).toBeGreaterThan(0) - const clusterNames = clusters.map((c) => c.name) - expect(clusterNames).toContain('managed-cluster') - }) - - it('should include kubeApiServer and consoleURL in cluster objects', async () => { - const managedCluster: ManagedCluster = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'full-cluster', - uid: 'full-uid-1', - resourceVersion: '1', - }, - status: { - clusterClaims: [ - { - name: 'consoleurl.cluster.open-cluster-management.io', - value: 'https://console.example.com', - }, - ], - }, - } - - const managedClusterInfo: ManagedClusterInfo = { - kind: 'ManagedClusterInfo', - apiVersion: 'internal.open-cluster-management.io/v1beta1', - metadata: { - name: 'full-cluster', - uid: 'full-info-uid-1', - resourceVersion: '1', - }, - spec: { - masterEndpoint: 'https://api.fullcluster.com:6443', - }, - status: { - consoleURL: 'https://console.fullcluster.com', - }, - } - - await cacheResource(managedCluster) - await cacheResource(managedClusterInfo) - - const clusters = await getClusters() - - expect(clusters.length).toBeGreaterThan(0) - const fullCluster = clusters.find((c) => c.name === 'full-cluster') - expect(fullCluster).toBeDefined() - expect(fullCluster.kubeApiServer).toBeDefined() - }) - - it('should filter out ClusterDeployments with AgentCluster owners', async () => { - const clusterDeploymentWithAgent: ClusterDeployment = { - kind: 'ClusterDeployment', - apiVersion: 'hive.openshift.io/v1', - metadata: { - name: 'agent-owned', - uid: 'agent-uid-1', - resourceVersion: '1', - ownerReferences: [ - { - kind: 'AgentCluster', - name: 'agent', - apiVersion: 'agent.open-cluster-management.io/v1', - }, - ], - }, - spec: { - clusterName: 'agent-owned', - baseDomain: 'example.com', - }, - status: {}, - } - - await cacheResource(clusterDeploymentWithAgent) - - const clusters = await getClusters() - - // Should not include clusters owned by AgentCluster - expect(clusters.every((c) => c.name !== 'agent-owned')).toBe(true) - }) - }) - - describe('getApplicationType', () => { - it('should identify subscription app', () => { - const app: IResource = { - kind: 'Application', - apiVersion: 'app.k8s.io/v1beta1', - metadata: { - name: 'sub-app', - uid: 'sub-uid', - resourceVersion: '1', - }, - } - - expect(getApplicationType(app)).toBe('subscription') - }) - - it('should identify argo app', () => { - const app: IArgoApplication = { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'argo-app', - uid: 'argo-uid', - resourceVersion: '1', - }, - spec: { - destination: { - namespace: 'default', - server: 'https://kubernetes.default.svc', - }, - }, - } - - expect(getApplicationType(app)).toBe('argo') - }) - - it('should identify appset', () => { - const appSet: IResource = { - kind: 'ApplicationSet', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'my-appset', - uid: 'appset-uid', - resourceVersion: '1', - }, - } - - expect(getApplicationType(appSet)).toBe('appset') - }) - - it('should return - for unknown types', () => { - const app: IResource = { - kind: 'Unknown', - apiVersion: 'unknown/v1', - metadata: { - name: 'unknown-app', - uid: 'unknown-uid', - resourceVersion: '1', - }, - } - - expect(getApplicationType(app)).toBe('-') - }) - }) - - describe('getAppNamespace', () => { - it('should return metadata namespace for regular resources', () => { - const resource: IResource = { - kind: 'Application', - apiVersion: 'app.k8s.io/v1beta1', - metadata: { - name: 'test', - namespace: 'default', - uid: 'test-uid', - resourceVersion: '1', - }, - } - - expect(getAppNamespace(resource)).toBe('default') - }) - - it('should return destination namespace for Argo apps', () => { - const argoApp: IArgoApplication = { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'argo-app', - namespace: 'argocd', - uid: 'argo-uid', - resourceVersion: '1', - }, - spec: { - destination: { - namespace: 'target-namespace', - server: 'https://kubernetes.default.svc', - }, - }, - } - - expect(getAppNamespace(argoApp)).toBe('target-namespace') - }) - - it('should handle resource without namespace', () => { - const resource: IResource = { - kind: 'ClusterRole', - apiVersion: 'rbac.authorization.k8s.io/v1', - metadata: { - name: 'cluster-role', - uid: 'role-uid', - resourceVersion: '1', - }, - } - - expect(getAppNamespace(resource)).toBeUndefined() - }) - }) - - describe('computeAppHealthStatus', () => { - it('should compute healthy status', () => { - const health = [[0, 0, 0, 0, 0], []] as [number[], Record[]] - const app: ISearchResource = { - apigroup: '', - apiversion: 'v1', - cluster: 'test', - kind: 'application', - name: 'test-app', - namespace: 'default', - created: '2024-01-01T00:00:00Z', - healthStatus: 'Healthy', - } - - computeAppHealthStatus(health, app) - - expect(health[0][0]).toBe(1) // healthy count - }) - - it('should compute degraded status with message', () => { - const health = [[0, 0, 0, 0, 0], []] as [number[], Record[]] - const app: ISearchResource = { - apigroup: '', - apiversion: 'v1', - cluster: 'test', - kind: 'application', - name: 'test-app', - namespace: 'default', - created: '2024-01-01T00:00:00Z', - healthStatus: 'Degraded', - } - - computeAppHealthStatus(health, app) - - expect(health[0][3]).toBe(1) // danger count - expect(health[1].length).toBeGreaterThan(0) - }) - }) - - describe('computeAppSyncStatus', () => { - it('should compute synced status', () => { - const synced = [[0, 0, 0, 0, 0], []] as [number[], Record[]] - const app: ISearchResource = { - apigroup: '', - apiversion: 'v1', - cluster: 'test', - kind: 'application', - name: 'test-app', - namespace: 'default', - created: '2024-01-01T00:00:00Z', - syncStatus: 'Synced', - } - - computeAppSyncStatus(synced, app) - - expect(synced[0][0]).toBe(1) // healthy count - }) - - it('should compute out of sync status', () => { - const synced = [[0, 0, 0, 0, 0], []] as [number[], Record[]] - const app: ISearchResource = { - apigroup: '', - apiversion: 'v1', - cluster: 'test', - kind: 'application', - name: 'test-app', - namespace: 'default', - created: '2024-01-01T00:00:00Z', - syncStatus: 'OutOfSync', - } - - computeAppSyncStatus(synced, app) - - expect(synced[0][2]).toBe(1) // warning count - }) - }) - - describe('extractMessages', () => { - it('should extract status message', () => { - const ase = [[0, 0, 0, 0, 0], []] as [number[], Record[]] - const app: ISearchResource = { - apigroup: '', - apiversion: 'v1', - cluster: 'test', - kind: 'pod', - name: 'test-pod', - namespace: 'default', - created: '2024-01-01T00:00:00Z', - } - - extractMessages(ase, app, 'Running') - - expect(ase[1]).toContainEqual({ key: 'Status', value: 'Running' }) - }) - - it('should extract condition messages', () => { - const ase = [[0, 0, 0, 0, 0], []] as [number[], Record[]] - // Use a more flexible type since ISearchResource doesn't allow dynamic properties - const app = { - apigroup: '', - apiversion: 'v1', - cluster: 'test', - kind: 'pod', - name: 'test-pod', - namespace: 'default', - created: '2024-01-01T00:00:00Z', - _condition_message: 'Pod is not ready', - } as unknown as ISearchResource - - extractMessages(ase, app) - - expect(ase[1]).toContainEqual({ key: '_condition_message', value: 'Pod is not ready' }) - }) - - it('should not duplicate messages', () => { - const ase = [[0, 0, 0, 0, 0], [{ key: 'Status', value: 'Running' }]] as [number[], Record[]] - const app: ISearchResource = { - apigroup: '', - apiversion: 'v1', - cluster: 'test', - kind: 'pod', - name: 'test-pod', - namespace: 'default', - created: '2024-01-01T00:00:00Z', - } - - extractMessages(ase, app, 'Running') - - // The extractMessages function doesn't deduplicate status messages, it always adds them - // So we should have 2 messages now - expect(ase[1].filter((m) => m.key === 'Status')).toHaveLength(2) - }) - }) - - describe('getAppNameFromLabel', () => { - it('should extract app name from Flux label', () => { - // The function finds the first matching label in appOwnerLabels order - // kustomize.toolkit.fluxcd.io/name comes before app= in the appOwnerLabels array - const label = 'app=myapp;kustomize.toolkit.fluxcd.io/name=flux-app;other=value' - expect(getAppNameFromLabel(label)).toBe('flux-app') - }) - - it('should extract app name from app label', () => { - const label = 'app=test-application;env=prod' - expect(getAppNameFromLabel(label)).toBe('test-application') - }) - - it('should extract app name from app.kubernetes.io label', () => { - const label = 'app.kubernetes.io/part-of=my-app;tier=frontend' - expect(getAppNameFromLabel(label)).toBe('my-app') - }) - - it('should return default when no matching label found', () => { - const label = 'env=prod;tier=frontend' - expect(getAppNameFromLabel(label, 'default-app')).toBe('default-app') - }) - - it('should handle label without semicolon at end', () => { - const label = 'app=single-app' - expect(getAppNameFromLabel(label)).toBe('single-app') - }) - }) - - describe('isSystemApp', () => { - beforeAll(async () => { - // Initialize system app namespace prefixes once for all tests in this suite - await discoverSystemAppNamespacePrefixes() - }) - - it('should identify openshift namespace as system', () => { - expect(isSystemApp('openshift-config')).toBe(true) - }) - - it('should identify hive namespace as system', () => { - expect(isSystemApp('hive-system')).toBe(true) - }) - - it('should identify open-cluster-management namespace as system', () => { - expect(isSystemApp('open-cluster-management-hub')).toBe(true) - }) - - it('should not identify custom namespace as system', () => { - expect(isSystemApp('my-application')).toBe(false) - }) - - it('should handle undefined namespace', () => { - expect(isSystemApp(undefined)).toBeFalsy() - }) - }) - - describe('getArgoPushModelClusterList', () => { - it('should return cluster list for push model apps', async () => { - const apps: IArgoApplication[] = [ - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'app1', - uid: 'app1-uid', - resourceVersion: '1', - }, - spec: { - destination: { - name: 'in-cluster', - namespace: 'default', - }, - }, - }, - ] - - const localCluster: Cluster = { - name: 'local-cluster', - kubeApiServer: 'https://api.local.com:6443', - } - - const clusters = await getArgoPushModelClusterList(apps, localCluster, []) - - expect(clusters).toContain('local-cluster') - }) - - it('should identify remote clusters', async () => { - const apps: IArgoApplication[] = [ - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'app2', - uid: 'app2-uid', - resourceVersion: '1', - }, - spec: { - destination: { - server: 'https://api.remote.com:6443', - namespace: 'default', - }, - }, - status: { - cluster: 'remote-cluster', - }, - }, - ] - - const managedClusters: Cluster[] = [ - { - name: 'remote-cluster', - kubeApiServer: 'https://api.remote.com:6443', - }, - ] - - const clusters = await getArgoPushModelClusterList(apps, undefined, managedClusters) - - expect(clusters).toContain('remote-cluster') - }) - - it('should deduplicate cluster names', async () => { - const apps: IArgoApplication[] = [ - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'app3', - uid: 'app3-uid', - resourceVersion: '1', - }, - spec: { - destination: { - name: 'in-cluster', - namespace: 'default', - }, - }, - }, - { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'app4', - uid: 'app4-uid', - resourceVersion: '1', - }, - spec: { - destination: { - name: 'in-cluster', - namespace: 'default', - }, - }, - }, - ] - - const localCluster: Cluster = { - name: 'local-cluster', - kubeApiServer: 'https://api.local.com:6443', - } - - const clusters = await getArgoPushModelClusterList(apps, localCluster, []) - - // Should only have one entry for local-cluster - expect(clusters.filter((c) => c === 'local-cluster')).toHaveLength(1) - }) - }) - - describe('getArgoDestinationCluster', () => { - it('should return cluster name from server API', async () => { - const destination = { - server: 'https://api.test.com:6443', - namespace: 'default', - } - - const clusters: Cluster[] = [ - { - name: 'test-cluster', - kubeApiServer: 'https://api.test.com:6443', - }, - ] - - const result = await getArgoDestinationCluster(destination, clusters) - - expect(result).toBe('test-cluster') - }) - - it('should return hub cluster for kubernetes.default.svc', async () => { - const destination = { - server: 'https://kubernetes.default.svc', - namespace: 'default', - } - - const result = await getArgoDestinationCluster(destination, [], undefined, 'hub-cluster') - - expect(result).toBe('hub-cluster') - }) - - it('should return unknown for non-matching server', async () => { - const destination = { - server: 'https://api.unknown.com:6443', - namespace: 'default', - } - - const result = await getArgoDestinationCluster(destination, []) - - expect(result).toBe('unknown') - }) - - it('should use destination name when server is not provided', async () => { - const destination = { - name: 'named-cluster', - namespace: 'default', - } - - const result = await getArgoDestinationCluster(destination, []) - - expect(result).toBe('named-cluster') - }) - - it('should convert in-cluster to hub cluster name', async () => { - const destination = { - name: 'in-cluster', - namespace: 'default', - } - - const result = await getArgoDestinationCluster(destination, [], undefined, 'my-hub') - - expect(result).toBe('my-hub') - }) - - it('should resolve cluster name via cluster proxy url match', async () => { - const proxyService: IService = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'cluster-proxy-addon-user', - namespace: 'multicluster-engine', - uid: 'service-uid-proxy', - resourceVersion: '1', - }, - spec: { - ports: [{ port: 8443 }], - }, - } - await cacheResource(proxyService) - - const destination = { - server: 'https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:8443/cluster-1', - namespace: 'default', - } - const clusters: Cluster[] = [ - { - name: 'cluster-1', - kubeApiServer: 'https://api.cluster-1.example:6443', - }, - ] - - const result = await getArgoDestinationCluster(destination, clusters) - - expect(result).toBe('cluster-1') - }) - - it('should return unknown when proxy url does not match any cluster', async () => { - const proxyService: IService = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'cluster-proxy-addon-user', - namespace: 'multicluster-engine', - uid: 'service-uid-proxy-2', - resourceVersion: '1', - }, - spec: { - ports: [{ port: 8443 }], - }, - } - await cacheResource(proxyService) - - const destination = { - server: 'https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:8443/cluster-2', - namespace: 'default', - } - const clusters: Cluster[] = [ - { - name: 'cluster-1', - kubeApiServer: 'https://api.cluster-1.example:6443', - }, - ] - - const result = await getArgoDestinationCluster(destination, clusters) - - expect(result).toBe('unknown') - }) - }) - - describe('getClusterProxyService', () => { - it('should return cluster proxy service when present', async () => { - const proxyService: IService = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'cluster-proxy-addon-user', - namespace: 'multicluster-engine', - uid: 'service-uid-1', - resourceVersion: '1', - }, - spec: { - ports: [{ port: 8443 }], - }, - } - const otherService: IService = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'other-service', - namespace: 'default', - uid: 'service-uid-2', - resourceVersion: '1', - }, - } - - await cacheResource(otherService) - await cacheResource(proxyService) - - const result = await getClusterProxyService() - - expect(result?.metadata?.name).toBe('cluster-proxy-addon-user') - expect(result?.metadata?.namespace).toBe('multicluster-engine') - }) - - it('should return undefined when cluster proxy service is absent', async () => { - const otherService: IService = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'other-service', - namespace: 'default', - uid: 'service-uid-3', - resourceVersion: '1', - }, - } - - await cacheResource(otherService) - - const result = await getClusterProxyService() - - expect(result).toBeUndefined() - }) - }) - - describe('getClusterProxyServiceURL', () => { - it('should build URL using service port when provided', () => { - const service: IService = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'cluster-proxy-addon-user', - namespace: 'multicluster-engine', - uid: 'service-uid-4', - resourceVersion: '1', - }, - spec: { - ports: [{ port: 8443 }], - }, - } - - const result = getClusterProxyServiceURL(service, 'my-cluster') - - expect(result).toBe('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:8443/my-cluster') - }) - - it('should fall back to default port when no ports are defined', () => { - const service: IService = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'cluster-proxy-addon-user', - namespace: 'multicluster-engine', - uid: 'service-uid-5', - resourceVersion: '1', - }, - } - - const result = getClusterProxyServiceURL(service, 'my-cluster') - - expect(result).toBe('https://cluster-proxy-addon-user.multicluster-engine.svc.cluster.local:9092/my-cluster') - }) - - it('should return undefined when service is missing', () => { - const result = getClusterProxyServiceURL(undefined, 'my-cluster') - - expect(result).toBeUndefined() - }) - - it('should return undefined when cluster is missing', () => { - const service: IService = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'cluster-proxy-addon-user', - namespace: 'multicluster-engine', - uid: 'service-uid-6', - resourceVersion: '1', - }, - } - - const result = getClusterProxyServiceURL(service, '') - - expect(result).toBeUndefined() - }) - }) - - describe('keyBy', () => { - it('should create map by string selector', () => { - const resources: IResource[] = [ - { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'pod1', - uid: 'pod1-uid', - resourceVersion: '1', - }, - }, - { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'pod2', - uid: 'pod2-uid', - resourceVersion: '1', - }, - }, - ] - - const result = keyBy(resources, 'metadata.name') - - expect(result['pod1']).toBeDefined() - expect(result['pod2']).toBeDefined() - expect(result['pod1'].metadata.uid).toBe('pod1-uid') - }) - - it('should create map by function selector', () => { - const resources: IResource[] = [ - { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'pod1', - uid: 'uid1', - resourceVersion: '1', - }, - }, - { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'pod2', - uid: 'uid2', - resourceVersion: '1', - }, - }, - ] - - const result = keyBy(resources, (item) => item.metadata.uid) - - expect(result['uid1']).toBeDefined() - expect(result['uid2']).toBeDefined() - expect(result['uid1'].metadata.name).toBe('pod1') - }) - }) - - describe('sizeOf', () => { - it('should calculate size of simple object', () => { - const data = { name: 'test', value: 123 } - const size = sizeOf(data) - - expect(size).toBeGreaterThan(0) - expect(typeof size).toBe('number') - }) - - it('should calculate size of nested object', () => { - const data = { - metadata: { - name: 'test', - labels: { - app: 'myapp', - }, - }, - spec: { - replicas: 3, - }, - } - - const size = sizeOf(data) - - expect(size).toBeGreaterThan(0) - }) - - it('should handle arrays in data', () => { - const data = { - items: [1, 2, 3, 4, 5], - } - - const size = sizeOf(data) - - expect(size).toBeGreaterThan(0) - }) - - it('should return size for null', () => { - const size = sizeOf(null) - - expect(size).toBeGreaterThan(0) - }) - }) - - describe('getApplicationClusters', () => { - it('should return hub cluster for unknown type', async () => { - const resource: IResource = { - kind: 'Unknown', - apiVersion: 'unknown/v1', - metadata: { - name: 'test', - uid: 'test-uid', - resourceVersion: '1', - }, - } - - const clusters = await getApplicationClusters(resource, '-', [], [], undefined, []) - - expect(clusters).toContain('local-cluster') - }) - - it('should return cluster for OpenShift app', async () => { - const resource = { - kind: 'Deployment', - apiVersion: 'apps/v1', - metadata: { - name: 'test', - namespace: 'myapp', - uid: 'test-uid', - resourceVersion: '1', - }, - status: { - cluster: 'ocp-cluster', - }, - } - - const clusters = await getApplicationClusters(resource, 'openshift', [], [], undefined, []) - - expect(clusters).toContain('ocp-cluster') - }) - - it('should return cluster for Argo app', async () => { - const argoApp: IArgoApplication = { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'argo-test', - namespace: 'argocd', - uid: 'argo-uid', - resourceVersion: '1', - }, - spec: { - destination: { - server: 'https://kubernetes.default.svc', - namespace: 'default', - }, - }, - } - - const localCluster: Cluster = { - name: 'local-cluster', - kubeApiServer: 'https://api.local.com:6443', - consoleUrl: 'https://console.local.com', - } - - const clusters = await getApplicationClusters(argoApp, 'argo', [], [], localCluster, []) - - expect(clusters).toHaveLength(1) - expect(clusters[0]).toBe('local-cluster') - }) - }) - - describe('logApplicationCountChanges', () => { - it('logs memory usage and dictionary growth at debug level', () => { - const isLevelEnabledSpy = jest.spyOn(logger, 'isLevelEnabled').mockReturnValue(true) - const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => {}) - const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {}) - - const appDict = getAppDict() - appDict.add('test-app-key-for-coverage') - const eventDict = getEventDict() - eventDict.add('test-event-key-for-coverage') - - logApplicationCountChanges({}, 1) - - expect(infoSpy).toHaveBeenCalledWith(expect.objectContaining({ msg: 'memory' })) - expect(debugSpy).toHaveBeenCalledWith(expect.objectContaining({ msg: 'appDict growth' })) - expect(debugSpy).toHaveBeenCalledWith(expect.objectContaining({ msg: 'eventDict growth' })) - - isLevelEnabledSpy.mockRestore() - debugSpy.mockRestore() - infoSpy.mockRestore() - }) - - it('does not log dictionary growth when there are no new entries', () => { - const isLevelEnabledSpy = jest.spyOn(logger, 'isLevelEnabled').mockReturnValue(true) - const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => {}) - const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => {}) - - const appDict = getAppDict() - appDict.drainRecentlyAdded() - - logApplicationCountChanges({}, 50) - - expect(infoSpy).toHaveBeenCalledWith(expect.objectContaining({ msg: 'memory' })) - expect(debugSpy).not.toHaveBeenCalledWith(expect.objectContaining({ msg: 'appDict growth' })) - - isLevelEnabledSpy.mockRestore() - debugSpy.mockRestore() - infoSpy.mockRestore() - }) - }) -}) diff --git a/backend-node/test/routes/events.test.ts b/backend-node/test/routes/events.test.ts deleted file mode 100644 index 215ea9834d4..00000000000 --- a/backend-node/test/routes/events.test.ts +++ /dev/null @@ -1,1498 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { Writable } from 'node:stream' -import nock from 'nock' -import { request } from '../mock-request' -import { - getKubeResources, - cacheResource, - getEventCache, - getHubClusterName, - getIsHubSelfManaged, - getIsObservabilityInstalled, - resetIsObservabilityInstalled, - createSplitStream, - errorToString, - createWatchEventProcessor, - listAndWatch, - stopWatching, - canAccess, - resetAccessCache, - getAccessCache, - cleanupAccessCache, - ACCESS_CACHE_TTL, - ACCESS_CACHE_MAX_TOKENS, -} from '../../src/routes/events' -import * as serviceAccountTokenModule from '../../src/lib/serviceAccountToken' -import type { IArgoApplication, IResource } from '../../src/resources/resource' -import { ServerSideEvents } from '../../src/lib/server-side-events' - -jest.mock('../../src/lib/serviceAccountToken') - -describe('events Route', () => { - describe('GET /events', () => { - it('should handle events endpoint - returns error without proper setup', async () => { - // Without full SSE infrastructure setup, expect error responses - const res = await request('GET', '/events', undefined, {}) - // Could be 401 (no auth) or 500 (server error) depending on setup - expect([401, 500]).toContain(res.statusCode) - }) - - it('should handle events endpoint with token', async () => { - // This test is mainly to ensure the endpoint exists and responds - // Full SSE testing would require more complex stream handling - const res = await request('GET', '/events') - // Could be 200 (success), 401 (auth), or 500 (error) depending on environment - expect([200, 401, 500]).toContain(res.statusCode) - }) - }) - - describe('getKubeResources', () => { - beforeEach(() => { - // Clear the cache before each test - const cache = getEventCache() - for (const key in cache) { - delete cache[key] - } - }) - - it('should return empty array when no resources are cached', async () => { - const resources = await getKubeResources('ManagedCluster', 'cluster.open-cluster-management.io/v1') - expect(resources).toEqual([]) - }) - - it('should return cached resources for a given kind and apiVersion', async () => { - const mockResource: IResource = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'test-cluster', - namespace: 'default', - uid: 'test-uid-123', - resourceVersion: '12345', - }, - } - - // Cache the resource - await cacheResource(mockResource) - - // Retrieve it using getKubeResources - const resources = await getKubeResources('ManagedCluster', 'cluster.open-cluster-management.io/v1') - - expect(resources).toHaveLength(1) - expect(resources[0].kind).toBe('ManagedCluster') - expect(resources[0].metadata.name).toBe('test-cluster') - expect(resources[0].metadata.uid).toBe('test-uid-123') - }) - - it('should return multiple cached resources of the same type', async () => { - const mockResource1: IResource = { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'pod-1', - namespace: 'default', - uid: 'pod-uid-1', - resourceVersion: '1', - }, - } - - const mockResource2: IResource = { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'pod-2', - namespace: 'default', - uid: 'pod-uid-2', - resourceVersion: '2', - }, - } - - await cacheResource(mockResource1) - await cacheResource(mockResource2) - - const resources = await getKubeResources('Pod', 'v1') - - expect(resources).toHaveLength(2) - expect(resources.map((r) => r.metadata.name).sort()).toEqual(['pod-1', 'pod-2']) - }) - - it('should only return resources matching the specified kind', async () => { - const clusterResource: IResource = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'test-cluster', - namespace: 'default', - uid: 'cluster-uid', - resourceVersion: '1', - }, - } - - const podResource: IResource = { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'test-pod', - namespace: 'default', - uid: 'pod-uid', - resourceVersion: '1', - }, - } - - await cacheResource(clusterResource) - await cacheResource(podResource) - - const clusters = await getKubeResources('ManagedCluster', 'cluster.open-cluster-management.io/v1') - expect(clusters).toHaveLength(1) - expect(clusters[0].kind).toBe('ManagedCluster') - - const pods = await getKubeResources('Pod', 'v1') - expect(pods).toHaveLength(1) - expect(pods[0].kind).toBe('Pod') - }) - }) - - describe('cacheResource', () => { - beforeEach(() => { - // Clear the cache and events before each test - const cache = getEventCache() - for (const key in cache) { - delete cache[key] - } - - // Clear ServerSideEvents - const events = ServerSideEvents.getEvents() - for (const key in events) { - if (key !== '1' && key !== '2') { - // Keep START and LOADED events - delete events[key] - } - } - - resetIsObservabilityInstalled() - }) - - it('should cache a new resource', async () => { - const mockResource: IResource = { - kind: 'ConfigMap', - apiVersion: 'v1', - metadata: { - name: 'test-config', - namespace: 'default', - uid: 'config-uid-123', - resourceVersion: '100', - }, - } - - await cacheResource(mockResource) - - const cache = getEventCache() - const apiVersionPlural = '/v1/configmaps' - expect(cache[apiVersionPlural]).toBeDefined() - expect(cache[apiVersionPlural]['config-uid-123']).toBeDefined() - expect(await cache[apiVersionPlural]['config-uid-123'].compressed).toBeDefined() - expect(await cache[apiVersionPlural]['config-uid-123'].eventID).toBeGreaterThan(0) - }) - - it('should not update cache if resourceVersion is unchanged', async () => { - const mockResource: IResource = { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'test-secret', - namespace: 'default', - uid: 'secret-uid-456', - resourceVersion: '200', - }, - } - - // Cache the resource first time - await cacheResource(mockResource) - const cache = getEventCache() - const apiVersionPlural = '/v1/secrets' - const firstEventID = cache[apiVersionPlural]['secret-uid-456'].eventID - - // Try to cache the same resource with same resourceVersion - await cacheResource(mockResource) - - // EventID should remain the same (no new event created) - expect(cache[apiVersionPlural]['secret-uid-456'].eventID).toBe(firstEventID) - }) - - it('should update cache when resourceVersion changes', async () => { - const mockResource: IResource = { - kind: 'Deployment', - apiVersion: 'apps/v1', - metadata: { - name: 'test-deployment', - namespace: 'default', - uid: 'deploy-uid-789', - resourceVersion: '300', - }, - } - - // Cache the resource first time - await cacheResource(mockResource) - const cache = getEventCache() - const apiVersionPlural = '/apps/v1/deployments' - const firstEventID = await cache[apiVersionPlural]['deploy-uid-789'].eventID - - // Update resourceVersion and cache again - mockResource.metadata.resourceVersion = '301' - await cacheResource(mockResource) - - // EventID should be different (new event created) - expect(await cache[apiVersionPlural]['deploy-uid-789'].eventID).not.toBe(firstEventID) - expect(await cache[apiVersionPlural]['deploy-uid-789'].eventID).toBeGreaterThan(firstEventID) - }) - - it('should set hubClusterName when caching local ManagedCluster', async () => { - const localCluster: IResource = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'my-local-cluster', - uid: 'local-cluster-uid', - resourceVersion: '1', - labels: { - 'local-cluster': 'true', - }, - }, - } - - await cacheResource(localCluster) - - expect(getHubClusterName()).toBe('my-local-cluster') - expect(getIsHubSelfManaged()).toBe(true) - }) - - it('should not change hubClusterName for non-local ManagedCluster', async () => { - const initialHubName = getHubClusterName() - - const remoteCluster: IResource = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'remote-cluster', - uid: 'remote-cluster-uid', - resourceVersion: '1', - labels: { - 'local-cluster': 'false', - }, - }, - } - - await cacheResource(remoteCluster) - - expect(getHubClusterName()).toBe(initialHubName) - }) - - it('should set observability flag when caching observability-controller addon', async () => { - const observabilityAddon: IResource = { - kind: 'ManagedClusterAddOn', - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - metadata: { - name: 'observability-controller', - namespace: 'local-cluster', - uid: 'obs-addon-uid', - resourceVersion: '1', - }, - } - - await cacheResource(observabilityAddon) - - expect(getIsObservabilityInstalled()).toBe(true) - }) - - it('should set observability flag when caching multicluster-observability-addon', async () => { - const observabilityAddon: IResource = { - kind: 'ManagedClusterAddOn', - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - metadata: { - name: 'multicluster-observability-addon', - namespace: 'local-cluster', - uid: 'mco-addon-uid', - resourceVersion: '1', - }, - } - - await cacheResource(observabilityAddon) - - expect(getIsObservabilityInstalled()).toBe(true) - }) - - it('should not set observability flag for other addons', async () => { - const otherAddon: IResource = { - kind: 'ManagedClusterAddOn', - apiVersion: 'addon.open-cluster-management.io/v1alpha1', - metadata: { - name: 'other-addon', - namespace: 'local-cluster', - uid: 'other-addon-uid', - resourceVersion: '1', - }, - } - - await cacheResource(otherAddon) - - expect(getIsObservabilityInstalled()).toBe(false) - }) - - it('should not set observability flag for addon with wrong API group', async () => { - const wrongGroupAddon: IResource = { - kind: 'ManagedClusterAddOn', - apiVersion: 'other.group.io/v1alpha1', - metadata: { - name: 'observability-controller', - namespace: 'local-cluster', - uid: 'wrong-group-addon-uid', - resourceVersion: '1', - }, - } - - await cacheResource(wrongGroupAddon) - - expect(getIsObservabilityInstalled()).toBe(false) - }) - - it('should avoid race condition when caching same resource concurrently', async () => { - // This test guards against a race condition where concurrent calls to cacheResource - // for the same UID could create duplicate/orphaned events in ServerSideEvents. - // - // The race condition occurred when: - // 1. Call A checks cache (finds nothing) - // 2. Call A starts async compression (await deflateResource yields control) - // 3. Call B checks cache (still finds nothing because A hasn't written yet) - // 4. Call B starts async compression (yields control) - // 5. ... same for C, D, etc. — all see empty cache before any has written - // 6. Multiple calls create separate events; all but the last become orphaned - // - // The fix: store promises immediately in the cache so concurrent callers see pending - // entries, and recheck state after awaiting async work so that only one caller - // proceeds to create/update the event (double-check pattern). - - const uid = 'race-test-uid-concurrent' - const resources: IResource[] = [1, 2, 3, 4].map((i) => ({ - kind: 'ConfigMap', - apiVersion: 'v1', - metadata: { - name: 'race-test-config', - namespace: 'default', - uid, - resourceVersion: String(i), - }, - })) - - // Reset ServerSideEvents to a clean state to ensure test isolation - ServerSideEvents.reset() - - // Flush the microtask queue multiple times to ensure any pending promises - // from previous tests (like getKubeResources) have resolved - for (let i = 0; i < 5; i++) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - - // Reset AGAIN after flushing to clear any events created by resolved promises - ServerSideEvents.reset() - - // Snapshot event IDs BEFORE our concurrent calls (should only be START=1 and LOADED=2) - const eventIdsBefore = new Set(Object.keys(ServerSideEvents.getEvents()).map(Number)) - - // Start all 4 cache operations concurrently WITHOUT awaiting first. - // This simulates the race where multiple callers can interleave around deflateResource. - const promises = resources.map((r) => cacheResource(r)) - await Promise.all(promises) - - // With the fix, cacheResource stores promises and returns without awaiting pushEvent. - // We must await the eventID promise to ensure pushEvent has been called. - const cache = getEventCache() - const cachedEventID = await Promise.resolve(cache['/v1/configmaps'][uid].eventID) - - // Snapshot event IDs AFTER our concurrent calls - const events = ServerSideEvents.getEvents() - const eventIdsAfter = new Set(Object.keys(events).map(Number)) - - // Find NEW MODIFIED events created during this test - const newModifiedEventIds = [...eventIdsAfter] - .filter((id) => !eventIdsBefore.has(id)) - .filter((id) => { - const event = events[id] - return event && (event.data as { type: string }).type === 'MODIFIED' - }) - - // THE KEY ASSERTION: - // With the fix: Only 1 MODIFIED event should survive (recheck-after-await ensures - // only one caller wins; others see existing entry and skip creating a new event). - // Without the fix: up to 4 MODIFIED events can survive (multiple orphaned events). - expect(newModifiedEventIds.length).toBe(1) - - // The surviving event should be the one in the cache - expect(newModifiedEventIds[0]).toBe(cachedEventID) - }) - - it('should handle resources with complex nested structures', async () => { - const complexResource = { - kind: 'Application', - apiVersion: 'argoproj.io/v1alpha1', - metadata: { - name: 'complex-app', - namespace: 'argocd', - uid: 'complex-uid', - resourceVersion: '500', - labels: { - 'app.kubernetes.io/name': 'test-app', - 'app.kubernetes.io/instance': 'test-instance', - }, - annotations: { - 'argocd.argoproj.io/sync-wave': '0', - }, - }, - spec: { - project: 'default', - source: { - repoURL: 'https://github.com/example/repo', - path: 'manifests', - targetRevision: 'main', - }, - destination: { - server: 'https://kubernetes.default.svc', - namespace: 'default', - }, - syncPolicy: { - automated: { - prune: true, - selfHeal: true, - }, - }, - }, - } as IResource - - await cacheResource(complexResource) - - const resources = await getKubeResources('Application', 'argoproj.io/v1alpha1') - expect(resources).toHaveLength(1) - expect(resources[0].metadata.name).toBe('complex-app') - - expect((resources[0] as IArgoApplication).spec?.source?.repoURL).toBe('https://github.com/example/repo') - }) - - it('should handle multiple updates to the same resource', async () => { - const resource: IResource = { - kind: 'Service', - apiVersion: 'v1', - metadata: { - name: 'test-service', - namespace: 'default', - uid: 'service-uid', - resourceVersion: '1', - }, - } - - // First cache - await cacheResource(resource) - const cache = getEventCache() - const apiVersionPlural = '/v1/services' - - // Update multiple times - for (let i = 2; i <= 5; i++) { - resource.metadata.resourceVersion = String(i) - await cacheResource(resource) - } - - // Should still have only one entry for this UID - const cacheKeys = Object.keys(cache[apiVersionPlural]) - expect(cacheKeys).toHaveLength(1) - expect(cacheKeys[0]).toBe('service-uid') - }) - - it('should create events in ServerSideEvents when caching', async () => { - const eventsBefore = Object.keys(ServerSideEvents.getEvents()).length - - const resource: IResource = { - kind: 'Namespace', - apiVersion: 'v1', - metadata: { - name: 'test-namespace', - uid: 'namespace-uid', - resourceVersion: '1', - }, - } - - await cacheResource(resource) - const cache = getEventCache() - const apiVersionPlural = '/v1/namespaces' - await cache[apiVersionPlural]['namespace-uid'].eventID - - const eventsAfter = Object.keys(ServerSideEvents.getEvents()).length - - // Should have created at least one new event (MODIFIED event + LOADED event) - expect(eventsAfter).toBeGreaterThan(eventsBefore) - }) - - it('should handle resources without optional metadata fields', async () => { - const minimalResource: IResource = { - kind: 'ConfigMap', - apiVersion: 'v1', - metadata: { - name: 'minimal-config', - uid: 'minimal-uid', - resourceVersion: '1', - }, - // No namespace, no labels, no annotations - } - - await cacheResource(minimalResource) - - const resources = await getKubeResources('ConfigMap', 'v1') - expect(resources).toHaveLength(1) - expect(resources[0].metadata.name).toBe('minimal-config') - }) - - it('should properly pluralize resource kinds', async () => { - // Test various pluralization scenarios - const testCases = [ - { kind: 'Policy', apiVersion: 'policy.open-cluster-management.io/v1' }, - { kind: 'Namespace', apiVersion: 'v1' }, - { kind: 'Ingress', apiVersion: 'networking.k8s.io/v1' }, - ] - - for (const testCase of testCases) { - const resource: IResource = { - kind: testCase.kind, - apiVersion: testCase.apiVersion, - metadata: { - name: `test-${testCase.kind.toLowerCase()}`, - uid: `${testCase.kind.toLowerCase()}-uid-${Date.now()}`, - resourceVersion: '1', - }, - } - - await cacheResource(resource) - - // Verify the resource was cached by retrieving it - const resources = await getKubeResources(testCase.kind, testCase.apiVersion) - expect(resources.length).toBeGreaterThan(0) - expect(resources.some((r) => r.metadata.name === resource.metadata.name)).toBe(true) - } - }) - }) - - describe('forwardEventsToClients', () => { - beforeEach(async () => { - const cache = getEventCache() - for (const key in cache) { - delete cache[key] - } - ServerSideEvents.reset() - // Drain microtask queue so stale promises from prior tests resolve - for (let i = 0; i < 5; i++) { - await new Promise((resolve) => setTimeout(resolve, 0)) - } - ServerSideEvents.reset() - }) - - it('should not push SSE events when forwardEventsToClients is false', async () => { - const pushSpy = jest.spyOn(ServerSideEvents, 'pushEvent') - - const resource: IResource = { - kind: 'Authentication', - apiVersion: 'config.openshift.io/v1', - metadata: { - name: 'cluster', - uid: 'auth-uid-1', - resourceVersion: '1', - }, - } - - await cacheResource(resource, false) - - expect(pushSpy).not.toHaveBeenCalled() - - const cache = getEventCache() - const entry = cache['/config.openshift.io/v1/authentications']?.['auth-uid-1'] - expect(entry).toBeDefined() - expect(await entry.compressed).toBeDefined() - expect(await entry.eventID).toBe(-1) - - const resources = await getKubeResources('Authentication', 'config.openshift.io/v1') - expect(resources).toHaveLength(1) - expect(resources[0].metadata.name).toBe('cluster') - - pushSpy.mockRestore() - }) - - it('should still push SSE events when forwardEventsToClients is true (default)', async () => { - const pushSpy = jest.spyOn(ServerSideEvents, 'pushEvent') - - const resource: IResource = { - kind: 'ConfigMap', - apiVersion: 'v1', - metadata: { - name: 'test-cm', - uid: 'cm-forward-uid', - resourceVersion: '1', - }, - } - - await cacheResource(resource, true) - const cache = getEventCache() - await cache['/v1/configmaps']['cm-forward-uid'].eventID - - expect(pushSpy).toHaveBeenCalled() - - pushSpy.mockRestore() - }) - - it('should not push SSE events for delete when forwardEventsToClients is false', async () => { - await cacheResource( - { - kind: 'Authentication', - apiVersion: 'config.openshift.io/v1', - metadata: { name: 'cluster', uid: 'auth-del-uid', resourceVersion: '1' }, - }, - false - ) - - const pushSpy = jest.spyOn(ServerSideEvents, 'pushEvent') - - const options = { kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false } - const resourceVersionRef = { value: '1' } - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const watchEvent = { - type: 'DELETED', - object: { - kind: 'Authentication', - apiVersion: 'config.openshift.io/v1', - metadata: { name: 'cluster', namespace: '', uid: 'auth-del-uid', resourceVersion: '2' }, - }, - } - - processor.write(JSON.stringify(watchEvent)) - processor.end() - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(pushSpy).not.toHaveBeenCalled() - - const cache = getEventCache() - expect(cache['/config.openshift.io/v1/authentications']?.['auth-del-uid']).toBeUndefined() - - pushSpy.mockRestore() - }) - - it('should not push SSE events via watch processor when forwardEventsToClients is false', async () => { - const pushSpy = jest.spyOn(ServerSideEvents, 'pushEvent') - - const options = { kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false } - const resourceVersionRef = { value: '0' } - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const watchEvent = { - type: 'ADDED', - object: { - kind: 'Authentication', - apiVersion: 'config.openshift.io/v1', - metadata: { name: 'cluster', namespace: '', uid: 'auth-watch-uid', resourceVersion: '10' }, - }, - } - - processor.write(JSON.stringify(watchEvent)) - processor.end() - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(pushSpy).not.toHaveBeenCalled() - expect(resourceVersionRef.value).toBe('10') - - const cache = getEventCache() - expect(cache['/config.openshift.io/v1/authentications']?.['auth-watch-uid']).toBeDefined() - - pushSpy.mockRestore() - }) - - it('should still run kind-specific side effects when forwardEventsToClients is false', async () => { - const localCluster: IResource = { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { - name: 'my-hub', - uid: 'hub-no-forward-uid', - resourceVersion: '1', - labels: { 'local-cluster': 'true' }, - }, - } - - await cacheResource(localCluster, false) - - expect(getHubClusterName()).toBe('my-hub') - expect(getIsHubSelfManaged()).toBe(true) - }) - }) - - describe('getEventCache', () => { - it('should return the resource cache object', () => { - const cache = getEventCache() - expect(typeof cache).toBe('object') - }) - - it('should return the same cache instance', () => { - const cache1 = getEventCache() - const cache2 = getEventCache() - expect(cache1).toBe(cache2) - }) - }) - - describe('Hub cluster state functions', () => { - it('getHubClusterName should return default value', () => { - expect(typeof getHubClusterName()).toBe('string') - }) - - it('getIsHubSelfManaged should return boolean', () => { - expect(typeof getIsHubSelfManaged()).toBe('boolean') - }) - - it('getIsObservabilityInstalled should return boolean', () => { - expect(typeof getIsObservabilityInstalled()).toBe('boolean') - }) - }) - - describe('Integration test: Full resource lifecycle', () => { - beforeEach(() => { - const cache = getEventCache() - for (const key in cache) { - delete cache[key] - } - }) - - it('should handle complete lifecycle: cache, retrieve, update, retrieve', async () => { - interface DeploymentResource extends IResource { - spec?: { - replicas?: number - } - } - - // Initial resource - const resource: DeploymentResource = { - kind: 'Deployment', - apiVersion: 'apps/v1', - metadata: { - name: 'lifecycle-deployment', - namespace: 'test-ns', - uid: 'lifecycle-uid', - resourceVersion: '1', - }, - spec: { - replicas: 1, - }, - } - - // Cache initial version - await cacheResource(resource) - - // Retrieve and verify - let resources = await getKubeResources('Deployment', 'apps/v1') - expect(resources).toHaveLength(1) - expect((resources[0] as DeploymentResource).spec?.replicas).toBe(1) - - // Update the resource - resource.metadata.resourceVersion = '2' - if (resource.spec) { - resource.spec.replicas = 3 - } - await cacheResource(resource) - - // Retrieve and verify update - resources = await getKubeResources('Deployment', 'apps/v1') - expect(resources).toHaveLength(1) - expect((resources[0] as DeploymentResource).spec?.replicas).toBe(3) - expect(resources[0].metadata.resourceVersion).toBe('2') - }) - - it('should handle multiple different resource types in cache', async () => { - const resources: IResource[] = [ - { - kind: 'ConfigMap', - apiVersion: 'v1', - metadata: { name: 'cm1', uid: 'cm-uid-1', resourceVersion: '1' }, - }, - { - kind: 'Secret', - apiVersion: 'v1', - metadata: { name: 'secret1', uid: 'secret-uid-1', resourceVersion: '1' }, - }, - { - kind: 'Service', - apiVersion: 'v1', - metadata: { name: 'svc1', uid: 'svc-uid-1', resourceVersion: '1' }, - }, - { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { name: 'cluster1', uid: 'cluster-uid-1', resourceVersion: '1' }, - }, - ] - - // Cache all resources - await Promise.all(resources.map((r) => cacheResource(r))) - - // Verify each type can be retrieved independently - const configMaps = await getKubeResources('ConfigMap', 'v1') - expect(configMaps).toHaveLength(1) - - const secrets = await getKubeResources('Secret', 'v1') - expect(secrets).toHaveLength(1) - - const services = await getKubeResources('Service', 'v1') - expect(services).toHaveLength(1) - - const clusters = await getKubeResources('ManagedCluster', 'cluster.open-cluster-management.io/v1') - expect(clusters).toHaveLength(1) - }) - }) - - describe('createSplitStream', () => { - it('should split data by newline characters', async () => { - const collected: string[] = [] - const splitStream = createSplitStream() - const collectStream = new Writable({ - objectMode: true, - write(chunk: string, _encoding, callback) { - collected.push(chunk) - callback() - }, - }) - - splitStream.pipe(collectStream) - splitStream.write(Buffer.from('line1\nline2\nline3\n')) - splitStream.end() - - await new Promise((resolve) => collectStream.on('finish', resolve)) - - expect(collected).toEqual(['line1', 'line2', 'line3']) - }) - - it('should buffer incomplete lines across chunks', async () => { - const collected: string[] = [] - const splitStream = createSplitStream() - const collectStream = new Writable({ - objectMode: true, - write(chunk: string, _encoding, callback) { - collected.push(chunk) - callback() - }, - }) - - splitStream.pipe(collectStream) - splitStream.write(Buffer.from('partial')) - splitStream.write(Buffer.from('_line\ncomplete\n')) - splitStream.end() - - await new Promise((resolve) => collectStream.on('finish', resolve)) - - expect(collected).toEqual(['partial_line', 'complete']) - }) - - it('should flush remaining buffered data on end', async () => { - const collected: string[] = [] - const splitStream = createSplitStream() - const collectStream = new Writable({ - objectMode: true, - write(chunk: string, _encoding, callback) { - collected.push(chunk) - callback() - }, - }) - - splitStream.pipe(collectStream) - splitStream.write(Buffer.from('line1\nno_newline_at_end')) - splitStream.end() - - await new Promise((resolve) => collectStream.on('finish', resolve)) - - expect(collected).toEqual(['line1', 'no_newline_at_end']) - }) - - it('should skip empty lines', async () => { - const collected: string[] = [] - const splitStream = createSplitStream() - const collectStream = new Writable({ - objectMode: true, - write(chunk: string, _encoding, callback) { - collected.push(chunk) - callback() - }, - }) - - splitStream.pipe(collectStream) - splitStream.write(Buffer.from('line1\n\n\nline2\n')) - splitStream.end() - - await new Promise((resolve) => collectStream.on('finish', resolve)) - - expect(collected).toEqual(['line1', 'line2']) - }) - - it('should skip lines with only whitespace', async () => { - const collected: string[] = [] - const splitStream = createSplitStream() - const collectStream = new Writable({ - objectMode: true, - write(chunk: string, _encoding, callback) { - collected.push(chunk) - callback() - }, - }) - - splitStream.pipe(collectStream) - splitStream.write(Buffer.from('line1\n \n\t\nline2\n')) - splitStream.end() - - await new Promise((resolve) => collectStream.on('finish', resolve)) - - expect(collected).toEqual(['line1', 'line2']) - }) - - it('should handle empty input', async () => { - const collected: string[] = [] - const splitStream = createSplitStream() - const collectStream = new Writable({ - objectMode: true, - write(chunk: string, _encoding, callback) { - collected.push(chunk) - callback() - }, - }) - - splitStream.pipe(collectStream) - splitStream.write(Buffer.from('')) - splitStream.end() - - await new Promise((resolve) => collectStream.on('finish', resolve)) - - expect(collected).toEqual([]) - }) - - it('should handle single line without newline', async () => { - const collected: string[] = [] - const splitStream = createSplitStream() - const collectStream = new Writable({ - objectMode: true, - write(chunk: string, _encoding, callback) { - collected.push(chunk) - callback() - }, - }) - - splitStream.pipe(collectStream) - splitStream.write(Buffer.from('single_line')) - splitStream.end() - - await new Promise((resolve) => collectStream.on('finish', resolve)) - - expect(collected).toEqual(['single_line']) - }) - - it('should handle multiple chunks forming one line', async () => { - const collected: string[] = [] - const splitStream = createSplitStream() - const collectStream = new Writable({ - objectMode: true, - write(chunk: string, _encoding, callback) { - collected.push(chunk) - callback() - }, - }) - - splitStream.pipe(collectStream) - splitStream.write(Buffer.from('part1')) - splitStream.write(Buffer.from('part2')) - splitStream.write(Buffer.from('part3\n')) - splitStream.end() - - await new Promise((resolve) => collectStream.on('finish', resolve)) - - expect(collected).toEqual(['part1part2part3']) - }) - }) - - describe('errorToString', () => { - it('should convert Error instance to message string', () => { - const error = new Error('test error message') - expect(errorToString(error)).toBe('test error message') - }) - - it('should handle Error with empty message', () => { - const error = new Error('') - expect(errorToString(error)).toBe('') - }) - - it('should return string directly if input is string', () => { - expect(errorToString('simple string error')).toBe('simple string error') - }) - - it('should JSON stringify objects', () => { - const errorObj = { code: 500, message: 'Internal error' } - expect(errorToString(errorObj)).toBe('{"code":500,"message":"Internal error"}') - }) - }) - - describe('createWatchEventProcessor', () => { - beforeEach(() => { - // Clear the cache before each test - const cache = getEventCache() - for (const key in cache) { - delete cache[key] - } - }) - - it('should process ADDED event and cache the resource', async () => { - const options = { kind: 'ConfigMap', apiVersion: 'v1' } - const resourceVersionRef = { value: '0' } - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const watchEvent = { - type: 'ADDED', - object: { - kind: 'ConfigMap', - apiVersion: 'v1', - metadata: { - name: 'test-config', - namespace: 'default', - uid: 'added-uid-123', - resourceVersion: '100', - }, - }, - } - - processor.write(JSON.stringify(watchEvent)) - processor.end() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(resourceVersionRef.value).toBe('100') - const cache = getEventCache() - expect(cache['/v1/configmaps']?.['added-uid-123']).toBeDefined() - }) - - it('should process MODIFIED event and update the cache', async () => { - const options = { kind: 'Secret', apiVersion: 'v1' } - const resourceVersionRef = { value: '0' } - - // First cache a resource - await cacheResource({ - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'test-secret', - namespace: 'default', - uid: 'modified-uid-456', - resourceVersion: '50', - }, - }) - - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const watchEvent = { - type: 'MODIFIED', - object: { - kind: 'Secret', - apiVersion: 'v1', - metadata: { - name: 'test-secret', - namespace: 'default', - uid: 'modified-uid-456', - resourceVersion: '200', - }, - }, - } - - processor.write(JSON.stringify(watchEvent)) - processor.end() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(resourceVersionRef.value).toBe('200') - }) - - it('should process DELETED event and remove from cache', async () => { - const options = { kind: 'Pod', apiVersion: 'v1' } - const resourceVersionRef = { value: '0' } - - // First cache a resource - await cacheResource({ - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'test-pod', - namespace: 'default', - uid: 'deleted-uid-789', - resourceVersion: '100', - }, - }) - - const cache = getEventCache() - expect(cache['/v1/pods']?.['deleted-uid-789']).toBeDefined() - - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const watchEvent = { - type: 'DELETED', - object: { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'test-pod', - namespace: 'default', - uid: 'deleted-uid-789', - resourceVersion: '300', - }, - }, - } - - processor.write(JSON.stringify(watchEvent)) - processor.end() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(resourceVersionRef.value).toBe('300') - expect(cache['/v1/pods']?.['deleted-uid-789']).toBeUndefined() - }) - - it('should process BOOKMARK event and update resourceVersion', async () => { - const options = { kind: 'Namespace', apiVersion: 'v1' } - const resourceVersionRef = { value: '0' } - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const watchEvent = { - type: 'BOOKMARK', - object: { - kind: 'Namespace', - apiVersion: 'v1', - metadata: { - resourceVersion: '500', - }, - }, - } - - processor.write(JSON.stringify(watchEvent)) - processor.end() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(resourceVersionRef.value).toBe('500') - }) - - it('should handle ERROR event with too old resource version', async () => { - const options = { kind: 'Service', apiVersion: 'v1' } - const resourceVersionRef = { value: '100' } - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const watchEvent = { - type: 'ERROR', - object: { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - message: 'too old resource version: 100 (12345)', - reason: 'Expired', - }, - } - - let caughtError: Error | null = null - processor.on('error', (err) => { - caughtError = err - }) - - processor.write(JSON.stringify(watchEvent)) - processor.end() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Should throw an error so that listAndWatch will retry - expect(caughtError).not.toBeNull() - expect(caughtError?.message).toBe('too old resource version: 100 (12345)') - expect(resourceVersionRef.value).toBe('100') // Should remain unchanged for ERROR - }) - - it('should handle ERROR event with other error messages', async () => { - const options = { kind: 'Deployment', apiVersion: 'apps/v1' } - const resourceVersionRef = { value: '100' } - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const watchEvent = { - type: 'ERROR', - object: { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - message: 'some other error', - reason: 'InternalError', - }, - } - - let caughtError: Error | null = null - processor.on('error', (err) => { - caughtError = err - }) - - processor.write(JSON.stringify(watchEvent)) - processor.end() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Should throw an error so that listAndWatch will retry - expect(caughtError).not.toBeNull() - expect(caughtError?.message).toBe('some other error') - expect(resourceVersionRef.value).toBe('100') - }) - - it('should handle invalid JSON and throw error', async () => { - const options = { kind: 'ConfigMap', apiVersion: 'v1' } - const resourceVersionRef = { value: '0' } - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - let caughtError: Error | null = null - processor.on('error', (err) => { - caughtError = err - }) - - processor.write('not valid json') - processor.end() - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(caughtError).not.toBeNull() - expect(caughtError).toBeInstanceOf(SyntaxError) - }) - - it('should work with pipeline and splitStream', async () => { - const options = { kind: 'ConfigMap', apiVersion: 'v1' } - const resourceVersionRef = { value: '0' } - - const splitStream = createSplitStream() - const processor = createWatchEventProcessor(options, 'http://test/url', resourceVersionRef) - - const event1 = JSON.stringify({ - type: 'ADDED', - object: { - kind: 'ConfigMap', - apiVersion: 'v1', - metadata: { name: 'cm1', namespace: 'ns1', uid: 'uid-1', resourceVersion: '1' }, - }, - }) - - const event2 = JSON.stringify({ - type: 'ADDED', - object: { - kind: 'ConfigMap', - apiVersion: 'v1', - metadata: { name: 'cm2', namespace: 'ns2', uid: 'uid-2', resourceVersion: '2' }, - }, - }) - - splitStream.pipe(processor) - - splitStream.write(Buffer.from(event1 + '\n' + event2 + '\n')) - splitStream.end() - - await new Promise((resolve) => setTimeout(resolve, 100)) - - expect(resourceVersionRef.value).toBe('2') - const cache = getEventCache() - expect(cache['/v1/configmaps']?.['uid-1']).toBeDefined() - expect(cache['/v1/configmaps']?.['uid-2']).toBeDefined() - }) - }) - - describe('listAndWatch', () => { - const mockedGetServiceAccountToken = serviceAccountTokenModule.getServiceAccountToken as jest.MockedFunction< - typeof serviceAccountTokenModule.getServiceAccountToken - > - const mockedGetCACertificate = serviceAccountTokenModule.getCACertificate as jest.MockedFunction< - typeof serviceAccountTokenModule.getCACertificate - > - - beforeEach(() => { - jest.clearAllMocks() - process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' - mockedGetServiceAccountToken.mockReturnValue('mock-token') - mockedGetCACertificate.mockReturnValue(undefined) - }) - - afterEach(() => { - stopWatching() - nock.abortPendingRequests() - nock.cleanAll() - delete process.env.CLUSTER_API_URL - }) - - it('should retry immediately when receiving "too old resource version" error', async () => { - // This test verifies that line 289 in events.ts handles "too old resource version" - // errors by retrying immediately without the 60-second delay. - - const options = { kind: 'ConfigMap', apiVersion: 'v1' } - let listCallCount = 0 - let secondListTime = 0 - - // First list call - succeeds - nock('https://api.test-cluster.com:6443') - .get('/api/v1/configmaps') - .query(true) - .reply(200, { - kind: 'ConfigMapList', - apiVersion: 'v1', - metadata: { resourceVersion: '1000' }, - items: [], - }) - - // Watch call - returns ERROR event with "too old resource version" - const errorEvent = JSON.stringify({ - type: 'ERROR', - object: { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - message: 'too old resource version: 1000 (5000)', - reason: 'Expired', - }, - }) - - nock('https://api.test-cluster.com:6443') - .get('/api/v1/configmaps') - .query((query) => query.watch !== undefined) - .reply(200, errorEvent + '\n') - - // Second list call - after immediate retry due to "too old resource version" error - nock('https://api.test-cluster.com:6443') - .get('/api/v1/configmaps') - .query((query) => query.watch === undefined && query.limit !== undefined) - .reply(200, () => { - listCallCount++ - secondListTime = Date.now() - return { - kind: 'ConfigMapList', - apiVersion: 'v1', - metadata: { resourceVersion: '5000' }, - items: [], - } - }) - - // Second watch - will hang until stopWatching is called - nock('https://api.test-cluster.com:6443') - .get('/api/v1/configmaps') - .query((query) => query.watch !== undefined) - .delay(60000) // Long delay - will be interrupted by stopWatching - .reply(200, '') - - const startTime = Date.now() - - // Start listAndWatch and schedule stopWatching after a brief delay - const listAndWatchPromise = listAndWatch(options) - - // Wait for the second list to happen, then stop - await new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (listCallCount >= 1) { - clearInterval(checkInterval) - // Give a small buffer then stop - setTimeout(() => { - stopWatching() - resolve() - }, 100) - } - }, 50) - }) - - await listAndWatchPromise - - // The second list should have happened quickly (< 5 seconds) because "too old resource version" - // triggers an immediate retry without the 60-second delay (line 289) - const retryTime = secondListTime - startTime - expect(retryTime).toBeLessThan(5000) - expect(listCallCount).toBe(1) // Only counting second list call - }) - }) - - describe('Access Cache Cleanup', () => { - beforeEach(() => { - resetAccessCache() - jest.clearAllMocks() - process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' - }) - - afterEach(() => { - resetAccessCache() - delete process.env.CLUSTER_API_URL - nock.cleanAll() - }) - - it('should cache RBAC access check results', async () => { - const mockToken = 'test-token-123' - const resource = { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'test-pod' } } - - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - const result1 = await canAccess(resource, 'get', mockToken) - const result2 = await canAccess(resource, 'get', mockToken) - - expect(result1).toBe(true) - expect(result1).toBe(result2) - }) - - it('should respect TTL and refetch after expiry', async () => { - const cache = getAccessCache() - const mockToken = 'test-token-ttl' - - cache[mockToken] = { - 'Secret:default:credentials': { time: Date.now() - ACCESS_CACHE_TTL - 1000, promise: Promise.resolve(true) }, - } - - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: false } }) - - const result = await canAccess( - { kind: 'Secret', apiVersion: 'v1', metadata: { namespace: 'default', name: 'credentials' } }, - 'get', - mockToken - ) - expect(result).toBe(false) - }) - - it('should remove stale cache entries during cleanup', () => { - const cache = getAccessCache() - const now = Date.now() - - cache['token1'] = { - stale: { time: now - ACCESS_CACHE_TTL - 1000, promise: Promise.resolve(true) }, - fresh: { time: now - 30000, promise: Promise.resolve(true) }, - } - cache['token2'] = { 'stale-only': { time: now - ACCESS_CACHE_TTL - 5000, promise: Promise.resolve(false) } } - - cleanupAccessCache() - - expect(cache['token1']['stale']).toBeUndefined() - expect(cache['token1']['fresh']).toBeDefined() - expect(cache['token2']).toBeUndefined() - }) - - it('should enforce maximum token limit with LRU eviction', () => { - const cache = getAccessCache() - const now = Date.now() - const tokenCount = ACCESS_CACHE_MAX_TOKENS + 100 - - for (let i = 0; i < tokenCount; i++) { - cache[`token-${i}`] = { - 'Pod:default:test': { time: now - (i / tokenCount) * 50 * 1000, promise: Promise.resolve(true) }, - } - } - - cleanupAccessCache() - - expect(Object.keys(cache).length).toBe(ACCESS_CACHE_MAX_TOKENS) - expect(cache['token-0']).toBeDefined() - expect(cache[`token-${tokenCount - 1}`]).toBeUndefined() - }) - }) -}) diff --git a/backend-node/test/routes/liveness.test.ts b/backend-node/test/routes/liveness.test.ts deleted file mode 100644 index 44af54e72cb..00000000000 --- a/backend-node/test/routes/liveness.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import nock from 'nock' -import { apiServerPing } from '../../src/routes/liveness' - -describe(`liveness Route`, function () { - it(`GET /livenessProbe should return status code 200`, async function () { - const res = await request('GET', '/livenessProbe') - expect(res.statusCode).toEqual(200) - }) - it(`GET /livenessProbe should return status code 500 if dead`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(401) - await apiServerPing() - const res = await request('GET', '/livenessProbe') - expect(res.statusCode).toEqual(500) - }) -}) diff --git a/backend-node/test/routes/ping.test.ts b/backend-node/test/routes/ping.test.ts deleted file mode 100644 index 1e523ce38de..00000000000 --- a/backend-node/test/routes/ping.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' - -describe(`Ping Route`, function () { - it(`GET /ping should return status code 200`, async function () { - const res = await request('GET', '/ping') - expect(res.statusCode).toEqual(200) - }) -}) diff --git a/backend-node/test/routes/readiness.test.ts b/backend-node/test/routes/readiness.test.ts deleted file mode 100644 index 12cab40f7dc..00000000000 --- a/backend-node/test/routes/readiness.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -import { request } from '../mock-request' -import nock from 'nock' -import { apiServerPing } from '../../src/routes/liveness' - -describe(`readiness Route`, function () { - it(`GET /readinessProbe should return status code 200`, async function () { - const res = await request('GET', '/readinessProbe') - expect(res.statusCode).toEqual(200) - }) - it(`GET /readinessProbe should return status code 500 if dead`, async function () { - nock(process.env.CLUSTER_API_URL).get('/apis').reply(401) - await apiServerPing() - const res = await request('GET', '/readinessProbe') - expect(res.statusCode).toEqual(500) - }) -}) diff --git a/backend-node/test/tsconfig.json b/backend-node/test/tsconfig.json deleted file mode 100644 index e516b7faf6a..00000000000 --- a/backend-node/test/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["**/*.ts"] -} diff --git a/backend-node/tsconfig.build.json b/backend-node/tsconfig.build.json deleted file mode 100644 index e20f937f9fa..00000000000 --- a/backend-node/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["src"] -} diff --git a/backend-node/tsconfig.json b/backend-node/tsconfig.json deleted file mode 100644 index fbe7c97d785..00000000000 --- a/backend-node/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "lib": ["ES2022"], - "allowSyntheticDefaultImports": true, - "allowJs": false, - "sourceMap": true, - "declaration": true, - "alwaysStrict": true, - "strictBindCallApply": true, - "strictBuiltinIteratorReturn": true, - "strictFunctionTypes": false, - "strictNullChecks": false, - "strictPropertyInitialization": false, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", - "noImplicitAny": true, - "outDir": "./build", - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "skipLibCheck": true, - "esModuleInterop": true, - "verbatimModuleSyntax": true - }, - "include": ["src", "test"] -} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index d5001933670..0791f1d8964 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -1,12 +1,11 @@ # Backend (Go) -Public listener for the ACM/MCE console. During the Node-to-Go migration it owns TLS, health probes, config, and auth helpers, and reverse-proxies every unmigrated route to the Node sidecar in `../backend-node`. +Public listener for the ACM/MCE console. It owns TLS, health probes, config, auth, hub watches, and every public HTTP route. ## Key Technologies - **Runtime**: Go 1.26+ (`net/http`; TLS enables HTTP/2 automatically) -- **Router**: `chi` — probes and migrated routes registered natively; static GET assets; everything else is `NotFound` → reverse proxy -- **Proxy**: `httputil.ReverseProxy` (HTTP/1.1 to the sidecar so WebSocket upgrades work; `FlushInterval: -1` for SSE) +- **Router**: `chi` — all public routes registered natively; static GET assets; unknown paths 404; wrong method 405 - **Logging**: `log/slog` JSON (`method`, `path`, `status`, `duration`) - **Config watch**: `fsnotify` on `config/` (1s debounce) - **Auth**: cookie `acm-access-token-cookie` then `Authorization: Bearer`; TokenReview is a library, not a global gate @@ -17,13 +16,12 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns |------|---------| | `cmd/console` | Process entry: load config, require SA token, listen, SIGINT/SIGTERM | | `internal/server` | TLS listener, chi mux, `/multicloud` probe aliases | -| `internal/proxy` | Reverse proxy to `NODE_BACKEND_URL` (original path, including `/multicloud`) | | `internal/k8sproxy` | Hub kube-apiserver passthrough for `/api`, `/apis`, `/version` (user Bearer token) | | `internal/clusterproxy` | cluster-proxy-addon-user URL discovery (MCE target namespace / env overrides) | | `internal/mcproxy` | Managed-cluster reverse proxy (`/managedclusterproxy/*`, including WebSocket) | | `internal/metricsproxy` | Prometheus and observability query reverse proxies | | `internal/vmproxy` | VirtualMachine GET helpers, actions, and resource-usage aggregation | -| `internal/health` | `/ping`, `/livenessProbe` (Go only), `/readinessProbe` (Go + sidecar `/ping`) | +| `internal/health` | `/ping`, `/livenessProbe`, `/readinessProbe` (process live) | | `internal/config` | `.env` + `config/` directory (filename = key) | | `internal/auth` | Cookie/Bearer, SA token/CA, TokenReview helper, OCM SSO client-credentials token | | `internal/oauth` | `/configure` discovery; standalone `/login` `/login/callback` `/logout` (OpenShift OAuth and OIDC) | @@ -31,18 +29,18 @@ Public listener for the ACM/MCE console. During the Node-to-Go migration it owns | `internal/clusterinfo` | `/hub`, `/cluster-version`, `/hypershift-status`, MCH/MCE components, `/operatorCheck`, `/apiPaths` | | `internal/cors` | Development CORS middleware (OPTIONS preflight for standalone dev) | | `internal/events/rbac` | `GET /events/rbac` SSE: ClusterRole informer (`vm-clusterroles` label) + per-user SSAR | -| `internal/events/hub` | `GET /events` SSE: informer fan-out, snapshot packets, per-user SSAR (60s TTL). DELETED is not RBAC-filtered (bug-compatible with Node). `CONSOLE_INFORMER_CACHE=0` proxies `/events` to Node | -| `internal/aggregate` | `POST /aggregate/{applications,statuses,appSetData}`: informer cache + Search SA GraphQL, Fuse.js-compatible filter, windowed SSAR. `CONSOLE_INFORMER_CACHE=0` does not register the route | +| `internal/events/hub` | `GET /events` SSE: informer fan-out, snapshot packets, per-user SSAR (60s TTL). DELETED is not RBAC-filtered | +| `internal/aggregate` | `POST /aggregate/{applications,statuses,appSetData}`: informer cache + Search SA GraphQL, Fuse.js-compatible filter, windowed SSAR | | `internal/searchapi` | Search GraphQL client used by the aggregator (`/searchapi/graphql` or `/federated`) | | `internal/searchproxy` | `POST /proxy/search` and graphql-ws relay to search-api with the **user** token (`connection_init` Authorization injection) | | `internal/rosa` | ROSA HCP wizard POSTs to `sso.redhat.com` + `api.openshift.com` (OCM service-account token) | | `internal/ansibletower` | `POST /ansibletower`: user-token Secret GET, AAP path allowlist, TLS skip-verify | | `internal/placementdebug` | `POST /placement-debug` reverse proxy + independent watch of OCM CA ConfigMap | | `internal/upgraderisks` | `POST /upgrade-risks-prediction`: SA list `pull-secret`, chunked Insights POSTs | -| `internal/informers` | Hub resource cache (~67 watch specs, dual-run with Node). Dev: `GET /debug/informer-snapshot` | +| `internal/informers` | Hub resource cache (`DefaultWatchSpecs()`). Dev: `GET /debug/informer-snapshot` | | `internal/static` | Plugin and SPA files: cache headers, CSP, brotli/gzip negotiation | | `internal/log` | slog JSON helper | -| `config/` | Runtime settings shared with the Node sidecar | +| `config/` | Runtime settings from `config/` files and `.env` | | `certs/` | TLS material (`npm run setup` / `npm run ci:backend` create when missing; `npm run generate-certs` to force) | ## Commands @@ -51,7 +49,7 @@ From the repo root (preferred), or `cd backend`: | Command | Purpose | |---------|---------| -| `npm start` / `npm run plugins` | Go `:4000` in front of Node sidecar `:4001`. Air rebuilds and restarts Go when `cmd/` or `internal/` change | +| `npm start` / `npm run plugins` | Go `:4000`. Air rebuilds and restarts Go when `cmd/` or `internal/` change | | `npm run test:backend` | `go test ./...` | | `npm run lint:backend` | `golangci-lint` (see `backend/.golangci.yml`) | | `npm run check:backend` | tests + golangci-lint | @@ -74,7 +72,7 @@ Go backend :4000 (TLS / HTTP/2) ├─ POST ROSA wizard (/aws-account-ids, /regions, /vpcs, …) → OCM ├─ POST /ansibletower, /placement-debug, /upgrade-risks-prediction ├─ GET /debug/informer-snapshot (dev only; Go informer cache dump) - ├─ SA informers (~67 specs) feed GET /events and POST /aggregate; Node startWatching() still runs for hub.ts + ├─ SA informers (~67 specs) feed GET /events and POST /aggregate ├─ ALL /api, /apis, GET /version → hub kube-apiserver (user token) │ (also /multicloud/…) ├─ GET /configure (OAuth/OIDC token_endpoint discovery) @@ -87,30 +85,27 @@ Go backend :4000 (TLS / HTTP/2) ├─ /virtualmachines/*, /virtualmachineinstances/*, /virtualmachinesnapshots/*, │ /virtualmachinerestores, GET /vmResourceUsage/* → managed cluster via addon ├─ GET static assets (/plugin/*, hashed JS/CSS, locales, index.html) - └─ everything else (original URL) ──HTTP/1.1──► Node sidecar :4001 - │ - ▼ - Hub cluster API (unmigrated routes) + └─ unknown paths → 404 (empty body); wrong method → 405 ``` -`/multicloud` is stripped only when matching Go-owned routes. The proxy forwards the original path so Node can keep stripping it. +`/multicloud` is stripped only when matching Go-owned routes. -During ACM-42597/42598 the Go process watches the same specs as Node `startWatching()` **after** the public listener is bound. Startup is capped at 8 concurrent list/watch setups; the informer client uses QPS 20 / Burst 40; resync is disabled. Set `CONSOLE_INFORMER_CACHE=0` (or `false`/`off`) to skip Go watches and keep proxying `GET /events` to Node (and not register `POST /aggregate`). Node `startWatching()` still runs for `hub.ts` (`getKubeResources`). After informers sync, logs `informer cache memory` with `heapAlloc` — compare that to the sidecar deflate cache, not combined RSS. +The Go process starts hub list/watch **after** the public listener is bound. Startup is capped at 8 concurrent list/watch setups; the informer client uses QPS 20 / Burst 40; resync is disabled. After informers sync, logs `informer cache memory` with `heapAlloc`. Watch specs live only in `internal/informers/specs.go` (`DefaultWatchSpecs()`). `POST /aggregate/*` rebuilds ACM/Argo Application rows from `InformerCache.ListByKind` and refreshes remote OCP/Flux/Argo status from Search (15s for the first three passes, then `APP_SEARCH_INTERVAL` or 60s). Pagination uses Fuse.js 6.6.2 options (`ignoreLocation`, threshold 0.3) when there are more than 500 items; `itemCount` in `/aggregate/statuses` is a JSON string. -`POST /proxy/search` and the Search WebSocket are served by Go (`backend/internal/searchproxy`). Auth is GET `/api`. GraphQL POST injects the user Bearer token and forwards the Node header allowlist (`accept`, `accept-encoding`, `content-encoding`, `content-length`, `content-type`). The graphql-ws relay opens `wss` to the same Search URL, sends `Authorization` on the upgrade, and rewrites the first `connection_init` payload with `Authorization: Bearer `. Upstream connect timeout 60s → 504; connect failure → 502. Discovery matches the aggregator: `SEARCH_API_URL` or `search-search-api..svc.cluster.local:4010` plus `/searchapi/graphql` (or `/federated` when `globalSearchFeatureFlag=enabled`). +`POST /proxy/search` and the Search WebSocket are served by Go (`backend/internal/searchproxy`). Auth is GET `/api`. GraphQL POST injects the user Bearer token and forwards the header allowlist (`accept`, `accept-encoding`, `content-encoding`, `content-length`, `content-type`). The graphql-ws relay opens `wss` to the same Search URL, sends `Authorization` on the upgrade, and rewrites the first `connection_init` payload with `Authorization: Bearer `. Upstream connect timeout 60s → 504; connect failure → 502. Discovery matches the aggregator: `SEARCH_API_URL` or `search-search-api..svc.cluster.local:4010` plus `/searchapi/graphql` (or `/federated` when `globalSearchFeatureFlag=enabled`). -Long-tail HTTP is always registered in Go (not gated on `CONSOLE_INFORMER_CACHE`). Auth is GET `/api` (401 empty body). ROSA wizard POSTs exchange OCM client credentials at SSO then call `api.openshift.com`. `POST /ansibletower` reads the credential Secret with the **user** token, allow-lists AAP pathnames, and GETs the tower with `InsecureSkipVerify`. `POST /placement-debug` reverse-proxies to `PLACEMENT_DEBUG_URL` (or the in-cluster placement service) with the OCM CA ConfigMap `open-cluster-management-hub/ca-bundle-configmap`; missing CA → 503. `POST /upgrade-risks-prediction` lists `openshift-config` secrets with the **SA**, extracts `pull-secret` `cloud.openshift.com` auth, and POSTs Insights in chunks of 100 (`UPGRADE_RISKS_PREDICTION_URL` or console.redhat.com). The Node sidecar still serves `GET /events` when the Go cache is off, plus leftover aggregators/`startWatching` until ACM-42603. +Long-tail HTTP is always registered. Auth is GET `/api` (401 empty body). ROSA wizard POSTs exchange OCM client credentials at SSO then call `api.openshift.com`. `POST /ansibletower` reads the credential Secret with the **user** token, allow-lists AAP pathnames, and GETs the tower with `InsecureSkipVerify`. `POST /placement-debug` reverse-proxies to `PLACEMENT_DEBUG_URL` (or the in-cluster placement service) with the OCM CA ConfigMap `open-cluster-management-hub/ca-bundle-configmap`; missing CA → 503. `POST /upgrade-risks-prediction` lists `openshift-config` secrets with the **SA**, extracts `pull-secret` `cloud.openshift.com` auth, and POSTs Insights in chunks of 100 (`UPGRADE_RISKS_PREDICTION_URL` or console.redhat.com). -`GET /events` framing matches Node `server-side-events.ts`: `id:` + `data:` (no space), gzip when `Accept-Encoding` includes gzip, keepalive `:\n\n` every 10s, snapshot `START` → `SETTINGS` → priority packets with `EOP` → `LOADED`, live `MODIFIED`/`DELETED` then `LOADED`. Creates and updates are both `MODIFIED` (not `ADDED`). **DELETED events are broadcast without per-user SSAR** — the same known gap as Node; do not “fix” it in this stream without a follow-up. +`GET /events` framing: `id:` + `data:` (no space), gzip when `Accept-Encoding` includes gzip, keepalive `:\n\n` every 10s, snapshot `START` → `SETTINGS` → priority packets with `EOP` → `LOADED`, live `MODIFIED`/`DELETED` then `LOADED`. Creates and updates are both `MODIFIED` (not `ADDED`). **DELETED events are broadcast without per-user SSAR** — a known gap; do not “fix” it in this stream without a follow-up. ## Shared artifacts -`npm run setup` writes `backend/.env`. The sidecar loads the same file via `ENV_FILE` / `CONFIG_DIR` / `CERTS_DIR`. `godotenv` does not override `PORT`, so the sidecar can listen on `NODE_BACKEND_PORT` while `.env` still has `PORT=4000` for Go. +`npm run setup` writes `backend/.env`. Go exits 1 at startup if the service-account token is missing (`TOKEN` or `/var/run/secrets/kubernetes.io/serviceaccount/token`). -Migrated proxy routes also read `CLUSTER_PROXY_ADDON_USER_HOST` / `CLUSTER_PROXY_ADDON_USER_ROUTE`, `PROMETHEUS_ROUTE`, `OBSERVABILITY_ROUTE`, `SERVICE_CA_CERT`, `PLACEMENT_DEBUG_URL`, and `UPGRADE_RISKS_PREDICTION_URL` from the same `.env` / `config/` directory. +Proxy routes also read `CLUSTER_PROXY_ADDON_USER_HOST` / `CLUSTER_PROXY_ADDON_USER_ROUTE`, `PROMETHEUS_ROUTE`, `OBSERVABILITY_ROUTE`, `SERVICE_CA_CERT`, `PLACEMENT_DEBUG_URL`, and `UPGRADE_RISKS_PREDICTION_URL` from the same `.env` / `config/` directory. `PUBLIC_FOLDER` (default `public`) is the on-disk plugin/SPA tree. Production images copy `frontend/plugins/{acm|mce}/dist` to `/app/public/plugin`. diff --git a/backend/README.md b/backend/README.md index 14d1298f89a..93e7c2cba11 100644 --- a/backend/README.md +++ b/backend/README.md @@ -2,7 +2,7 @@ # Console backend (Go) -This directory is the ACM/MCE console backend. During the Node-to-Go migration it fronts a Node sidecar (`../backend-node`) and reverse-proxies unmigrated routes. +This directory is the ACM/MCE console backend. It is the only backend process: TLS, health probes, hub watches, and every public HTTP route. ## Local development @@ -22,4 +22,4 @@ rm -rf backend/.env backend/certs/ && npm run setup && npm run ci:backend See [AGENTS.md](AGENTS.md) for layout, architecture, and commands. -Go listens on `BACKEND_PORT` (default 4000). The Node sidecar listens on `NODE_BACKEND_PORT` (default 4001). +Go listens on `BACKEND_PORT` (default 4000). diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index 50a215e03ae..548416ae367 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -112,10 +112,8 @@ func run() error { ssar := eventshub.NewSSARAccess(restCfg) ssar.StartCleanup(ctx) eventsHandler := eventshub.NewHandler(eventHub, eventshub.NewAPIAuth(restCfg), ssar) - if cfg.InformerCache { - infCache.SetSink(eventHub) - cfg.OnReload(eventHub.PublishSettings) - } + infCache.SetSink(eventHub) + cfg.OnReload(eventHub.PublishSettings) oauthH := oauth.New(oauth.Options{ ClientID: cfg.OAuth2ClientID, @@ -142,26 +140,23 @@ func run() error { return ns }, } - var aggEng *aggregate.Engine - if cfg.InformerCache { - opts = append(opts, server.WithEvents(eventsHandler)) - ca := sa.ServiceCACert - if len(ca) == 0 { - ca = sa.CACert - } - searchClient := &searchapi.Client{ - HTTP: auth.HTTPClient(ca, 0), - Token: sa.Token, - SearchAPIURL: searchDiscovery.SearchAPIURL, - Federated: searchDiscovery.Federated, - Namespace: searchDiscovery.Namespace, - MCHNamespace: searchDiscovery.MCHNamespace, - } - aggEng = aggregate.NewEngine(infCache, searchClient, dyn) - aggAccess := aggregate.NewSSARAccess(restCfg) - aggAccess.StartCleanup(ctx) - opts = append(opts, server.WithAggregate(aggregate.NewHandler(aggEng, restCfg, aggAccess))) + opts = append(opts, server.WithEvents(eventsHandler)) + ca := sa.ServiceCACert + if len(ca) == 0 { + ca = sa.CACert } + searchClient := &searchapi.Client{ + HTTP: auth.HTTPClient(ca, 0), + Token: sa.Token, + SearchAPIURL: searchDiscovery.SearchAPIURL, + Federated: searchDiscovery.Federated, + Namespace: searchDiscovery.Namespace, + MCHNamespace: searchDiscovery.MCHNamespace, + } + aggEng := aggregate.NewEngine(infCache, searchClient, dyn) + aggAccess := aggregate.NewSSARAccess(restCfg) + aggAccess.StartCleanup(ctx) + opts = append(opts, server.WithAggregate(aggregate.NewHandler(aggEng, restCfg, aggAccess))) if !cfg.Production { opts = append(opts, server.WithOAuthLogin(), server.WithDebugSnapshot(informers.NewSnapshotHandler(infCache, restCfg))) } @@ -254,20 +249,17 @@ func run() error { applog.Logger().Info("process start", "PORT", cfg.Port, - "NODE_BACKEND_URL", cfg.NodeBackendURL, - "informerCache", cfg.InformerCache, + "disableEvents", cfg.DisableEvents, slog.String("CONFIG_DIR", cfg.ConfigDir), slog.String("PUBLIC_FOLDER", cfg.PublicFolder), ) return server.ListenAndServe(ctx, cfg, handler, func() { - if !cfg.InformerCache { - applog.Logger().Info("informer cache disabled", "CONSOLE_INFORMER_CACHE", os.Getenv("CONSOLE_INFORMER_CACHE")) + if !cfg.DisableEvents { + applog.Logger().Info("disable events", "DISABLE_EVENTS", os.Getenv("DISABLE_EVENTS")) return } informers.StartCache(ctx, infCache, infDyn, mapper) - if aggEng != nil { - aggEng.Start(ctx) - } + aggEng.Start(ctx) }) } diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index dfd3491dc17..91955d0bbd8 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -179,7 +179,7 @@ func ValidateUserTokenStatus(ctx context.Context, base *rest.Config, token strin return resp.StatusCode, nil } -// ValidateUserToken checks the token the same way the Node sidecar does: GET /api. +// ValidateUserToken checks the token the same way GET /api does. // TokenReview is not used here because console-mce can create TokenReviews for some // identities that still fail Review, while GET /api matches /events auth. func ValidateUserToken(ctx context.Context, base *rest.Config, token string) error { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 06ab4cc404d..85a209af49d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -19,7 +19,6 @@ const debounce = time.Second // Config is process configuration loaded from env, .env, and the config/ directory. type Config struct { Port string - NodeBackendURL string ConfigDir string CertsDir string EnvFile string @@ -40,7 +39,6 @@ type Config struct { OIDCIssuerURL string FrontendURL string Production bool - InformerCache bool mu sync.RWMutex settings map[string]string @@ -54,16 +52,6 @@ func envOr(key, fallback string) string { return fallback } -// envEnabledDefaultOn is true unless the env var is an explicit off value (0/false/off/no). -func envEnabledDefaultOn(key string) bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { - case "0", "false", "off", "no": - return false - default: - return true - } -} - // Load reads ENV_FILE (if present) then environment variables. func Load() *Config { envFile := envOr("ENV_FILE", ".env") @@ -71,7 +59,6 @@ func Load() *Config { cfg := &Config{ Port: envOr("PORT", "4000"), - NodeBackendURL: envOr("NODE_BACKEND_URL", "https://127.0.0.1:4001"), ConfigDir: envOr("CONFIG_DIR", "config"), CertsDir: envOr("CERTS_DIR", "certs"), EnvFile: envFile, @@ -91,7 +78,7 @@ func Load() *Config { OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), FrontendURL: os.Getenv("FRONTEND_URL"), Production: os.Getenv("NODE_ENV") == "production", - InformerCache: envEnabledDefaultOn("CONSOLE_INFORMER_CACHE"), + DisableEvents: os.Getenv("DISABLE_EVENTS"), settings: map[string]string{}, } _ = cfg.ReloadSettings() diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 3d9cd3721c8..54e9897336d 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -120,7 +120,6 @@ func TestLoad_ProxyEnvVars(t *testing.T) { dir := t.TempDir() t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) t.Setenv("PORT", "4100") - t.Setenv("NODE_BACKEND_URL", "https://127.0.0.1:4101") t.Setenv("PROMETHEUS_ROUTE", "https://prom.example") t.Setenv("OBSERVABILITY_ROUTE", "https://obs.example") t.Setenv("CLUSTER_PROXY_ADDON_USER_HOST", "proxy.example") @@ -130,9 +129,6 @@ func TestLoad_ProxyEnvVars(t *testing.T) { if cfg.Port != "4100" { t.Fatalf("Port=%q", cfg.Port) } - if cfg.NodeBackendURL != "https://127.0.0.1:4101" { - t.Fatalf("NodeBackendURL=%q", cfg.NodeBackendURL) - } if cfg.PrometheusRoute != "https://prom.example" { t.Fatalf("PrometheusRoute=%q", cfg.PrometheusRoute) } @@ -157,28 +153,6 @@ func TestLoad_PublicFolder(t *testing.T) { } } -func TestLoad_InformerCacheDefaultOn(t *testing.T) { - dir := t.TempDir() - t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) - t.Setenv("CONSOLE_INFORMER_CACHE", "") - cfg := config.Load() - if !cfg.InformerCache { - t.Fatal("expected InformerCache on by default") - } -} - -func TestLoad_InformerCacheOff(t *testing.T) { - dir := t.TempDir() - t.Setenv("ENV_FILE", filepath.Join(dir, ".env")) - for _, v := range []string{"0", "false", "off", "NO"} { - t.Setenv("CONSOLE_INFORMER_CACHE", v) - cfg := config.Load() - if cfg.InformerCache { - t.Fatalf("CONSOLE_INFORMER_CACHE=%q should disable cache", v) - } - } -} - func TestReloadSettings_MissingDir(t *testing.T) { cfg := &config.Config{ConfigDir: filepath.Join(t.TempDir(), "missing")} if err := cfg.ReloadSettings(); err != nil { diff --git a/backend/internal/cors/cors.go b/backend/internal/cors/cors.go index a745d828563..4978825e68a 100644 --- a/backend/internal/cors/cors.go +++ b/backend/internal/cors/cors.go @@ -6,9 +6,8 @@ import ( "net/http" ) -// Comment to be removed as a part of the backend-node decommissioning, see ACM-42603 -// Middleware mirrors backend-node/src/lib/cors.ts: reflect Origin and answer OPTIONS. -// with 200 in non-production so standalone dev (webpack on :3000/:3001/:3002) can call :4000. +// Middleware reflects Origin and answers OPTIONS with 200 in non-production so +// standalone dev (webpack on :3000/:3001/:3002) can call :4000. func Middleware(production bool) func(http.Handler) http.Handler { if production { return func(next http.Handler) http.Handler { return next } diff --git a/backend/internal/health/health.go b/backend/internal/health/health.go index 0cd02648604..c034021bfff 100644 --- a/backend/internal/health/health.go +++ b/backend/internal/health/health.go @@ -3,28 +3,18 @@ package health import ( - "crypto/tls" "net/http" - "net/url" "sync/atomic" - "time" ) -// Probes serves /livenessProbe, /readinessProbe, and /ping. +// Probes serves /ping, /livenessProbe, and /readinessProbe. type Probes struct { - live atomic.Bool - sidecarURL *url.URL - client *http.Client + live atomic.Bool } -func New(sidecarURL *url.URL, sidecarTLS *tls.Config) *Probes { - p := &Probes{sidecarURL: sidecarURL} +func New() *Probes { + p := &Probes{} p.live.Store(true) - transport := &http.Transport{ - ForceAttemptHTTP2: false, - TLSClientConfig: sidecarTLS, - } - p.client = &http.Client{Transport: transport, Timeout: 2 * time.Second} return p } @@ -47,20 +37,5 @@ func (p *Probes) Readiness(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - if p.sidecarURL == nil { - w.WriteHeader(http.StatusOK) - return - } - pingURL := p.sidecarURL.ResolveReference(&url.URL{Path: "/ping"}) - resp, err := p.client.Get(pingURL.String()) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - w.WriteHeader(http.StatusInternalServerError) - return - } w.WriteHeader(http.StatusOK) } diff --git a/backend/internal/health/health_test.go b/backend/internal/health/health_test.go index ec48d5eae9e..e96e603eb11 100644 --- a/backend/internal/health/health_test.go +++ b/backend/internal/health/health_test.go @@ -3,18 +3,16 @@ package health_test import ( - "io" "net/http" "net/http/httptest" - "net/url" "testing" "github.com/stolostron/console/backend/internal/health" ) func TestPingAndLiveness(t *testing.T) { - p := health.New(nil, nil) - for _, fn := range []http.HandlerFunc{p.Ping, p.Liveness} { + p := health.New() + for _, fn := range []http.HandlerFunc{p.Ping, p.Liveness, p.Readiness} { rec := httptest.NewRecorder() fn(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusOK { @@ -27,7 +25,7 @@ func TestPingAndLiveness(t *testing.T) { } func TestLivenessDead(t *testing.T) { - p := health.New(nil, nil) + p := health.New() p.SetLive(false) rec := httptest.NewRecorder() p.Liveness(rec, httptest.NewRequest(http.MethodGet, "/", nil)) @@ -36,30 +34,12 @@ func TestLivenessDead(t *testing.T) { } } -func TestReadinessRequiresSidecar(t *testing.T) { - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/ping" { - t.Errorf("path %s", r.URL.Path) - } - w.WriteHeader(http.StatusOK) - })) - defer sidecar.Close() - u, _ := url.Parse(sidecar.URL) - p := health.New(u, nil) - rec := httptest.NewRecorder() - p.Readiness(rec, httptest.NewRequest(http.MethodGet, "/", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("status %d", rec.Code) - } -} - -func TestReadinessSidecarDown(t *testing.T) { - u, _ := url.Parse("http://127.0.0.1:1") - p := health.New(u, nil) +func TestReadinessDead(t *testing.T) { + p := health.New() + p.SetLive(false) rec := httptest.NewRecorder() p.Readiness(rec, httptest.NewRequest(http.MethodGet, "/", nil)) if rec.Code != http.StatusInternalServerError { t.Fatalf("status %d", rec.Code) } - _, _ = io.ReadAll(rec.Body) } diff --git a/backend/internal/informers/factory.go b/backend/internal/informers/factory.go index bb0c34e82a1..88f91293fa0 100644 --- a/backend/internal/informers/factory.go +++ b/backend/internal/informers/factory.go @@ -218,6 +218,5 @@ func (c *InformerCache) logHeap(msg string) { "heapAlloc", ms.HeapAlloc, "heapInuse", ms.HeapInuse, "items", c.itemCount(), - "note", "compare Go heapAlloc of this process after sync to Node deflate cache size, not combined dual-run RSS", ) } diff --git a/backend/internal/informers/specs.go b/backend/internal/informers/specs.go index db7e1b8ce49..fcd0e13799c 100644 --- a/backend/internal/informers/specs.go +++ b/backend/internal/informers/specs.go @@ -2,8 +2,7 @@ // Package informers watches hub resources with client-go (ACM-42597). // GET /events SSE is served by internal/events/hub (ACM-42598). -// POST /aggregate/* reads this cache (ACM-42600). Node startWatching() still -// runs so hub.ts can use getKubeResources until ACM-42596 is wired in main.go. +// POST /aggregate/* reads this cache (ACM-42600). package informers import ( @@ -79,7 +78,7 @@ func pairsToMap(pairs []string) map[string]string { return m } -// DefaultWatchSpecs is the port of backend-node/src/routes/events.ts `definitions`. +// DefaultWatchSpecs is the source of truth for hub list/watch specs (GET /events, POST /aggregate). func DefaultWatchSpecs() []WatchSpec { return []WatchSpec{ watch("ClusterManagementAddOn", "addon.open-cluster-management.io/v1alpha1"), diff --git a/backend/internal/informers/specs_test.go b/backend/internal/informers/specs_test.go index 72583a36bb9..7f280322d9e 100644 --- a/backend/internal/informers/specs_test.go +++ b/backend/internal/informers/specs_test.go @@ -3,11 +3,6 @@ package informers import ( - "os" - "path/filepath" - "regexp" - "runtime" - "strings" "testing" ) @@ -39,47 +34,6 @@ func TestDefaultWatchSpecsCount(t *testing.T) { } } -func TestDefaultWatchSpecsMatchEventsTS(t *testing.T) { - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("caller") - } - eventsPath := filepath.Join(filepath.Dir(file), "..", "..", "..", "backend-node", "src", "routes", "events.ts") - src, err := os.ReadFile(eventsPath) - if err != nil { - t.Fatal(err) - } - tsSpecs := parseEventsTSSpecs(t, src) - if len(tsSpecs) != 67 { - t.Fatalf("events.ts keys=%d", len(tsSpecs)) - } - got := map[string]WatchSpec{} - for _, s := range DefaultWatchSpecs() { - got[s.SpecKey()] = s - } - for k, ts := range tsSpecs { - goSpec, ok := got[k] - if !ok { - t.Errorf("missing spec %s", k) - continue - } - if goSpec.Polled != ts.Polled { - t.Errorf("%s polled go=%v ts=%v", k, goSpec.Polled, ts.Polled) - } - if goSpec.ForwardEventsToClients != ts.ForwardEventsToClients { - t.Errorf("%s forwardEventsToClients go=%v ts=%v", k, goSpec.ForwardEventsToClients, ts.ForwardEventsToClients) - } - if goSpec.ShouldForward() != ts.ShouldForward() { - t.Errorf("%s shouldForward go=%v ts=%v", k, goSpec.ShouldForward(), ts.ShouldForward()) - } - } - for k, s := range got { - if _, ok := tsSpecs[k]; !ok { - t.Errorf("extra spec %s (%s %s)", k, s.APIVersion, s.Kind) - } - } -} - func TestWatchSpecBuilders(t *testing.T) { s := watch("Secret", "v1"). labels("cluster.open-cluster-management.io/type", "ans"). @@ -158,91 +112,3 @@ func TestSelectorQueryOrder(t *testing.T) { t.Fatal(got) } } - -var ( - kindRE = regexp.MustCompile(`kind:\s*'([^']+)'`) - apiVersionRE = regexp.MustCompile(`apiVersion:\s*'([^']+)'`) - selectorRE = regexp.MustCompile(`'([^']+)':\s*'([^']*)'`) -) - -func parseEventsTSSpecs(t *testing.T, src []byte) map[string]WatchSpec { - t.Helper() - s := string(src) - marker := "const definitions: IWatchOptions[] = [" - start := strings.Index(s, marker) - if start < 0 { - t.Fatal("definitions not found") - } - rest := s[start+len(marker):] - end := strings.Index(rest, "\nexport function startWatching") - body := rest[:end] - var lines []string - for _, line := range strings.Split(body, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "//") { - continue - } - lines = append(lines, line) - } - body = strings.Join(lines, "\n") - specs := map[string]WatchSpec{} - depth, objStart, inQ := 0, -1, false - for i := 0; i < len(body); i++ { - c := body[i] - if c == '\'' && (i == 0 || body[i-1] != '\\') { - inQ = !inQ - continue - } - if inQ { - continue - } - switch c { - case '{': - if depth == 0 { - objStart = i - } - depth++ - case '}': - depth-- - if depth == 0 && objStart >= 0 { - spec := parseTSWatchSpec(body[objStart : i+1]) - specs[spec.SpecKey()] = spec - objStart = -1 - } - } - } - return specs -} - -func parseTSWatchSpec(obj string) WatchSpec { - km := kindRE.FindStringSubmatch(obj) - am := apiVersionRE.FindStringSubmatch(obj) - spec := WatchSpec{ - Kind: km[1], - APIVersion: am[1], - ForwardEventsToClients: true, - } - if strings.Contains(obj, "isPolled: true") { - spec.Polled = true - } - if strings.Contains(obj, "forwardEventsToClients: false") { - spec.ForwardEventsToClients = false - } - if j := strings.Index(obj, "labelSelector:"); j >= 0 { - spec.LabelSelector = parseSel(obj[j:]) - } - if j := strings.Index(obj, "fieldSelector:"); j >= 0 { - spec.FieldSelector = parseSel(obj[j:]) - } - return spec -} - -func parseSel(s string) map[string]string { - b := strings.Index(s, "{") - e := strings.Index(s[b:], "}") - inner := s[b : b+e] - out := map[string]string{} - for _, m := range selectorRE.FindAllStringSubmatch(inner, -1) { - out[m[1]] = m[2] - } - return out -} diff --git a/backend/internal/proxy/proxy.go b/backend/internal/proxy/proxy.go deleted file mode 100644 index 4dfacb03c51..00000000000 --- a/backend/internal/proxy/proxy.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright Contributors to the Open Cluster Management project - -package proxy - -import ( - "crypto/tls" - "net/http" - "net/http/httputil" - "net/url" - "time" - - "github.com/stolostron/console/backend/internal/outbound" -) - -// New returns a reverse proxy to the Node sidecar. HTTP/1.1 only so WebSocket -// upgrades succeed. Original request paths (including /multicloud) are kept. -func New(target *url.URL, tlsConfig *tls.Config) http.Handler { - transport := outbound.Transport(tlsConfig, false) - rp := &httputil.ReverseProxy{ - Rewrite: func(r *httputil.ProxyRequest) { - r.SetURL(target) - r.Out.Host = target.Host - }, - Transport: transport, - FlushInterval: -1 * time.Millisecond, - } - return rp -} diff --git a/backend/internal/proxy/proxy_test.go b/backend/internal/proxy/proxy_test.go deleted file mode 100644 index 11813a5a78c..00000000000 --- a/backend/internal/proxy/proxy_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright Contributors to the Open Cluster Management project - -package proxy_test - -import ( - "crypto/tls" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/stolostron/console/backend/internal/proxy" -) - -func newHandler(t *testing.T, upstream http.Handler) http.Handler { - t.Helper() - up := httptest.NewServer(upstream) - t.Cleanup(up.Close) - target, err := url.Parse(up.URL) - if err != nil { - t.Fatal(err) - } - return proxy.New(target, nil) -} - -func TestPreservesOriginalPath(t *testing.T) { - var capturedPath string - h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedPath = r.URL.Path - w.WriteHeader(http.StatusOK) - })) - ts := httptest.NewServer(h) - t.Cleanup(ts.Close) - - for _, path := range []string{"/multicloud/hub", "/proxy/search", "/events"} { - resp, err := ts.Client().Get(ts.URL + path) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if capturedPath != path { - t.Fatalf("%s: upstream path %q", path, capturedPath) - } - } -} - -func TestForwardsQueryMethodAndBody(t *testing.T) { - var capturedPath, capturedQuery, capturedMethod, capturedBody string - h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedPath = r.URL.Path - capturedQuery = r.URL.RawQuery - capturedMethod = r.Method - b, _ := io.ReadAll(r.Body) - capturedBody = string(b) - w.WriteHeader(http.StatusCreated) - })) - ts := httptest.NewServer(h) - t.Cleanup(ts.Close) - - body := `{"q":"clusters"}` - req, _ := http.NewRequest(http.MethodPost, ts.URL+"/multicloud/proxy/search?limit=10", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusCreated { - t.Fatalf("status %d", resp.StatusCode) - } - if capturedPath != "/multicloud/proxy/search" { - t.Fatalf("path %q", capturedPath) - } - if capturedQuery != "limit=10" { - t.Fatalf("query %q", capturedQuery) - } - if capturedMethod != http.MethodPost { - t.Fatalf("method %q", capturedMethod) - } - if capturedBody != body { - t.Fatalf("body %q", capturedBody) - } -} - -func TestSetsUpstreamHost(t *testing.T) { - up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Captured-Host", r.Host) - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(up.Close) - target, err := url.Parse(up.URL) - if err != nil { - t.Fatal(err) - } - h := proxy.New(target, nil) - ts := httptest.NewServer(h) - t.Cleanup(ts.Close) - - resp, err := ts.Client().Get(ts.URL + "/ping") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - wantHost := target.Host - if got := resp.Header.Get("X-Captured-Host"); got != wantHost { - t.Fatalf("upstream Host %q want %q", got, wantHost) - } -} - -func TestForwardsRequestHeaders(t *testing.T) { - var captured http.Header - h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured = r.Header.Clone() - w.WriteHeader(http.StatusOK) - })) - ts := httptest.NewServer(h) - t.Cleanup(ts.Close) - - req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/events", nil) - req.Header.Set("Authorization", "Bearer user-token") - req.Header.Set("Accept-Encoding", "gzip") - req.Header.Set("X-Custom", "keep-me") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if captured.Get("Authorization") != "Bearer user-token" { - t.Fatalf("Authorization %q", captured.Get("Authorization")) - } - if captured.Get("Accept-Encoding") != "gzip" { - t.Fatal("missing Accept-Encoding") - } - if captured.Get("X-Custom") != "keep-me" { - t.Fatal("custom header not forwarded") - } -} - -func TestPassesThroughResponse(t *testing.T) { - h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Header().Set("X-Sidecar", "yes") - w.WriteHeader(http.StatusTeapot) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - ts := httptest.NewServer(h) - t.Cleanup(ts.Close) - - resp, err := ts.Client().Get(ts.URL + "/multicloud/hub") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusTeapot { - t.Fatalf("status %d", resp.StatusCode) - } - if string(body) != `{"ok":true}` { - t.Fatalf("body %q", body) - } - if resp.Header.Get("Content-Type") != "application/json" { - t.Fatal("missing Content-Type") - } - if resp.Header.Get("X-Sidecar") != "yes" { - t.Fatal("missing X-Sidecar") - } -} - -func TestHTTPSUpstreamWithTLSConfig(t *testing.T) { - var hit bool - up := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hit = true - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(up.Close) - target, err := url.Parse(up.URL) - if err != nil { - t.Fatal(err) - } - h := proxy.New(target, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // test server - ts := httptest.NewServer(h) - t.Cleanup(ts.Close) - - resp, err := ts.Client().Get(ts.URL + "/ping") - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if !hit { - t.Fatal("TLS upstream not reached") - } - if resp.StatusCode != http.StatusOK { - t.Fatalf("status %d", resp.StatusCode) - } -} - -func TestBadGatewayWhenUpstreamUnreachable(t *testing.T) { - target, err := url.Parse("http://127.0.0.1:1") - if err != nil { - t.Fatal(err) - } - h := proxy.New(target, nil) - ts := httptest.NewServer(h) - t.Cleanup(ts.Close) - - resp, err := ts.Client().Get(ts.URL + "/multicloud/hub") - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusBadGateway { - t.Fatalf("status %d", resp.StatusCode) - } -} - -func TestForwardsWebSocketUpgradeHeaders(t *testing.T) { - var capturedUpgrade, capturedConnection string - h := newHandler(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedUpgrade = r.Header.Get("Upgrade") - capturedConnection = r.Header.Get("Connection") - w.WriteHeader(http.StatusSwitchingProtocols) - })) - ts := httptest.NewServer(h) - t.Cleanup(ts.Close) - - req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/ws", nil) - req.Header.Set("Connection", "Upgrade") - req.Header.Set("Upgrade", "websocket") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if capturedUpgrade != "websocket" { - t.Fatalf("Upgrade %q", capturedUpgrade) - } - if !strings.EqualFold(capturedConnection, "Upgrade") { - t.Fatalf("Connection %q", capturedConnection) - } -} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 8cd23c94407..ab958b97267 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -5,11 +5,9 @@ package server import ( "bufio" "context" - "crypto/tls" "errors" "net" "net/http" - "net/url" "os" "path/filepath" "strings" @@ -22,7 +20,6 @@ import ( "github.com/stolostron/console/backend/internal/health" applog "github.com/stolostron/console/backend/internal/log" "github.com/stolostron/console/backend/internal/oauth" - "github.com/stolostron/console/backend/internal/proxy" "github.com/stolostron/console/backend/internal/static" ) @@ -285,28 +282,13 @@ func registerK8sProxyRoutes(r chi.Router, h http.Handler) { } } -// TLSConfigForSidecar is for the loopback Node sidecar. Local generate-certs -// writes a self-signed cert with no SAN, so hostname verification cannot succeed. -func TLSConfigForSidecar(_ *config.Config) *tls.Config { - return &tls.Config{ - InsecureSkipVerify: true, //nolint:gosec // loopback sidecar; cert has no SAN - MinVersion: tls.VersionTLS12, - } -} - -// Handler builds the public mux: probes and migrated routes on Go, everything else to the sidecar. +// Handler builds the public mux: probes, migrated routes, and static assets. func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { o := &handlerOptions{} for _, opt := range opts { opt(o) } - target, err := url.Parse(cfg.NodeBackendURL) - if err != nil { - return nil, err - } - sidecarTLS := TLSConfigForSidecar(cfg) - probes := health.New(target, sidecarTLS) - sidecar := proxy.New(target, sidecarTLS) + probes := health.New() r := chi.NewRouter() r.Use(cors.Middleware(cfg.Production)) @@ -369,8 +351,10 @@ func Handler(cfg *config.Config, opts ...Option) (http.Handler, error) { r.Get("/debug/informer-snapshot", o.debugSnapshot.ServeHTTP) r.Get(multicloudPrefix+"/debug/informer-snapshot", o.debugSnapshot.ServeHTTP) } - r.NotFound(notFoundHandler(o.staticH, sidecar)) - r.MethodNotAllowed(sidecar.ServeHTTP) + r.NotFound(notFoundHandler(o.staticH)) + r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusMethodNotAllowed) + }) return r, nil } @@ -418,7 +402,7 @@ func registerClusterInfoRoutes(r chi.Router, o *handlerOptions) { registerAliased(r, h, "/operatorCheck") } -func notFoundHandler(staticH, sidecar http.Handler) http.HandlerFunc { +func notFoundHandler(staticH http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { stripped := StripMulticloud(r.URL.Path) if staticH != nil && r.Method == http.MethodGet && static.IsStaticPath(stripped) { @@ -427,7 +411,7 @@ func notFoundHandler(staticH, sidecar http.Handler) http.HandlerFunc { staticH.ServeHTTP(w, r2) return } - sidecar.ServeHTTP(w, r) + w.WriteHeader(http.StatusNotFound) } } @@ -435,7 +419,7 @@ func requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { stripped := StripMulticloud(r.URL.Path) // Do not wrap SSE: the wrapper can prevent HTTP/2 from flushing events to EventSource. - // Do not wrap WebSocket: ReverseProxy needs the raw Hijacker. + // Do not wrap WebSocket: hijacked upgrades need the raw ResponseWriter. if isProbe(stripped) || isEventStream(stripped) || isWebSocket(r) { next.ServeHTTP(w, r) return diff --git a/backend/internal/server/server_test.go b/backend/internal/server/server_test.go index d27990ddb59..d66ab44482d 100644 --- a/backend/internal/server/server_test.go +++ b/backend/internal/server/server_test.go @@ -16,6 +16,20 @@ import ( "github.com/stolostron/console/backend/internal/server" ) +func testCfg(t *testing.T) *config.Config { + t.Helper() + return &config.Config{CertsDir: t.TempDir()} +} + +func newHandler(t *testing.T, opts ...server.Option) http.Handler { + t.Helper() + h, err := server.Handler(testCfg(t), opts...) + if err != nil { + t.Fatal(err) + } + return h +} + func TestStripMulticloud(t *testing.T) { cases := map[string]string{ "/multicloud": "/", @@ -32,32 +46,8 @@ func TestStripMulticloud(t *testing.T) { } } -func TestProbesAndProxy(t *testing.T) { - var capturedPath, capturedMethod, capturedBody string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/ping" { - w.WriteHeader(http.StatusOK) - return - } - capturedPath = r.URL.Path - capturedMethod = r.Method - b, _ := io.ReadAll(r.Body) - capturedBody = string(b) - w.Header().Set("X-Sidecar", "yes") - w.WriteHeader(http.StatusTeapot) - _, _ = w.Write([]byte(`{"ok":true}`)) - })) - defer sidecar.Close() - - cfg := &config.Config{ - NodeBackendURL: sidecar.URL, - CertsDir: t.TempDir(), - } - h, err := server.Handler(cfg) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) +func TestProbes(t *testing.T) { + ts := httptest.NewServer(newHandler(t)) defer ts.Close() for _, path := range []string{"/ping", "/livenessProbe", "/readinessProbe", "/multicloud/ping", "/multicloud/livenessProbe", "/multicloud/readinessProbe"} { @@ -74,6 +64,11 @@ func TestProbesAndProxy(t *testing.T) { t.Fatalf("%s expected empty body", path) } } +} + +func TestUnknownRouteNotFound(t *testing.T) { + ts := httptest.NewServer(newHandler(t)) + defer ts.Close() req, _ := http.NewRequest(http.MethodPost, ts.URL+"/multicloud/hub", strings.NewReader("hello")) resp, err := ts.Client().Do(req) @@ -82,119 +77,45 @@ func TestProbesAndProxy(t *testing.T) { } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusTeapot { - t.Fatalf("proxy status %d", resp.StatusCode) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) } - if string(body) != `{"ok":true}` { + if len(body) != 0 { t.Fatalf("body %s", body) } - if resp.Header.Get("X-Sidecar") != "yes" { - t.Fatal("missing sidecar header") - } - if capturedPath != "/multicloud/hub" { - t.Fatalf("sidecar path %q, want original /multicloud/hub", capturedPath) - } - if capturedMethod != http.MethodPost { - t.Fatalf("method %s", capturedMethod) - } - if capturedBody != "hello" { - t.Fatalf("body %q", capturedBody) - } -} - -func TestProxyForwardsAuthorization(t *testing.T) { - var capturedAuth string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedAuth = r.Header.Get("Authorization") - w.WriteHeader(http.StatusNoContent) - })) - defer sidecar.Close() - - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) - defer ts.Close() - - req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/username", nil) - req.Header.Set("Authorization", "Bearer user-token") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatal(err) - } - resp.Body.Close() - if capturedAuth != "Bearer user-token" { - t.Fatalf("Authorization %q", capturedAuth) - } } -func TestWebSocketUpgradeForwardsOriginalPath(t *testing.T) { - var capturedPath, capturedUpgrade string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedPath = r.URL.Path - capturedUpgrade = r.Header.Get("Upgrade") - w.WriteHeader(http.StatusOK) - })) - defer sidecar.Close() - - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) +func TestUnknownMethodNotAllowed(t *testing.T) { + ts := httptest.NewServer(newHandler(t)) defer ts.Close() - req, _ := http.NewRequest(http.MethodGet, ts.URL+"/multicloud/proxy/search", nil) - req.Header.Set("Upgrade", "websocket") - req.Header.Set("Connection", "Upgrade") + req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/ping", nil) resp, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } - resp.Body.Close() - if capturedPath != "/multicloud/proxy/search" { - t.Fatalf("path %q", capturedPath) - } - if capturedUpgrade != "websocket" { - t.Fatalf("upgrade %q", capturedUpgrade) + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status %d", resp.StatusCode) } } -func TestRBACEventsNotProxied(t *testing.T) { - var proxied bool - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - proxied = true - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestRBACEventsRegistered(t *testing.T) { rbac := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("data: {\"type\":\"START\"}\n\n")) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithRBACEvents(rbac)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithRBACEvents(rbac))) defer ts.Close() for _, path := range []string{"/events/rbac", "/multicloud/events/rbac"} { - proxied = false resp, getErr := ts.Client().Get(ts.URL + path) if getErr != nil { t.Fatal(getErr) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if proxied { - t.Fatalf("%s was proxied to sidecar", path) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d", path, resp.StatusCode) } @@ -204,38 +125,22 @@ func TestRBACEventsNotProxied(t *testing.T) { } } -func TestEventsNotProxied(t *testing.T) { - var proxied bool - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - proxied = true - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestEventsRegistered(t *testing.T) { events := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("id:1\ndata:{\"type\":\"START\"}\n\n")) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithEvents(events)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithEvents(events))) defer ts.Close() for _, path := range []string{"/events", "/multicloud/events"} { - proxied = false resp, getErr := ts.Client().Get(ts.URL + path) if getErr != nil { t.Fatal(getErr) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if proxied { - t.Fatalf("%s was proxied to sidecar", path) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d", path, resp.StatusCode) } @@ -245,20 +150,8 @@ func TestEventsNotProxied(t *testing.T) { } } -func TestEventsProxiedWithoutWithEvents(t *testing.T) { - var captured string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured = r.URL.Path - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) +func TestEventsWithoutHandlerReturns404(t *testing.T) { + ts := httptest.NewServer(newHandler(t)) defer ts.Close() resp, err := ts.Client().Get(ts.URL + "/events") @@ -266,22 +159,12 @@ func TestEventsProxiedWithoutWithEvents(t *testing.T) { t.Fatal(err) } resp.Body.Close() - if captured != "/events" { - t.Fatalf("sidecar path %q", captured) - } - if resp.StatusCode != http.StatusTeapot { + if resp.StatusCode != http.StatusNotFound { t.Fatalf("status %d", resp.StatusCode) } } -func TestOAuthNotProxiedToSidecar(t *testing.T) { - var sidecarPaths []string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarPaths = append(sidecarPaths, r.URL.Path) - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestOAuthRegistered(t *testing.T) { oa := oauth.New(oauth.Options{ ClientID: "cid", RedirectURL: "https://localhost:3000/multicloud/login/callback", @@ -292,55 +175,38 @@ func TestOAuthNotProxiedToSidecar(t *testing.T) { }, nil }, }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithOAuth(oa), server.WithOAuthLogin()) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithOAuth(oa), server.WithOAuthLogin())) defer ts.Close() client := &http.Client{ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } for _, path := range []string{"/login", "/multicloud/login"} { - sidecarPaths = nil resp, getErr := client.Get(ts.URL + path) if getErr != nil { t.Fatal(getErr) } resp.Body.Close() - if len(sidecarPaths) != 0 { - t.Fatalf("%s proxied to sidecar: %v", path, sidecarPaths) - } if resp.StatusCode != http.StatusFound { t.Fatalf("%s status %d", path, resp.StatusCode) } } - sidecarPaths = nil resp, err := ts.Client().Get(ts.URL + "/logout") if err != nil { t.Fatal(err) } resp.Body.Close() - if len(sidecarPaths) != 0 { - t.Fatalf("logout proxied: %v", sidecarPaths) - } if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("logout status %d", resp.StatusCode) } - sidecarPaths = nil resp, err = ts.Client().Get(ts.URL + "/configure") if err != nil { t.Fatal(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if len(sidecarPaths) != 0 { - t.Fatalf("configure proxied: %v", sidecarPaths) - } if resp.StatusCode != http.StatusOK { t.Fatalf("configure status %d", resp.StatusCode) } @@ -349,30 +215,18 @@ func TestOAuthNotProxiedToSidecar(t *testing.T) { } } -func TestStatelessProxiesNotProxiedToSidecar(t *testing.T) { - var sidecarPaths []string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarPaths = append(sidecarPaths, r.URL.Path) - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestStatelessProxiesRegistered(t *testing.T) { ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Go", r.URL.Path) w.WriteHeader(http.StatusOK) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, + ts := httptest.NewServer(newHandler(t, server.WithManagedClusterProxy(ok), server.WithPrometheusProxy(ok), server.WithObservabilityProxy(ok), server.WithVMProxy(ok), - ) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + )) defer ts.Close() paths := []string{ @@ -390,16 +244,12 @@ func TestStatelessProxiesNotProxiedToSidecar(t *testing.T) { "/vmResourceUsage/cluster/c/namespace/ns", } for _, path := range paths { - sidecarPaths = nil req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) resp, getErr := ts.Client().Do(req) if getErr != nil { t.Fatal(getErr) } resp.Body.Close() - if len(sidecarPaths) != 0 { - t.Fatalf("%s was proxied to sidecar: %v", path, sidecarPaths) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d", path, resp.StatusCode) } @@ -409,38 +259,22 @@ func TestStatelessProxiesNotProxiedToSidecar(t *testing.T) { } } -func TestStaticNotProxiedToSidecar(t *testing.T) { - var sidecarPaths []string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarPaths = append(sidecarPaths, r.URL.Path) - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestStaticServedUnknownAPINotFound(t *testing.T) { staticH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Static", r.URL.Path) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("plugin")) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithStatic(staticH)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithStatic(staticH))) defer ts.Close() for _, path := range []string{"/plugin/plugin-manifest.json", "/multicloud/plugin/plugin-entry.js", "/index.html", "/"} { - sidecarPaths = nil resp, getErr := ts.Client().Get(ts.URL + path) if getErr != nil { t.Fatal(getErr) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if len(sidecarPaths) != 0 { - t.Fatalf("%s proxied to sidecar: %v", path, sidecarPaths) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d", path, resp.StatusCode) } @@ -449,31 +283,18 @@ func TestStaticNotProxiedToSidecar(t *testing.T) { } } - sidecarPaths = nil resp, err := ts.Client().Get(ts.URL + "/hub") if err != nil { t.Fatal(err) } resp.Body.Close() - if len(sidecarPaths) != 1 || sidecarPaths[0] != "/hub" { - t.Fatalf("hub sidecar paths %v", sidecarPaths) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("hub status %d", resp.StatusCode) } } -func TestOAuthAbsentProxiesToSidecar(t *testing.T) { - var sidecarPaths []string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarPaths = append(sidecarPaths, r.URL.Path) - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) +func TestLoginWithoutOAuthReturns404(t *testing.T) { + ts := httptest.NewServer(newHandler(t)) defer ts.Close() resp, err := ts.Client().Get(ts.URL + "/login") @@ -481,30 +302,18 @@ func TestOAuthAbsentProxiesToSidecar(t *testing.T) { t.Fatal(err) } resp.Body.Close() - if len(sidecarPaths) != 1 || sidecarPaths[0] != "/login" { - t.Fatalf("sidecar paths %v", sidecarPaths) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) } } -func TestConfigureWithoutLoginNotProxied(t *testing.T) { - var sidecarPaths []string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarPaths = append(sidecarPaths, r.URL.Path) - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestConfigureWithoutLoginLeavesLoginUnregistered(t *testing.T) { oa := oauth.New(oauth.Options{ Discover: func(context.Context) (oauth.Info, error) { return oauth.Info{TokenEndpoint: "https://oauth.example.com/oauth/token"}, nil }, }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithOAuth(oa)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithOAuth(oa))) defer ts.Close() resp, err := ts.Client().Get(ts.URL + "/multicloud/configure") @@ -513,42 +322,28 @@ func TestConfigureWithoutLoginNotProxied(t *testing.T) { } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if len(sidecarPaths) != 0 { - t.Fatalf("configure proxied: %v", sidecarPaths) - } if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "oauth.example.com") { t.Fatalf("status %d body %s", resp.StatusCode, body) } - sidecarPaths = nil resp, err = ts.Client().Get(ts.URL + "/login") if err != nil { t.Fatal(err) } resp.Body.Close() - if len(sidecarPaths) != 1 || sidecarPaths[0] != "/login" { - t.Fatalf("login should still proxy without WithOAuthLogin: %v", sidecarPaths) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("login without WithOAuthLogin status %d", resp.StatusCode) } } func TestDevelopmentCORSOptionsPreflight(t *testing.T) { var k8sCalled bool - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - t.Fatal("sidecar should not be called") - })) - defer sidecar.Close() - k8s := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { k8sCalled = true w.WriteHeader(http.StatusOK) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithK8sProxy(k8s)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithK8sProxy(k8s))) defer ts.Close() for _, path := range []string{"/api", "/multicloud/api"} { @@ -577,26 +372,14 @@ func TestDevelopmentCORSOptionsPreflight(t *testing.T) { } } -func TestK8sProxyNotProxiedToSidecar(t *testing.T) { - var sidecarPaths []string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarPaths = append(sidecarPaths, r.URL.Path) - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestK8sProxyRegistered(t *testing.T) { var k8sPaths []string k8s := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { k8sPaths = append(k8sPaths, r.URL.Path) w.WriteHeader(http.StatusOK) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithK8sProxy(k8s)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithK8sProxy(k8s))) defer ts.Close() paths := []string{ @@ -608,7 +391,6 @@ func TestK8sProxyNotProxiedToSidecar(t *testing.T) { "/multicloud/version/", } for _, path := range paths { - sidecarPaths = nil k8sPaths = nil req, _ := http.NewRequest(http.MethodGet, ts.URL+path, nil) req.Header.Set("Authorization", "Bearer token") @@ -617,24 +399,13 @@ func TestK8sProxyNotProxiedToSidecar(t *testing.T) { t.Fatal(getErr) } resp.Body.Close() - if len(sidecarPaths) != 0 { - t.Fatalf("%s was proxied to sidecar: %v", path, sidecarPaths) - } if len(k8sPaths) != 1 || k8sPaths[0] != path { t.Fatalf("%s k8s paths %v", path, k8sPaths) } } } -func TestUnmigratedRoutesStillProxied(t *testing.T) { - var capturedPath string - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedPath = r.URL.Path - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`["/api/v1"]`)) - })) - defer sidecar.Close() - +func TestUnregisteredRoutesReturn404(t *testing.T) { ok := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("go handler should not run") }) @@ -642,17 +413,12 @@ func TestUnmigratedRoutesStillProxied(t *testing.T) { t.Fatal("k8s proxy should not handle /apiPaths") }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, + ts := httptest.NewServer(newHandler(t, server.WithK8sProxy(k8s), server.WithPrometheusProxy(ok), server.WithManagedClusterProxy(ok), server.WithVMProxy(ok), - ) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + )) defer ts.Close() for _, path := range []string{"/multicloud/proxy/search", "/multicloud/events"} { @@ -661,20 +427,13 @@ func TestUnmigratedRoutesStillProxied(t *testing.T) { t.Fatal(getErr) } resp.Body.Close() - if capturedPath != path { - t.Fatalf("%s sidecar path %q", path, capturedPath) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("%s status %d", path, resp.StatusCode) } } } -func TestMigratedUserAndClusterInfoNotProxied(t *testing.T) { - var sidecarHit bool - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarHit = true - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestMigratedUserAndClusterInfoRegistered(t *testing.T) { userH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"route":"user"}`)) @@ -684,12 +443,7 @@ func TestMigratedUserAndClusterInfoNotProxied(t *testing.T) { _, _ = w.Write([]byte(`{"route":"cluster"}`)) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithUser(userH), server.WithClusterInfo(clusterH)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithUser(userH), server.WithClusterInfo(clusterH))) defer ts.Close() for _, path := range []string{ @@ -700,7 +454,6 @@ func TestMigratedUserAndClusterInfoNotProxied(t *testing.T) { "/apiPaths", "/multicloud/operatorCheck", } { - sidecarHit = false method := http.MethodGet if path == "/multicloud/operatorCheck" { method = http.MethodPost @@ -715,76 +468,44 @@ func TestMigratedUserAndClusterInfoNotProxied(t *testing.T) { } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if sidecarHit { - t.Fatalf("%s was proxied to sidecar", path) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) } } } -func TestDebugSnapshotNotProxied(t *testing.T) { - var sidecarHit bool - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarHit = true - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestDebugSnapshotRegistered(t *testing.T) { dump := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"synced":true,"items":[]}`)) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithDebugSnapshot(dump)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithDebugSnapshot(dump))) defer ts.Close() for _, path := range []string{"/debug/informer-snapshot", "/multicloud/debug/informer-snapshot"} { - sidecarHit = false resp, getErr := ts.Client().Get(ts.URL + path) if getErr != nil { t.Fatal(getErr) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if sidecarHit { - t.Fatalf("%s was proxied to sidecar", path) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) } } } -func TestAggregateNotProxied(t *testing.T) { - var sidecarHit bool - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarHit = true - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestAggregateRegistered(t *testing.T) { agg := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithAggregate(agg)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithAggregate(agg))) defer ts.Close() for _, path := range []string{"/aggregate/applications", "/multicloud/aggregate/statuses", "/aggregate/appSetData"} { - sidecarHit = false req, _ := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") resp, getErr := ts.Client().Do(req) @@ -793,38 +514,22 @@ func TestAggregateNotProxied(t *testing.T) { } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if sidecarHit { - t.Fatalf("%s was proxied to sidecar", path) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) } } } -func TestSearchNotProxied(t *testing.T) { - var sidecarHit bool - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarHit = true - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestSearchRegistered(t *testing.T) { searchH := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, server.WithSearchProxy(searchH)) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + ts := httptest.NewServer(newHandler(t, server.WithSearchProxy(searchH))) defer ts.Close() for _, path := range []string{"/proxy/search", "/multicloud/proxy/search"} { - sidecarHit = false req, _ := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") resp, getErr := ts.Client().Do(req) @@ -833,14 +538,10 @@ func TestSearchNotProxied(t *testing.T) { } body, _ := io.ReadAll(resp.Body) resp.Body.Close() - if sidecarHit { - t.Fatalf("%s was proxied to sidecar", path) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) } - sidecarHit = false req, _ = http.NewRequest(http.MethodGet, ts.URL+path, nil) req.Header.Set("Upgrade", "websocket") req.Header.Set("Connection", "Upgrade") @@ -849,34 +550,22 @@ func TestSearchNotProxied(t *testing.T) { t.Fatal(getErr) } resp.Body.Close() - if sidecarHit { - t.Fatalf("%s websocket was proxied to sidecar", path) + if resp.StatusCode != http.StatusOK { + t.Fatalf("%s websocket status %d", path, resp.StatusCode) } } } -func TestLongTailNotProxied(t *testing.T) { - var sidecarHit bool - sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sidecarHit = true - w.WriteHeader(http.StatusTeapot) - })) - defer sidecar.Close() - +func TestLongTailRegistered(t *testing.T) { ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - cfg := &config.Config{NodeBackendURL: sidecar.URL, CertsDir: t.TempDir()} - h, err := server.Handler(cfg, + ts := httptest.NewServer(newHandler(t, server.WithRosa(ok), server.WithAnsibleTower(ok), server.WithPlacementDebug(ok), server.WithUpgradeRisks(ok), - ) - if err != nil { - t.Fatal(err) - } - ts := httptest.NewServer(h) + )) defer ts.Close() paths := []string{ @@ -888,7 +577,6 @@ func TestLongTailNotProxied(t *testing.T) { paths = append(paths, p, "/multicloud"+p) } for _, path := range paths { - sidecarHit = false req, _ := http.NewRequest(http.MethodPost, ts.URL+path, strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") resp, getErr := ts.Client().Do(req) @@ -896,9 +584,6 @@ func TestLongTailNotProxied(t *testing.T) { t.Fatal(getErr) } resp.Body.Close() - if sidecarHit { - t.Fatalf("%s was proxied to sidecar", path) - } if resp.StatusCode != http.StatusOK { t.Fatalf("%s status %d", path, resp.StatusCode) } diff --git a/backend/internal/static/static.go b/backend/internal/static/static.go index 107deecc87d..3ac3614c32d 100644 --- a/backend/internal/static/static.go +++ b/backend/internal/static/static.go @@ -79,9 +79,9 @@ func BundledFS() fs.FS { } // IsStaticPath reports whether a path (already stripped of /multicloud) should be -// served as a static file rather than reverse-proxied to the Node sidecar. +// served as a static file rather than returning 404. // Bare paths other than / are not treated as SPA fallback so API routes like /hub -// still reach the sidecar. +// are not claimed as static files. func IsStaticPath(stripped string) bool { urlPath := strings.TrimSuffix(stripped, "/") if urlPath == "" || urlPath == "/" || urlPath == "/index.html" { diff --git a/console.code-workspace b/console.code-workspace index e53ce555def..d6e3c775a8e 100644 --- a/console.code-workspace +++ b/console.code-workspace @@ -6,9 +6,6 @@ { "path": "backend" }, - { - "path": "backend-node" - }, { "name": "cursor-config", "path": ".cursor" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5cf736e4235..4180b34ca39 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,7 @@ The frontend has two builds. One for the stand alone version and one for the dyn ## Console Backend -The public listener is a Go process (`backend/`). Hub kube-apiserver passthrough routes (`/api`, `/apis`, `/version`), managed-cluster, metrics, VirtualMachine proxy, Search proxy, and long-tail HTTP (ROSA wizard, Ansible Tower, placement-debug, upgrade-risks) are served natively in Go. Routes that have not been migrated yet are reverse-proxied to the Node sidecar (`backend-node/`). The plugin and browser keep talking to the same Service and paths. +The public listener is a Go process (`backend/`). Hub kube-apiserver passthrough routes (`/api`, `/apis`, `/version`), managed-cluster, metrics, VirtualMachine proxy, Search proxy, and long-tail HTTP (ROSA wizard, Ansible Tower, placement-debug, upgrade-risks) are served natively in Go. The plugin and browser keep talking to the same Service and paths. The console backend uses a service account to `list` and `watch` kubernetes cluster resources. Resource events are streamed to the console frontend. @@ -41,8 +41,8 @@ All REST calls use the token passed from the console frontend. Standalone login (`GET /login`, `/login/callback`, `/logout`) is served by the Go listener in non-production. `GET /configure` returns `{ token_endpoint }` from OAuth/OIDC discovery for frontend logout and the Display Token page. The cookie `acm-access-token-cookie` (HttpOnly, Path=/, Secure in production) holds the OpenShift access token or OIDC id_token. Production plugin mode continues to use OpenShift Console authentication. -The Go listener also runs a client-go informer cache (`backend/internal/informers`) for the same watch specs as Node `events.ts` (`definitions`). `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). `POST /proxy/search` and the Search graphql-ws relay are served by Go (`backend/internal/searchproxy`) with the user token. ROSA wizard, `POST /ansibletower`, `POST /placement-debug`, and `POST /upgrade-risks-prediction` are served by Go (`backend/internal/rosa`, `ansibletower`, `placementdebug`, `upgraderisks`) and are not gated on `CONSOLE_INFORMER_CACHE`. Node `startWatching()` still runs so `hub.ts` can read `resourceCache` until ACM-42596 is wired. Set `CONSOLE_INFORMER_CACHE=0` to disable Go watches and proxy `/events` and `/aggregate` to the sidecar (aggregate then 404s after the Node route cutover). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. +The Go listener runs a client-go informer cache (`backend/internal/informers`) from `DefaultWatchSpecs()` in `backend/internal/informers/specs.go`. `GET /events` is served by Go (`backend/internal/events/hub`) with per-user SelfSubjectAccessReview filtering (60s cache). `POST /aggregate/{applications,statuses,appSetData}` is served by Go (`backend/internal/aggregate`) from that cache plus an in-cluster Search GraphQL client (service-account token). `POST /proxy/search` and the Search graphql-ws relay are served by Go (`backend/internal/searchproxy`) with the user token. ROSA wizard, `POST /ansibletower`, `POST /placement-debug`, and `POST /upgrade-risks-prediction` are served by Go (`backend/internal/rosa`, `ansibletower`, `placementdebug`, `upgraderisks`). The Go store holds `unstructured.Unstructured` (managedFields stripped except Policy). Development builds expose `GET /debug/informer-snapshot`. `GET /events/rbac` remains a separate ClusterRole informer. -DELETED resource events are sent to every SSE client without an access check (bug-compatible with Node). That is a known quirk to fix later. +DELETED resource events are sent to every SSE client without an access check. That is a known quirk to fix later. -Static plugin assets (`plugin-manifest.json`, `plugin-entry.js`, hashed JS/CSS, locales) are served by the Go listener with the same cache headers, CSP, and brotli/gzip content negotiation as the former Node `serve` route. +Static plugin assets (`plugin-manifest.json`, `plugin-entry.js`, hashed JS/CSS, locales) are served by the Go listener with cache headers, CSP, and brotli/gzip content negotiation. diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md index a376a795981..4acf2f2f475 100644 --- a/docs/RESOURCES.md +++ b/docs/RESOURCES.md @@ -1,10 +1,9 @@ # To add a new resource -1. Add a watch to `/backend-node/src/routes/events.ts` for the resource (still required for Node `getKubeResources` / `hub.ts` until ACM-42596). -2. Add the same watch to `/backend/internal/informers/specs.go` `DefaultWatchSpecs()` so Go `GET /events`, `POST /aggregate/*`, and the informer cache include it. -3. Add a resource definition in `/frontend/src/resources`. -4. Add recoil setup for the resource in `/frontend/src/atoms.tsx`. -5. In `frontend` use the resources by +1. Add a watch to `/backend/internal/informers/specs.go` `DefaultWatchSpecs()` so Go `GET /events`, `POST /aggregate/*`, and the informer cache include it. +2. Add a resource definition in `/frontend/src/resources`. +3. Add recoil setup for the resource in `/frontend/src/atoms.tsx`. +4. In `frontend` use the resources by ``` const namespaces = useRecoilValue(namespacesState) diff --git a/lint-staged.config.js b/lint-staged.config.js index b885ee31f26..b9c5da8836d 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -2,7 +2,6 @@ // lint-staged.config.js module.exports = { '*': 'npm run copyright:fix --', - 'backend-node/**/*.ts': 'npm run lint:fix:backend-node --', 'backend/**/*.go': 'npm run lint:fix:backend --', 'frontend/**/*.{ts,tsx}|frontend/src/**/*.{js,jsx}': (staged) => { const files = staged.join(' ') diff --git a/package.json b/package.json index 5fb50f999ee..4ff6c522a3f 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,10 @@ "postinstall": "concurrently npm:ci:* -c green,blue", "ci:frontend": "cd frontend && npm ci", "ci:backend": "npm run ensure-certs && if command -v go >/dev/null 2>&1; then cd backend && go mod download; else echo 'Go not installed; skipping go mod download'; fi", - "ci:backend-node": "cd backend-node && npm ci", "start": "concurrently npm:start:backend npm:start:frontend -c green,blue", "start:hot": "concurrently npm:start:backend npm:start:frontend:hot -c green,blue", "launch": "concurrently npm:start:backend npm:start:frontend:launch -c green,blue", - "start:backend": "concurrently -n go,sidecar -c green,yellow npm:start:backend:go npm:start:backend:sidecar", - "start:backend:go": ". ./port-defaults.sh && PORT=$BACKEND_PORT NODE_BACKEND_URL=https://127.0.0.1:$NODE_BACKEND_PORT ./scripts/air-backend.sh", - "start:backend:sidecar": ". ./port-defaults.sh && cd backend-node && PORT=$NODE_BACKEND_PORT ENV_FILE=../backend/.env CONFIG_DIR=../backend/config CERTS_DIR=../backend/certs npm start", + "start:backend": ". ./port-defaults.sh && PORT=$BACKEND_PORT ./scripts/air-backend.sh", "start:frontend": "cd frontend && npm start", "start:frontend:hot": "cd frontend && npm run start:hot", "start:frontend:launch": "cd frontend && npm run launch", @@ -26,24 +23,19 @@ "watch:react-form-wizard": "cd frontend && npm run watch -w @patternfly-labs/react-form-wizard", "check": "concurrently --kill-others-on-fail npm:copyright:check \"npm:check:*(!fix)\" -c green,blue,magenta", "check:backend": "cd backend && go test ./... && ../scripts/golangci-lint-backend.sh", - "check:backend-node": "cd backend-node && npm run check", "check:frontend": "cd frontend && npm run check", "check:fix": "concurrently --kill-others-on-fail npm:copyright:fix npm:check:fix:* -c green,blue,magenta", "check:fix:backend": "cd backend && gofmt -w . && go test ./... && ../scripts/golangci-lint-backend.sh", - "check:fix:backend-node": "cd backend-node && npm run check:fix", "check:fix:frontend": "cd frontend && npm run check:fix", "lint-staged": "npx lint-staged --no-stash", "lint": "concurrently --kill-others-on-fail \"npm:lint:*(!fix)\" -c green,blue", "lint:backend": "./scripts/golangci-lint-backend.sh", - "lint:backend-node": "cd backend-node && npm run lint", "lint:frontend": "cd frontend && npm run lint", "lint:fix": "concurrently --kill-others-on-fail npm:lint:fix:* -c green,blue", "lint:fix:backend": "cd backend && gofmt -w . && ../scripts/golangci-lint-backend.sh --fix", - "lint:fix:backend-node": "cd backend-node && npm run lint:fix", "lint:fix:frontend": "cd frontend && npm run lint:fix", - "test": "concurrently --kill-others-on-fail npm:test:backend npm:test:backend-node npm:test:frontend -c green,blue", + "test": "concurrently --kill-others-on-fail npm:test:backend npm:test:frontend -c green,blue", "test:backend": "cd backend && go test ./...", - "test:backend-node": "cd backend-node && npm test --", "test:frontend": "cd frontend && npm test --", "i18n": "concurrently --kill-others-on-fail \"npm:i18n:*(!fix)\" -c green,blue,magenta", "i18n:frontend": "cd frontend && npm run i18n --", @@ -51,24 +43,21 @@ "i18n:fix:frontend": "cd frontend && npm run i18n:fix --", "build": "concurrently npm:build:* -c green,blue,magenta", "build:backend": "cd backend && go build -o bin/console ./cmd/console", - "build:backend-node": "cd backend-node && npm run build", "build:frontend": "cd frontend && npm run build", "clean": "concurrently npm:clean:* -c green,blue", "clean:backend": "rm -rf backend/bin backend/coverage backend/tmp", - "clean:backend-node": "cd backend-node && npm run clean", "clean:frontend": "cd frontend && npm run clean", - "update": "npx npm-check-updates --upgrade && npm install && npm run backend-node:update && npm run frontend:update", - "backend-node:update": "cd backend-node && npm run update", + "update": "npx npm-check-updates --upgrade && npm install && npm run frontend:update", "frontend:update": "cd frontend && npm run update", "copyright:check": "ts-node --skip-project scripts/copyright-check", "copyright:fix": "ts-node --skip-project scripts/copyright-fix", "docker:build": "docker build --file Containerfile.acm --tag console .", "docker:build:mce": "docker build --file Containerfile.mce --tag console-mce .", - "docker:run": "npm run docker:build && docker run --rm --name console -p 3000:3000 -e PORT=3000 -v $PWD/backend/certs:/app/certs -v $PWD/backend/config:/app/config --env-file=backend/.env console | ./backend-node/node_modules/.bin/pino-zen -i time && docker rm -f console", + "docker:run": "npm run docker:build && docker run --rm --name console -p 3000:3000 -e PORT=3000 -v $PWD/backend/certs:/app/certs -v $PWD/backend/config:/app/config --env-file=backend/.env console", "docker:deploy": "npm run docker:build && docker tag console quay.io/$USER/console:latest && docker push quay.io/$USER/console:latest && ./scripts/patch-deployment.sh latest quay.io/$USER/console", "podman:build": "podman build --arch amd64 --file Containerfile.acm --tag console .", "podman:build:mce": "podman build --arch amd64 --file Containerfile.mce --tag console-mce .", - "podman:run": "npm run podman:build && podman run --arch amd64 --rm --name console -p 3000:3000 -e PORT=3000 -v $PWD/backend/certs:/app/certs -v $PWD/backend/config:/app/config --env-file=backend/.env console | ./backend-node/node_modules/.bin/pino-zen -i time && podman rm -f console", + "podman:run": "npm run podman:build && podman run --arch amd64 --rm --name console -p 3000:3000 -e PORT=3000 -v $PWD/backend/certs:/app/certs -v $PWD/backend/config:/app/config --env-file=backend/.env console", "podman:deploy": "npm run podman:build && podman tag console quay.io/$USER/console:latest && podman push quay.io/$USER/console:latest && ./scripts/patch-deployment.sh latest quay.io/$USER/console", "podman:deploy:mce": "npm run podman:build:mce && podman tag console-mce quay.io/$USER/console-mce:latest && podman push quay.io/$USER/console-mce:latest && ./scripts/patch-deployment.sh latest quay.io/$USER/console-mce", "playwright:sanity": "npx playwright test --config e2e-template/playwright-sanity.config.ts", diff --git a/port-defaults.sh b/port-defaults.sh index 584516ee987..8acc3d19a98 100644 --- a/port-defaults.sh +++ b/port-defaults.sh @@ -8,4 +8,3 @@ export FRONTEND_PORT=${FRONTEND_PORT:=3000} export MCE_PORT=${MCE_PORT:=3001} export ACM_PORT=${ACM_PORT:=3002} export BACKEND_PORT=${BACKEND_PORT:=4000} -export NODE_BACKEND_PORT=${NODE_BACKEND_PORT:=4001} diff --git a/scripts/check-hub-alignment.sh b/scripts/check-hub-alignment.sh index 5895bf7d92b..a6e112dc39b 100755 --- a/scripts/check-hub-alignment.sh +++ b/scripts/check-hub-alignment.sh @@ -58,7 +58,7 @@ listener serves plain HTTP and the console logs: http: proxy error: tls: first record does not look like a TLS handshake Fix: npm run generate-certs -Then restart npm run plugins (Go and the Node sidecar read certs only at startup). +Then restart npm run plugins (the Go listener reads certs only at startup). EOF exit 1 fi diff --git a/scripts/console-entrypoint.sh b/scripts/console-entrypoint.sh index 554d0ce1385..eb1d8d46fd1 100755 --- a/scripts/console-entrypoint.sh +++ b/scripts/console-entrypoint.sh @@ -1,11 +1,7 @@ #!/bin/sh # Copyright Contributors to the Open Cluster Management project -# Public listener is Go; Node sidecar handles unmigrated routes. set -eu -NODE_BACKEND_PORT="${NODE_BACKEND_PORT:-4001}" -export NODE_BACKEND_URL="${NODE_BACKEND_URL:-https://127.0.0.1:${NODE_BACKEND_PORT}}" export PUBLIC_FOLDER="${PUBLIC_FOLDER:-/app/public}" export CERTS_DIR="${CERTS_DIR:-/app/certs}" export CONFIG_DIR="${CONFIG_DIR:-/app/config}" -PORT="${NODE_BACKEND_PORT}" node /app/backend.mjs & exec /app/console diff --git a/setup.sh b/setup.sh index 357f705ec57..9a9009f3908 100755 --- a/setup.sh +++ b/setup.sh @@ -9,8 +9,6 @@ source ./oauth-client-name.sh echo > ./backend/.env echo PORT="${BACKEND_PORT}" >> ./backend/.env -echo NODE_BACKEND_PORT="${NODE_BACKEND_PORT}" >> ./backend/.env -echo NODE_BACKEND_URL="https://127.0.0.1:${NODE_BACKEND_PORT}" >> ./backend/.env echo NODE_ENV=development >> ./backend/.env CLUSTER_API_URL=`oc get infrastructure cluster -o jsonpath={.status.apiServerURL}` diff --git a/sonar-project.properties b/sonar-project.properties index 34129de67c1..20c8870711e 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,10 +1,10 @@ sonar.projectKey=open-cluster-management_console sonar.projectName=console sonar.organization=open-cluster-management -sonar.sources=frontend/src,frontend/packages/multicluster-sdk/src,backend-node/src +sonar.sources=frontend/src,frontend/packages/multicluster-sdk/src sonar.exclusions=node_modules/**/*,frontend/node_modules/**/*,frontend/src/atoms.tsx,frontend/src/lib/nock-util.ts,frontend/src/**/*.stories.tsx,frontend/src/**/*.fixtures.ts,frontend/src/routes/Search/search-sdk/search-sdk.ts sonar.coverage.exclusions=**/*.sharedmocks.tsx,**/*.sharedmocks.ts,**/test-shots.ts,**/setupTests.ts sonar.tests=frontend/src -sonar.test.inclusions=frontend/**/*.test.tsx,frontend/**/*.test.ts,frontend/**/*.test.js,backend-node/test/**/*.spec.ts -sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info,backend-node/coverage/lcov.info -sonar.testExecutionReportPaths=frontend/test-report.xml,backend-node/test-report.xml +sonar.test.inclusions=frontend/**/*.test.tsx,frontend/**/*.test.ts,frontend/**/*.test.js +sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info +sonar.testExecutionReportPaths=frontend/test-report.xml From 45fa89082fd096aa3680da974f6433e3873bb7b4 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Mon, 14 Sep 2026 16:51:14 +0200 Subject: [PATCH 14/16] performance improvements (#64) * ACM-42600 Signed-off-by: Enrique Mingorance Cano * ACM-42601 Migrate search proxy and WebSocket relay to Go Signed-off-by: Enrique Mingorance Cano * ACM-42602 Migrate long-tail routes to Go Signed-off-by: Enrique Mingorance Cano * ACM-42603 Decommission Node.js backend Signed-off-by: Enrique Mingorance Cano * performance improvements Signed-off-by: Enrique Mingorance Cano * Additional backend performance improvements: cache list calls, reuse SSAR clients, prefetch RBAC checks, and serve cluster info from informer cache Signed-off-by: Enrique Mingorance Cano --------- Signed-off-by: Enrique Mingorance Cano --- backend/cmd/console/main.go | 1 + backend/internal/aggregate/engine.go | 67 ++-- backend/internal/aggregate/engine_test.go | 73 ++++ backend/internal/aggregate/lister.go | 11 +- backend/internal/clusterinfo/clusterinfo.go | 315 +++++++++++++++--- .../internal/clusterinfo/clusterinfo_test.go | 224 ++++++++++++- backend/internal/events/hub/access.go | 184 ++++++++-- backend/internal/events/hub/access_test.go | 126 +++++++ backend/internal/events/hub/handler.go | 22 +- backend/internal/events/hub/handler_test.go | 2 + backend/internal/hubresources/components.go | 7 +- backend/internal/informers/specs.go | 1 + backend/internal/informers/specs_test.go | 12 +- backend/internal/informers/store.go | 12 +- backend/internal/upgraderisks/upgraderisks.go | 57 +++- .../upgraderisks/upgraderisks_test.go | 85 +++++ 16 files changed, 1074 insertions(+), 125 deletions(-) diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index 548416ae367..f422730fb12 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -218,6 +218,7 @@ func run() error { RESTConfig: restCfg, Dynamic: dyn, Discovery: disc, + Cache: infCache, })), server.WithSearchProxy(searchproxy.New(searchproxy.Options{ RESTConfig: restCfg, diff --git a/backend/internal/aggregate/engine.go b/backend/internal/aggregate/engine.go index b552b4e4d7e..70ff2b8007b 100644 --- a/backend/internal/aggregate/engine.go +++ b/backend/internal/aggregate/engine.go @@ -27,6 +27,7 @@ type Engine struct { mu sync.RWMutex cache map[string]*cacheBucket + listCache map[string][]map[string]any appSetAppsMap map[string][]map[string]any pulledAppSetMap map[string][]map[string]any tempPulled map[string][]map[string]any @@ -178,7 +179,7 @@ func (e *Engine) searchLoop(ctx context.Context) { func (e *Engine) applications() []App { e.mu.Lock() defer e.mu.Unlock() - e.rebuildLocalLocked() + e.withListCache(e.rebuildSubscriptionLocked) items := getApplicationsHelper(e.cache, cacheKeys) if items == nil { return []App{} @@ -186,39 +187,51 @@ func (e *Engine) applications() []App { return items } -func (e *Engine) rebuildLocalLocked() { +func (e *Engine) withListCache(fn func()) { + e.listCache = map[string][]map[string]any{} + defer func() { e.listCache = nil }() + fn() +} + +func (e *Engine) rebuildSubscriptionLocked() { subs := e.listKind("app.k8s.io/v1beta1", "Application") e.cache[cacheSubscription].Resources = e.transform(subs, map[string]StatusMap{}, false, nil, nil, nil) e.cache[cacheSubscription].ResourceUIDMap = nil e.cache[cacheSubscription].ResourceMap = nil +} - clusters := e.clusters() - hub := e.hubClusterName() - var local *Cluster - for i := range clusters { - if clusters[i].Name == hub { - c := clusters[i] - local = &c - break +func (e *Engine) rebuildLocalLocked() { + e.withListCache(func() { + e.rebuildSubscriptionLocked() + + clusters := e.clusters() + hub := e.hubClusterName() + var local *Cluster + for i := range clusters { + if clusters[i].Name == hub { + c := clusters[i] + local = &c + break + } } - } - e.ocpArgoFilter = map[string]struct{}{} - temp := map[string][]map[string]any{} - argoItems := e.listKind("argoproj.io/v1alpha1", "Application") - filtered := filterArgoApps(argoItems, clusters, e.ocpArgoFilter, temp, hub) - e.appSetAppsMap = temp - uidMap := map[string]App{} - e.transform(filtered, e.lastArgoStatus, false, local, clusters, uidMap) - e.cache[cacheLocalArgo].Resources = nil - e.cache[cacheLocalArgo].ResourceUIDMap = uidMap - e.cache[cacheLocalArgo].ResourceMap = nil + e.ocpArgoFilter = map[string]struct{}{} + temp := map[string][]map[string]any{} + argoItems := e.listKind("argoproj.io/v1alpha1", "Application") + filtered := filterArgoApps(argoItems, clusters, e.ocpArgoFilter, temp, hub) + e.appSetAppsMap = temp + uidMap := map[string]App{} + e.transform(filtered, e.lastArgoStatus, false, local, clusters, uidMap) + e.cache[cacheLocalArgo].Resources = nil + e.cache[cacheLocalArgo].ResourceUIDMap = uidMap + e.cache[cacheLocalArgo].ResourceMap = nil - appsets := e.listKind("argoproj.io/v1alpha1", "ApplicationSet") - asetMap := map[string]App{} - e.transform(appsets, e.lastArgoStatus, false, local, clusters, asetMap) - e.cache[cacheAppSet].Resources = nil - e.cache[cacheAppSet].ResourceUIDMap = asetMap - e.cache[cacheAppSet].ResourceMap = nil + appsets := e.listKind("argoproj.io/v1alpha1", "ApplicationSet") + asetMap := map[string]App{} + e.transform(appsets, e.lastArgoStatus, false, local, clusters, asetMap) + e.cache[cacheAppSet].Resources = nil + e.cache[cacheAppSet].ResourceUIDMap = asetMap + e.cache[cacheAppSet].ResourceMap = nil + }) } func (e *Engine) aggregateRemote(ctx context.Context, pass int) error { diff --git a/backend/internal/aggregate/engine_test.go b/backend/internal/aggregate/engine_test.go index 9f5d925188e..f38cc959aa5 100644 --- a/backend/internal/aggregate/engine_test.go +++ b/backend/internal/aggregate/engine_test.go @@ -8,8 +8,11 @@ import ( "io" "net/http" "net/http/httptest" + "sync" "testing" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/stolostron/console/backend/internal/searchapi" ) @@ -93,3 +96,73 @@ func TestPushModelQueryFromAppSet(t *testing.T) { t.Fatalf("query %+v", q) } } + +type countingLister struct { + inner MapLister + mu sync.Mutex + n map[string]int +} + +func (c *countingLister) ListByKind(apiVersion, kind string) []unstructured.Unstructured { + c.mu.Lock() + if c.n == nil { + c.n = map[string]int{} + } + c.n[apiVersion+"|"+kind]++ + c.mu.Unlock() + return c.inner.ListByKind(apiVersion, kind) +} + +func (c *countingLister) count(key string) int { + c.mu.Lock() + defer c.mu.Unlock() + return c.n[key] +} + +func TestApplicationsRebuildsOnlySubscriptions(t *testing.T) { + cl := &countingLister{inner: MapLister{ + "app.k8s.io/v1beta1|Application": { + uObj("app.k8s.io/v1beta1", "Application", "sub-app", "ns", nil), + }, + "argoproj.io/v1alpha1|Application": { + uObj("argoproj.io/v1alpha1", "Application", "argo-app", "argocd", nil), + }, + "cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()}, + }} + e := NewEngine(cl, nil, nil) + e.cache[cacheLocalArgo].Resources = []App{ + {Object: map[string]any{"metadata": map[string]any{"name": "cached-argo"}}}, + } + apps := e.applications() + if cl.count("argoproj.io/v1alpha1|Application") != 0 { + t.Fatalf("listed local argo %d", cl.count("argoproj.io/v1alpha1|Application")) + } + if cl.count("app.k8s.io/v1beta1|Application") != 1 { + t.Fatalf("listed subscription apps %d", cl.count("app.k8s.io/v1beta1|Application")) + } + found := false + for _, a := range apps { + if metaName(a.Object) == "cached-argo" { + found = true + } + } + if !found { + t.Fatalf("expected cached argo in %+v", apps) + } +} + +func TestRebuildLocalMemoizesListKind(t *testing.T) { + cl := &countingLister{inner: MapLister{ + "app.k8s.io/v1beta1|Application": {}, + "argoproj.io/v1alpha1|Application": {}, + "argoproj.io/v1alpha1|ApplicationSet": {}, + "cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()}, + }} + e := NewEngine(cl, nil, nil) + e.mu.Lock() + e.rebuildLocalLocked() + e.mu.Unlock() + if got := cl.count("cluster.open-cluster-management.io/v1|ManagedCluster"); got != 1 { + t.Fatalf("ManagedCluster lists %d want 1", got) + } +} diff --git a/backend/internal/aggregate/lister.go b/backend/internal/aggregate/lister.go index d90dff030ed..1451e8278c2 100644 --- a/backend/internal/aggregate/lister.go +++ b/backend/internal/aggregate/lister.go @@ -25,10 +25,19 @@ func (e *Engine) listKind(apiVersion, kind string) []map[string]any { if e == nil || e.Lister == nil { return nil } + key := apiVersion + "|" + kind + if e.listCache != nil { + if items, ok := e.listCache[key]; ok { + return items + } + } items := e.Lister.ListByKind(apiVersion, kind) out := make([]map[string]any, 0, len(items)) for i := range items { - out = append(out, items[i].DeepCopy().Object) + out = append(out, items[i].Object) + } + if e.listCache != nil { + e.listCache[key] = out } return out } diff --git a/backend/internal/clusterinfo/clusterinfo.go b/backend/internal/clusterinfo/clusterinfo.go index a5b0472e51e..dff7d2db471 100644 --- a/backend/internal/clusterinfo/clusterinfo.go +++ b/backend/internal/clusterinfo/clusterinfo.go @@ -8,18 +8,21 @@ import ( "io" "net/http" "strings" + "sync" + "time" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/api/meta" "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" "github.com/stolostron/console/backend/internal/auth" "github.com/stolostron/console/backend/internal/hubresources" + "github.com/stolostron/console/backend/internal/informers" applog "github.com/stolostron/console/backend/internal/log" ) @@ -61,11 +64,25 @@ const ( OperatorKubeVirt SupportedOperator = "kubevirt-hyperconverged" ) +const ( + globalHubCRDCacheTTL = 30 * time.Second + hubFlagsCacheTTL = 10 * time.Second +) + // Options configure cluster-info route handlers. type Options struct { RESTConfig *rest.Config Dynamic dynamic.Interface Discovery discovery.DiscoveryInterface + Cache *informers.InformerCache +} + +type hubFlags struct { + isGlobalHub bool + localHubName string + isHubSelfManaged bool + isObservabilityInstalled bool + authentication map[string]interface{} } // Handler serves hub, cluster-version, hypershift-status, MCH/MCE components, operatorCheck, and apiPaths. @@ -73,6 +90,15 @@ type Handler struct { base *rest.Config dynamic dynamic.Interface discovery discovery.DiscoveryInterface + cache *informers.InformerCache + + globalHubMu sync.Mutex + globalHubUntil time.Time + globalHubValue bool + + hubFlagsMu sync.RWMutex + hubFlagsUntil time.Time + hubFlagsValue hubFlags } // New builds a cluster-info routes handler. @@ -81,6 +107,7 @@ func New(opts Options) *Handler { base: opts.RESTConfig, dynamic: opts.Dynamic, discovery: opts.Discovery, + cache: opts.Cache, } } @@ -110,26 +137,111 @@ func (h *Handler) hub(w http.ResponseWriter, r *http.Request) { if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { return } - ctx := r.Context() + flags := h.hubFlags(r.Context()) + resp := map[string]interface{}{ + "isGlobalHub": flags.isGlobalHub, + "localHubName": flags.localHubName, + "isHubSelfManaged": flags.isHubSelfManaged, + "isObservabilityInstalled": flags.isObservabilityInstalled, + "authentication": flags.authentication, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} - isGlobalHub := false - crd, err := h.dynamic.Resource(crdGVR).Get(ctx, - "multiclusterglobalhubs.operator.open-cluster-management.io", metav1.GetOptions{}) - if err == nil { - kind, _, _ := unstructured.NestedString(crd.Object, "kind") - if kind == "CustomResourceDefinition" { - isGlobalHub = true - } - } else if !apierrors.IsNotFound(err) { - applog.Logger().Error("get global hub CRD failed", "error", err) +func (h *Handler) hubFlags(ctx context.Context) hubFlags { + now := time.Now() + h.hubFlagsMu.RLock() + if now.Before(h.hubFlagsUntil) { + flags := h.hubFlagsValue + h.hubFlagsMu.RUnlock() + return flags + } + h.hubFlagsMu.RUnlock() + + flags := h.loadHubFlags(ctx) + h.hubFlagsMu.Lock() + h.hubFlagsValue = flags + h.hubFlagsUntil = now.Add(hubFlagsCacheTTL) + h.hubFlagsMu.Unlock() + return flags +} + +func (h *Handler) loadHubFlags(ctx context.Context) hubFlags { + if flags, ok := h.hubFlagsFromCache(ctx); ok { + return flags + } + return h.hubFlagsLive(ctx) +} + +func (h *Handler) hubFlagsFromCache(ctx context.Context) (hubFlags, bool) { + if h.cache == nil || !h.cache.HasSynced() { + return hubFlags{}, false } localHubName := "local-cluster" isHubSelfManaged := false - mcList, err := h.dynamic.Resource(managedClusterGVR).List(ctx, metav1.ListOptions{}) - if err != nil { - applog.Logger().Error("list managedclusters failed", "error", err) - } else { + for _, mc := range h.cache.ListByKind("cluster.open-cluster-management.io/v1", "ManagedCluster") { + if mc.GetLabels()["local-cluster"] == "true" { + if name := mc.GetName(); name != "" { + localHubName = name + } + isHubSelfManaged = true + break + } + } + + isObservabilityInstalled := false + for _, addon := range h.cache.ListByKind("addon.open-cluster-management.io/v1alpha1", "ManagedClusterAddOn") { + if addon.GetNamespace() != localHubName { + continue + } + name := addon.GetName() + if name == "observability-controller" || name == "multicluster-observability-addon" { + isObservabilityInstalled = true + break + } + } + + authentication := buildAuthentication(nil) + for _, authObj := range h.cache.ListByKind("config.openshift.io/v1", "Authentication") { + if authObj.GetName() == "cluster" { + authentication = buildAuthentication(authObj.Object) + break + } + } + + return hubFlags{ + isGlobalHub: h.isGlobalHubCached(ctx), + localHubName: localHubName, + isHubSelfManaged: isHubSelfManaged, + isObservabilityInstalled: isObservabilityInstalled, + authentication: authentication, + }, true +} + +func (h *Handler) hubFlagsLive(ctx context.Context) hubFlags { + var ( + isGlobalHub bool + localHubName = "local-cluster" + isHubSelfManaged bool + isObservabilityInstalled bool + authentication = buildAuthentication(nil) + ) + + var wg sync.WaitGroup + wg.Add(3) + go func() { + isGlobalHub = h.isGlobalHubCached(ctx) + wg.Done() + }() + go func() { + mcList, err := h.dynamic.Resource(managedClusterGVR).List(ctx, metav1.ListOptions{}) + if err != nil { + applog.Logger().Error("list managedclusters failed", "error", err) + wg.Done() + return + } for _, item := range mcList.Items { labels, _, _ := unstructured.NestedStringMap(item.Object, "metadata", "labels") if labels["local-cluster"] == "true" { @@ -141,9 +253,19 @@ func (h *Handler) hub(w http.ResponseWriter, r *http.Request) { break } } - } + wg.Done() + }() + go func() { + authObj, err := h.dynamic.Resource(authenticationGVR).Get(ctx, "cluster", metav1.GetOptions{}) + if err == nil { + authentication = buildAuthentication(authObj.Object) + } else if !apierrors.IsNotFound(err) { + applog.Logger().Error("get authentication cluster failed", "error", err) + } + wg.Done() + }() + wg.Wait() - isObservabilityInstalled := false addonList, err := h.dynamic.Resource(managedClusterAddOnGVR).Namespace(localHubName).List(ctx, metav1.ListOptions{}) if err != nil { applog.Logger().Error("list managedclusteraddons failed", "error", err) @@ -157,23 +279,42 @@ func (h *Handler) hub(w http.ResponseWriter, r *http.Request) { } } - authObj, err := h.dynamic.Resource(authenticationGVR).Get(ctx, "cluster", metav1.GetOptions{}) - authentication := buildAuthentication(nil) + return hubFlags{ + isGlobalHub: isGlobalHub, + localHubName: localHubName, + isHubSelfManaged: isHubSelfManaged, + isObservabilityInstalled: isObservabilityInstalled, + authentication: authentication, + } +} + +func (h *Handler) isGlobalHubCached(ctx context.Context) bool { + now := time.Now() + h.globalHubMu.Lock() + if now.Before(h.globalHubUntil) { + value := h.globalHubValue + h.globalHubMu.Unlock() + return value + } + h.globalHubMu.Unlock() + + isGlobalHub := false + crd, err := h.dynamic.Resource(crdGVR).Get(ctx, + "multiclusterglobalhubs.operator.open-cluster-management.io", metav1.GetOptions{}) if err == nil { - authentication = buildAuthentication(authObj.Object) + kind, _, _ := unstructured.NestedString(crd.Object, "kind") + if kind == "CustomResourceDefinition" { + isGlobalHub = true + } } else if !apierrors.IsNotFound(err) { - applog.Logger().Error("get authentication cluster failed", "error", err) + applog.Logger().Error("get global hub CRD failed", "error", err) } - resp := map[string]interface{}{ - "isGlobalHub": isGlobalHub, - "localHubName": localHubName, - "isHubSelfManaged": isHubSelfManaged, - "isObservabilityInstalled": isObservabilityInstalled, - "authentication": authentication, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + h.globalHubMu.Lock() + h.globalHubValue = isGlobalHub + h.globalHubUntil = now.Add(globalHubCRDCacheTTL) + h.globalHubMu.Unlock() + return isGlobalHub } func buildAuthentication(obj map[string]interface{}) map[string]interface{} { @@ -233,8 +374,12 @@ func (h *Handler) clusterVersion(w http.ResponseWriter, r *http.Request) { if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { return } - obj, err := h.dynamic.Resource(clusterVersionGVR).Get(r.Context(), "version", metav1.GetOptions{}) w.Header().Set("Content-Type", "application/json") + if obj, ok := h.clusterVersionFromCache(); ok { + _ = json.NewEncoder(w).Encode(clusterVersionPayload(obj)) + return + } + obj, err := h.dynamic.Resource(clusterVersionGVR).Get(r.Context(), "version", metav1.GetOptions{}) if err != nil { applog.Logger().Error("get clusterversion failed", "error", err) _ = json.NewEncoder(w).Encode(map[string]string{ @@ -242,12 +387,33 @@ func (h *Handler) clusterVersion(w http.ResponseWriter, r *http.Request) { }) return } - version, _, _ := unstructured.NestedString(obj.Object, "status", "desired", "version") + _ = json.NewEncoder(w).Encode(clusterVersionPayload(obj)) +} + +func (h *Handler) clusterVersionFromCache() (*unstructured.Unstructured, bool) { + if h.cache == nil || !h.cache.HasSynced() { + return nil, false + } + items := h.cache.ListByKind("config.openshift.io/v1", "ClusterVersion") + for i := range items { + if items[i].GetName() == "version" { + obj := items[i] + return &obj, true + } + } + return nil, false +} + +func clusterVersionPayload(obj *unstructured.Unstructured) map[string]interface{} { payload := map[string]interface{}{} + if obj == nil { + return payload + } + version, _, _ := unstructured.NestedString(obj.Object, "status", "desired", "version") if version != "" { payload["version"] = version } - _ = json.NewEncoder(w).Encode(payload) + return payload } func (h *Handler) hypershiftStatus(w http.ResponseWriter, r *http.Request) { @@ -260,17 +426,15 @@ func (h *Handler) hypershiftStatus(w http.ResponseWriter, r *http.Request) { hubName = "local-cluster" } + if components, addon, ok := h.hypershiftFromCache(hubName); ok { + writeHypershiftStatus(w, processHypershiftStatus(components, addon)) + return + } + components, err := hubresources.MCEComponents(ctx, h.dynamic) if err != nil { if isMissingAPI(err) { - enabled := processHypershiftStatus(nil, nil) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "statusCode": http.StatusOK, - "body": map[string]bool{ - "isHypershiftEnabled": enabled, - }, - }) + writeHypershiftStatus(w, processHypershiftStatus(nil, nil)) return } applog.Logger().Error("hypershift status mce components failed", "error", err) @@ -287,7 +451,10 @@ func (h *Handler) hypershiftStatus(w http.ResponseWriter, r *http.Request) { return } } - enabled := processHypershiftStatus(components, addon) + writeHypershiftStatus(w, processHypershiftStatus(components, addon)) +} + +func writeHypershiftStatus(w http.ResponseWriter, enabled bool) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ "statusCode": http.StatusOK, @@ -297,6 +464,30 @@ func (h *Handler) hypershiftStatus(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) hypershiftFromCache(hubName string) ([]hubresources.Component, *unstructured.Unstructured, bool) { + if h.cache == nil || !h.cache.HasSynced() { + return nil, nil, false + } + var components []hubresources.Component + mces := h.cache.ListByKind("multicluster.openshift.io/v1", "MultiClusterEngine") + if len(mces) > 0 { + parsed, err := hubresources.ParseComponents(mces[0].Object) + if err != nil { + return nil, nil, false + } + components = parsed + } + var addon *unstructured.Unstructured + for _, item := range h.cache.ListByKind("addon.open-cluster-management.io/v1alpha1", "ManagedClusterAddOn") { + if item.GetNamespace() == hubName && item.GetName() == "hypershift-addon" { + cp := item + addon = &cp + break + } + } + return components, addon, true +} + func processHypershiftStatus(components []hubresources.Component, addon *unstructured.Unstructured) bool { if len(components) == 0 { return false @@ -355,6 +546,10 @@ func (h *Handler) mchComponents(w http.ResponseWriter, r *http.Request) { if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { return } + if components, ok := h.mchComponentsFromCache(); ok { + writeJSON(w, components) + return + } components, err := hubresources.MCHComponents(r.Context(), h.dynamic) if err != nil { if isMissingAPI(err) { @@ -368,10 +563,29 @@ func (h *Handler) mchComponents(w http.ResponseWriter, r *http.Request) { writeJSON(w, components) } +func (h *Handler) mchComponentsFromCache() ([]hubresources.Component, bool) { + if h.cache == nil || !h.cache.HasSynced() { + return nil, false + } + items := h.cache.ListByKind("operator.open-cluster-management.io/v1", "MultiClusterHub") + if len(items) == 0 { + return nil, true + } + components, err := hubresources.ParseComponents(items[0].Object) + if err != nil { + return nil, false + } + return components, true +} + func (h *Handler) mceComponents(w http.ResponseWriter, r *http.Request) { if _, ok := auth.AuthenticateRequest(r.Context(), h.base, w, r); !ok { return } + if components, ok := h.mceComponentsFromCache(); ok { + writeJSON(w, components) + return + } components, err := hubresources.MCEComponents(r.Context(), h.dynamic) if err != nil { if isMissingAPI(err) { @@ -385,6 +599,21 @@ func (h *Handler) mceComponents(w http.ResponseWriter, r *http.Request) { writeJSON(w, components) } +func (h *Handler) mceComponentsFromCache() ([]hubresources.Component, bool) { + if h.cache == nil || !h.cache.HasSynced() { + return nil, false + } + items := h.cache.ListByKind("multicluster.openshift.io/v1", "MultiClusterEngine") + if len(items) == 0 { + return nil, true + } + components, err := hubresources.ParseComponents(items[0].Object) + if err != nil { + return nil, false + } + return components, true +} + type operatorCheckRequest struct { Operator SupportedOperator `json:"operator"` } diff --git a/backend/internal/clusterinfo/clusterinfo_test.go b/backend/internal/clusterinfo/clusterinfo_test.go index f824db26bd0..0da6927308d 100644 --- a/backend/internal/clusterinfo/clusterinfo_test.go +++ b/backend/internal/clusterinfo/clusterinfo_test.go @@ -4,10 +4,12 @@ package clusterinfo_test import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" "testing" + "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -19,6 +21,7 @@ import ( k8stesting "k8s.io/client-go/testing" "github.com/stolostron/console/backend/internal/clusterinfo" + "github.com/stolostron/console/backend/internal/informers" ) func apiProbeServer(t *testing.T) (*httptest.Server, *rest.Config) { @@ -71,7 +74,7 @@ func TestHypershiftStatus_Disabled(t *testing.T) { }, } dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ - {Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"}: "MultiClusterEngineList", + {Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"}: "MultiClusterEngineList", {Group: "addon.open-cluster-management.io", Version: "v1alpha1", Resource: "managedclusteraddons"}: "ManagedClusterAddOnList", }, mce) h := clusterinfo.New(clusterinfo.Options{RESTConfig: base, Dynamic: dyn}) @@ -121,3 +124,222 @@ func TestAPIPaths(t *testing.T) { t.Fatalf("got %#v", got) } } + +type staticMapper struct { + lists map[string]*metav1.APIResourceList +} + +func (m staticMapper) ServerResourcesForGroupVersion(gv string) (*metav1.APIResourceList, error) { + if l, ok := m.lists[gv]; ok { + return l, nil + } + return nil, runtime.NewMissingKindErr(gv) +} + +func waitCacheSynced(t *testing.T, c *informers.InformerCache) { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + if c.HasSynced() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("cache did not sync; statuses=%+v", c.SpecStatuses()) +} + +func TestClusterVersionFromCache(t *testing.T) { + _, base := apiProbeServer(t) + cv := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "config.openshift.io/v1", + "kind": "ClusterVersion", + "metadata": map[string]interface{}{"name": "version"}, + "status": map[string]interface{}{"desired": map[string]interface{}{"version": "4.19.0"}}, + }} + gvr := schema.GroupVersionResource{Group: "config.openshift.io", Version: "v1", Resource: "clusterversions"} + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + gvr: "ClusterVersionList", + }, cv) + mapper := staticMapper{lists: map[string]*metav1.APIResourceList{ + "config.openshift.io/v1": {GroupVersion: "config.openshift.io/v1", APIResources: []metav1.APIResource{ + {Name: "clusterversions", Kind: "ClusterVersion", Verbs: []string{"list", "watch"}}, + }}, + }} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + cache := informers.StartSpecs(ctx, dyn, mapper, []informers.WatchSpec{ + {Kind: "ClusterVersion", APIVersion: "config.openshift.io/v1", ForwardEventsToClients: true}, + }) + waitCacheSynced(t, cache) + h := clusterinfo.New(clusterinfo.Options{RESTConfig: base, Cache: cache}) + req := httptest.NewRequest(http.MethodGet, "/cluster-version", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got["version"] != "4.19.0" { + t.Fatalf("got %#v", got) + } +} + +func TestMCHComponentsFromCache(t *testing.T) { + _, base := apiProbeServer(t) + mch := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "operator.open-cluster-management.io/v1", + "kind": "MultiClusterHub", + "metadata": map[string]interface{}{"name": "mch", "namespace": "open-cluster-management"}, + "spec": map[string]interface{}{ + "overrides": map[string]interface{}{ + "components": []interface{}{ + map[string]interface{}{"name": "console", "enabled": true}, + }, + }, + }, + }} + gvr := schema.GroupVersionResource{Group: "operator.open-cluster-management.io", Version: "v1", Resource: "multiclusterhubs"} + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + gvr: "MultiClusterHubList", + }, mch) + mapper := staticMapper{lists: map[string]*metav1.APIResourceList{ + "operator.open-cluster-management.io/v1": {GroupVersion: "operator.open-cluster-management.io/v1", APIResources: []metav1.APIResource{ + {Name: "multiclusterhubs", Kind: "MultiClusterHub", Verbs: []string{"list", "watch"}}, + }}, + }} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + cache := informers.StartSpecs(ctx, dyn, mapper, []informers.WatchSpec{ + {Kind: "MultiClusterHub", APIVersion: "operator.open-cluster-management.io/v1"}, + }) + waitCacheSynced(t, cache) + h := clusterinfo.New(clusterinfo.Options{RESTConfig: base, Cache: cache}) + req := httptest.NewRequest(http.MethodGet, "/multiclusterhub/components", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var got []map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0]["name"] != "console" || got[0]["enabled"] != true { + t.Fatalf("got %#v", got) + } +} + +func TestMCEComponentsFromCache(t *testing.T) { + _, base := apiProbeServer(t) + mce := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "multicluster.openshift.io/v1", + "kind": "MultiClusterEngine", + "metadata": map[string]interface{}{"name": "engine"}, + "spec": map[string]interface{}{ + "overrides": map[string]interface{}{ + "components": []interface{}{ + map[string]interface{}{"name": "hypershift", "enabled": true}, + }, + }, + }, + }} + gvr := schema.GroupVersionResource{Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"} + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + gvr: "MultiClusterEngineList", + }, mce) + mapper := staticMapper{lists: map[string]*metav1.APIResourceList{ + "multicluster.openshift.io/v1": {GroupVersion: "multicluster.openshift.io/v1", APIResources: []metav1.APIResource{ + {Name: "multiclusterengines", Kind: "MultiClusterEngine", Verbs: []string{"list", "watch"}}, + }}, + }} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + cache := informers.StartSpecs(ctx, dyn, mapper, []informers.WatchSpec{ + {Kind: "MultiClusterEngine", APIVersion: "multicluster.openshift.io/v1"}, + }) + waitCacheSynced(t, cache) + h := clusterinfo.New(clusterinfo.Options{RESTConfig: base, Cache: cache}) + req := httptest.NewRequest(http.MethodGet, "/multiclusterengine/components", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var got []map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0]["name"] != "hypershift" || got[0]["enabled"] != true { + t.Fatalf("got %#v", got) + } +} + +func TestHypershiftStatusFromCache(t *testing.T) { + _, base := apiProbeServer(t) + mce := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "multicluster.openshift.io/v1", + "kind": "MultiClusterEngine", + "metadata": map[string]interface{}{"name": "engine"}, + "spec": map[string]interface{}{ + "overrides": map[string]interface{}{ + "components": []interface{}{ + map[string]interface{}{"name": "hypershift", "enabled": true}, + map[string]interface{}{"name": "hypershift-local-hosting", "enabled": true}, + }, + }, + }, + }} + addon := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "addon.open-cluster-management.io/v1alpha1", + "kind": "ManagedClusterAddOn", + "metadata": map[string]interface{}{"name": "hypershift-addon", "namespace": "local-cluster"}, + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{"reason": "ManagedClusterAddOnLeaseUpdated", "status": "True"}, + }, + }, + }} + mceGVR := schema.GroupVersionResource{Group: "multicluster.openshift.io", Version: "v1", Resource: "multiclusterengines"} + addonGVR := schema.GroupVersionResource{Group: "addon.open-cluster-management.io", Version: "v1alpha1", Resource: "managedclusteraddons"} + dyn := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), map[schema.GroupVersionResource]string{ + mceGVR: "MultiClusterEngineList", + addonGVR: "ManagedClusterAddOnList", + }, mce, addon) + mapper := staticMapper{lists: map[string]*metav1.APIResourceList{ + "multicluster.openshift.io/v1": {GroupVersion: "multicluster.openshift.io/v1", APIResources: []metav1.APIResource{ + {Name: "multiclusterengines", Kind: "MultiClusterEngine", Verbs: []string{"list", "watch"}}, + }}, + "addon.open-cluster-management.io/v1alpha1": {GroupVersion: "addon.open-cluster-management.io/v1alpha1", APIResources: []metav1.APIResource{ + {Name: "managedclusteraddons", Kind: "ManagedClusterAddOn", Namespaced: true, Verbs: []string{"list", "watch"}}, + }}, + }} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + cache := informers.StartSpecs(ctx, dyn, mapper, []informers.WatchSpec{ + {Kind: "MultiClusterEngine", APIVersion: "multicluster.openshift.io/v1", ForwardEventsToClients: true}, + {Kind: "ManagedClusterAddOn", APIVersion: "addon.open-cluster-management.io/v1alpha1", ForwardEventsToClients: true}, + }) + waitCacheSynced(t, cache) + h := clusterinfo.New(clusterinfo.Options{RESTConfig: base, Cache: cache}) + req := httptest.NewRequest(http.MethodGet, "/hypershift-status?hubName=local-cluster", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } + var payload map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + body := payload["body"].(map[string]interface{}) + if body["isHypershiftEnabled"] != true { + t.Fatalf("payload %#v", payload) + } +} diff --git a/backend/internal/events/hub/access.go b/backend/internal/events/hub/access.go index 75646c51cd5..7ed8488c246 100644 --- a/backend/internal/events/hub/access.go +++ b/backend/internal/events/hub/access.go @@ -20,8 +20,9 @@ import ( ) const ( - accessCacheTTL = 60 * time.Second - accessCleanupEvery = 90 * time.Second + accessCacheTTL = 60 * time.Second + accessCleanupEvery = 90 * time.Second + prefetchConcurrency = 32 ) var accessCacheMaxTokens = 1000 @@ -29,6 +30,7 @@ var accessCacheMaxTokens = 1000 // AccessChecker decides whether a user may receive an SSE event. type AccessChecker interface { Allow(ctx context.Context, token string, ev Event) (bool, error) + Prefetch(ctx context.Context, token string, events []Event) } // AllowAllAccess is for tests. @@ -38,6 +40,8 @@ func (AllowAllAccess) Allow(context.Context, string, Event) (bool, error) { return true, nil } +func (AllowAllAccess) Prefetch(context.Context, string, []Event) {} + type ssarKey struct { kind, namespace, name string } @@ -47,9 +51,28 @@ type cacheEntry struct { expiry time.Time } +type inflight struct { + done chan struct{} + allowed bool + err error +} + type tokenState struct { - last time.Time - entries map[ssarKey]cacheEntry + last time.Time + entries map[ssarKey]cacheEntry + flight map[ssarKey]*inflight + client kubernetes.Interface + clientErr error + clientWait chan struct{} +} + +type prefetchJob struct { + key ssarKey + group string + resource string + verb string + name string + namespace string } // SSARAccess ports Node eventFilter / canAccess (list cluster → list namespaced → get). @@ -62,7 +85,10 @@ type SSARAccess struct { func NewSSARAccess(base *rest.Config) *SSARAccess { return NewSSARAccessWithClient(func(userToken string) (kubernetes.Interface, error) { - return kubernetes.NewForConfig(auth.UserRESTConfig(base, userToken)) + cfg := auth.UserRESTConfig(base, userToken) + cfg.QPS = 50 + cfg.Burst = 100 + return kubernetes.NewForConfig(cfg) }) } @@ -123,6 +149,53 @@ func (a *SSARAccess) Allow(ctx context.Context, token string, ev Event) (bool, e } } +// Prefetch warms cluster-scoped list SSARs for distinct kinds so snapshot writes hit cache. +func (a *SSARAccess) Prefetch(ctx context.Context, token string, events []Event) { + if a == nil || token == "" || len(events) == 0 { + return + } + jobs := map[ssarKey]prefetchJob{} + for _, ev := range events { + if ev.Type != TypeModified && ev.Type != "ADDED" { + continue + } + kind, apiVersion, _, _ := objectMeta(ev) + resource := resourceName(ev) + if kind == "" || resource == "" { + continue + } + key := ssarKey{kind: kind} + if _, ok := jobs[key]; ok { + continue + } + jobs[key] = prefetchJob{ + key: key, + group: apiGroup(apiVersion), + resource: resource, + verb: "list", + } + } + if len(jobs) == 0 { + return + } + sem := make(chan struct{}, prefetchConcurrency) + var wg sync.WaitGroup + for _, job := range jobs { + wg.Add(1) + go func(j prefetchJob) { + defer wg.Done() + select { + case <-ctx.Done(): + return + case sem <- struct{}{}: + } + defer func() { <-sem }() + _, _ = a.ssar(ctx, token, j.key, j.group, j.resource, j.verb, j.name, j.namespace) + }(job) + } + wg.Wait() +} + func (a *SSARAccess) canSee(ctx context.Context, token string, ev Event) (bool, error) { kind, apiVersion, name, namespace := objectMeta(ev) resource := resourceName(ev) @@ -158,22 +231,85 @@ func ssarNamespace(kind, name, namespace string) string { return namespace } +func (a *SSARAccess) ensureTokenLocked(th string) *tokenState { + st := a.byToken[th] + if st == nil { + st = &tokenState{ + entries: map[ssarKey]cacheEntry{}, + flight: map[ssarKey]*inflight{}, + } + a.byToken[th] = st + } + if st.entries == nil { + st.entries = map[ssarKey]cacheEntry{} + } + if st.flight == nil { + st.flight = map[ssarKey]*inflight{} + } + return st +} + +func (a *SSARAccess) clientFor(token, th string) (kubernetes.Interface, error) { + a.mu.Lock() + st := a.ensureTokenLocked(th) + if st.client != nil || st.clientErr != nil { + c, err := st.client, st.clientErr + a.mu.Unlock() + return c, err + } + if st.clientWait != nil { + wait := st.clientWait + a.mu.Unlock() + <-wait + a.mu.Lock() + st = a.ensureTokenLocked(th) + c, err := st.client, st.clientErr + a.mu.Unlock() + return c, err + } + st.clientWait = make(chan struct{}) + wait := st.clientWait + a.mu.Unlock() + + client, err := a.newClient(token) + + a.mu.Lock() + st = a.ensureTokenLocked(th) + st.client = client + st.clientErr = err + close(wait) + st.clientWait = nil + a.mu.Unlock() + return client, err +} + func (a *SSARAccess) ssar(ctx context.Context, token string, key ssarKey, group, resource, verb, name, namespace string) (bool, error) { now := time.Now() th := hashToken(token) a.mu.Lock() - if st, ok := a.byToken[th]; ok { - if e, hit := st.entries[key]; hit && e.expiry.After(now) { - st.last = now - allowed := e.allowed - a.mu.Unlock() - return allowed, nil + st := a.ensureTokenLocked(th) + st.last = now + if e, hit := st.entries[key]; hit && e.expiry.After(now) { + allowed := e.allowed + a.mu.Unlock() + return allowed, nil + } + if f, ok := st.flight[key]; ok { + a.mu.Unlock() + select { + case <-f.done: + return f.allowed, f.err + case <-ctx.Done(): + return false, ctx.Err() } } + f := &inflight{done: make(chan struct{})} + st.flight[key] = f a.mu.Unlock() - client, err := a.newClient(token) + client, err := a.clientFor(token, th) if err != nil { + a.finishFlight(th, key, f, false, err, false) return false, err } review, err := client.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, &authzv1.SelfSubjectAccessReview{ @@ -188,19 +324,27 @@ func (a *SSARAccess) ssar(ctx context.Context, token string, key ssarKey, group, }, }, metav1.CreateOptions{}) if err != nil { + a.finishFlight(th, key, f, false, err, false) return false, err } allowed := review.Status.Allowed + a.finishFlight(th, key, f, allowed, nil, true) + return allowed, nil +} + +func (a *SSARAccess) finishFlight(th string, key ssarKey, f *inflight, allowed bool, err error, cache bool) { + f.allowed = allowed + f.err = err a.mu.Lock() - st := a.byToken[th] - if st == nil { - st = &tokenState{entries: map[ssarKey]cacheEntry{}} - a.byToken[th] = st + if st := a.byToken[th]; st != nil { + delete(st.flight, key) + st.last = time.Now() + if cache && err == nil { + st.entries[key] = cacheEntry{allowed: allowed, expiry: time.Now().Add(accessCacheTTL)} + } } - st.last = now - st.entries[key] = cacheEntry{allowed: allowed, expiry: now.Add(accessCacheTTL)} a.mu.Unlock() - return allowed, nil + close(f.done) } func (a *SSARAccess) StartCleanup(ctx context.Context) { @@ -230,7 +374,7 @@ func (a *SSARAccess) cleanup(now time.Time) { delete(st.entries, k) } } - if len(st.entries) == 0 { + if len(st.entries) == 0 && len(st.flight) == 0 { delete(a.byToken, th) } } diff --git a/backend/internal/events/hub/access_test.go b/backend/internal/events/hub/access_test.go index 0ed6e9ffddf..3cc0d4c5ae7 100644 --- a/backend/internal/events/hub/access_test.go +++ b/backend/internal/events/hub/access_test.go @@ -4,6 +4,10 @@ package hub import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" "testing" "time" @@ -12,6 +16,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/rest" ktesting "k8s.io/client-go/testing" ) @@ -165,6 +170,127 @@ func TestSSARCleanupExpiresAndMaxTokens(t *testing.T) { } } +func TestSSARReusesClientPerToken(t *testing.T) { + var n int + client := fake.NewSimpleClientset() + client.PrependReactor("create", "selfsubjectaccessreviews", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, &authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: true}, + }, nil + }) + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { + n++ + return client, nil + }) + kinds := []string{"Namespace", "Secret", "ConfigMap"} + for _, kind := range kinds { + ev := Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, + Object: map[string]any{ + "kind": kind, "apiVersion": "v1", + "metadata": map[string]any{"name": "x"}, + }, + } + if _, err := a.Allow(context.Background(), "tok", ev); err != nil { + t.Fatal(err) + } + } + if n != 1 { + t.Fatalf("newClient calls %d want 1", n) + } +} + +func TestPrefetchParallelKindList(t *testing.T) { + const kinds = 8 + const delay = 40 * time.Millisecond + var mu sync.Mutex + var calls, inFlight, maxFlight int + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + inFlight++ + if inFlight > maxFlight { + maxFlight = inFlight + } + mu.Unlock() + time.Sleep(delay) + mu.Lock() + inFlight-- + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(&authzv1.SelfSubjectAccessReview{ + Status: authzv1.SubjectAccessReviewStatus{Allowed: true}, + }) + })) + t.Cleanup(ts.Close) + cfg := &rest.Config{ + Host: ts.URL, + TLSClientConfig: rest.TLSClientConfig{Insecure: true}, + QPS: 100, + Burst: 100, + } + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { + return kubernetes.NewForConfig(cfg) + }) + events := make([]Event, 0, kinds) + for i := 0; i < kinds; i++ { + kind := "Kind" + string(rune('A'+i)) + events = append(events, Event{ + Type: TypeModified, + GVR: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, + Object: map[string]any{ + "kind": kind, "apiVersion": "v1", + "metadata": map[string]any{"name": "n"}, + }, + }) + } + start := time.Now() + a.Prefetch(context.Background(), "tok", events) + elapsed := time.Since(start) + if elapsed >= time.Duration(kinds)*delay { + t.Fatalf("prefetch took %s; want parallel ~%s not serial %s", elapsed, delay, time.Duration(kinds)*delay) + } + mu.Lock() + got, peak := calls, maxFlight + mu.Unlock() + if got != kinds { + t.Fatalf("SSAR calls %d want %d", got, kinds) + } + if peak < 4 { + t.Fatalf("max in-flight %d want concurrent", peak) + } + for _, ev := range events { + if _, err := a.Allow(context.Background(), "tok", ev); err != nil { + t.Fatal(err) + } + } + mu.Lock() + got = calls + mu.Unlock() + if got != kinds { + t.Fatalf("after Allow SSAR calls %d want cached %d", got, kinds) + } +} + +func TestPrefetchSkipsDeleted(t *testing.T) { + var n int + a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { + n++ + return fake.NewSimpleClientset(), nil + }) + a.Prefetch(context.Background(), "tok", []Event{ + {Type: TypeDeleted, GVR: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, Object: map[string]any{ + "kind": "Namespace", "apiVersion": "v1", "metadata": map[string]any{"name": "x"}, + }}, + {Type: TypeStart}, + {Type: TypeLoaded}, + }) + if n != 0 { + t.Fatalf("prefetch client %d", n) + } +} + func TestAllowUnknownTypeDenied(t *testing.T) { a := NewSSARAccessWithClient(func(string) (kubernetes.Interface, error) { return fake.NewSimpleClientset(), nil diff --git a/backend/internal/events/hub/handler.go b/backend/internal/events/hub/handler.go index 8b4c30c0cb4..e06ee353485 100644 --- a/backend/internal/events/hub/handler.go +++ b/backend/internal/events/hub/handler.go @@ -122,7 +122,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { c := h.hub.subscribe() defer h.hub.unsubscribe(c) - for _, ev := range h.hub.snapshotEvents() { + events := h.hub.snapshotEvents() + h.access.Prefetch(r.Context(), token, events) + for _, ev := range events { if err := h.writeFiltered(r.Context(), token, enc, ev); err != nil { return } @@ -161,7 +163,16 @@ func (h *Handler) writeFiltered(ctx context.Context, token string, enc *streamEn if !allowed { return nil } - return writeEvent(enc, h.hub.assignID(ev)) + return writeEvent(enc, h.hub.assignID(ev), shouldFlushEvent(ev)) +} + +func shouldFlushEvent(ev Event) bool { + switch ev.Type { + case TypeEOP, TypeLoaded: + return true + default: + return false + } } func marshalEvent(ev Event) ([]byte, error) { @@ -183,7 +194,7 @@ func marshalEvent(ev Event) ([]byte, error) { } } -func writeEvent(enc *streamEncoder, ev Event) error { +func writeEvent(enc *streamEncoder, ev Event, flush bool) error { body, err := marshalEvent(ev) if err != nil { return err @@ -191,5 +202,8 @@ func writeEvent(enc *streamEncoder, ev Event) error { if _, err := enc.Write(FormatSSE(ev.ID, body)); err != nil { return err } - return enc.Flush() + if flush { + return enc.Flush() + } + return nil } diff --git a/backend/internal/events/hub/handler_test.go b/backend/internal/events/hub/handler_test.go index b5ccfe96443..e2848179339 100644 --- a/backend/internal/events/hub/handler_test.go +++ b/backend/internal/events/hub/handler_test.go @@ -206,6 +206,8 @@ func (denyAccess) Allow(_ context.Context, _ string, ev Event) (bool, error) { return true, nil } +func (denyAccess) Prefetch(context.Context, string, []Event) {} + func TestHandlerLiveModifiedThenLoaded(t *testing.T) { hub := New(nil, nil) h := NewHandler(hub, StaticAuth{OK: true}, AllowAllAccess{}) diff --git a/backend/internal/hubresources/components.go b/backend/internal/hubresources/components.go index 52c9bd55ebc..4aab432badc 100644 --- a/backend/internal/hubresources/components.go +++ b/backend/internal/hubresources/components.go @@ -29,7 +29,7 @@ func MCHComponents(ctx context.Context, client dynamic.Interface) ([]Component, if len(list.Items) == 0 { return nil, nil } - return parseComponents(list.Items[0].Object) + return ParseComponents(list.Items[0].Object) } // MCEComponents returns spec.overrides.components from the first MultiClusterEngine. @@ -44,10 +44,11 @@ func MCEComponents(ctx context.Context, client dynamic.Interface) ([]Component, if len(list.Items) == 0 { return nil, nil } - return parseComponents(list.Items[0].Object) + return ParseComponents(list.Items[0].Object) } -func parseComponents(obj map[string]interface{}) ([]Component, error) { +// ParseComponents reads spec.overrides.components from an MCH or MCE object. +func ParseComponents(obj map[string]interface{}) ([]Component, error) { raw, found, err := unstructured.NestedSlice(obj, "spec", "overrides", "components") if err != nil { return nil, err diff --git a/backend/internal/informers/specs.go b/backend/internal/informers/specs.go index fcd0e13799c..384477da6a8 100644 --- a/backend/internal/informers/specs.go +++ b/backend/internal/informers/specs.go @@ -97,6 +97,7 @@ func DefaultWatchSpecs() []WatchSpec { watch("ApplicationSet", "argoproj.io/v1alpha1").polled(), watch("ArgoCD", "argoproj.io/v1alpha1"), watch("Authentication", "config.openshift.io/v1").cacheOnly(), + watch("MultiClusterHub", "operator.open-cluster-management.io/v1").cacheOnly(), watch("Infrastructure", "config.openshift.io/v1"), watch("CertificateSigningRequest", "certificates.k8s.io/v1").labels("open-cluster-management.io/cluster-name", ""), watch("ManagedCluster", "cluster.open-cluster-management.io/v1"), diff --git a/backend/internal/informers/specs_test.go b/backend/internal/informers/specs_test.go index 7f280322d9e..d1a26e58465 100644 --- a/backend/internal/informers/specs_test.go +++ b/backend/internal/informers/specs_test.go @@ -8,8 +8,8 @@ import ( func TestDefaultWatchSpecsCount(t *testing.T) { specs := DefaultWatchSpecs() - if len(specs) != 67 { - t.Fatalf("got %d specs, want 67", len(specs)) + if len(specs) != 68 { + t.Fatalf("got %d specs, want 68", len(specs)) } var polled, cacheOnly, withSel int for _, s := range specs { @@ -26,8 +26,8 @@ func TestDefaultWatchSpecsCount(t *testing.T) { if polled != 2 { t.Fatalf("polled=%d want 2", polled) } - if cacheOnly != 1 { - t.Fatalf("cacheOnly=%d want 1 (Authentication)", cacheOnly) + if cacheOnly != 2 { + t.Fatalf("cacheOnly=%d want 2 (Authentication, MultiClusterHub)", cacheOnly) } if withSel != 12 { t.Fatalf("selector specs=%d want 12", withSel) @@ -95,8 +95,8 @@ func TestDefaultWatchSpecsShouldForwardCount(t *testing.T) { if forward != 64 { t.Fatalf("forward=%d want 64", forward) } - if skip != 3 { - t.Fatalf("skip=%d want 3 (2 polled + 1 cacheOnly)", skip) + if skip != 4 { + t.Fatalf("skip=%d want 4 (2 polled + 2 cacheOnly)", skip) } } diff --git a/backend/internal/informers/store.go b/backend/internal/informers/store.go index 35ed135fd1e..9c2a50610f4 100644 --- a/backend/internal/informers/store.go +++ b/backend/internal/informers/store.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/cache" ) @@ -186,10 +187,13 @@ func (c *InformerCache) ListForwarded() []ForwardedObject { continue } seen[key] = struct{}{} - cp := u.DeepCopy() - cp.SetAPIVersion(s.spec.APIVersion) - cp.SetKind(s.spec.Kind) - out = append(out, ForwardedObject{GVR: s.gvr, Object: *cp}) + obj := unstructured.Unstructured{} + if u.Object != nil { + obj.Object = runtime.DeepCopyJSON(u.Object) + } + obj.SetAPIVersion(s.spec.APIVersion) + obj.SetKind(s.spec.Kind) + out = append(out, ForwardedObject{GVR: s.gvr, Object: obj}) } } return out diff --git a/backend/internal/upgraderisks/upgraderisks.go b/backend/internal/upgraderisks/upgraderisks.go index f175e7c9a2c..e322dd40ddb 100644 --- a/backend/internal/upgraderisks/upgraderisks.go +++ b/backend/internal/upgraderisks/upgraderisks.go @@ -10,6 +10,7 @@ import ( "os" "strings" "sync" + "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -25,6 +26,7 @@ const ( chunkSize = 100 pullSecretName = "pull-secret" configNamespace = "openshift-config" + crcTokenTTL = 60 * time.Second ) type requestBody struct { @@ -58,6 +60,10 @@ type Handler struct { Kube kubernetes.Interface Client *http.Client Endpoint func() string + + crcMu sync.Mutex + cachedCRC string + crcExpiry time.Time } // New returns an Insights upgrade-risks handler. @@ -106,7 +112,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !h.authenticate(w, r) { return } - crcToken := h.crcToken(r.Context()) raw, err := io.ReadAll(r.Body) if err != nil { applog.Logger().Error("upgrade-risks-prediction", "error", err) @@ -120,6 +125,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } chunks := chunkIDs(body.ClusterIDs, chunkSize) + if len(chunks) == 0 { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("[]\n")) + return + } + crcToken := h.crcToken(r.Context()) results := make([]any, len(chunks)) var wg sync.WaitGroup for i, ids := range chunks { @@ -143,29 +154,43 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (h *Handler) crcToken(ctx context.Context) string { + h.crcMu.Lock() + if time.Now().Before(h.crcExpiry) { + tok := h.cachedCRC + h.crcMu.Unlock() + return tok + } + h.crcMu.Unlock() + + tok := h.loadCRCToken(ctx) + if tok == "" { + return "" + } + h.crcMu.Lock() + h.cachedCRC = tok + h.crcExpiry = time.Now().Add(crcTokenTTL) + h.crcMu.Unlock() + return tok +} + +func (h *Handler) loadCRCToken(ctx context.Context) string { if h.Kube == nil { return "" } - list, err := h.Kube.CoreV1().Secrets(configNamespace).List(ctx, metav1.ListOptions{}) + secret, err := h.Kube.CoreV1().Secrets(configNamespace).Get(ctx, pullSecretName, metav1.GetOptions{}) if err != nil { applog.Logger().Error("Error getting pull-secret in namespace openshift-config", "error", err) return "" } - for i := range list.Items { - if list.Items[i].Name != pullSecretName { - continue - } - raw := list.Items[i].Data[".dockerconfigjson"] - if len(raw) == 0 { - return "" - } - var cred pullAuth - if err = json.Unmarshal(raw, &cred); err != nil { - return "" - } - return cred.Auths["cloud.openshift.com"].Auth + raw := secret.Data[".dockerconfigjson"] + if len(raw) == 0 { + return "" + } + var cred pullAuth + if err = json.Unmarshal(raw, &cred); err != nil { + return "" } - return "" + return cred.Auths["cloud.openshift.com"].Auth } func (h *Handler) postChunk(ctx context.Context, crcToken string, ids []string) (postResult, error) { diff --git a/backend/internal/upgraderisks/upgraderisks_test.go b/backend/internal/upgraderisks/upgraderisks_test.go index d65db7d3556..3a323f28085 100644 --- a/backend/internal/upgraderisks/upgraderisks_test.go +++ b/backend/internal/upgraderisks/upgraderisks_test.go @@ -128,6 +128,91 @@ func TestEmptyClusterIDs(t *testing.T) { } } +func TestEmptyClusterIDsSkipsKube(t *testing.T) { + kube := fake.NewSimpleClientset() + h := New(Options{Authn: authOK, Kube: kube, Client: http.DefaultClient}) + req := httptest.NewRequest(http.MethodPost, "/upgrade-risks-prediction", strings.NewReader(`{"clusterIds":[]}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d %s", rec.Code, rec.Body.String()) + } + if acts := kube.Actions(); len(acts) != 0 { + t.Fatalf("kube actions %v", acts) + } +} + +func pullSecretKube() *fake.Clientset { + docker := []byte(`{"auths":{"cloud.openshift.com":{"auth":"crc-token"}}}`) + return fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "pull-secret", Namespace: "openshift-config"}, + Data: map[string][]byte{".dockerconfigjson": docker}, + }) +} + +func TestPullSecretGetNotList(t *testing.T) { + insights := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{}`)) + })) + defer insights.Close() + kube := pullSecretKube() + h := New(Options{ + Authn: authOK, + Kube: kube, + Client: insights.Client(), + Endpoint: func() string { return insights.URL }, + }) + req := httptest.NewRequest(http.MethodPost, "/upgrade-risks-prediction", strings.NewReader(`{"clusterIds":["id-1"]}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d %s", rec.Code, rec.Body.String()) + } + var gets, lists int + for _, a := range kube.Actions() { + switch a.GetVerb() { + case "get": + gets++ + case "list": + lists++ + } + } + if gets != 1 || lists != 0 { + t.Fatalf("gets %d lists %d actions %v", gets, lists, kube.Actions()) + } +} + +func TestCRCTokenCached(t *testing.T) { + insights := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{}`)) + })) + defer insights.Close() + kube := pullSecretKube() + h := New(Options{ + Authn: authOK, + Kube: kube, + Client: insights.Client(), + Endpoint: func() string { return insights.URL }, + }) + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodPost, "/upgrade-risks-prediction", strings.NewReader(`{"clusterIds":["id-1"]}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status %d %s", rec.Code, rec.Body.String()) + } + } + var gets int + for _, a := range kube.Actions() { + if a.GetVerb() == "get" { + gets++ + } + } + if gets != 1 { + t.Fatalf("gets %d want 1 actions %v", gets, kube.Actions()) + } +} + func TestChunkIDs(t *testing.T) { got := chunkIDs([]string{"a", "b", "c"}, 2) if len(got) != 2 || len(got[0]) != 2 || len(got[1]) != 1 { From 9ba152a8985f99ac0507c6e95f7930ae916742f8 Mon Sep 17 00:00:00 2001 From: Feng Xiang Date: Tue, 15 Sep 2026 02:11:34 -0400 Subject: [PATCH 15/16] Fix disable events (#65) Signed-off-by: fxiang1 --- backend/cmd/console/main.go | 2 +- backend/internal/config/config.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go index f422730fb12..c3f9eee80c6 100644 --- a/backend/cmd/console/main.go +++ b/backend/cmd/console/main.go @@ -255,7 +255,7 @@ func run() error { slog.String("PUBLIC_FOLDER", cfg.PublicFolder), ) return server.ListenAndServe(ctx, cfg, handler, func() { - if !cfg.DisableEvents { + if cfg.DisableEvents { applog.Logger().Info("disable events", "DISABLE_EVENTS", os.Getenv("DISABLE_EVENTS")) return } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 85a209af49d..40aeceb29bf 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -39,6 +39,7 @@ type Config struct { OIDCIssuerURL string FrontendURL string Production bool + DisableEvents bool mu sync.RWMutex settings map[string]string @@ -78,7 +79,7 @@ func Load() *Config { OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), FrontendURL: os.Getenv("FRONTEND_URL"), Production: os.Getenv("NODE_ENV") == "production", - DisableEvents: os.Getenv("DISABLE_EVENTS"), + DisableEvents: envOr("DISABLE_EVENTS", "false") == "true", settings: map[string]string{}, } _ = cfg.ReloadSettings() From 6d55398d55bbca597bfc337c0e6514fc42df5196 Mon Sep 17 00:00:00 2001 From: Feng Xiang Date: Tue, 15 Sep 2026 02:12:24 -0400 Subject: [PATCH 16/16] Fix partial discovery failure and stale discover cache (#66) Signed-off-by: fxiang1 --- backend/internal/clusterinfo/clusterinfo.go | 10 ++++-- .../internal/clusterinfo/clusterinfo_test.go | 14 ++++++++ backend/internal/informers/factory.go | 3 ++ backend/internal/informers/factory_test.go | 33 +++++++++++++++++++ backend/internal/informers/gvr.go | 7 ++++ 5 files changed, 64 insertions(+), 3 deletions(-) diff --git a/backend/internal/clusterinfo/clusterinfo.go b/backend/internal/clusterinfo/clusterinfo.go index dff7d2db471..09ba86780e7 100644 --- a/backend/internal/clusterinfo/clusterinfo.go +++ b/backend/internal/clusterinfo/clusterinfo.go @@ -746,9 +746,13 @@ func (h *Handler) apiPaths(w http.ResponseWriter, r *http.Request) { } _, lists, err := h.discovery.ServerGroupsAndResources() if err != nil { - applog.Logger().Error("apiPaths discovery failed", "error", err) - w.WriteHeader(http.StatusInternalServerError) - return + if discovery.IsGroupDiscoveryFailedError(err) { + applog.Logger().Warn("apiPaths: some API groups unavailable, returning partial results", "error", err) + } else { + applog.Logger().Error("apiPaths discovery failed", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } } result := make(map[string]map[string]apiResourceMeta) for _, list := range lists { diff --git a/backend/internal/clusterinfo/clusterinfo_test.go b/backend/internal/clusterinfo/clusterinfo_test.go index 0da6927308d..9cc9f440f21 100644 --- a/backend/internal/clusterinfo/clusterinfo_test.go +++ b/backend/internal/clusterinfo/clusterinfo_test.go @@ -15,6 +15,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" discoveryfake "k8s.io/client-go/discovery/fake" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/rest" @@ -24,6 +25,19 @@ import ( "github.com/stolostron/console/backend/internal/informers" ) +// partialFailDiscovery wraps FakeDiscovery so that ServerGroupsAndResources +// returns the configured resources alongside an ErrGroupDiscoveryFailed error, +// simulating clusters where some API groups are unavailable. +type partialFailDiscovery struct { + discoveryfake.FakeDiscovery + failGroups map[schema.GroupVersion]error +} + +func (d *partialFailDiscovery) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) { + groups, lists, _ := d.FakeDiscovery.ServerGroupsAndResources() + return groups, lists, &discovery.ErrGroupDiscoveryFailed{Groups: d.failGroups} +} + func apiProbeServer(t *testing.T) (*httptest.Server, *rest.Config) { t.Helper() ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/informers/factory.go b/backend/internal/informers/factory.go index 88f91293fa0..9e19d688f32 100644 --- a/backend/internal/informers/factory.go +++ b/backend/internal/informers/factory.go @@ -106,6 +106,9 @@ func (c *InformerCache) runSpec(ctx context.Context, dyn dynamic.Interface, mapp applog.Logger().Warn("informer GVR resolve failed; retrying", "kind", st.spec.Kind, "apiVersion", st.spec.APIVersion, "error", err) } + if inv, ok := mapper.(CacheInvalidator); ok { + inv.Invalidate() + } if !waitRetry(ctx) { return } diff --git a/backend/internal/informers/factory_test.go b/backend/internal/informers/factory_test.go index 828df29df1a..f32dabeedb6 100644 --- a/backend/internal/informers/factory_test.go +++ b/backend/internal/informers/factory_test.go @@ -4,6 +4,7 @@ package informers import ( "context" + "fmt" "net/http" "net/http/httptest" "strings" @@ -338,6 +339,38 @@ func TestStartCacheNil(t *testing.T) { StartCache(ctx, nil, nil, nil) } +type staleMapper struct { + invalidated atomic.Bool + lists map[string]*metav1.APIResourceList +} + +func (m *staleMapper) ServerResourcesForGroupVersion(gv string) (*metav1.APIResourceList, error) { + return nil, fmt.Errorf("stale GroupVersion discovery: %s", gv) +} + +func (m *staleMapper) Invalidate() { + m.invalidated.Store(true) +} + +func TestStaleDiscoveryCacheInvalidatedOnRetry(t *testing.T) { + mapper := &staleMapper{} + + ctx, cancel := context.WithCancel(context.Background()) + c := StartSpecs(ctx, nil, mapper, []WatchSpec{watch("Namespace", "v1")}) + _ = c + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if mapper.invalidated.Load() { + cancel() + return + } + time.Sleep(10 * time.Millisecond) + } + cancel() + t.Fatal("expected mapper.Invalidate() to be called on stale discovery error") +} + func TestStartConcurrencyLimitsLists(t *testing.T) { orig := startConcurrency startConcurrency = 2 diff --git a/backend/internal/informers/gvr.go b/backend/internal/informers/gvr.go index e4215cc5eda..6026600e80e 100644 --- a/backend/internal/informers/gvr.go +++ b/backend/internal/informers/gvr.go @@ -17,6 +17,13 @@ type ResourceMapper interface { ServerResourcesForGroupVersion(groupVersion string) (*metav1.APIResourceList, error) } +// CacheInvalidator is optionally implemented by a ResourceMapper whose +// results are cached (e.g. k8s.io/client-go/discovery/cached/memory). +// Calling Invalidate forces the next lookup to fetch fresh data from the API server. +type CacheInvalidator interface { + Invalidate() +} + var errKindNotFound = errors.New("kind not found for apiVersion") // ResolveGVR maps apiVersion and kind using server discovery (not naive pluralize).