diff --git a/.dockerignore b/.dockerignore index 280b8f90403..d2c17aff3eb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,13 +5,7 @@ /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 /frontend/.vscode/ 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/.github/workflows/backend-upgrade.yml b/.github/workflows/backend-upgrade.yml deleted file mode 100644 index d3b098e71b9..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 - 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 061ba462636..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","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 e6ad3c736ab..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","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 69851bec9f8..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","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 979b1469f06..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","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 770d8f311d0..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","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 eb8bc28e31f..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","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 cdbebc2b6d1..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","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 88beaa90808..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","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/.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..50ea3f4d42b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -37,22 +37,15 @@ "program": "${workspaceFolder}/frontend/node_modules/.bin/jest" }, { - "type": "node", - "name": "vscode-jest-tests.v2.backend", + "name": "Go backend", + "type": "go", "request": "launch", - "args": [ - "--runInBand", - "--watchAll=false", - "--coverage=false", - "--testNamePattern", - "${jest.testNamePattern}", - "--runTestsByPath", - "${jest.testFile}" - ], + "mode": "auto", + "program": "${workspaceFolder}/backend/cmd/console", "cwd": "${workspaceFolder}/backend", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "program": "${workspaceFolder}/backend/node_modules/.bin/jest" + "env": { + "PORT": "4000" + } } ] } diff --git a/AGENTS.md b/AGENTS.md index 64ef5138c81..a12f9950885 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ 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) ├── docs/ # Architecture documentation ├── scripts/ # Build and development scripts └── resources/ # Sample K8s YAML fixtures @@ -25,21 +25,30 @@ 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; go mod download when Go is installed +npm run setup # writes backend/.env and backend/certs/ from the current oc context ``` +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 | 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 | @@ -64,7 +73,7 @@ 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) | | `CONSOLE_PORT` | 9000 | OpenShift console | | `MCE_PORT` | 3001 | MCE plugin | | `ACM_PORT` | 3002 | ACM plugin | @@ -79,7 +88,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 +- 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 +141,9 @@ 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 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` 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` +- **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 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 216895df10f..e7d8a931505 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,16 +58,34 @@ 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 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: + +```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 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 +94,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..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,33 +13,28 @@ 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 -# 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 ./ -RUN npm ci --omit=optional +FROM ${GO_BASE} as go-backend +WORKDIR /src +COPY ./backend/go.mod ./backend/go.sum ./ +RUN go mod download COPY ./backend . -RUN npm run build +RUN CGO_ENABLED=0 GOOS=linux go build -o /console ./cmd/console -FROM build-base as production -WORKDIR /app/backend -COPY ./backend/package-lock.json ./backend/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 -COPY --from=production /app/backend/node_modules ./node_modules -COPY --from=backend /app/backend/backend.mjs ./ +ENV PUBLIC_FOLDER=/app/public +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 e3c5e1e4800..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,33 +13,28 @@ 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 -# 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 ./ -RUN npm ci --omit=optional +FROM ${GO_BASE} as go-backend +WORKDIR /src +COPY ./backend/go.mod ./backend/go.sum ./ +RUN go mod download COPY ./backend . -RUN npm run build +RUN CGO_ENABLED=0 GOOS=linux go build -o /console ./cmd/console -FROM build-base as production -WORKDIR /app/backend -COPY ./backend/package-lock.json ./backend/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 -COPY --from=production /app/backend/node_modules ./node_modules -COPY --from=backend /app/backend/backend.mjs ./ +ENV PUBLIC_FOLDER=/app/public +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" \ @@ -49,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 639857a7d50..327199f3f98 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 .PHONY: check check: - npm run check + npx concurrently --kill-others-on-fail npm:copyright:check npm:check:frontend npm:check:backend -c green,blue,magenta .PHONY: lint lint: - npm run lint + npx concurrently --kill-others-on-fail npm:lint:frontend npm:lint:backend -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 diff --git a/README.md b/README.md index e211975abc6..981e536d1c0 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` 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 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. Start the console plugins + After `oc login` to a different hub, or to reset local config: + + ```sh + 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.) + +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, 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`), then: + ```sh -npm run setup # if not already done npm start ``` @@ -154,7 +167,7 @@ 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` | | 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 +214,9 @@ 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) 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 test -- # Run tests matching a file pattern ``` @@ -265,6 +278,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 +314,13 @@ 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 -[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... +[go] service account token missing ``` -`./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 +339,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 completely remove `./backend/certs` folder and then execute `npm run ci:backend` at the root level of the project. - -> Be sure openssl library is installed before running `npm run ci:backend` command. +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/.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/.vscode/launch.json b/backend/.vscode/launch.json deleted file mode 100644 index 1ae14f936f6..00000000000 --- a/backend/.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/AGENTS.md b/backend/AGENTS.md index 3d7a44a5ebc..0791f1d8964 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -1,128 +1,111 @@ -# 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. It owns TLS, health probes, config, auth, hub watches, and every public HTTP route. ## 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` — 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 ## 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/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`, `/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) | +| `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/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 (`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 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 -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`. 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` | `rm -rf backend/.env backend/certs && npm run setup && npm run ci:backend` 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 (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) + ├─ 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 + ├─ 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) + ├─ 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/*, + │ /virtualmachinerestores, GET /vmResourceUsage/* → managed cluster via addon + ├─ GET static assets (/plugin/*, hashed JS/CSS, locales, index.html) + └─ unknown paths → 404 (empty body); wrong method → 405 ``` -## Route Handlers +`/multicloud` is stripped only when matching Go-owned routes. -- 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 +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()`). -## Security +`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. -- 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 +`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`). -## Configuration +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). -### Environment Variables (`.env`) +`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. -Generated by `npm run setup` from the repo root. These are cluster-specific and should not be set manually unless overriding for special cases: +## Shared artifacts -| 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 | +`npm run setup` writes `backend/.env`. -Optional development/debug variables (not in `.env` by default): +Go exits 1 at startup if the service-account token is missing (`TOKEN` or `/var/run/secrets/kubernetes.io/serviceaccount/token`). -| 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`) | +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. -### 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. +`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 new file mode 100644 index 00000000000..93e7c2cba11 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,25 @@ +# Copyright Contributors to the Open Cluster Management project + +# Console backend (Go) + +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 + +From the repo root: + +```sh +npm ci # required once; runs go mod download when Go is installed +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 +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). diff --git a/backend/cmd/console/main.go b/backend/cmd/console/main.go new file mode 100644 index 00000000000..c3f9eee80c6 --- /dev/null +++ b/backend/cmd/console/main.go @@ -0,0 +1,275 @@ +// Copyright Contributors to the Open Cluster Management project + +package main + +import ( + "context" + "errors" + "log/slog" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + + "k8s.io/client-go/discovery" + discocache "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/dynamic" + "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" + "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/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" +) + +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 + } + 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 + } + rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacevents.NewSSARAccess(restCfg)) + + infCfg := informers.RESTConfig(restCfg) + infDyn, 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()) + eventHub := eventshub.New(infCache, cfg.Settings) + ssar := eventshub.NewSSARAccess(restCfg) + ssar.StartCleanup(ctx) + eventsHandler := eventshub.NewHandler(eventHub, eventshub.NewAPIAuth(restCfg), ssar) + infCache.SetSink(eventHub) + cfg.OnReload(eventHub.PublishSettings) + + 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), 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 + }, + } + 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))) + } + 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", + }))) + + 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, + 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, + })), + server.WithUser(user.New(user.Options{ + RESTConfig: restCfg, + Reviewer: reviewer, + Dynamic: dyn, + })), + server.WithClusterInfo(clusterinfo.New(clusterinfo.Options{ + RESTConfig: restCfg, + Dynamic: dyn, + Discovery: disc, + Cache: infCache, + })), + server.WithSearchProxy(searchproxy.New(searchproxy.Options{ + RESTConfig: restCfg, + 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...) + if err != nil { + return err + } + + applog.Logger().Info("process start", + "PORT", cfg.Port, + "disableEvents", cfg.DisableEvents, + slog.String("CONFIG_DIR", cfg.ConfigDir), + slog.String("PUBLIC_FOLDER", cfg.PublicFolder), + ) + return server.ListenAndServe(ctx, cfg, handler, func() { + if cfg.DisableEvents { + applog.Logger().Info("disable events", "DISABLE_EVENTS", os.Getenv("DISABLE_EVENTS")) + return + } + informers.StartCache(ctx, infCache, infDyn, mapper) + 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/eslint.config.mjs b/backend/eslint.config.mjs deleted file mode 100644 index a0ff302229a..00000000000 --- a/backend/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/go.mod b/backend/go.mod new file mode 100644 index 00000000000..5d4f4d4f481 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,53 @@ +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/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 + 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/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..3196b3fcc2d --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,162 @@ +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/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= +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/aggregate/appset.go b/backend/internal/aggregate/appset.go new file mode 100644 index 00000000000..7fdff40cf51 --- /dev/null +++ b/backend/internal/aggregate/appset.go @@ -0,0 +1,224 @@ +// 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 { + writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + return + } + appset, err := h.fetchAppSet(r.Context(), token, stub) + if err != nil { + writeJSONStatus(w, http.StatusBadRequest, 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..38c15079136 --- /dev/null +++ b/backend/internal/aggregate/argo.go @@ -0,0 +1,427 @@ +// 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 := splitSlash(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 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 == "" { + 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 := 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 !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") + 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 +} + +func containsLabel(label, needle string) bool { + return strings.Contains(label, needle) +} + +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 { + parts := splitOnce(statusPtr[entryKey], "\x00") + if len(parts) != 2 { + continue + } + st := argo[parts[0]][parts[1]] + computePodStatus(&st.Deployed, plist) + 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/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..4763f5bc799 --- /dev/null +++ b/backend/internal/aggregate/clusters.go @@ -0,0 +1,372 @@ +// 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 { + 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 + } + 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) +} + +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..70ff2b8007b --- /dev/null +++ b/backend/internal/aggregate/engine.go @@ -0,0 +1,286 @@ +// 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 + listCache map[string][]map[string]any + 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.withListCache(e.rebuildSubscriptionLocked) + items := getApplicationsHelper(e.cache, cacheKeys) + if items == nil { + return []App{} + } + return items +} + +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 +} + +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 + + 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..f38cc959aa5 --- /dev/null +++ b/backend/internal/aggregate/engine_test.go @@ -0,0 +1,168 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "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) + } +} + +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/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..4d2df45962d --- /dev/null +++ b/backend/internal/aggregate/handler.go @@ -0,0 +1,92 @@ +// 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) { + 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 new file mode 100644 index 00000000000..9cb85f3c938 --- /dev/null +++ b/backend/internal/aggregate/handler_test.go @@ -0,0 +1,268 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "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) + } + 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) + } + 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..1451e8278c2 --- /dev/null +++ b/backend/internal/aggregate/lister.go @@ -0,0 +1,70 @@ +// 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 + } + 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].Object) + } + if e.listCache != nil { + e.listCache[key] = out + } + 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..ceb1231735f --- /dev/null +++ b/backend/internal/aggregate/pages.go @@ -0,0 +1,137 @@ +// Copyright Contributors to the Open Cluster Management project + +package aggregate + +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 splitComma(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 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 { + 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/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/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 00000000000..91955d0bbd8 --- /dev/null +++ b/backend/internal/auth/auth.go @@ -0,0 +1,249 @@ +// 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 + 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. +// 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 + } + } + } + serviceCA := readCertFileOrEnv(filepath.Join(serviceAccountBaseDir, "service-ca.crt"), cfg.ServiceCACert) + if strings.TrimSpace(token) == "" { + return ServiceAccount{}, false + } + 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 { + 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 "" +} + +// 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) (TokenReviewResult, 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 +} + +// 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 0, errors.New("rest config is required") + } + cfg := UserRESTConfig(base, token) + httpClient, err := rest.HTTPClientFor(cfg) + if err != nil { + return 0, err + } + host := strings.TrimRight(cfg.Host, "/") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, host+"/api", nil) + if err != nil { + return 0, err + } + resp, err := httpClient.Do(req) + if err != nil { + return 0, err + } + defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }() + return resp.StatusCode, nil +} + +// 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 { + status, err := ValidateUserTokenStatus(ctx, base, token) + if err != nil { + return err + } + if status != http.StatusOK { + return &StatusError{Status: status} + } + 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) + 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) (TokenReviewResult, error) { + tr, err := k.client.AuthenticationV1().TokenReviews().Create(ctx, &authv1.TokenReview{ + Spec: authv1.TokenReviewSpec{Token: token}, + }, metav1.CreateOptions{}) + if err != nil { + return TokenReviewResult{}, err + } + username := tr.Status.User.Username + return TokenReviewResult{ + Authenticated: tr.Status.Authenticated, + Username: username, + }, nil +} diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go new file mode 100644 index 00000000000..357e31be42b --- /dev/null +++ b/backend/internal/auth/auth_test.go @@ -0,0 +1,147 @@ +// 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")), + ServiceCACert: base64.StdEncoding.EncodeToString([]byte("svc-ca")), + } + 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) + } + if string(sa.ServiceCACert) != "svc-ca" { + t.Fatalf("service ca %q", sa.ServiceCACert) + } +} + +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) + } + 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() + + 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) + } + if string(sa.ServiceCACert) != "file-svc-ca" { + t.Fatalf("service ca %q", sa.ServiceCACert) + } +} + +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/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 new file mode 100644 index 00000000000..533693b97ba --- /dev/null +++ b/backend/internal/auth/tls.go @@ -0,0 +1,56 @@ +// Copyright Contributors to the Open Cluster Management project + +package auth + +import ( + "crypto/tls" + "crypto/x509" + "net/http" + "os" + "time" + + "github.com/stolostron/console/backend/internal/outbound" +) + +// 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 +} + +// 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: outbound.Transport(TLSConfigFromCA(ca, true), true), + } +} + +// 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/clusterinfo/clusterinfo.go b/backend/internal/clusterinfo/clusterinfo.go new file mode 100644 index 00000000000..09ba86780e7 --- /dev/null +++ b/backend/internal/clusterinfo/clusterinfo.go @@ -0,0 +1,789 @@ +// Copyright Contributors to the Open Cluster Management project + +package clusterinfo + +import ( + "context" + "encoding/json" + "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/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" +) + +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" +) + +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. +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. +func New(opts Options) *Handler { + return &Handler{ + base: opts.RESTConfig, + dynamic: opts.Dynamic, + discovery: opts.Discovery, + cache: opts.Cache, + } +} + +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 + } + 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) +} + +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 + 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" { + name, _, _ := unstructured.NestedString(item.Object, "metadata", "name") + if name != "" { + localHubName = name + } + isHubSelfManaged = true + 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() + + 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 + } + } + } + + 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 { + 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) + } + + 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{} { + 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 + } + 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{ + "error": "Failed to get cluster version: " + err.Error(), + }) + return + } + _ = 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 + } + return 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" + } + + 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) { + writeHypershiftStatus(w, processHypershiftStatus(nil, nil)) + 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 + } + } + 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, + "body": map[string]bool{ + "isHypershiftEnabled": enabled, + }, + }) +} + +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 + } + 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 + } + if components, ok := h.mchComponentsFromCache(); ok { + writeJSON(w, components) + 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) 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) { + writeJSON(w, nil) + return + } + applog.Logger().Error("mce components failed", "error", err) + writeJSON(w, nil) + return + } + 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"` +} + +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 { + 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 { + 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..9cc9f440f21 --- /dev/null +++ b/backend/internal/clusterinfo/clusterinfo_test.go @@ -0,0 +1,359 @@ +// Copyright Contributors to the Open Cluster Management project + +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" + "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" + k8stesting "k8s.io/client-go/testing" + + "github.com/stolostron/console/backend/internal/clusterinfo" + "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) { + 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) + } +} + +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/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 new file mode 100644 index 00000000000..40aeceb29bf --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,222 @@ +// 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 + 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 + + OAuth2ClientID string + OAuth2ClientSecret string + OAuth2RedirectURL string + OIDCIssuerURL string + FrontendURL string + Production bool + DisableEvents bool + + mu sync.RWMutex + settings map[string]string + hooks []func() +} + +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"), + 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"), + 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", + DisableEvents: envOr("DISABLE_EVENTS", "false") == "true", + 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") + promote("PLACEMENT_DEBUG_URL") + + if lvl, ok := next["LOG_LEVEL"]; ok { + 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() + 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..54e9897336d --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,231 @@ +// 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("PLACEMENT_DEBUG_URL", "https://placement.example/debug/placements/") + 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("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") + } + settings := cfg.Settings() + if settings["ansibleIntegration"] != "available" { + t.Fatalf("settings missing ansibleIntegration: %#v", settings) + } +} + +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") + 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 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")) + t.Setenv("PORT", "4100") + 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.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")) + t.Setenv("PUBLIC_FOLDER", "/app/public") + cfg := config.Load() + if cfg.PublicFolder != "/app/public" { + t.Fatalf("PublicFolder=%q", cfg.PublicFolder) + } +} + +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") + 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/cors/cors.go b/backend/internal/cors/cors.go new file mode 100644 index 00000000000..4978825e68a --- /dev/null +++ b/backend/internal/cors/cors.go @@ -0,0 +1,35 @@ +// Copyright Contributors to the Open Cluster Management project + +package cors + +import ( + "net/http" +) + +// 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 } + } + 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/events/hub/access.go b/backend/internal/events/hub/access.go new file mode 100644 index 00000000000..7ed8488c246 --- /dev/null +++ b/backend/internal/events/hub/access.go @@ -0,0 +1,403 @@ +// 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 + prefetchConcurrency = 32 +) + +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. +type AllowAllAccess struct{} + +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 +} + +type cacheEntry struct { + allowed bool + expiry time.Time +} + +type inflight struct { + done chan struct{} + allowed bool + err error +} + +type tokenState struct { + 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). +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) { + cfg := auth.UserRESTConfig(base, userToken) + cfg.QPS = 50 + cfg.Burst = 100 + return kubernetes.NewForConfig(cfg) + }) +} + +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 + } +} + +// 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) + 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) 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() + 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.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{ + Spec: authzv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authzv1.ResourceAttributes{ + Group: group, + Resource: resource, + Verb: verb, + Name: name, + Namespace: namespace, + }, + }, + }, 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() + 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)} + } + } + a.mu.Unlock() + close(f.done) +} + +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 && len(st.flight) == 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..3cc0d4c5ae7 --- /dev/null +++ b/backend/internal/events/hub/access_test.go @@ -0,0 +1,302 @@ +// Copyright Contributors to the Open Cluster Management project + +package hub + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "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" + "k8s.io/client-go/rest" + 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 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 + }) + 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..e06ee353485 --- /dev/null +++ b/backend/internal/events/hub/handler.go @@ -0,0 +1,209 @@ +// 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) + + 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 + } + } + + 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), shouldFlushEvent(ev)) +} + +func shouldFlushEvent(ev Event) bool { + switch ev.Type { + case TypeEOP, TypeLoaded: + return true + default: + return false + } +} + +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, flush bool) error { + body, err := marshalEvent(ev) + if err != nil { + return err + } + if _, err := enc.Write(FormatSSE(ev.ID, body)); err != nil { + return err + } + 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 new file mode 100644 index 00000000000..e2848179339 --- /dev/null +++ b/backend/internal/events/hub/handler_test.go @@ -0,0 +1,267 @@ +// 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 (denyAccess) Prefetch(context.Context, string, []Event) {} + +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/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..2c704fbbb17 --- /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 == "" { + 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) + w.WriteHeader(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..d8dc5e6bbfb --- /dev/null +++ b/backend/internal/events/rbac/handler_test.go @@ -0,0 +1,249 @@ +// 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) + } + 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) + req.Header.Set("Authorization", "Bearer bad") + 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) { + 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..c034021bfff --- /dev/null +++ b/backend/internal/health/health.go @@ -0,0 +1,41 @@ +// Copyright Contributors to the Open Cluster Management project + +package health + +import ( + "net/http" + "sync/atomic" +) + +// Probes serves /ping, /livenessProbe, and /readinessProbe. +type Probes struct { + live atomic.Bool +} + +func New() *Probes { + p := &Probes{} + p.live.Store(true) + 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 + } + 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..e96e603eb11 --- /dev/null +++ b/backend/internal/health/health_test.go @@ -0,0 +1,45 @@ +// Copyright Contributors to the Open Cluster Management project + +package health_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stolostron/console/backend/internal/health" +) + +func TestPingAndLiveness(t *testing.T) { + 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 { + 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() + 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 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) + } +} diff --git a/backend/internal/hubresources/components.go b/backend/internal/hubresources/components.go new file mode 100644 index 00000000000..4aab432badc --- /dev/null +++ b/backend/internal/hubresources/components.go @@ -0,0 +1,70 @@ +// 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) +} + +// 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 + } + 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/hubresources/hubresources.go b/backend/internal/hubresources/hubresources.go new file mode 100644 index 00000000000..fcb4b707af7 --- /dev/null +++ b/backend/internal/hubresources/hubresources.go @@ -0,0 +1,99 @@ +// 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 +} + +// 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/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/informers/factory.go b/backend/internal/informers/factory.go new file mode 100644 index 00000000000..9e19d688f32 --- /dev/null +++ b/backend/internal/informers/factory.go @@ -0,0 +1,225 @@ +// 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 inv, ok := mapper.(CacheInvalidator); ok { + inv.Invalidate() + } + 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 + 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) + 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(), + ) +} diff --git a/backend/internal/informers/factory_test.go b/backend/internal/informers/factory_test.go new file mode 100644 index 00000000000..f32dabeedb6 --- /dev/null +++ b/backend/internal/informers/factory_test.go @@ -0,0 +1,430 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "context" + "fmt" + "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) + } + 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) { + 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) +} + +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 + 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..6026600e80e --- /dev/null +++ b/backend/internal/informers/gvr.go @@ -0,0 +1,56 @@ +// 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) +} + +// 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). +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/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 new file mode 100644 index 00000000000..384477da6a8 --- /dev/null +++ b/backend/internal/informers/specs.go @@ -0,0 +1,153 @@ +// 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). +// POST /aggregate/* reads this cache (ACM-42600). +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 +} + +// 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 { + m[pairs[i]] = pairs[i+1] + } + return m +} + +// 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"), + 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("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"), + 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..d1a26e58465 --- /dev/null +++ b/backend/internal/informers/specs_test.go @@ -0,0 +1,114 @@ +// Copyright Contributors to the Open Cluster Management project + +package informers + +import ( + "testing" +) + +func TestDefaultWatchSpecsCount(t *testing.T) { + specs := DefaultWatchSpecs() + if len(specs) != 68 { + t.Fatalf("got %d specs, want 68", 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 != 2 { + t.Fatalf("cacheOnly=%d want 2 (Authentication, MultiClusterHub)", cacheOnly) + } + if withSel != 12 { + t.Fatalf("selector specs=%d want 12", withSel) + } +} + +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 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 != 4 { + t.Fatalf("skip=%d want 4 (2 polled + 2 cacheOnly)", skip) + } +} + +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) + } +} diff --git a/backend/internal/informers/store.go b/backend/internal/informers/store.go new file mode 100644 index 00000000000..9c2a50610f4 --- /dev/null +++ b/backend/internal/informers/store.go @@ -0,0 +1,285 @@ +// 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" + "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 + 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 { + 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 +} + +// 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{}{} + 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 +} + +// 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..9f1f1fcb5f8 --- /dev/null +++ b/backend/internal/informers/store_test.go @@ -0,0 +1,180 @@ +// 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 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{ + 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/k8sproxy/k8sproxy.go b/backend/internal/k8sproxy/k8sproxy.go new file mode 100644 index 00000000000..b3e7aab89c9 --- /dev/null +++ b/backend/internal/k8sproxy/k8sproxy.go @@ -0,0 +1,97 @@ +// 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/outbound" + "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 := outbound.Transport(tlsConfig, true) + 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/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/mcproxy/mcproxy.go b/backend/internal/mcproxy/mcproxy.go new file mode 100644 index 00000000000..77368e08389 --- /dev/null +++ b/backend/internal/mcproxy/mcproxy.go @@ -0,0 +1,99 @@ +// 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" + "github.com/stolostron/console/backend/internal/outbound" + 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 := 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()) + 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..74659c7b14a --- /dev/null +++ b/backend/internal/metricsproxy/metricsproxy.go @@ -0,0 +1,91 @@ +// 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/outbound" + "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 := outbound.Transport(tlsConfig, true) + 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/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/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/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/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 new file mode 100644 index 00000000000..69e295276c3 --- /dev/null +++ b/backend/internal/searchapi/searchapi.go @@ -0,0 +1,184 @@ +// Copyright Contributors to the Open Cluster Management project + +package searchapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + applog "github.com/stolostron/console/backend/internal/log" +) + +const ( + 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 +} + +// 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 { + return c.Discovery().Endpoint(ctx) +} + +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..4f377f793ff --- /dev/null +++ b/backend/internal/searchapi/searchapi_test.go @@ -0,0 +1,110 @@ +// 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 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) { + 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/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..f5a364824cf --- /dev/null +++ b/backend/internal/searchproxy/proxy.go @@ -0,0 +1,151 @@ +// 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" + "github.com/stolostron/console/backend/internal/outbound" + 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 outbound.Transport(h.TLSConfig, false) +} + +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 new file mode 100644 index 00000000000..ab958b97267 --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,510 @@ +// Copyright Contributors to the Open Cluster Management project + +package server + +import ( + "bufio" + "context" + "errors" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "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" + "github.com/stolostron/console/backend/internal/static" +) + +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 + rosa http.Handler + ansibleTower http.Handler + placementDebug http.Handler + upgradeRisks 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 + } +} + +// WithAggregate registers POST /aggregate/* (and /multicloud/aggregate/*). +func WithAggregate(h http.Handler) Option { + return func(o *handlerOptions) { + o.aggregate = h + } +} + +// 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 + } +} + +// 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) { + o.rbacEvents = h + } +} + +// 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 + } +} + +// 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 { + 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) { + o.staticH = h + } +} + +// 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 + } +} + +// 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 { + 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 { + 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 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/*") + } + 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) { + 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) + } +} + +// 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) + } + probes := health.New() + + r := chi.NewRouter() + r.Use(cors.Middleware(cfg.Production)) + 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) + } + if o.events != nil { + r.Get("/events", o.events.ServeHTTP) + r.Get(multicloudPrefix+"/events", o.events.ServeHTTP) + } + if o.aggregate != nil { + registerAliasedPost(r, o.aggregate, "/aggregate/*") + } + 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) + } + if o.oauth != nil { + registerOAuth(r, "", o.oauth, o.oauthLogin) + registerOAuth(r, multicloudPrefix, o.oauth, o.oauthLogin) + } + 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)) + r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusMethodNotAllowed) + }) + 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 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 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 + } + w.WriteHeader(http.StatusNotFound) + } +} + +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: hijacked upgrades need the raw ResponseWriter. + if isProbe(stripped) || isEventStream(stripped) || isWebSocket(r) { + 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 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() { + 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.ServeTLS(ln, certFile, keyFile) + return + } + } + applog.Logger().Info("server start", "secure", false, "addr", addr) + errCh <- srv.Serve(ln) + }() + + 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..d66ab44482d --- /dev/null +++ b/backend/internal/server/server_test.go @@ -0,0 +1,591 @@ +// Copyright Contributors to the Open Cluster Management project + +package server_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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" +) + +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": "/", + "/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 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"} { + 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) + } + } +} + +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) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) + } + if len(body) != 0 { + t.Fatalf("body %s", body) + } +} + +func TestUnknownMethodNotAllowed(t *testing.T) { + ts := httptest.NewServer(newHandler(t)) + defer ts.Close() + + req, _ := http.NewRequest(http.MethodDelete, ts.URL+"/ping", nil) + resp, err := ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status %d", resp.StatusCode) + } +} + +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")) + }) + ts := httptest.NewServer(newHandler(t, server.WithRBACEvents(rbac))) + defer ts.Close() + + for _, path := range []string{"/events/rbac", "/multicloud/events/rbac"} { + 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 !strings.Contains(string(body), `"type":"START"`) { + t.Fatalf("%s body %s", path, body) + } + } +} + +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")) + }) + ts := httptest.NewServer(newHandler(t, server.WithEvents(events))) + defer ts.Close() + + for _, path := range []string{"/events", "/multicloud/events"} { + 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 !strings.Contains(string(body), `"type":"START"`) { + t.Fatalf("%s body %s", path, body) + } + } +} + +func TestEventsWithoutHandlerReturns404(t *testing.T) { + ts := httptest.NewServer(newHandler(t)) + defer ts.Close() + + resp, err := ts.Client().Get(ts.URL + "/events") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) + } +} + +func TestOAuthRegistered(t *testing.T) { + 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 + }, + }) + 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"} { + resp, getErr := client.Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + } + + resp, err := ts.Client().Get(ts.URL + "/logout") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("logout status %d", resp.StatusCode) + } + + resp, err = ts.Client().Get(ts.URL + "/configure") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + 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 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) + }) + + ts := httptest.NewServer(newHandler(t, + server.WithManagedClusterProxy(ok), + server.WithPrometheusProxy(ok), + server.WithObservabilityProxy(ok), + server.WithVMProxy(ok), + )) + 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 { + 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 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 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")) + }) + 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", "/"} { + 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 string(body) != "plugin" { + t.Fatalf("%s body %s", path, body) + } + } + + resp, err := ts.Client().Get(ts.URL + "/hub") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("hub status %d", resp.StatusCode) + } +} + +func TestLoginWithoutOAuthReturns404(t *testing.T) { + ts := httptest.NewServer(newHandler(t)) + defer ts.Close() + + resp, err := ts.Client().Get(ts.URL + "/login") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("status %d", resp.StatusCode) + } +} + +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 + }, + }) + ts := httptest.NewServer(newHandler(t, server.WithOAuth(oa))) + 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 resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "oauth.example.com") { + t.Fatalf("status %d body %s", resp.StatusCode, body) + } + + resp, err = ts.Client().Get(ts.URL + "/login") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("login without WithOAuthLogin status %d", resp.StatusCode) + } +} + +func TestDevelopmentCORSOptionsPreflight(t *testing.T) { + var k8sCalled bool + k8s := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + k8sCalled = true + w.WriteHeader(http.StatusOK) + }) + + ts := httptest.NewServer(newHandler(t, server.WithK8sProxy(k8s))) + 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 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) + }) + + ts := httptest.NewServer(newHandler(t, server.WithK8sProxy(k8s))) + 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 { + 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(k8sPaths) != 1 || k8sPaths[0] != path { + t.Fatalf("%s k8s paths %v", path, k8sPaths) + } + } +} + +func TestUnregisteredRoutesReturn404(t *testing.T) { + 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") + }) + + ts := httptest.NewServer(newHandler(t, + server.WithK8sProxy(k8s), + server.WithPrometheusProxy(ok), + server.WithManagedClusterProxy(ok), + server.WithVMProxy(ok), + )) + defer ts.Close() + + for _, path := range []string{"/multicloud/proxy/search", "/multicloud/events"} { + resp, getErr := ts.Client().Get(ts.URL + path) + if getErr != nil { + t.Fatal(getErr) + } + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + } +} + +func TestMigratedUserAndClusterInfoRegistered(t *testing.T) { + 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"}`)) + }) + + ts := httptest.NewServer(newHandler(t, server.WithUser(userH), server.WithClusterInfo(clusterH))) + defer ts.Close() + + for _, path := range []string{ + "/hub", + "/multicloud/hub", + "/username", + "/multicloud/authenticated", + "/apiPaths", + "/multicloud/operatorCheck", + } { + 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 resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) + } + } +} + +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":[]}`)) + }) + ts := httptest.NewServer(newHandler(t, server.WithDebugSnapshot(dump))) + defer ts.Close() + + for _, path := range []string{"/debug/informer-snapshot", "/multicloud/debug/informer-snapshot"} { + 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 body %s", path, resp.StatusCode, body) + } + } +} + +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}`)) + }) + ts := httptest.NewServer(newHandler(t, server.WithAggregate(agg))) + defer ts.Close() + + for _, path := range []string{"/aggregate/applications", "/multicloud/aggregate/statuses", "/aggregate/appSetData"} { + 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 resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) + } + } +} + +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}`)) + }) + ts := httptest.NewServer(newHandler(t, server.WithSearchProxy(searchH))) + defer ts.Close() + + for _, path := range []string{"/proxy/search", "/multicloud/proxy/search"} { + 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 resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d body %s", path, resp.StatusCode, body) + } + + 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 resp.StatusCode != http.StatusOK { + t.Fatalf("%s websocket status %d", path, resp.StatusCode) + } + } +} + +func TestLongTailRegistered(t *testing.T) { + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + ts := httptest.NewServer(newHandler(t, + server.WithRosa(ok), + server.WithAnsibleTower(ok), + server.WithPlacementDebug(ok), + server.WithUpgradeRisks(ok), + )) + 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 { + 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 resp.StatusCode != http.StatusOK { + t.Fatalf("%s status %d", path, resp.StatusCode) + } + } +} 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..3ac3614c32d --- /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 returning 404. +// Bare paths other than / are not treated as SPA fallback so API routes like /hub +// are not claimed as static files. +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/backend/internal/upgraderisks/upgraderisks.go b/backend/internal/upgraderisks/upgraderisks.go new file mode 100644 index 00000000000..e322dd40ddb --- /dev/null +++ b/backend/internal/upgraderisks/upgraderisks.go @@ -0,0 +1,240 @@ +// Copyright Contributors to the Open Cluster Management project + +package upgraderisks + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "sync" + "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" + 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" + crcTokenTTL = 60 * time.Second +) + +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 + + crcMu sync.Mutex + cachedCRC string + crcExpiry time.Time +} + +// 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 + } + 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) + 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 { + 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 { + 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 "" + } + 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 "" + } + raw := secret.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 +} + +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..3a323f28085 --- /dev/null +++ b/backend/internal/upgraderisks/upgraderisks_test.go @@ -0,0 +1,224 @@ +// 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 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 { + t.Fatalf("%v", got) + } + if chunkIDs(nil, 100) != nil { + t.Fatal("expected nil") + } +} 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/backend/internal/vmproxy/handler.go b/backend/internal/vmproxy/handler.go new file mode 100644 index 00000000000..8fe087bc031 --- /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" + "time" + + "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" + "github.com/stolostron/console/backend/internal/outbound" + 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{ + Timeout: 30 * time.Second, + Transport: outbound.Transport(opts.TLSConfig, 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/backend/package-lock.json b/backend/package-lock.json deleted file mode 100644 index c1bd73d5c81..00000000000 --- a/backend/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/package.json b/backend/package.json deleted file mode 100644 index 1053e8d7ee7..00000000000 --- a/backend/package.json +++ /dev/null @@ -1,129 +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", - "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", - "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/src/app.ts b/backend/src/app.ts deleted file mode 100644 index 50d0994ac87..00000000000 --- a/backend/src/app.ts +++ /dev/null @@ -1,190 +0,0 @@ -/* 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' -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 { 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 { 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' -import { serveHandler } from './routes/serve' -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' -import { watchPlacementDebugCA } from './lib/placementDebugCAWatch' -import { invalidatePlacementDebugAgent } from './lib/agent' -import { multiClusterEngineComponents } from './routes/multiClusterEngineComponents' -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' -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) -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) -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) -} -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('/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) -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) -router.get('/*', serveHandler) - -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 -let stopPlacementDebugCAWatch: (() => void) | undefined -export async function start() { - await loadSettings() - if (eventsEnabled) { - startWatching() - startAggregating() - } - stopPlacementDebugCAWatch = watchPlacementDebugCA(() => { - invalidatePlacementDebugAgent() - }) - 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() - stopAggregating() - stopPlacementDebugCAWatch?.() - stopTLSProfileWatch?.() - await stopServer() - stopLogger() -} - -if (process.env.LOG_MEMORY === 'true') { - startLoggingMemory() -} diff --git a/backend/src/lib/agent.ts b/backend/src/lib/agent.ts deleted file mode 100644 index eee941cba33..00000000000 --- a/backend/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/src/lib/authenticated.ts b/backend/src/lib/authenticated.ts deleted file mode 100644 index f9e1dd2caee..00000000000 --- a/backend/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/src/lib/batch-promise-all.ts b/backend/src/lib/batch-promise-all.ts deleted file mode 100644 index f2623ade754..00000000000 --- a/backend/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/src/lib/body-parser.ts b/backend/src/lib/body-parser.ts deleted file mode 100644 index 9bedec52096..00000000000 --- a/backend/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/src/lib/compression.ts b/backend/src/lib/compression.ts deleted file mode 100644 index cdf8113d271..00000000000 --- a/backend/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/src/lib/config.ts b/backend/src/lib/config.ts deleted file mode 100644 index 7ae0341b168..00000000000 --- a/backend/src/lib/config.ts +++ /dev/null @@ -1,70 +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 { 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('./config') - for (const filename of filenames) { - try { - const filePath = join('./config', 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/src/lib/cookies.ts b/backend/src/lib/cookies.ts deleted file mode 100644 index f5d73ffc948..00000000000 --- a/backend/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/src/lib/cors.ts b/backend/src/lib/cors.ts deleted file mode 100644 index 313a247b0fa..00000000000 --- a/backend/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/src/lib/delay.ts b/backend/src/lib/delay.ts deleted file mode 100644 index 5864ff5ddc1..00000000000 --- a/backend/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/src/lib/fetch-retry.ts b/backend/src/lib/fetch-retry.ts deleted file mode 100644 index 9c5ebe50e9f..00000000000 --- a/backend/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/src/lib/fileWatch.ts b/backend/src/lib/fileWatch.ts deleted file mode 100644 index 4988e954f92..00000000000 --- a/backend/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/src/lib/getServiceToken.ts b/backend/src/lib/getServiceToken.ts deleted file mode 100644 index fa530df79d0..00000000000 --- a/backend/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/src/lib/gigantic.ts b/backend/src/lib/gigantic.ts deleted file mode 100644 index 9ca0b9b5007..00000000000 --- a/backend/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/src/lib/json-request.ts b/backend/src/lib/json-request.ts deleted file mode 100644 index 009e45adb71..00000000000 --- a/backend/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/src/lib/logger.ts b/backend/src/lib/logger.ts deleted file mode 100644 index ea3944145e9..00000000000 --- a/backend/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/src/lib/main.ts b/backend/src/lib/main.ts deleted file mode 100644 index daa92af4caa..00000000000 --- a/backend/src/lib/main.ts +++ /dev/null @@ -1,65 +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' - -try { - config({ path: '.env' }) -} 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/src/lib/managed-cluster-addon.ts b/backend/src/lib/managed-cluster-addon.ts deleted file mode 100644 index afd93ac88ef..00000000000 --- a/backend/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/src/lib/memory.ts b/backend/src/lib/memory.ts deleted file mode 100644 index 6e60b926ac1..00000000000 --- a/backend/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/src/lib/multi-cluster-engine.ts b/backend/src/lib/multi-cluster-engine.ts deleted file mode 100644 index 5c525e6b06c..00000000000 --- a/backend/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/src/lib/multi-cluster-hub.ts b/backend/src/lib/multi-cluster-hub.ts deleted file mode 100644 index f7c8c12fc14..00000000000 --- a/backend/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/src/lib/noop.ts b/backend/src/lib/noop.ts deleted file mode 100644 index 386c2c8042f..00000000000 --- a/backend/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/src/lib/pagination.ts b/backend/src/lib/pagination.ts deleted file mode 100644 index cea0a5a3dcc..00000000000 --- a/backend/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/src/lib/placementDebugCAWatch.ts b/backend/src/lib/placementDebugCAWatch.ts deleted file mode 100644 index d7277cbd2e7..00000000000 --- a/backend/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/src/lib/random-string.ts b/backend/src/lib/random-string.ts deleted file mode 100644 index 7bdc712fd04..00000000000 --- a/backend/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/src/lib/request-retry.ts b/backend/src/lib/request-retry.ts deleted file mode 100644 index 1debadc7165..00000000000 --- a/backend/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/src/lib/respond.ts b/backend/src/lib/respond.ts deleted file mode 100644 index 050d5f1485b..00000000000 --- a/backend/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/src/lib/search.ts b/backend/src/lib/search.ts deleted file mode 100644 index c4b0ef01d08..00000000000 --- a/backend/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/src/lib/server-side-events.ts b/backend/src/lib/server-side-events.ts deleted file mode 100644 index 73840733644..00000000000 --- a/backend/src/lib/server-side-events.ts +++ /dev/null @@ -1,463 +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': - case 'ClusterRole': - 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/src/lib/server.ts b/backend/src/lib/server.ts deleted file mode 100644 index aba8863bedf..00000000000 --- a/backend/src/lib/server.ts +++ /dev/null @@ -1,208 +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 { managedClusterProxy } from '../routes/managedClusterProxy' -import { readFileSync } from 'node:fs' -import { searchWebSocket } from '../routes/search' - -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('certs/tls.crt') - key = readFileSync('certs/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('upgrade', (req: Http2ServerRequest, socket: TLSSocket, head: Buffer) => { - if (req.url.startsWith('/multicloud/proxy/search')) { - req.url = req.url.substring(11) - return searchWebSocket(req, socket, head) - } - if (req.url.startsWith('/multicloud/managedclusterproxy')) { - req.url = req.url.substring(11) - return managedClusterProxy(req, socket, head) - } - }) - .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/src/lib/serviceAccountToken.ts b/backend/src/lib/serviceAccountToken.ts deleted file mode 100644 index a8312a867c2..00000000000 --- a/backend/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/src/lib/tlsProfileWatch.ts b/backend/src/lib/tlsProfileWatch.ts deleted file mode 100644 index 83f4c1638d8..00000000000 --- a/backend/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/src/lib/token.ts b/backend/src/lib/token.ts deleted file mode 100644 index 263cc0d923f..00000000000 --- a/backend/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' - -const { HTTP2_HEADER_AUTHORIZATION } = constants - -const LOCAL_STORAGE = './certs' -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(LOCAL_STORAGE) - 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(LOCAL_STORAGE) - 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/src/lib/virtual-machine.ts b/backend/src/lib/virtual-machine.ts deleted file mode 100644 index d239aeac550..00000000000 --- a/backend/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/src/resources/resource-list.ts b/backend/src/resources/resource-list.ts deleted file mode 100644 index 14e29f96a4e..00000000000 --- a/backend/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/src/resources/resource.ts b/backend/src/resources/resource.ts deleted file mode 100644 index bb5f5e0c3ce..00000000000 --- a/backend/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/src/resources/route.ts b/backend/src/resources/route.ts deleted file mode 100644 index 9211b1cee13..00000000000 --- a/backend/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/src/resources/secret.ts b/backend/src/resources/secret.ts deleted file mode 100644 index 1e8cc68f3a5..00000000000 --- a/backend/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/src/resources/status.ts b/backend/src/resources/status.ts deleted file mode 100644 index 994bb928dee..00000000000 --- a/backend/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/src/resources/watch-options.ts b/backend/src/resources/watch-options.ts deleted file mode 100644 index 45ac79f88f3..00000000000 --- a/backend/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/src/routes/aggregator.ts b/backend/src/routes/aggregator.ts deleted file mode 100644 index 4a8d43c1d61..00000000000 --- a/backend/src/routes/aggregator.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* Copyright Contributors to the Open Cluster Management project */ -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/src/routes/aggregators/appSetData.ts b/backend/src/routes/aggregators/appSetData.ts deleted file mode 100644 index 619bcd340ed..00000000000 --- a/backend/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/src/routes/aggregators/applications.ts b/backend/src/routes/aggregators/applications.ts deleted file mode 100644 index 11fd1860723..00000000000 --- a/backend/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/src/routes/aggregators/applicationsArgo.ts b/backend/src/routes/aggregators/applicationsArgo.ts deleted file mode 100644 index 77a39cdd496..00000000000 --- a/backend/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/src/routes/aggregators/applicationsOCP.ts b/backend/src/routes/aggregators/applicationsOCP.ts deleted file mode 100644 index 19409d6c3f5..00000000000 --- a/backend/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/src/routes/aggregators/applicationsPushModel.ts b/backend/src/routes/aggregators/applicationsPushModel.ts deleted file mode 100644 index cce7b7f35ce..00000000000 --- a/backend/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/src/routes/aggregators/statuses.ts b/backend/src/routes/aggregators/statuses.ts deleted file mode 100644 index d114a5472f8..00000000000 --- a/backend/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/src/routes/aggregators/utils.ts b/backend/src/routes/aggregators/utils.ts deleted file mode 100644 index 366ac5fe5b5..00000000000 --- a/backend/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/src/routes/ansibletower.ts b/backend/src/routes/ansibletower.ts deleted file mode 100644 index d8a23348448..00000000000 --- a/backend/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/src/routes/apiPaths.ts b/backend/src/routes/apiPaths.ts deleted file mode 100644 index dea9bc1853f..00000000000 --- a/backend/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/src/routes/clusterVersion.ts b/backend/src/routes/clusterVersion.ts deleted file mode 100644 index c4443ee57f1..00000000000 --- a/backend/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/src/routes/configure.ts b/backend/src/routes/configure.ts deleted file mode 100644 index 6cf9253f462..00000000000 --- a/backend/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/src/routes/events.ts b/backend/src/routes/events.ts deleted file mode 100644 index 15a64bf2121..00000000000 --- a/backend/src/routes/events.ts +++ /dev/null @@ -1,1018 +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 { polledAggregation } from './aggregator' -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: 'ClusterRole', - apiVersion: 'rbac.authorization.k8s.io/v1', - labelSelector: { 'rbac.open-cluster-management.io/filter': 'vm-clusterroles' }, - }, - { - 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) { - await polledAggregation(options, pruned, !_continue) - 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/src/routes/hub.ts b/backend/src/routes/hub.ts deleted file mode 100644 index c58238b00fb..00000000000 --- a/backend/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/src/routes/hypershift-status.ts b/backend/src/routes/hypershift-status.ts deleted file mode 100644 index 69bdcb67ded..00000000000 --- a/backend/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/src/routes/liveness.ts b/backend/src/routes/liveness.ts deleted file mode 100644 index b84e8670e11..00000000000 --- a/backend/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/src/routes/managedClusterProxy.ts b/backend/src/routes/managedClusterProxy.ts deleted file mode 100644 index f6bf2ae86f5..00000000000 --- a/backend/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/src/routes/metricsProxy.ts b/backend/src/routes/metricsProxy.ts deleted file mode 100644 index 90e025d51c0..00000000000 --- a/backend/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/src/routes/multiClusterEngineComponents.ts b/backend/src/routes/multiClusterEngineComponents.ts deleted file mode 100644 index 856e2fbf87c..00000000000 --- a/backend/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/src/routes/multiClusterHubComponents.ts b/backend/src/routes/multiClusterHubComponents.ts deleted file mode 100644 index 333459de7bc..00000000000 --- a/backend/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/src/routes/oauth.ts b/backend/src/routes/oauth.ts deleted file mode 100644 index 7dab03c9e6b..00000000000 --- a/backend/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/src/routes/operatorCheck.ts b/backend/src/routes/operatorCheck.ts deleted file mode 100644 index 5ca68fa54dd..00000000000 --- a/backend/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/src/routes/placementDebug.ts b/backend/src/routes/placementDebug.ts deleted file mode 100644 index 22b54ae04f3..00000000000 --- a/backend/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/src/routes/proxy.ts b/backend/src/routes/proxy.ts deleted file mode 100644 index 70fac4fef32..00000000000 --- a/backend/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/src/routes/readiness.ts b/backend/src/routes/readiness.ts deleted file mode 100644 index 049569b480e..00000000000 --- a/backend/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/src/routes/rosaWizardApi.ts b/backend/src/routes/rosaWizardApi.ts deleted file mode 100644 index 1c1988630ce..00000000000 --- a/backend/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/src/routes/search.ts b/backend/src/routes/search.ts deleted file mode 100644 index 7b73f04dc2c..00000000000 --- a/backend/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/src/routes/serve.ts b/backend/src/routes/serve.ts deleted file mode 100644 index b1bac067fb2..00000000000 --- a/backend/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/src/routes/upgrade-risks-prediction.ts b/backend/src/routes/upgrade-risks-prediction.ts deleted file mode 100644 index 7dfe1f63493..00000000000 --- a/backend/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/src/routes/username.ts b/backend/src/routes/username.ts deleted file mode 100644 index 6f22a5f701e..00000000000 --- a/backend/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/src/routes/userpreference.ts b/backend/src/routes/userpreference.ts deleted file mode 100644 index 0774b47da52..00000000000 --- a/backend/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/src/routes/virtualMachineProxy.ts b/backend/src/routes/virtualMachineProxy.ts deleted file mode 100644 index 4fac5f8e36b..00000000000 --- a/backend/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/test/app.test.ts b/backend/test/app.test.ts deleted file mode 100644 index c80b2b7787d..00000000000 --- a/backend/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/test/jest-setup.ts b/backend/test/jest-setup.ts deleted file mode 100644 index 0f380109859..00000000000 --- a/backend/test/jest-setup.ts +++ /dev/null @@ -1,10 +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.PUBLIC_FOLDER = '../frontend/public' - -iconvLite.encodingExists('foo') diff --git a/backend/test/lib/agent.test.ts b/backend/test/lib/agent.test.ts deleted file mode 100644 index b33cc5fc79f..00000000000 --- a/backend/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/test/lib/batch-promise-all.test.ts b/backend/test/lib/batch-promise-all.test.ts deleted file mode 100644 index cb73a05f8a2..00000000000 --- a/backend/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/test/lib/compression.test.ts b/backend/test/lib/compression.test.ts deleted file mode 100644 index e8e1ff210b2..00000000000 --- a/backend/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/test/lib/fileWatch.test.ts b/backend/test/lib/fileWatch.test.ts deleted file mode 100644 index aaf2a4d9088..00000000000 --- a/backend/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/test/lib/getServiceToken.test.ts b/backend/test/lib/getServiceToken.test.ts deleted file mode 100644 index a8550dad95b..00000000000 --- a/backend/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/test/lib/placementDebugCAWatch.test.ts b/backend/test/lib/placementDebugCAWatch.test.ts deleted file mode 100644 index 8bbf1e2ba19..00000000000 --- a/backend/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/test/lib/tlsProfileWatch.test.ts b/backend/test/lib/tlsProfileWatch.test.ts deleted file mode 100644 index f152bf4f4bd..00000000000 --- a/backend/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/test/mock-request.ts b/backend/test/mock-request.ts deleted file mode 100644 index 874403dee99..00000000000 --- a/backend/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/test/routes/aggregator.test.ts b/backend/test/routes/aggregator.test.ts deleted file mode 100644 index cc71224147f..00000000000 --- a/backend/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/test/routes/aggregators/applications.test.ts b/backend/test/routes/aggregators/applications.test.ts deleted file mode 100644 index 151dceef3ec..00000000000 --- a/backend/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/test/routes/aggregators/applicationsArgoMergePush.test.ts b/backend/test/routes/aggregators/applicationsArgoMergePush.test.ts deleted file mode 100644 index 379894bc2f9..00000000000 --- a/backend/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/test/routes/aggregators/applicationsPushModel.test.ts b/backend/test/routes/aggregators/applicationsPushModel.test.ts deleted file mode 100644 index 9ebd347067e..00000000000 --- a/backend/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/test/routes/aggregators/utils.test.ts b/backend/test/routes/aggregators/utils.test.ts deleted file mode 100644 index 58d776bb4b6..00000000000 --- a/backend/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/test/routes/ansibletower.test.ts b/backend/test/routes/ansibletower.test.ts deleted file mode 100644 index daa7409adab..00000000000 --- a/backend/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/test/routes/apiPath.test.ts b/backend/test/routes/apiPath.test.ts deleted file mode 100644 index 2d24f1f7d36..00000000000 --- a/backend/test/routes/apiPath.test.ts +++ /dev/null @@ -1,48 +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('/.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, - }) - - 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/test/routes/clusterVersion.test.ts b/backend/test/routes/clusterVersion.test.ts deleted file mode 100644 index cf6e8a5b72e..00000000000 --- a/backend/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/test/routes/configure.test.ts b/backend/test/routes/configure.test.ts deleted file mode 100644 index 390b4192bb2..00000000000 --- a/backend/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/test/routes/events.test.ts b/backend/test/routes/events.test.ts deleted file mode 100644 index 215ea9834d4..00000000000 --- a/backend/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/test/routes/hub.test.ts b/backend/test/routes/hub.test.ts deleted file mode 100644 index 87e801ef6cc..00000000000 --- a/backend/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/test/routes/hypershift-status.test.ts b/backend/test/routes/hypershift-status.test.ts deleted file mode 100644 index 10467df915f..00000000000 --- a/backend/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/test/routes/liveness.test.ts b/backend/test/routes/liveness.test.ts deleted file mode 100644 index 44af54e72cb..00000000000 --- a/backend/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/test/routes/managedClusterProxy.test.ts b/backend/test/routes/managedClusterProxy.test.ts deleted file mode 100644 index acbe5840b2f..00000000000 --- a/backend/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/test/routes/metricsProxy.test.ts b/backend/test/routes/metricsProxy.test.ts deleted file mode 100644 index 6539faee920..00000000000 --- a/backend/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/test/routes/operatorCheck.test.ts b/backend/test/routes/operatorCheck.test.ts deleted file mode 100644 index cb4878a7af7..00000000000 --- a/backend/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/test/routes/ping.test.ts b/backend/test/routes/ping.test.ts deleted file mode 100644 index 1e523ce38de..00000000000 --- a/backend/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/test/routes/placementDebug.test.ts b/backend/test/routes/placementDebug.test.ts deleted file mode 100644 index ce35f3d7049..00000000000 --- a/backend/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 and metricsProxy.ts, which also omit connection error tests). -}) diff --git a/backend/test/routes/proxy.test.ts b/backend/test/routes/proxy.test.ts deleted file mode 100644 index f60cf0e316d..00000000000 --- a/backend/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/test/routes/readiness.test.ts b/backend/test/routes/readiness.test.ts deleted file mode 100644 index 12cab40f7dc..00000000000 --- a/backend/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/test/routes/rosaWizardApi.test.ts b/backend/test/routes/rosaWizardApi.test.ts deleted file mode 100644 index 86e84f34bf9..00000000000 --- a/backend/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/test/routes/search.test.ts b/backend/test/routes/search.test.ts deleted file mode 100644 index f4fc879f29f..00000000000 --- a/backend/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/test/routes/searchWebSocket.test.ts b/backend/test/routes/searchWebSocket.test.ts deleted file mode 100644 index d9a0b50873a..00000000000 --- a/backend/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/test/routes/serve.test.ts b/backend/test/routes/serve.test.ts deleted file mode 100644 index 8b99d5c0b6c..00000000000 --- a/backend/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/test/routes/upgrade-risks-prediction.test.ts b/backend/test/routes/upgrade-risks-prediction.test.ts deleted file mode 100644 index da4dde8083c..00000000000 --- a/backend/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/test/routes/username.test.ts b/backend/test/routes/username.test.ts deleted file mode 100644 index 2f9c24eec5f..00000000000 --- a/backend/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/test/routes/userpreference.test.ts b/backend/test/routes/userpreference.test.ts deleted file mode 100644 index 8f53479cf89..00000000000 --- a/backend/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/test/routes/virtualMachineProxy.test.ts b/backend/test/routes/virtualMachineProxy.test.ts deleted file mode 100644 index 98f96d3b254..00000000000 --- a/backend/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/test/tsconfig.json b/backend/test/tsconfig.json deleted file mode 100644 index e516b7faf6a..00000000000 --- a/backend/test/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../tsconfig.json", - "include": ["**/*.ts"] -} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json deleted file mode 100644 index e20f937f9fa..00000000000 --- a/backend/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "include": ["src"] -} diff --git a/backend/tsconfig.json b/backend/tsconfig.json deleted file mode 100644 index fbe7c97d785..00000000000 --- a/backend/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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d3757662e99..4180b34ca39 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,10 +29,20 @@ 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. 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. + +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 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. 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 cache headers, CSP, and brotli/gzip content negotiation. diff --git a/docs/RESOURCES.md b/docs/RESOURCES.md index 298cfea2f00..4acf2f2f475 100644 --- a/docs/RESOURCES.md +++ b/docs/RESOURCES.md @@ -1,7 +1,7 @@ # To add a new resource -1. Add a watch to `/backend/src/routes/events.ts` for the resource. -2. Add a resource definition in `/fronend/src/resources`. +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 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/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() 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..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/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: 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..b9c5da8836d 100644 --- a/lint-staged.config.js +++ b/lint-staged.config.js @@ -2,7 +2,7 @@ // lint-staged.config.js module.exports = { '*': 'npm run copyright:fix --', - 'backend/**/*.ts': 'npm run lint:fix:backend --', + '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..4ff6c522a3f 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "scripts": { "postinstall": "concurrently npm:ci:* -c green,blue", "ci:frontend": "cd frontend && npm ci", - "ci:backend": "cd backend && 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", "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": ". ./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", @@ -22,47 +22,49 @@ "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: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: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: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: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:frontend -c green,blue", + "test:backend": "cd backend && go 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: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: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 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_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_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", + "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 ci:backend", "prepare": "husky install" }, "devDependencies": { 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/check-hub-alignment.sh b/scripts/check-hub-alignment.sh new file mode 100755 index 00000000000..a6e112dc39b --- /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 < { 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/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/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..9a9009f3908 100755 --- a/setup.sh +++ b/setup.sh @@ -186,3 +186,5 @@ 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 + +"$(dirname "$0")/scripts/generate-backend-certs.sh" --if-missing diff --git a/sonar-project.properties b/sonar-project.properties index 540f1eea40c..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/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/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 +sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info +sonar.testExecutionReportPaths=frontend/test-report.xml 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}